Skip to content

Conversation

@CodeWithKyrian
Copy link
Contributor

@CodeWithKyrian CodeWithKyrian commented Dec 21, 2025

This PR introduces an MCP client component for the SDK, enabling PHP applications to connect to MCP servers (via STDIO subprocess or HTTP) and interact with server-exposed tools, resources, and prompts.

Important

This PR is currently in draft mode to gather early feedback on the API design and architecture before finalizing. The core client functionality is working and I'm opening this early to ensure alignment with the project's direction before solidifying specific implementation decisions.

Motivation & Context

This addresses several community requests for client-side functionality (#185 and #15). The MCP ecosystem requires both servers (exposing tools, resources, prompts) and clients (consuming them). While the SDK has robust server support, this PR adds the complementary client-side implementation.

This will fix #185

What's Changed

Core Client Components

  • Client — High-level API for connecting to servers, initializing sessions, calling tools, listing resources/prompts, and handling real-time notifications
  • Builder — Fluent builder pattern for constructing Client instances with timeouts, capabilities, handlers, and logger
  • Protocol — Central message dispatcher handling JSON-RPC request/response routing, server notifications, and server-initiated requests (sampling)
  • Configuration — Value object holding client settings (timeouts, capabilities, retries)

Transports

  • StdioClientTransport — Spawns a subprocess and communicates via stdin/stdout (ideal for local MCP servers)
  • HttpClientTransport — Connects to HTTP-based MCP servers with SSE streaming support.
  • ClientTransportInterface — Contract for custom transport implementations

Handler Registration Approach

Unlike the server, which auto-registers internal handlers for all standard notifications/requests (allowing user overrides with precedence), the client takes a more explicit opt-in approach:

  • Auto-registered: Only ProgressNotificationHandler is internal — it stores progress data so the transport's tick loop can pull and execute user callbacks
  • Opt-in: LoggingNotificationHandler and SamplingRequestHandler must be explicitly registered by the user

This is intentional as logging and sampling are capabilities the client not only chooses to support, but has to provide a mechanism on how to handle them, so handlers are registered when needed. To reduce boilerplate, I created convenience handlers that accept typed callbacks:

// Instead of implementing NotificationHandlerInterface manually:
->addNotificationHandler(new LoggingNotificationHandler(function (LoggingMessageNotification $n) {
    echo "[{$n->level->value}] {$n->data}\n";
}))

->addRequestHandler(new SamplingRequestHandler(function (CreateSamplingMessageRequest $req): CreateSamplingMessageResult {
    return $this->callMyLLM($req); // User implements their LLM integration
}))

Regardless, users can still register their own custom handlers as they choose just like the server component

Session Management

  • ClientSession — In-memory session tracking pending requests, responses, and progress data
  • ClientSessionInterface — Contract for session storage

Exceptions

  • TimeoutException — Thrown when requests exceed configured timeout
  • RequestException — Thrown when server returns an error response
  • ConnectionException — Thrown when transport connection fails
  • SamplingException - Can be thrown by user callbacks in SamplingRequestHandler to return custom error messages to the server. This exception's message is surfaced in the JSON-RPC error response, while other unexpected exceptions are caught and logged internally with a generic error message to avoid leaking implementation details.

Bug Fixes

  • Fixed CreateSamplingMessageRequest::fromParams() to properly hydrate raw JSON arrays into SamplingMessage objects

Examples Reorganization

  • Moved server examples to examples/server/
  • Added client examples in examples/client/:
    • stdio_client_communication.php — Demo with logging, progress, and sampling
    • http_client_communication.php — Same demo over HTTP with SSE streaming
    • stdio_discovery_calculator.php — Tool discovery example
    • http_discovery_calculator.php — Same over HTTP

Why client examples are separated by transport: Unlike server examples (which auto-detect transport based on runtime context), client examples are inherently transport-specific — STDIO requires specifying the command and arguments to spawn, while HTTP requires an endpoint URL. Keeping them separate makes the examples cleaner and easier to follow (for now at least)

Request for Comments

I'd appreciate feedback on a few design decisions:

1. Transport Naming Convention

Currently:

  • Server: StdioTransport, StreamableHttpTransport (namespace Mcp\Server\Transport)
  • Client: StdioClientTransport, HttpClientTransport (namespace Mcp\Client\Transport)

Options:

  1. Current approach: Class names include context (StdioClientTransport and StdioServerTransport vs StdioTransport) — explicit but verbose
  2. Namespace-only: Both use the same class names (StdioTransport), differentiated only by namespace — cleaner but requires careful imports

What naming convention would be preferred?

2. Handler Interface Sharing

I originally intended to have shared handler interfaces at Mcp\Handler (see src/Handler/RequestHandlerInterface.php and src/Handler/NotificationHandlerInterface.php) that both client and server would use. The client uses them, but server handlers need a SessionInterfaceparameter that the shared interface doesn't include.

What's the best approach here?

  • Keep separate interfaces - Server keeps handle($request, $session), client uses interface with handle($request) but moved to src/Client/Handler. Two different RequestHandlerInterface definitions.
  • Unified interface with context object - Create either HandlerContext object that wraps optional parameters (session, etc.), or a simple array $context. Both sides use handle($request, $context) where context contents differ between client/server (I'm leaning towards this, but with an array context, though it'll be a breaking change)
  • Other suggestions? - Open to alternative patterns for sharing handler contracts while accommodating different parameter needs.

3. STDIO Implementation: proc_open vs symfony/process

StdioClientTransport uses native proc_open() with pipes for subprocess management. I was tempted to pull in symfony/process as a more mature solution, but since the server's StdioTransport also uses native functions, I kept it consistent.

Should we consider adopting symfony/process for more robust process handling esp. for weird cases (Windows and co.), or stick with native functions to minimize dependencies?

4. ClientSession Role

ClientSession currently acts as in-memory storage for:

  • Pending request tracking (request ID → timestamp/timeout)
  • Response buffering before fiber resumption
  • Progress data consumption

Unlike server sessions, it's not persisted. It exists only for the lifetime of a connection, which works well. Should this remain a simple in-memory store, or is there a use case for persistent client sessions?

Looking forward to feedback on the overall direction.

@chr-hertel
Copy link
Member

i like this very much and can follow your reason about opting into features unlike the server side 👍


  1. What naming convention would be preferred?
    => I usually prefer to go short and take the namespace into account => StdioTransport

  2. Handler Interface Sharing
    => First I thought, that'd be nice, but what is actually the benefit of sharing those interfaces? would we be able to reuse implementations on client- and server-side? if it's just for the sake of symmetric design, i would rather not do it
    => anyhow, you asked for other suggestions, here you go - just dropping them:

    • make session part of the request
    • inject session into handlers in a different way - don't use the method arguments
    • how does the client handle session? why is it injected there via constructor (which i def favor over method arguments)
  3. I'd favor symfony/process tbh - less to care about and it doesn't drag in more dependencies

  4. let's go incremental here - totally fine to start with in-memory first I'd say

@chr-hertel
Copy link
Member

can we please pull out the move of the current examples into the server subfolder to a separate PR?
would clean up the diff here a bit and make it easier.

Nyholm added a commit to Nyholm/php-sdk that referenced this pull request Dec 27, 2025
This will prepare for the client implementation in modelcontextprotocol#192
chr-hertel pushed a commit that referenced this pull request Dec 27, 2025
* move examples to server directory

This will prepare for the client implementation in #192

* fix move bootstrap too
@CodeWithKyrian
Copy link
Contributor Author

can we please pull out the move of the current examples into the server subfolder to a separate PR?

would clean up the diff here a bit and make it easier.

Done, thanks to @Nyholm

@CodeWithKyrian
Copy link
Contributor Author

Thanks for the feedback @chr-hertel

  • Transport naming: Done

  • Handler interface sharing: My thought was mostly symmetry since Request/Notification classes are shared Schema. But the handlers on each side handle different methods per spec, and the server genuinely needs session context. Given that, separate interfaces makes more sense so I've kept them distinct now

…ts directly, adding a `SamplingException` and error logging for sampling requests.
CodeWithKyrian and others added 7 commits December 28, 2025 17:48
…d move response result deserialization to client methods.
The processFiber that executes per tick while fiber is suspended already handles timeouts while waiting
Co-authored-by: Christopher Hertel <[email protected]>
Co-authored-by: Christopher Hertel <[email protected]>
…nsport send method to directly dispatch messages.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Client] Implement PHP MCP Client Component in php-sdk

3 participants