Typed requests¶
Each method mints a request ID, sends the request, and awaits the typed result. The list methods accept an
optional Cursor for pagination. The full signatures are in the API reference.
| Client method | JSON-RPC method | Returns |
|---|---|---|
discover() |
server/discover |
DiscoverResult |
listTools() |
tools/list |
ListToolsResult |
callTool() |
tools/call |
CallToolResult\|InputRequiredResult |
listResources() |
resources/list |
ListResourcesResult |
listResourceTemplates() |
resources/templates/list |
ListResourceTemplatesResult |
readResource() |
resources/read |
ReadResourceResult\|InputRequiredResult |
listPrompts() |
prompts/list |
ListPromptsResult |
getPrompt() |
prompts/get |
GetPromptResult\|InputRequiredResult |
complete() |
completion/complete |
CompleteResult |
An InputRequiredResult in a union means the server may ask for input before it finishes. See
When the server asks for input first.
$tools = $client->listTools();
$result = $client->callTool('greet', ['name' => 'Paul']);
$about = $client->readResource('example://about');
$prompt = $client->getPrompt('walkthrough', ['audience' => 'a junior developer']);
Walking pages¶
A list result carries a nextCursor while more entries remain. Pass it back to the same method until it is
null.
$tools = [];
$cursor = null;
do {
$page = $client->listTools($cursor);
$tools = [...$tools, ...$page->tools];
$cursor = $page->nextCursor;
} while (null !== $cursor);
The server decides the page size. See Stores and pagination for the server side.
The capability gate¶
Once discover() has run, every typed request requires the server to have advertised the matching capability.
tools/* needs tools, resources/* needs resources, prompts/* needs prompts, and completion/complete
needs completions. Calling one the server did not advertise throws ServerCapabilityNotSupportedException
before anything reaches the transport.
Before discovery, and again after a disconnect(), there are no advertised capabilities to gate against, so
requests pass through. Check getServerCapabilities() when you need to branch on what the server supports.
Mirrored tool parameters over HTTP¶
A server may annotate a tool parameter with x-mcp-header in its inputSchema. That asks clients to mirror the
argument into an Mcp-Param-{Name} HTTP header, so gateways can route or rate-limit on it without parsing the
body. Supporting this is mandatory for a client on the Streamable HTTP transport, and the SDK does it for you:
listTools()scans each tool'sinputSchemaand caches its declarations.callTool()extracts the annotated arguments, encodes them, and sends them asMcp-Param-{Name}headers. An argument that is absent ornullsends no header, which is what the server expects. An integral float such as5.0is mirrored as5, the integer JSON Schema reads it as.- A
-32020 HeaderMismatchrejection re-lists the tool and retries the call once, so a cached schema that has fallen behind the server's recovers on its own.
A tool with invalid declarations disappears from listTools()¶
The spec requires a client to exclude a tool it cannot mirror rather than call it unmirrored, since the server would reject the call anyway. The SDK drops the tool and logs a warning that names the tool and the reason. Check your logger if a tool you expect is missing.
Declarations are invalid when they are empty, are not a valid HTTP field-name token, collide case-insensitively,
sit on a number parameter, or sit somewhere not reachable through a plain properties chain.
Only listTools() populates the cache¶
Calling a tool you never listed sends no mirrored headers. The server answers -32020 HeaderMismatch, and the
client recovers by listing and retrying. That costs an extra round trip each time, so call listTools() first
when you can. A second mismatch on the retry propagates to you, as does any other error code. disconnect()
clears the cache, since it described the server you just left.
The re-listing walk is bounded¶
The walk runs inside the callTool() you are awaiting, and every page is answered, so no request deadline can end
it. It stops when the server sends a cursor it has already followed, and at 100 pages either way. It logs a
warning in both cases. Giving up also forgets the tool's cached declarations, so the one retry goes out unmirrored
rather than carry the header the server just rejected. A listing deeper than 100 pages therefore behaves like a
tool you never listed.
None of this applies on stdio. That transport may ignore the annotations entirely, so the listing passes through untouched and no tool is dropped.
The escape hatch: sendRequest()¶
Every standard client-to-server method has a typed wrapper above, so sendRequest() is for vendor extension
methods, or any pre-built request. Pass the request plus the *ResultResponse envelope class to decode the reply
into, and it returns that response. GenericResultResponse decodes a bare ack into an EmptyResult. For a vendor
reply with its own shape, subclass JsonRpcResultResponse with a matching fromArray().
use Nexus\Mcp\Core\Schema\ResultResponse\GenericResultResponse;
// AcmeSnapshotRequest is your own JsonRpcRequest subclass bound to a vendor method
// literal, e.g. "acme/snapshot". stampMeta() supplies the lifecycle `_meta` fields
// (protocol version, client info, declared capabilities) every request must carry.
$response = $client->sendRequest(
new AcmeSnapshotRequest(
id: $client->mintRequestId(),
params: new AcmeSnapshotRequestParams(meta: $client->stampMeta()),
),
GenericResultResponse::class,
);
mintRequestId() draws from the builder-configured ID factory, which auto-increments by default, so hand-built
requests share the ID scheme of the typed methods. Supplying your own RequestId is equally valid.
The capability gate covers the methods behind the typed requests above, so a tools/list against a server that
advertised no tools throws ServerCapabilityNotSupportedException (see Typed requests). A
vendor method like acme/snapshot passes through ungated, unless an enabled extension declared
it as an outbound method. In that case a server that did not advertise the extension is refused the same way.
listen() also passes ungated. The spec defines no capability for subscriptions/listen, so a server that does
not serve it answers -32601, and the failure arrives as a remote error rather than a local one.