[Client] Feat: Implement MCP client component #192
+2,784
−70
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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 notificationsBuilder— Fluent builder pattern for constructingClientinstances with timeouts, capabilities, handlers, and loggerProtocol— 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 implementationsHandler 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:
ProgressNotificationHandleris internal — it stores progress data so the transport's tick loop can pull and execute user callbacksLoggingNotificationHandlerandSamplingRequestHandlermust be explicitly registered by the userThis 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:
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 dataClientSessionInterface— Contract for session storageExceptions
TimeoutException— Thrown when requests exceed configured timeoutRequestException— Thrown when server returns an error responseConnectionException— Thrown when transport connection failsBug Fixes
CreateSamplingMessageRequest::fromParams()to properly hydrate raw JSON arrays into SamplingMessage objectsExamples Reorganization
examples/server/examples/client/:stdio_client_communication.php— Demo with logging, progress, and samplinghttp_client_communication.php— Same demo over HTTP with SSE streamingstdio_discovery_calculator.php— Tool discovery examplehttp_discovery_calculator.php— Same over HTTPWhy 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:
StdioTransport,StreamableHttpTransport(namespaceMcp\Server\Transport)StdioClientTransport,HttpClientTransport(namespaceMcp\Client\Transport)Options:
StdioClientTransportandStdioServerTransportvsStdioTransport) — explicit but verboseStdioTransport), differentiated only by namespace — cleaner but requires careful importsWhat naming convention would be preferred?
2. Handler Interface Sharing
I originally intended to have shared handler interfaces at
Mcp\Handler(seesrc/Handler/RequestHandlerInterface.phpandsrc/Handler/NotificationHandlerInterface.php) that both client and server would use. The client uses them, but server handlers need aSessionInterfaceparameter that the shared interface doesn't include.What's the best approach here?
handle($request, $session), client uses interface withhandle($request)but moved tosrc/Client/Handler. Two differentRequestHandlerInterfacedefinitions.HandlerContextobject that wraps optional parameters (session, etc.), or a simplearray $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)3. STDIO Implementation:
proc_openvssymfony/processStdioClientTransportuses nativeproc_open()with pipes for subprocess management. I was tempted to pull insymfony/processas a more mature solution, but since the server'sStdioTransportalso uses native functions, I kept it consistent.Should we consider adopting
symfony/processfor more robust process handling esp. for weird cases (Windows and co.), or stick with native functions to minimize dependencies?4. ClientSession Role
ClientSessioncurrently acts as in-memory storage for: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.