MCP client core — JSON-RPC codec, stdio transport, tool narrowing
A Model Context Protocol client core the whole ecosystem can share: JSON-RPC 2.0 envelopes, a newline-framed stdio transport with a bounded, orphan-proof child lifecycle, the initialize/tools handshake, and deny-before-allow tool narrowing. Extracted from sugar-crush's src/MCP stack and McpMessage codec, so every surface that talks to an MCP server rides the same framing, the same timeouts, and the same error shapes. First-party — MCP is a spec, not a Go port.
composer require sugarcraft/sugar-mcp:@dev
use SugarCraft\Mcp\StdioMcpServer;
$server = new StdioMcpServer(
name: 'docs',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-fetch'],
);
$server->start(); // initialize → initialized → tools/list, one 60s clock
foreach ($server->listTools() as $tool) {
echo $tool->name, ' — ', $tool->description, PHP_EOL;
}
$result = $server->callTool('fetch', ['url' => 'https://example.com']);
// ['content' => [['type' => 'text', 'text' => '…']]] (scalars wrapped at the boundary)
$server->stop(); // close pipes first, then TERM→KILL→reap. Never an orphan.
// __destruct calls stop() for you if you forget.
| Class | Method | Description |
|---|---|---|
| McpMessage | parse(string $json): ?self | Decode a wire frame; null for anything that is not a valid JSON-RPC 2.0 message |
| McpMessage | request(string $id, string $method, ?array $params) / notification(string $method, ?array $params): self | Outbound request and notification envelopes |
| McpMessage | success(string $id, mixed $result) / error(string $id, int $code, string $message, mixed $data = null): self | Outbound response envelopes; result is mixed by law |
| McpMessage | toJson(): string / isRequest() / isResponse() / isNotification() / isError(): bool | Wire serialization + role predicates; toJson() throws InvalidArgumentException for an envelope JSON cannot carry (INF/NAN, invalid UTF-8) instead of emitting a blank frame |
| StdioMcpServer | start(): void / stop(): void / isUp(): bool | Handshake under one deadline; every leg must succeed — an initialize or tools/list error or missing reply, or an undelivered notifications/initialized, reaps the child and throws RuntimeException. Close-pipes-first teardown with TERM→KILL→reap via candy-core BoundedShutdown |
| StdioMcpServer | listTools(): list<McpTool> / callTool(string $name, array $arguments): array | tools/list result; tools/call with scalar-result wrapping at the boundary, no wall-clock deadline but a liveness probe on idle polls (a dead server ends the call within about a second, even when a forked helper holds its pipes); an unencodable argument becomes an {"error": …} payload |
| StdioMcpServer | request(string $method, ?array $params = null, ?float $deadline = null): ?McpMessage / notify(string $method, ?array $params = null, ?float $deadline = null): bool | Escapeway to any other protocol method (resources, prompts) without subclasses; notify() reports whether the whole line went out |
| McpTool | fromArray(array $def, string $serverName): self / tryFromArray(?array $def, string $serverName): ?self | Parse a tool definition; tryFromArray gates well-typedness before construction |
| McpRouter | resolveAllowedTools(array $allowList): array / resolveAllowedServers(array $allowList): list<string> | Deny-first narrowing; empty allowlist = allow-all |
| McpRouter | static serverAllowed(string $name, array $allowList): bool | Exact or fnmatch-glob membership; malformed entries throw |
| ExchangeLock | static new(string $label, ?string $dir = null): self / static sweepStale(?string $dir = null): int | Cross-process flock for one stdio connection shared across pcntl_fork(); new() sweeps lock files whose owner died, then creates this owner's file. create() remains as a deprecated alias of new() |
| ExchangeLock | acquire(?float $deadline, \Closure $serverAlive): bool / load(): array / store(string $phase, string $buffer): bool / markPhase(string $phase): bool | Phase byte + carried read buffer under the lock; store()/markPhase() return false when the write did not land, and an empty file loads as dirty |