milpa / mcp-client
MCP (Model Context Protocol) client for the Milpa PHP framework: stdio and HTTP/SSE transports, JSON-RPC framing, capability negotiation, and tool/resource discovery for connecting to external MCP servers.
Requires
- php: >=8.3
- guzzlehttp/guzzle: ^7.10
- guzzlehttp/psr7: ^2.7
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/log: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.65
- milpa/core: *
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
README
Milpa MCP Client
An MCP (Model Context Protocol) client for PHP — stdio and HTTP/SSE transports, typed tool/resource contracts, and connection management for talking to external MCP servers.
McpClientManagerconnects to several servers at once and routes tool calls to the right one by a namespaced registry name.
milpa/mcp-client is the outbound half of MCP for the Milpa framework: it lets a PHP
process act as an MCP client, connecting to one or more external MCP servers — local
ones spawned as subprocesses (stdio) or remote ones over HTTP with Server-Sent Events —
and discovering, listing, and calling the tools and resources they expose. No product
coupling: HttpSseTransport speaks HTTP through PSR-18 (Psr\Http\Client\ClientInterface),
defaulting to guzzlehttp/guzzle when no client is injected.
Install
composer require milpa/mcp-client
Quick example
Register a server, connect, and call one of its tools. McpClientManager picks the
right TransportInterface from the transport key in the config you pass it:
use Milpa\McpClient\McpClientManager; $manager = new McpClientManager(); $manager->registerServer('calculator', [ 'transport' => 'stdio', 'command' => 'php', 'args' => [__DIR__ . '/mcp-servers/calculator.php'], ]); $manager->connect('calculator');
connect() runs the MCP handshake (initialize → notifications/initialized) and
discovers the server's tools and resources; every tool is indexed under a namespaced
registry name — mcp_{server}_{tool} — so a McpClientManager juggling several
servers never collides on a bare tool name:
foreach ($manager->listAllTools() as $tool) { echo $tool->getRegistryName(), ' — ', $tool->description, "\n"; } // mcp_calculator_add — Add two numbers $result = $manager->callTool('mcp_calculator_add', ['a' => 2, 'b' => 3]); // ['sum' => 5] $manager->disconnectAll();
A remote server over HTTP/SSE looks the same, just with a different transport config:
$manager->registerServer('remote-docs', [ 'transport' => 'http-sse', 'url' => 'https://mcp.example.com/', 'headers' => ['Authorization' => 'Bearer ' . $token], 'timeout' => 30, ]);
connectAll() connects every registered server and returns a [name => Throwable] map
of the ones that failed, instead of throwing on the first bad server.
Bringing your own HTTP client (PSR-18)
McpClientManager::registerServer() always builds its own HttpSseTransport from the
config array, so it always gets the default Guzzle client. To inject your own PSR-18
client — a shared connection pool, a client wrapped with retry/circuit-breaker
middleware, or a test double — construct HttpSseTransport directly and wrap it in a
McpConnection yourself:
use Milpa\McpClient\McpConnection; use Milpa\McpClient\Transports\HttpSseTransport; $transport = new HttpSseTransport( baseUrl: 'https://mcp.example.com/', headers: ['Authorization' => 'Bearer ' . $token], serverName: 'remote-docs', httpClient: $yourPsr18Client, // Psr\Http\Client\ClientInterface; omit for Guzzle requestFactory: $yourPsr17Factory, // Psr\Http\Message\RequestFactoryInterface; optional streamFactory: $yourPsr17Factory, // Psr\Http\Message\StreamFactoryInterface; optional logger: $yourLogger, // Psr\Log\LoggerInterface; optional, see below ); $connection = new McpConnection('remote-docs', $transport); $connection->connect();
$timeout only configures the default Guzzle client — it has no effect once you
inject your own $httpClient, since PSR-18's sendRequest() takes no per-call options.
notify() (fire-and-forget JSON-RPC notifications) never throws — that's the
TransportInterface contract — but a transport failure during notify() is reported to
$logger (at warning level) instead of being silently dropped, when a logger is
injected.
What it is
McpClientManager— the entry point most callers use. Registers servers from plain config arrays, owns theirMcpConnections, and aggregates + routes tool calls across all of them by registry name.McpConnection— one server's live session: handshake, capability discovery,listTools()/getTool()/callTool(),listResources()/readResource(), andrefresh()to re-discover after the server's tool list changes.TransportInterface— the seam a transport implements:connect(),disconnect(),isConnected(),request()(JSON-RPC call, blocks for a reply), andnotify()(JSON-RPC notification, no reply expected).StdioTransport— spawns the server as a subprocess (proc_open) and speaks newline-delimited JSON-RPC over its stdin/stdout.HttpSseTransport— POSTs JSON-RPC through an injectable PSR-18ClientInterface(Guzzle by default) and parses the reply whether the server answers with a plain JSON body or atext/event-streamSSE payload.
- Typed contracts —
McpTool,McpResource, andMcpCapabilitiesare immutable value objects built withfromArray()from the server's raw JSON-RPC responses, not arrays passed around by convention. - A layered exception hierarchy — every exception extends
McpException, which carries the offendingserverNamefor context;McpConnectionException,McpTransportException,McpToolException, andMcpServerException(which also carries the JSON-RPCerrorCodeanderrorData) narrow the failure mode.
Be honest about scope: this package is a client only — it does not implement or host an MCP server. It does not persist tool schemas, cache results, or retry failed calls; those policies belong to the host application.
What's inside
| Namespace | What it provides |
|---|---|
Milpa\McpClient |
McpClientManager, McpConnection |
Milpa\McpClient\Contracts |
TransportInterface, McpTool, McpResource, McpCapabilities, McpException, McpConnectionException, McpServerException, McpToolException, McpTransportException |
Milpa\McpClient\Transports |
StdioTransport, HttpSseTransport |
Every public symbol carries a DocBlock.
Requirements
- PHP ≥ 8.3
guzzlehttp/guzzle^7.10 — the default PSR-18 implementationHttpSseTransportfalls back to when noClientInterfaceis injected (also bringsguzzlehttp/psr7, used as the default PSR-17 factory)psr/http-client,psr/http-factory,psr/http-message,psr/log— the interfacesHttpSseTransport's constructor is typed against
Documentation
Full API reference: getmilpa.github.io/mcp-client — generated straight from the source DocBlocks and dressed with the Milpa design system.
Contributing
Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.
License
Apache-2.0 © Rodrigo Vicente - TeamX Agency.
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.