An MCP Server in PHP: Signed State Is Provenance, Not Single Use
A tool that rolls a service back should ask before it acts. Under the MCP revision dated 2026-07-28, asking works in a way that has no equivalent in the session-based protocol before it: the server does not call out for an answer, it returns the question as its result, the client re-sends the whole call with the answer attached, and the state that ties the second request to the first travels through the client as a signed blob. That is a gift to PHP, because the two things a request/response runtime is worst at — a server-side session and a connection held open — are exactly the two things the revision deleted. It also moves a set of problems from the protocol into your handler, and this article is about three of them, in the order they turned up while reviewing a working demo.
Each one is a real defect found while reviewing a working version of that demo, and each is now pinned by a test:
- a refusal that carried content was acted on, because the code read the answer's content without reading its action;
- a signed answer could be replayed, because a signature proves where an answer came from and not that it is still fresh;
- the replay guard had a race, because checking a record and writing it are two steps and two workers can do both at once.
The code lives at michalwolinski/mcp-server-php-symfony: a Symfony application on PHP 8.6 that exposes an ops catalogue — service status, deployment history, runbooks — over both protocol eras, with 52 tests behind the claims made here. What follows is not a getting-started guide; the Symfony cookbook already covers installing the MCP Bundle, and the sibling post on ETags owns concurrency control between two writers of one resource. This is about what statelessness hands to the client, and therefore to your server.
What the stateless revision actually removed
The 2026-07-28 revision is the largest change the protocol has had, and almost all of it is subtraction. The initialize handshake and notifications/initialized are gone; so is Mcp-Session-Id and the session it named. So is the HTTP GET stream that used to hold a connection open for server-to-client notifications, and with it SSE resumability: a broken response stream now loses the request, and the client re-issues it. Server-initiated requests are gone too — no more calling out for roots, sampling or elicitation mid-call, and on this revision sample() raises a LogicException rather than quietly returning nothing.
What replaces them is a per-request envelope. Every request names the revision it speaks and the capabilities it has, in params._meta, and names its subject in HTTP headers; a new server/discover method doubles as the front door for clients that want to negotiate before their first real call. Notifications move to a single long-lived POST-response stream that a client opts into, list results carry cache hints, and anything the server needs from the client comes back as part of the result.
Statelessness shows up in one more place that is easy to overlook, and it is the same trade. With no session, a list result no longer varies per connection, so a client may cache it — provided the server says for how long. That is what ttlMs and cacheScope are for (SEP-2549), and the SDK adds them to a fixed set of methods: server/discover, the list methods and resources/read. Everything else carries no hints at all — tools/call among them, which is why a tool result measured here has neither field. The two that exist carry different promises: public says any intermediary may reuse the answer, private says it was shaped for this caller. The demo's tool list is identical for everyone, so it declares a minute of public caching; the catalogue read carries the policy default, ttlMs: 0, cacheScope: "private" — "private, do not cache" — because the most specific author wins and a resource that knows better may override it.
For a PHP-FPM application this is not an incremental improvement, it is a change of category. A server that used to need a session store and a connection it could hold became a server that needs neither — every request describes itself, so any worker can answer any request. The whole server configuration is one file:
# config/packages/mcp.yaml
mcp:
servers:
ops:
name: 'acme-ops'
version: '1.0.0'
description: 'Deployment state and runbooks for the platforms team.'
instructions: |
Use this server to answer questions about service health, deployments and runbooks.
Prefer the tools over guessing: every answer here comes from the ops catalogue.
transports:
http: true
stdio: true
http:
path: /mcp
allowed_hosts: ['localhost', '127.0.0.1']
registry:
tools: ['App\Mcp\Tool\']
resources: ['App\Mcp\Resource\']
resource_templates: ['App\Mcp\ResourceTemplate\']
prompts: ['App\Mcp\Prompt\']
# State a multi-round-trip answer carries through the client, signed with this key.
# The TTL is shared with the record of answers already acted on — see services.yaml.
request_state:
key: '%env(MCP_REQUEST_STATE_KEY)%'
ttl: '%ops.request_state_ttl%'
cache:
ttl_ms: 0
methods:
'tools/list': { ttl_ms: 60000, scope: public }
'resources/list': { ttl_ms: 60000, scope: public }
subscriptions:
bus: cache
lifetime: 30.0
Both eras are served on that one endpoint with nothing else to configure, which is worth seeing rather than believing. Measured against the running demo:
| Request | Answer |
|---|---|
server/discover, modern envelope only | 200, supportedVersions: ["2026-07-28"], resultType: "complete", no session issued |
tools/list, modern envelope only | 200, ttlMs: 60000, cacheScope: "public" from the config above |
tools/call, modern envelope only | 200, content and structuredContent side by side |
initialize, the old handshake | 200, negotiated 2025-11-25, session issued |
| a malformed JSON body | 400 -32022 Unsupported protocol version — not "parse error" |

That last row is a trap worth knowing before you debug your first client, and it is not the error it appears to be. A body the classifier cannot read is routed to the handshake leg, where the modern version header is not a revision that leg knows, so you are told the protocol version is unsupported while the actual problem is three characters of broken JSON. The verdict comes from the transport's request classifier, not from the taxonomy in the spec: the codes are listed under error codes, and -32022 means "unsupported protocol version" to everything downstream of that classifier.
A separate and genuinely useful PHP 8.6 change lives one layer in, and it is worth keeping apart from the above. json_decode now reports where the JSON broke — Syntax error near location 1:10, where 8.5 says only Syntax error. The demo reads its ops catalogue from data/ops.json and turns a malformed fixture into an error naming the file and the offset, which is the difference between a five-minute fix and a bisect. It does nothing for a malformed request body: that one never reaches json_decode in application code, because the classifier rejects it first.
Versions matter for everything below, so they are pinned rather than assumed: PHP 8.6.0RC3 (the branch is an RC; the image tag is exact), symfony/framework-bundle 8.2.x-dev, mcp/sdk 0.8.1 and symfony/mcp-bundle 0.14.1. The SDK is experimental before 1.0 and neither it nor the bundle is tested against PHP 8.6 or Symfony 8.2 — this demo is ahead of both, which is a statement about the demo, not about the stack.
The answer travels through the client
Without a session, a server that needs something from the client has nowhere to keep its progress, so the protocol hands the round trip to the client. The mechanism is called a multi round-trip request (SEP-2322): the handler returns an InputRequiredResult carrying the asks it needs, and the client re-sends the original call with the answers. Here is the ask that the demo's rollback tool returns when a caller does not name a version — real output, trimmed to the interesting part:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"inputRequests": {
"version": {
"method": "elicitation/create",
"params": {
"message": "Which version should checkout roll back to?",
"requestedSchema": {
"type": "object",
"properties": {
"version": {
"type": "string",
"enum": ["2026.10.1"],
"description": "An earlier version that worked."
}
},
"required": ["version"]
}
}
}
},
"requestState": "…opaque, signed…",
"resultType": "input_required"
}
}
The client answers by sending the same call again, carrying an inputResponses map keyed by the ask name and the requestState echoed back byte for byte. Under the stateless revision the client drives that loop, and the server has nothing to count: every retry is a fresh request in a fresh process. The SDK says as much in the one place where it has to own the loop itself, the handshake-era emulation, where the handler "is re-entered with the answers under the keys it asked for" and the SDK counts the re-entries, because a handler that never settles has to be stopped somewhere. Everything above the ask has to be safe to repeat, and side effects belong below it.
Two details of the handler signature decide whether that flow works at all, and both fail in ways that look like a protocol problem rather than a code problem. The first is the schema:
// src/Mcp/Tool/RollbackTool.php
#[McpTool(name: 'rollback_deployment')]
public function rollback(
RequestContext $context,
#[Schema(description: 'Service to roll back, e.g. "checkout".')]
string $service,
#[Schema(description: 'Version to roll back to. Omit and the server asks for it.')]
?string $version = null,
): CallToolResult|InputRequiredResult {
A nullable parameter without a default is still a required parameter. Written as plain ?string $version, the generated input schema came out as "required": ["service", "version"], and a caller who obeyed the description and omitted the version was refused with -32602 Missing required properties: version — before the handler ran, so the ask that was supposed to collect it was unreachable. The = null is what makes the description true.
The second is the order of the parameters, and it is the first trap seen from the other side. RequestContext is injected by type: the SDK keeps one list of injectable types and reads it both to fill the argument and to leave those parameters out of the published inputSchema, so where the context sits does not matter. The optional parameter's position does. PHP will not let an optional parameter be followed by a required one — it promotes the optional one to required — and it does not do that quietly:
Deprecated: Optional parameter $version declared before required parameter $service
is implicitly treated as a required parameter
Declared that way round, the version is mandatory again and the same -32602 returns, this time because of the language rather than the schema. The demo's suite fails on deprecations, so for it that ordering is a red test rather than a surprise in production; a project without failOnDeprecation gets the same schema and a warning nobody reads.
A refusal with content is still a refusal
The first defect in the demo's guard was reading an answer's content without reading its action. An elicitation answer carries both: an action of accept, decline or cancel, and a content map. The SDK validates the action, and then keeps whatever content came with it — only accept is required to carry content, and nothing strips it from a decline. So a client that declines with the field filled in sends this:
{
"inputResponses": {
"version": {
"action": "decline",
"content": { "version": "2026.10.1" }
}
}
}
The first draft of the demo read $answer->content['version'] and acted on it. Measured against the running server, that request answered Rollback of checkout to 2026.10.1 accepted. — the operation ran against a refusal. The fix is one ordering rule with a broad reach: check the action before the content. A refusal is not an empty answer, and the handler ends the call with a result that says so.
// src/Mcp/Tool/RollbackTool.php
$answer = $input?->elicitResult('version');
if (null !== $answer && false === $answer->isAccepted()) {
return new CallToolResult([
new TextContent(sprintf('Rollback of %s cancelled: no version was chosen.', $service)),
]);
}
The same confusion produced a second bug in the same three lines. Treating "no acceptable answer" as "no answer yet" meant a decline or a cancel without content sent the handler back to ask again — for ever, because asking again was exactly what the refusal was answering. A refusal is an answer. It ends the call; only an unreadable answer is a reason to ask a second time — and under this revision nobody is counting the rounds on the server's side, so a handler that keeps asking is a handler that costs a full request on both sides, every round. Both halves of that defect are pinned by tests — aDeclinedAnswerWithContentDoesNotRollBack, aDeclinedAnswerWithoutContentEndsTheCallInsteadOfAskingAgain and aCancelledAnswerEndsTheCall — because both are the kind of bug that a passing happy-path suite hides: the first only shows up when a client declines and fills in the field, and the second only when it declines without one.
Underneath both is a rule from the same revision that is easy to miss when you wire up a client: the server will not ask a question the client has not said it can answer. A call whose _meta declares no capabilities is refused with -32021 The server needs input this client did not declare it can provide and the missing set in data.requiredCapabilities — rather than the server sending an ask that could never come back. That is the protocol being helpful, and it also makes the capability declaration in your test client load-bearing rather than decorative.
A signature is not a once-only ticket
Because the state travels through the client, it is attacker-controlled on the way back. The SDK signs it with an HMAC and a TTL, and a state whose bytes have been touched is refused before your handler sees it: sending a state with its last three characters replaced is answered with -32602 The supplied requestState failed verification. So far, so good — the signature does what a signature does.
What it does not do is make the answer single-use. A signed blob verifies every time it is presented, and a client that retries — after a lost response, through a proxy, behind a load balancer that re-sends a request it never saw answered — presents exactly the same blob. The demo, at the point where the signature was the whole guard, answered three consecutive identical retries like this:
# the same signed state, sent three times
retry #1: Rollback of checkout to 2026.10.1 accepted.
retry #2: Rollback of checkout to 2026.10.1 accepted.
retry #3: Rollback of checkout to 2026.10.1 accepted.
Three executions of a destructive operation from one answer. The lesson generalises past this protocol: a signature establishes provenance, not freshness, and a request that is retried is indistinguishable from a request that is replayed.
Two additions close it. First, the state carries a nonce, and the server keeps a record of the nonces it has already acted on:
// src/Mcp/Tool/RollbackTool.php
$context->mintRequestState([
'service' => $service,
'asked' => 'version',
'nonce' => bin2hex(random_bytes(16)),
]),
Second, the state's payload is read back and checked against the request being served, so an answer minted for one question cannot be reused against another:
// src/Mcp/Tool/RollbackTool.php
$input = $context->getInputContext();
$state = $input?->requestState() ?? [];
if (null !== $input && [] === $state) {
throw new ToolCallException('An answer arrived without the requestState of the ask, so it cannot be attributed to this request.');
}
if (null !== $input && $service !== ($state['service'] ?? null)) {
throw new ToolCallException('This answer belongs to a different rollback request.');
}
Measured, after both additions: an answer with no state is refused with the first message, a valid answer minted for checkout but replayed against postgres-orders is refused with the second, and repeating a used answer answers was already accepted from this answer; nothing was done twice. Those three outcomes are pinned by anAnswerWithoutRequestStateIsRefused, anAnswerMintedForAnotherRequestIsRefused and aReplayedAnswerIsNotActedOnTwice. The record itself lives in a shared cache rather than in the process, and that is not a detail: under PHP-FPM the retry may land on any worker, so a record in one worker's memory is a record the next worker cannot see. The TTL of the record is the same parameter as the TTL of the signature — one number with two consumers, so a state can never outlive the note that says it was used.
One path is deliberately left unguarded. When the caller names a version in the arguments there is no ask, so there is no state and no nonce to consume: that is an ordinary tool call, and repeating it is repeating a request — the same thing that happens when any tool is called twice. The asymmetry is a decision, not an oversight, and it is asserted by a test.
Single-use needs atomicity
The record alone still was not enough, and this is the part that a sequential test suite will never tell you. Checking the record and writing it are two steps, and two workers can interleave them: both read "not used yet" and both act, so the rollback runs twice — and the record afterwards is perfectly correct, which is why nothing will look wrong until someone counts the deployments. The demo runs four PHP workers behind its HTTP server, so the question is easy to ask properly — one state, four parallel retries, repeated over several rounds:
# 8 rounds, 4 parallel retries of one state each
performed the rollback : 13 (should be 8 — one per state)
refused as already used : 19
Five of the eight rounds ran the rollback twice. A check-then-act guard is not a guard under concurrency, and the failure is invisible to any test that sends its retries one after another — which is what the demo's own tests did, and why they passed.

The fix is to make the check and the record one step, with a lock held across both, keyed by the nonce. Symfony's lock component provides it, and the store is configuration rather than code: LOCK_DSN=flock works for one container on one filesystem, and a deployment spread over several hosts moves the same DSN to Redis or PostgreSQL — which is the same constraint the record itself carries, since a PSR-16 cache on the local filesystem is just as per-host as a file lock. The lock is acquired blocking, on purpose, and that sentence is about this section rather than about rollbacks in general: what it protects here is a check, a record and an empty callback, which is microseconds. A duplicate is better served by waiting that long and then hearing what was accepted than by a third state — "in progress" — it would have to understand.
// src/Ops/ConsumedAnswers.php
public function once(string $nonce, string $target, callable $act): ?string
{
// Blocking on purpose: a second worker retrying the same answer should wait the few
// microseconds this section takes and then be told what was accepted, rather than being
// handed a third state ("in progress") it would have to handle.
$lock = $this->lock($nonce);
$lock->acquire(true);
try {
$accepted = $this->acceptedTarget($nonce);
if (null !== $accepted) {
return $accepted;
}
$act();
$this->remember($nonce, $target);
return null;
} finally {
$lock->release();
}
}
The shape of that method is deliberate. The caller brings the work and the version it was accepted for; the method writes the record itself, so there is no way to run the work and forget to record it — the previous signature took a callback and trusted the caller to call remember() inside it, and a caller that did not left the guard quietly inert. The work itself runs inside the lock — the note and the operation cannot be separated by a second worker — and that is the right shape for as long as the operation is short.
It is the wrong shape for a real rollback, and the difference is worth stating plainly. A deployment rollback takes seconds or minutes. With the lock held blocking across it, every duplicate waits that long while the client, or the proxy in front of it, gives up first — and if the store expires its keys, the lock's TTL can run out mid-operation, which is worse than the wait: the next retry acquires a lock it should not have, finds no record because the record is written after success, and runs the rollback a second time. The race this section closed reopens at the first honest $act, and the demo's own probe cannot see it, because an empty callback finishes before anything can expire.
Two designs avoid it, and which is right depends on whether the operation can be interrupted. Keep the lock around the record only, and let the record say in progress before the work starts and done once it succeeds — then a duplicate gets an immediate answer instead of a wait, which is the only shape that survives a client timeout, and that answer can carry a handle the caller polls. Or keep the operation inside the lock and refresh the lock while it runs, with lockTtl above the worst case and the HTTP timeouts above that; the wait stays the duplicate's price, and a client that times out has lost the answer to a rollback that nonetheless succeeded. The demo does not have to choose, because its callback is empty; a server with a real deployment behind it does.
Measured after the change, on the same probe: eight rounds of four parallel retries produced eight executions, one per state, and all 24 duplicates waited and were told what had been accepted. None of them saw an "in progress" answer, because the wait is short enough that waiting is indistinguishable from arriving second.
That measurement is also why this repository has fewer tests than its density of claims suggests it should, and the reason is worth stating rather than hiding. The waiting cannot be asserted from a single PHP process: a test that takes the same lock and waits for it deadlocks against itself, which is why the check here runs from inside the act. Asserting the wait itself needs a second process — a symfony/process child holding the lock while the parent calls once() and measures how long it blocked — and that test is deliberately absent rather than impossible. What the suite does pin is the invariant the waiting rests on: the nonce is held for the whole operation, checked from inside the act by a second worker attempting a non-blocking acquire (theNonceIsLockedForTheWholeOperation). The concurrency behaviour itself is measured end to end, on the running server, by the probe whose numbers are above.
One more honesty note for whoever moves this to Redis: with flock the lock's TTL is inert. The store says so itself — its putOffExpiration() does nothing, because a file lock is released when the process that holds it dies. That is fine for a check and a record that take microseconds, and it is exactly why the TTL parameter exists: with a store that expires its keys, the TTL is what frees a nonce whose worker was killed mid-section — and, as the paragraph above turns on, what has to outlast the operation whenever the operation is inside the lock. A TTL shorter than the work is a lock that expires while the work it guards is still running.
When this shape is wrong
None of the above is a reason to put every tool behind a lock and a record. The guard is worth building when a tool is asked a question, answered by a human or a model, and then does something that cannot be taken back — and it is largely wasted on anything else.
A tool that cannot be made idempotent and cannot be made single-use is the wrong tool for a round trip. If your operation has no natural "already done" answer — no version to compare, no state to check — then a replayed answer runs it twice, and the only remedy is the one shown here. If you cannot have a shared store and a shared lock, do not use multi round-trip requests to authorise a destructive action; ask for the value in the arguments, where repetition is visibly the caller's choice.
A server that must push data is a different shape. Change notifications now arrive on a long-lived subscriptions/listen stream (SEP-2575), and a stream holds a connection: under PHP-FPM that pins a worker for as long as the client listens. The demo runs the built-in server with four workers because of exactly this — two requests that each sleep a second take 2.03 seconds with one worker and 1.04 with four — and even that is a demo convenience, not a deployment. A process per request cannot serve a stream, so a push-heavy workload wants a runtime whose workers outlive the request (FrankenPHP, RoadRunner, Swoole) or a client that polls instead.
STDIO is not FPM. The same code serves a command-line host — bin/console mcp:server ops speaks the handshake era over stdin and stdout, one process and one server per invocation — and there the constraints invert: no per-request envelope to classify, no workers, no shared store to reach for. The demo's record lives in a cache precisely because HTTP deployments have several workers; a single-process stdio server does not need it.
Older hosts still exist, and they get a different mechanism. The SDK emulates a round trip for handshake-era clients by sending a real elicitation/create request and re-entering the handler when the answer arrives, which means the originating request stays open while a person thinks. That path is not covered by the demo's tests, and it is the one place where "the state travels through the client" is not literally true.
And the stack itself is ahead of its own releases: an RC of PHP, a development branch of Symfony, an SDK that is experimental before 1.0, and a bundle at 0.14. Roots, sampling and MCP logging are deprecated as of this revision (SEP-2577) with removal no earlier than 2027-07-28 — they keep working, but they announce themselves. Pin what you depend on — the image tag, the framework branch, the SDK version — because a composer update on this demo will move it to a commit nobody has tested.
The counter-argument is worth stating as plainly as the advice: if the only consumer of these tools is your own application, an MCP server is an extra protocol between two things you already control. A documented HTTP API with an OpenAPI description gives you the same functions without revision negotiation, capability declarations, elicitation semantics or signed state. MCP earns its keep when one client wants a uniform surface across many servers it did not write — which is a different problem from exposing your own database to your own code.
What to check in your own server
- Read the action before the content. An answer of
declinethat carries fields is still a refusal, and treating "no acceptable answer" as "no answer" asks a question the user has already answered. - Make the guard own the record. One method that takes the nonce, the value and the work, and writes the record itself; a guard that trusts the caller to record is not a guard.
- Put the record where the retry will land. A shared cache, not process memory, when more than one worker can serve the call. Align its TTL with the state's.
- Make check and record one step. A lock per answer, with a store the whole deployment shares, or the guard has a race that sequential tests will not find — and size the lock to the operation. Hold it across the work only while the work is short; a lock held for minutes is a lock that expires mid-flight and lets the race back in.
- Give optional tool parameters a default, or the generated schema marks them required and the handler that was supposed to ask for them never runs.
- Record the outcome after the operation, not before. Writing "done" ahead of the work lets a failed attempt block an honest retry — and that is what forces an in progress state the moment the operation outlasts the lock's TTL or the client's timeout. A duplicate that cannot wait has to be given an answer.
- Send the headers the revision requires. A subject-addressing call without
Mcp-Nameis refused with-32020, and the header must match the body value — forresources/readthat is the URI, not the resource's name. The set is in the transports section (SEP-2243).
None of the three defects needed a clever fix, and they did not have the same cause. Two of them needed the specification read closely — an answer's action decides what the answer means, and a handler is entered again from the top, so everything above the ask is re-run. The third needed no specification at all: checking a record and writing it are two operations, which is a fact about concurrency rather than about MCP. Underneath all three is the same habit — trusting the sequence the code was written for, and never asking what happens when the same request arrives twice at the same moment.