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.

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

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. Happy to adjust the API design based on maintainer preferences before removing the draft status.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant