sugarcraft / sugar-mcp-client
MCP client core — JSON-RPC 2.0 codec, newline-delimited stdio transport and initialize/tools protocol layer. Extracted from candy-code.
Requires
- php: ^8.3
- sugarcraft/sugar-core: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:02:17 UTC
README
Formerly published as
sugarcraft/sugar-mcp(renamed 2026-10). Namespace changed:SugarCraft\Mcp\→SugarCraft\McpClient\.
MCP (Model Context Protocol) client core for PHP: a JSON-RPC 2.0 envelope codec, a newline-delimited JSON (NDJSON) stdio transport with a fully bounded lifecycle, the initialize/tools protocol layer, and policy-free allow/deny routing.
Here "client" means the tool-CALLING side: this library is what an AI coding agent or editor embeds to spawn and talk to an MCP SERVER process. You run it inside your application; the server is the child it launches.
Requires PHP >= 8.3. Speaks protocol revision 2024-11-05 and refuses a
server that answers the handshake claiming a different revision (a legacy
server that omits the field is allowed to proceed).
Extracted from the candy-code MCP stack, where these classes carry its entire stdio server lifecycle. The wire algorithms are faithful ports; everything product-specific stayed downstream.
Install
composer require sugarcraft/sugar-mcp-client
What ships
| Class | Role |
|---|---|
McpMessage |
Immutable JSON-RPC 2.0 envelope — request / notification / success / error — with a null-preserving result sentinel |
McpTool |
Tool definition value object with a well-typedness gate (tryFromArray) |
ArgumentShape |
Restores {} vs [] in decoded tool-call arguments against the tool's inputSchema |
McpServer |
Transport contract: start / stop / listTools / callTool |
StdioMcpServer |
Child-process stdio transport: argv-array spawn, explicit spawn directory (cwd, default inherit), NDJSON framing, one-clock handshake deadline, tail-bounded stderr capture, BoundedShutdown teardown, __destruct orphan-proofing |
McpRouter |
Deny-before-allow tool/server narrowing over raw config keys; empty allow-list means allow-all |
RequestIdSequence |
JSON-RPC ids unique across pcntl_fork(): plain counters in the owner, <pid>-<nonce>-<n> elsewhere |
ExchangeLock |
Cross-process flock for one connection, carrying the shared read buffer and the dead-holder phase marker |
Quick start
use SugarCraft\McpClient\StdioMcpServer; $server = new StdioMcpServer( name: 'everything', command: 'npx', args: ['-y', '@modelcontextprotocol/server-everything'], ); $server->start(); // initialize → notifications/initialized → tools/list foreach ($server->listTools() as $tool) { echo $tool->name, ': ', $tool->description, PHP_EOL; } $result = $server->callTool('echo', ['message' => 'hi']); $server->stop(); // also runs automatically on destruct
callTool() answers with the tool's result envelope — normally
['content' => [['type' => 'text', 'text' => ...], ...], 'isError' => bool]
as the server shaped it (a non-table result is wrapped into a one-item
content list for you) — or, on failure, ['error' => 'why']. It never
throws for a call that merely failed; see Failure shapes.
Trust boundary
A stdio server entry is arbitrary code execution as the user running this
library. The config you hand a StdioMcpServer names a command and
arguments; start() spawns them, and the child speaks whatever protocol it
likes back into your process. Treat every server you connect to the way you
treat a shell command you paste from the internet — the quick start above runs
npx -y @modelcontextprotocol/server-everything, which downloads and executes
third-party code on first use.
The child inherits the FULL parent environment by default. With env: []
(nothing passed) the spawn carries the entire ambient environment: SSH agent
socket paths, cloud-provider credentials, API keys — everything the invoking
process has, not just what the server needs. PATH is resolved at exec time
and never verified, so the binary that runs is simply the first match.
env: is additive and can never scrub. Entries you pass override or add
names; a name you do not mention still rides through from the parent, exactly
as it does with no map at all (StdioMcpServerTrustBoundaryTest pins both
halves of that contract). The option is for telling a server where its own
token lives, not for withholding the rest of your environment.
To narrow, wrap spawnPlanner. The planner sees the computed argv and env
and returns the pair that actually spawns — a curated key/value map there is
the full replacement, not a merge. This is exactly what candy-code does: it
ships credential-shaped-name scrubbing on top of this library
(ProcessContainment) because the ambient-secrets hazard is real in
production. That remediation is deliberately downstream product policy here —
this library has no list of which names are secrets, and any list it shipped
would be wrong for someone's environment.
The planner is a callable(string $name, array $argv, array $env): array
returning [$argv, $env]; throwing from it refuses the launch:
$curated = ['PATH' => '/usr/local/bin:/usr/bin:/bin']; $server = new StdioMcpServer( name: 'narrow', command: $binary, args: $argv, spawnPlanner: static fn (string $name, array $argv, array $env): array => [$argv, $curated], // full replacement env — nothing ambient rides );
cwd: pins the spawn directory (see Spawn directory), and lockDir: pins
where the ExchangeLock file lives — useful when TMPDIR moves or a janitor
sweeps it; the directory must exist, and a lock that cannot be created there
fails start() loudly rather than silently sharing less.
Spawn directory
The child's working directory is the embedder's decision, not an accident of
where the parent happened to be standing: pass cwd: to start the server in a
specific absolute directory (new StdioMcpServer(name: …, cwd: $home)), and
the default null keeps the historic inherited spawn byte-identical. The move
is proc_open's own cwd argument, so it lands on the child at exec time and
the parent's directory is never touched — and it composes with a spawn
planner: the planner decides what runs, cwd decides where. candy-code
uses the seam to run its user-tier MCP servers in the home directory and its
project-tier servers at the project root rather than inheriting whatever
checkout the launch stood in.
Timeout discipline
- The handshake runs under one monotonic (
hrtime) deadline (DEFAULT_START_TIMEOUT_SECONDS, 60s, sized for coldnpxfetches) shared acrossinitialize,notifications/initializedandtools/list. A slow start is not a silence:start(onWait: $beat)gets the same liveness beat every poll slice while the handshake runs. - Every handshake leg must succeed, or
start()stops the child and throws aRuntimeException(with any captured stderr) rather than reporting "up, 0 tools": aninitializeortools/listreply carryingerrornames the server's error code and message; a leg that gets no reply, or anotifications/initializedthat cannot be delivered, names that leg. A server claiming a differentprotocolVersionthan this client speaks refuses the start too; one that omits the field proceeds. callTool()carries no deadline by default: a tool call is somebody's real work and is bounded by child liveness, never by a wall-clock kill of in-flight work. Liveness is checked whenever a poll leaves the pipe idle — at most once a second, and also while stderr alone is chatty — on both the read and the write wait, so a server that dies while a forked helper still holds its stdin/stdout open ends the call within about a second instead of waiting for the helper to exit, even when that helper keeps logging to the inherited stderr. Whatever the server wrote before dying is still drained and read. The bound holds from apcntl_fork()ed caller as well: a server the owner has not reaped yet is a zombie, which signal 0 reports as alive, so where/procexists the probe reads/proc/<pid>/statand treats a zombie, or a pid whose start time no longer matches the server's (a reused pid), as dead. Without/proc(macOS, BSD) a forked caller falls back to signal 0 and to pipe EOF.- A caller that DOES want a bound opts in per call:
callTool(name: $name, arguments: $args, timeoutSeconds: 30.0)abandons the wait at the deadline (time queued for the exchange lock counts), sendsnotifications/cancellednaming the request, and returns{"error": "Tool call timed out after 30s and was cancelled; the server may still finish the work"}; the connection stays usable and the late reply is skipped by id. Null, zero, a negative or a non-finite value leaves the call unbounded. callTool(..., onWait: $beat)calls$beatat least once a second for as long as the call waits, so an embedder whose own watchdog measures silence can keep a long unbounded call visibly alive. Throttle it yourself; it may fire far more often.- Every socket wait is bounded by
stream_selectpolls; stderr is absorbed on both wait sets so a full 64KiB kernel pipe can never deadlock the child. Aselectthat keeps failing (an EINTR storm) is counted on both the read and the write side and ends the wait with a named cause. request()andnotify()take a$deadlineas an absolute monotonic instant — the samehrtimebasis asStdioMcpServer::monotonicNow()— not a duration and not wall clock. PassmonotonicNow() + 30.0for "30 seconds"; a past instant gives up silently (null/false), and a wall-clock-magnitude value would never fire, so the magnitude is refused with anInvalidArgumentExceptioninstead.callTool(timeoutSeconds:)remains the relative-duration spelling.
Failure shapes
What you get when things go wrong, so you can branch on it knowingly:
start()throws — the connection either comes up with a tool roster or does not come up. The exception names the leg and the cause:Failed to start MCP server: <name> — could not execute <command> (<os cause>)when the spawn itself fails (a missingnpxsays so, with the OS-level reason);… — initialize refused (code): <server message>/ the same fortools/list, plus the stderr tail when the server wrote one;… — protocol mismatch: this client speaks X, the server answered Y;- a
tools/listpage that is not shaped like a tool list — a non-arraytools, or a definition malformed enough that the name cannot be trusted — SKIPS and COUNTS rather than axing the boot: the poisoned shapes never enter the roster, the server's well-formed definitions do, andmalformedToolDefinitionsReport()announces the count and first names for whoever launches the server to surface (a legitimately EMPTYtools: []stays legal). Server-level failures — an error reply, no reply, lying pagination — still throw by name and cause.
callTool()answers, never throws: a failed call comes back as['error' => ...]naming its CAUSE — the server's own JSON-RPC code and message when it refused (Tool call failed — tools/call refused (-32602): ...), or the reader's reason when no valid answer arrived (the server is not started,the server process exited,the request could not be written to the server,the server refused an unparseable frame (error code -32700),no reply arrived before the wait ended, the bounded-timeout text above). Frames a JSON-RPC 2.0 server must not send — bothresultanderrorin one reply, or neither — are reported, never silently taken as success. The captured stderr tail rides along in the message;stderrTail()exposes it on its own and survivesstop(), so a post-mortem still has the evidence.- A server-request handler that throws or answers malformed never crashes the
caller: the in-flight
callTool()ends through its error payload, naming the handler class and message — never a mislabelled encoding failure and never an escaped exception; the server's own request stays unanswered on that leg. - A server that floods the pipe with frames no exchange can attribute (junk, id-less pushes, endless blank keep-alives) is refused after a bounded number of them instead of holding a deadline-less call forever.
request()returnsnullandnotify()returnsfalsewhen the write or the wait failed (not started, deadline passed, lock lost); neither throws for those. Both letMcpMessage::toJson()'sInvalidArgumentExceptionthrough for an unencodable envelope, with nothing written.- Diagnostic strings embed SERVER-authored bytes after a control-character
sweep (no ESC/OSC/NUL survives), but they are still UNTRUSTED text: render
them through
SugarCraft\Core\Sanitize::untrusted()before they reach a terminal, never raw.
Routing policy
McpRouter narrows which servers and tools a caller may see, before any
transport runs. It holds a name-keyed map of already-built servers and takes
deny patterns as config — either a plain list of glob patterns (all deny) or
a pattern => action map; an action other than the exact string "deny" is
ignored, and a malformed spec throws at construction:
use SugarCraft\McpClient\{McpRouter, StdioMcpServer}; $servers = ['fs' => $fsServer, 'scratch' => $scratchServer]; $router = new McpRouter($servers, denyPatterns: ['scratch', 'untrusted_*']); $visible = $router->resolveAllowedServers(); // deny applies first $fsTools = $router->resolveAllowedTools('fs', ['read']);// per-server allow-list
The two membership laws are public statics so downstream gates read exactly
this doctrine: McpRouter::serverDenied($name, $denyPatterns) and
McpRouter::serverAllowed($name, $allowList). Deny runs before allow; an
empty allow-list means allow-all (distinct from "restrict to nothing"); a
non-string or empty allow entry throws. Note the deliberate asymmetry: deny
patterns are fnmatch-matched whole (broad, because a missed denial is a
security hole), while an allow entry only globs when it contains *
(strict, because a mis-globbed allowance is worse than a missed one).
Failure reporting
McpMessage::toJson()throwsInvalidArgumentExceptionfor an envelope JSON cannot carry (INF/NAN — a model's1e999decodes toINF— or invalid UTF-8) instead of emitting a blank line no server would answer.request()andnotify()let it through, with nothing written;callTool()turns it into an{"error": ...}payload, per theMcpServercontract.notify()returnsbool: whether the whole line went out.- A spawn planner must return its command as a non-empty argv list of
strings; a shell string would make the server a grandchild of
/bin/shthatstop()cannot reach, so it is refused up front. ExchangeLock::store()/markPhase()returnfalsewhen a state write fails (full or read-only temp filesystem); the exchange then fails rather than running without its recovery marker, and a failed store leaves the file empty, which reads as dirty. The lock's factory isExchangeLock::new()(create()remains as a deprecated alias); its file is opened at mode0600, verified before any payload lands, and a filesystem that refuses the mode fails the open loudly.callTool()/callTool(name:, arguments:): the concrete parameter names match theMcpServerinterface, so named-argument calls written against the contract bind here. The three trailing opt-ins (onWait:,timeoutSeconds:,onServerMessage:) exist onStdioMcpServeronly — code typed against the interface cannot pass them.
Wire shape
MCP params, capabilities and tool arguments are JSON objects, and the
official TypeScript and Python SDK servers reject a JSON array in their place.
PHP's [] encodes as an array, so McpMessage::toJson() emits empty params
as {}, initialize sends "capabilities":{}, an argument-less callTool()
sends "arguments":{}, and tools/list omits params entirely (as the
reference TS client does). The capabilities: constructor argument configures
what that object HOLDS — a configured map rides through the same object cast,
so ['roots' => ['listChanged' => false]] arrives as a populated JSON object
and the default still lands as {} byte-for-byte. Advertise what you will
answer: a capability-gating server asks roots/list or sends
elicitation/create only of clients that declared the capability, so the
handler below section cannot fire without it.
Tool arguments are shaped one level further: a model's {"filter":{}}
decodes (assoc) to ['filter' => []], so callTool() walks the arguments
against the tool's advertised inputSchema (ArgumentShape::conform()) and
sends every empty array the schema types as an object — through nested
properties, additionalProperties/patternProperties maps, array items,
anyOf/oneOf/allOf, ["object","null"] unions and local $refs — as
{}. An empty array the schema types as an array, or whose position the
schema does not type, stays []; a \stdClass you pass is kept as-is. A
nested empty map inside your own request() params still has to be spelled
new \stdClass().
Server requests
MCP is bidirectional: a server may send requests of its own (ping,
roots/list, elicitation/create, sampling/createMessage) and
notifications (notifications/progress) while a call is in flight.
readResponse() answers them inline instead of skipping them: pass
onServerMessage: to the constructor for a connection-wide answerer, or to
callTool()/request() for one exchange. The handler is called as
($method, $params, $isNotification); for a request it returns
['result' => ...], ['error' => ['code' => int, 'message' => string]],
or null for the built-in default — ping is acknowledged with {},
anything else is refused "Method not found" (-32601). Replies preserve the
request id's wire TYPE (an int id comes back an int, never a string);
notifications write nothing back. Two consequences worth knowing:
- answers are written under the exchange lock, so a handler that waits (an elicitation held open for a human) holds the lock — every other call to that server queues behind it. One pipe, one conversation; an answerer must never call back into the same server instance.
- the wall clock a request handler spends is paused out of a bounded
call's
timeoutSecondsbudget — waiting on an answer this client owes is not the server being slow.sampling/createMessageships no answer beyond the default refusal: running a model in the client's name is product policy, downstream.
Fork safety
A started StdioMcpServer may be used from pcntl_fork()ed children of the
process that started it — one server shared by the whole process tree:
- ids are process-unique (
RequestIdSequence), so a reply — even the late reply of a SIGKILLed child's call — only ever matches the call that sent it; a forked child that re-opens the connection cannot silently take over the id space while its owner is still live —claim()refuses a foreign claim against a live owner, and the child keeps minting pid-tagged ids. When a foreign process succeeds a claim because the old owner DIED, its counter resumes far ahead of the dead owner's window, so a stale reply still sitting in the pipe can never alias a new call; - every exchange runs under a cross-process
flock(ExchangeLock; each process opens its own handle, since an inherited one shares the lock). Concurrent calls on one server therefore serialise; the lock wait honours a request's deadline and gives up if the server dies; - the flip side of serialisation: a holder that stays alive but hung —
waiting on a deadline-less call to a wedged server — wedges every sharer
for as long as it holds the lock, and
flockcarries no FIFO promise, so a waiter can be starved even after the holder releases. Nothing in this library can bound another process's work; each waiter bounds itself withcallTool(..., timeoutSeconds:)(in candy-code, the per-server"toolTimeout"entry in.mcp.jsonfeeds exactly that knob). Set it if you share a server across forked turns or sub-agents; - stdout bytes read past a response are kept in the lock file, so the next exchange starts at a line boundary whichever process runs it;
- a holder killed mid-exchange leaves a phase marker: the next exchange terminates a half-written request with a leading newline and skips the fragment of a half-read reply until the first parseable message;
- only the starting process stops the server;
stop()/destruct in a child closes that child's handles only. A child checks liveness withposix_kill($pid, 0)(it cannotwaitpida sibling's child). If the OWNER is SIGKILLed while a forked child still holds the connection, nothing here reaps the server child — that cleanup is the embedding process manager's job (candy-code runs it through its own lifecycle supervisor). - the lock file's name records its owner
(
sugar-mcp-client-lock-<pid-namespace>-<pid>-<random>). An owner that is killed before it stops its server leaves the file behind, so every new connection first removes the files whose owner pid is gone in this pid namespace and whoseflocknobody holds (ExchangeLock::sweepStale()). Files of live owners, of other pid namespaces, and pre-existingtempnam()-named files are left alone.
stderr is not locked — it is diagnostics only.
A note on
LspExchangeLock. candy-code's LSP transport keeps its own close-looking twin ofExchangeLock(src/LSP/LspExchangeLock.php). That duplication is deliberate, not a missed reuse: the LSP side needs phase state carried as an atomic write-to-temp +rename()(store()), so a holder SIGKILLed mid-store leaves the previous file whole. Neither behaviour is a defect in this library's lock; each file locks what its transport needs.
Deliberate exclusions
- Downstream (product policy, not protocol): contained/detached spawning
(setsid wrapping, PATH pre-checks, curated environments) — wrap
proc_openpolicy at the embedder. - Deferred (not planned in this package): HTTP/SSE transport, server-side
implementation, and OAuth (PKCE, loopback, dynamic client registration).
resources/*andprompts/*traffic is already oneStdioMcpServer::request()away (the product's provider pages ride exactly that), and the server's own request/notification traffic is answered inline — see Server requests above. What is deliberately NOT answered for you issampling/createMessage: model policy stays downstream. - Deprecated surfaces (
ExchangeLock::create()) keep working and announce their replacement in the docblock; removal is a package-major change, announced in the release notes well before it lands.
Tests
cd sugar-mcp-client && composer install && vendor/bin/phpunit
The suite spawns real short-lived PHP child servers over stdio
(tests/Fixtures/) — bounded, reaped, no network access.
License
MIT