Best practices¶
Practical guidance for building servers and clients with the SDK. These are conventions the SDK is shaped to reward, not hard requirements.
Server¶
Advertise capabilities honestly¶
ServerBuilder derives the advertised ServerCapabilities from what you register. Adding tools advertises
tools, a completion store advertises completions, and so on. Do not hand-advertise a capability whose
notifications you cannot deliver. A client tolerates an absent capability, but it breaks when a declared one stays
silent. That is why the SDK does not advertise listChanged for the immutable-after-build stores.
Keep STDOUT for the protocol¶
A stdio server MUST NOT write anything to STDOUT except the JSON-RPC stream. Send all diagnostics to STDERR through
the PSR-3 logger you pass to the builder. The examples' PsrLogger
(examples/PsrLogger.php) does this.
Signal tool failures with isError, not exceptions¶
A tool that fails for a domain reason should return a CallToolResult with isError: true and a descriptive
content block. Bad input the schema allowed and an upstream timeout are domain reasons. Reserve thrown exceptions
for protocol-level faults. The dispatcher turns an uncaught handler throwable into a generic -32603, so internal
details never leak. See error handling.
Lean on schema validation¶
Give tools an inputSchema, and an outputSchema when they return structuredContent. The SDK validates the
arguments before your executor runs and validates the structured result after, so your handler can assume
well-formed input.
Register everything before run()¶
Tools, prompts, resources, handlers, and the logger all register on the builder. The built Server is immutable.
There is no runtime registration, so compose fully, then call Server::run().
Client¶
Connect, then call¶
Each request stands on its own, so typed calls can start as soon as connect() returns. Typed methods throw
LogicException if called before connect(). Always pair connect() with a disconnect() in a finally, so
the transport closes even when a call throws:
$client->connect($transport);
try {
// Optionally learn the server's identity and capabilities first.
$client->discover();
// ... typed calls ...
} finally {
$client->disconnect();
}
Degrade gracefully on missing capabilities¶
Before you rely on an optional capability, call discover() and then check getServerCapabilities(), or catch
ServerCapabilityNotSupportedException. Once discovery has run, the client gates each typed request on what that
server advertised, so a complete() against a server without completions fails before it sends. A disconnect()
forgets the advertisement, so a reconnected client is ungated until it discovers again. See
examples/capability-aware-client.php.
Stream progress for long tools¶
Pass an onProgress callback to callTool() to receive notifications/progress while the call is in flight.
Bound the calls that need a different deadline¶
Every request already carries a deadline. setRequestTimeout() defaults to 60 seconds of silence, and each
progress notification restarts it. setMaxRequestTimeout() caps the total.
A call that legitimately runs long takes a per-request override, sendRequest($request, $response,
timeout: 900.0), rather than a wider default for everything. A lapsed deadline raises RequestTimeoutException
and tells the server to stop working on the request. See
request timeouts.
Both sides¶
Catch McpExceptionInterface¶
A single catch traps every SDK-originated failure. Narrow to specific exceptions only where you act on them differently. See error handling.
Pass a real PSR-3 logger¶
Both ServerBuilder and ClientBuilder accept a logger and default to NullLogger. A real logger surfaces the
transport and dispatch diagnostics, which the SDK keeps off STDOUT.
Construct schema objects with named arguments¶
The classes under Nexus\Mcp\Core\Schema mirror the MCP spec shape. The protocol dictates their structure, not
this SDK's backward-compatibility promise. As the spec evolves, a class may gain, drop, or reorder constructor
parameters without that counting as a breaking change under the SDK's own versioning.
Always pass arguments by name: new TextContent(text: $body), not new TextContent($body). Do this regardless of
how many parameters a constructor takes today, so a future reordering or insertion cannot silently bind your values
to the wrong slot. The bundled examples follow this convention.
See also¶
- Getting started: a minimal server and client.
- Server API and Client API: the full builder and method surface.
- Error handling: the exception model and JSON-RPC error codes.
- Design rationale: why the SDK is shaped this way.