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.

Co-authored-by: Christopher Hertel <[email protected]>
@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

php -S localhost:8080 examples/http-discovery-userprofile/server.php

# Then run the client
php examples/client/http_discovery_userprofile.php
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
php examples/client/http_discovery_userprofile.php
php examples/client/http_discovery_calculator.php


```bash
# First, start an HTTP server
php -S localhost:8080 examples/http-discovery-userprofile/server.php
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

client example expects port 8000 and server example moved

Suggested change
php -S localhost:8080 examples/http-discovery-userprofile/server.php
php -S localhost:8000 examples/server/discovery-calculator/server.php

not sure which one you wanted:

Suggested change
php -S localhost:8080 examples/http-discovery-userprofile/server.php
php -S localhost:8000 examples/server/discovery-userprofile/server.php

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.

2 participants