Error handling¶
Every exception the SDK throws implements the marker interface
Nexus\Mcp\Core\Exception\McpExceptionInterface. A single
catch block traps anything the SDK raises:
use Nexus\Mcp\Core\Exception\McpExceptionInterface;
try {
$client->callTool('do_thing', ['x' => 1]);
} catch (McpExceptionInterface $e) {
// Any SDK-originated failure: lifecycle, transport, capability gate, or a
// server-returned error.
$logger->error('MCP call failed: {error}', ['error' => $e->getMessage()]);
}
Exceptions live in three namespaces under the same marker. Nexus\Mcp\Core\Exception\* holds the protocol and
transport failures. Nexus\Mcp\Server\Exception\* holds the server-side handler and lifecycle failures.
Nexus\Mcp\Client\Exception\* holds the client-side lifecycle and capability-gating failures.
Implementation-detail exceptions are tagged @internal, and PHPStan flags external use of them.
JSON-RPC error codes¶
Failures become JSON-RPC error responses that carry a numeric code. The SDK models the standard set in
ProtocolErrorCode:
| Code | Name | Meaning |
|---|---|---|
| -32700 | ParseError |
The inbound line was not valid JSON. |
| -32600 | InvalidRequest |
The envelope is not a valid JSON-RPC request (bad or empty id, wrong shape). |
| -32601 | MethodNotFound |
No handler is registered for the method. |
| -32602 | InvalidParams |
The params are invalid, or the named tool / prompt / resource does not exist. |
| -32603 | InternalError |
An unexpected server-side failure. |
The 2026-07-28 spec adds three codes in the reserved -320xx band for lifecycle and header failures:
| Code | Name | Meaning | Emitted by the SDK |
|---|---|---|---|
| -32020 | HeaderMismatch |
A request-metadata header disagreed with the message body (Streamable HTTP). | Yes, by ParameterHeaderValidationMiddleware, which answers 400 before the transport reads the body. |
| -32021 | MissingRequiredClientCapability |
The request needs a client capability absent from _meta.clientCapabilities. |
Yes, when a handler raises MissingRequiredClientCapabilityException. Only a handler knows what serving its request needs. |
| -32022 | UnsupportedProtocolVersion |
The request's _meta.protocolVersion is not supported. |
Yes, by the server dispatcher, which rejects the request before it reaches a handler. |
Every one of them decodes into the matching Error subclass. A peer's error response is typed whether or not this
SDK is the side that sends it.
The SDK also defines its own code outside the spec's bands, in
SdkErrorCode:
| Code | Name | Meaning |
|---|---|---|
| -32000 | Overloaded |
The peer shed the request at one of its dispatch budgets. See setMaxInFlightDispatches() on the server and the client. |
Server side: handler failures become error responses¶
When a request handler throws, the server's dispatcher converts the exception into a JSON-RPC error response. The exception never escapes.
Protocol exceptions pin a code¶
An exception that implements
JsonRpcProtocolExceptionInterface pins its code
through getErrorCode(). InvalidParamsException maps to -32602 and MethodNotFoundException to -32601. The
not-found exceptions map to -32602 too, because the named entity is treated as an invalid parameter. Those are
ToolNotFoundException, PromptNotFoundException, ResourceNotFoundException,
ResourceNotRegisteredException, and InvalidCursorException.
The data slot¶
Any of them may also carry an errorData payload. It becomes the error response's data slot, and the slot is
omitted when the payload is null. Three producers ship with the SDK:
ResourceNotFoundExceptionandResourceNotRegisteredExceptioncarry thedata.urithe spec's resource-not-found example shows.- A
tools/callargument failure carries thedata.validation_errorslist described under Diagnostic message conventions. MissingRequiredClientCapabilityExceptioncarries thedata.requiredCapabilitiesits code requires.
Only the last is required. The spec defines data as "defined by the sender", so the URI echo is this SDK's
choice. The SDK echoes the URI whole, so a client can match it against the URI it sent. That is safe, because
decode already confines params.uri to printable ASCII by the RFC 3986 grammar and to 8192 bytes. The echo
carries no control bytes, and it is never longer than the URI the peer itself sent.
Bounded messages¶
An error message that quotes a peer-supplied value is bounded. Every byte outside printable ASCII (\x20-\x7E)
is rendered as \xNN before it leaves the server, so non-ASCII text comes back escaped too. An é reads as
\xc3\xa9. A short identifier, such as a tool name, a cursor, or a protocol version, is cut to 80 bytes with a
trailing .... A URI or a whole nested cause is cut to 256.
The spec asks that a message stay "a concise single sentence". An unbounded echo is both a response amplifier and
a way to put terminal escapes into whatever renders the error. The same treatment reaches a data slot the request
grammar does not already confine. UnsupportedProtocolVersionError's data.requested is capped and escaped,
because _meta accepts any non-empty string as a protocol version.
What the policy does not cover¶
The policy covers the values this SDK composes, and it stops there. A protocol exception thrown by a handler of
your own reaches the peer with both its message and its errorData unchanged. The SDK never rewrites an error
you meant to send. The tasks extension stores the same two values on the task record, where a later tasks/get
returns them.
A handler that interpolates request arguments into either value inherits none of the bounding above. Apply it
yourself with SafeDisplay: SafeDisplay::sanitise() for a short identifier, and
SafeDisplay::sanitiseCause() for a URI or a composed message.
Missing client capabilities¶
A handler that needs a client capability the request did not declare raises
MissingRequiredClientCapabilityException with the ClientCapabilities it wanted. That answers -32021. The
Streamable HTTP transport pins it to 400, even though handler-raised errors otherwise ride 200.
Everything else¶
Any other \Throwable from a handler becomes a generic -32603 InternalError, so a handler bug never leaks a
stack trace or an internal message to the client. The dispatcher logs the original throwable on the server side.
ToolOutputValidationException is the exception to that rule. A tool whose structuredContent fails its
outputSchema is logged on the server side and returned as a normal CallToolResult with isError: true. The
malformed structured data is never sent.
Tool authors signal a tool-level failure by returning a CallToolResult with isError: true, not by throwing.
A thrown exception is a protocol error.
Client side: lifecycle, capability, and remote errors¶
The typed Client methods throw before they send when the call is out of order or unsupported. They surface
server-returned errors as exceptions:
| Exception | Thrown when |
|---|---|
LogicException |
A request is issued before connect(), or connect() is called twice. |
ServerCapabilityNotSupportedException |
A typed request targets a capability the server did not advertise via server/discover (for example complete() against a server with no completions). |
RemoteCallFailedException |
The server answered with a JSON-RPC error response. The decoded Error (code, message, data) is available on the exception. |
TransportAlreadyClosedException |
The transport closed while a request was in flight (also raised on send-after-close). |
OutboundRequestFailedException |
The transport could not carry the request to completion (connection refused, TLS failure, a stalled read, an HTTP status whose body settles nothing), so no response can arrive. The underlying fault is the exception's previous. |
UnexpectedHttpStatusException |
An HTTP exchange answered with a status carrying no JSON-RPC payload that settles the message it was sent for (a 502 from a proxy, an id-less error envelope, a 202 to a request). Arrives as the previous of OutboundRequestFailedException, with the status and the leading bytes of the body on it. |
RequestTimeoutException |
The request's deadline elapsed before the peer answered. See request timeouts for the two bounds and how progress notifications extend them. |
use Nexus\Mcp\Client\Exception\ServerCapabilityNotSupportedException;
use Nexus\Mcp\Core\Exception\RemoteCallFailedException;
try {
$result = $client->complete($ref, ['name' => 'arg', 'value' => 'a']);
} catch (ServerCapabilityNotSupportedException) {
// Degrade gracefully: the server has no completions.
} catch (RemoteCallFailedException $e) {
// The server ran the method but returned an error.
$logger->warning('Server error {code}: {message}', [
'code' => $e->error->code,
'message' => $e->error->message,
]);
}
See examples/capability-aware-client.php for a runnable demonstration of the capability gate.
Transport errors¶
Out-of-order transport operations throw typed exceptions, so misuse surfaces early rather than as silently dropped
envelopes. TransportNotStartedException means a send before start(). TransportAlreadyStartedException means a
double start(). TransportAlreadyClosedException means use after close(). See
docs/transports.md for the per-transport state machine.
Diagnostic message conventions¶
Every Assert::that(...) chain and bare \InvalidArgumentException in Core/Schema/ follows a fixed shape.
Consumers can parse the messages programmatically, and non-PHP clients can recognise the structure.
Field labels¶
Each message identifies its target with the JSON field name in double quotes, optionally scoped by a parent key.
Envelope fields¶
Top-level request, result, notification, and error-response fields use a dotted path from the JSON-RPC envelope key:
'"params.name" must be a string, {type} given.'
'"result.completion.values" must be a list, non-list array given.'
'"params._meta" must be an object, {type} given.'
'"error.code" must be an integer, {type} given.'
Classes with one wrapping field¶
Schema classes with a single canonical wrapping field use that field as the label:
| Class | Label |
|---|---|
ServerCapabilities, ClientCapabilities |
"capabilities" |
Annotations, ToolAnnotations |
"annotations" |
Icon (array item under icons) |
"icons" |
PromptArgument (array item under arguments) |
"arguments" |
MetaObject and its MetaObject\* subclasses |
"_meta" |
RequestId |
"id" |
ProtocolVersion |
"protocolVersion" |
Cursor |
"cursor" |
ElicitRequestedSchema |
"requestedSchema" |
EnumOption (array item under oneOf) |
"oneOf" |
Multi-context classes¶
A class referenced under several keys drops the prefix entirely. Implementation sits under both serverInfo
and clientInfo, so its messages start with the field name directly:
Classes without a wrapping field¶
These use the lowercased, space-separated form of their class name as the prefix: text content,
image content, embedded resource, resource link, boolean schema, number schema, tool, prompt,
resource template, prompt message, and so on.
Requests and notifications¶
*Request and *Notification classes have no label. Their messages start with the field name directly:
Envelope-kind wrapper¶
The JsonRpcMessageParser prefixes every decode failure with one wrapper per envelope kind, so the inner message
never repeats it:
Invalid success response: "result" is missing the required "content" key.
Invalid error response: "error.code" must be an integer, {type} given.
Invalid "tools/call" request: "params" is missing the required "name" key.
Invalid "notifications/progress" notification: "params" is missing the required "progressToken" key.
The four kinds (request, notification, success response, error response) are the only omitted top scope.
Everything below the envelope keeps its scope in the inner message: params, result, the error object, and
nested objects.
Rules¶
- JSON field names are double-quoted (
"name","capabilities.tasks.cancel"). Assert::that(...)->values()and->keys()chains prependeachto the message. The message stays singular to agree with it:each "params.stopSequences" entry must be a string, notentries must be strings.- Type mismatches use the PHP idiom
<type> given.(int given.,array given.). - Required-key checks mirror the matching type-mismatch's scope, drop the envelope kind (the wrapper above supplies
it), and read
is missing. Envelope-root fields stay bare:'missing the required "id" key.'. Payload and deeper fields keep their scope:'"params" is missing the required "name" key.'and'"error.data" is missing the required "elicitations" key.'. - Value mismatches against a constant use Assert's lazy
{value}and{other}template tokens instead of\sprintf, so the comparand renders throughvar_exportat exception-render time.
Schema violations¶
Tool argument and structuredContent conformance failures follow the same shape. The server's
ValidationErrorFormatter renders each leaf schema violation with the dotted data path double-quoted and the
<type> given. idiom. At the root the path is bare, because the Invalid arguments for tool "x": ... wrapper
supplies the scope.
An argument failure also lists each violation under data.validation_errors with its RFC 6901 pointer. The list
is capped at eight entries and sanitised like the message, since an undeclared key name or an enum value is the
peer's own text.
ArgumentBinder's failures speak the same grammar, and the owning store wraps them with the same feature
identity.
Reusable validators¶
Core/Validation/ exposes five field-format validators. Each takes the value plus a $context label that becomes
the message prefix:
| Validator | Purpose |
|---|---|
IdentifierNameValidator::validate($name, $context) |
1-128 chars from [A-Za-z0-9._-], authoring only |
IconSrcValidator::validate($icons, $context) |
HTTP/HTTPS URL or base64 data: URI, authoring only |
Rfc3986UriValidator::validate($uri, $context) |
RFC 3986 absolute URI |
Rfc6570UriTemplateValidator::validate($uri, $context) |
RFC 6570 URI Template |
Iso8601DateTimeValidator::parse($value, $context) |
ISO 8601 datetime parse |
The validator templates have no hardcoded field noun. Callers pass the full label they want in the emitted
message, for example '"params.name"', 'tool "name"', 'resource link "uri"', or
'resource template "uriTemplate"'.
See also¶
- Client API: each typed method documents the exceptions it can throw.
- Server API: handler registration and capability advertisement.
- Transports: the transport state machine and its exceptions.
- Best practices: degrading gracefully and advertising capabilities honestly.