Skip to content

Commit 5e73683

Browse files
authored
[Server] Close the conformance gaps in the modern lifecycle (#447)
* [Server] Let the client gateway see modern-era capabilities The six supports*() probes read session state only InitializeHandler wrote, so every request served statelessly reported no client capabilities at all — a tool guarding on them took the unsupported branch without a signal. StatelessProtocol now writes the request's declaration under the same keys. * [Server] Reject the RPCs 2026-07-28 removed resources/subscribe and resources/unsubscribe were still dispatched and answered 200 OK, recording subscriptions the modern era never reads. They join initialize, ping and logging/setLevel in the era guard; the handlers stay registered because the handshake era still serves them. * [Server] Decode a wrapped Mcp-Name before comparing it The Base64 sentinel was only unwrapped for Mcp-Param-*, so any resource URI or tool name outside the header-safe ASCII set — which the spec explicitly tells clients to wrap — was compared encoded and refused with -32020. * [Server] Require the MCP-Protocol-Version header It was only checked for contradicting the body, so omitting it entirely passed — leaving the value intermediaries route on unenforced. Tied to the header validator's presence, since that is what a header-bearing transport installs and stdio carries its metadata inline. * [Server] Read the modern-era request body whole A single read() takes whatever the stream cares to give — 64 bytes from a chunked transfer is legal — so an oversized-but-valid POST came back as a parse error. Shares the legacy transport's incremental read as a trait. * [Schema][Server] Answer a notification with a status, not a response A body with no id was dispatched as a request and came back as a JSON-RPC error carrying "id": "" — an id nobody issued, and on a path a client uses to tell a modern server from a legacy one. Notifications now get 202 and no body, and Error omits an id it could not read instead of emptying it. * [Server] Stop emitting the error codes 2026-07-28 reserved resources/read answered -32002, which this revision forbids; prompts/get and completion/complete answered it for an unknown *name*, which it never meant; tools/call answered -32601, which belongs to an unknown method. Only the resources/read code is revision-gated — older peers expect -32002 there, and nowhere else. Also routes the -32021 capability exception out of the prompt and resource handlers, which were swallowing it into -32603.
1 parent ef0c1c2 commit 5e73683

20 files changed

Lines changed: 855 additions & 106 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
55
0.8.0
66
-----
77

8+
* [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`.
89
* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestStateKey()`.
910
* Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored.
1011
* [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them.

src/Schema/Enum/ProtocolVersion.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,21 @@ public function requiresObjectStructuredContent(): bool
115115
return !$this->isAtLeast(self::V2026_07_28);
116116
}
117117

118+
/**
119+
* Whether this revision answers a missing resource with `-32602`.
120+
*
121+
* SEP-2164, part of {@see self::V2026_07_28}, retired the bespoke `-32002`
122+
* in favour of the JSON-RPC code that already meant this, and reserved
123+
* `-32002` so it is never reused. Earlier revisions still expect it, and
124+
* clients are told to keep accepting it from them.
125+
*
126+
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes
127+
*/
128+
public function usesInvalidParamsForResourceNotFound(): bool
129+
{
130+
return $this->isAtLeast(self::V2026_07_28);
131+
}
132+
118133
/**
119134
* Whether this revision is at least as new as $minimum.
120135
*/

src/Server/Handler/Request/CallToolHandler.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er
7373
} catch (ToolNotFoundException $e) {
7474
$this->logger->error('Tool not found', ['name' => $toolName, 'exception' => $e]);
7575

76-
return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage());
76+
// -32601 answers an unknown *method*; tools/call exists, it is the
77+
// name in its params that does not.
78+
return Error::forInvalidParams($e->getMessage(), $request->getId());
7779
}
7880

7981
$inputSchema = $reference->tool->inputSchema;

src/Server/Handler/Request/CompletionCompleteHandler.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er
8585

8686
return new Response($request->getId(), new CompletionCompleteResult($paged, $total, $hasMore));
8787
} catch (PromptNotFoundException|ResourceNotFoundException $e) {
88-
return Error::forResourceNotFound($e->getMessage(), $request->getId());
88+
// The reference names something the server does not have, which is
89+
// a bad parameter rather than a missing resource.
90+
return Error::forInvalidParams($e->getMessage(), $request->getId());
8991
} catch (\Throwable $e) {
9092
return Error::forInternalError('Error while handling completion request', $request->getId());
9193
}

src/Server/Handler/Request/GetPromptHandler.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Mcp\Capability\Registry\ReferenceHandlerInterface;
1515
use Mcp\Capability\RegistryInterface;
16+
use Mcp\Exception\MissingRequiredClientCapabilityException;
1617
use Mcp\Exception\PromptGetException;
1718
use Mcp\Exception\PromptNotFoundException;
1819
use Mcp\Schema\JsonRpc\Error;
@@ -70,14 +71,20 @@ public function handle(Request $request, SessionInterface $session): Response|Er
7071
$formatted = $reference->formatResult($result);
7172

7273
return new Response($request->getId(), new GetPromptResult($formatted));
74+
} catch (MissingRequiredClientCapabilityException $e) {
75+
// Not a handler failure — the request was unservable, and the client
76+
// needs to retry declaring the capability. Rendered as -32021.
77+
throw $e;
7378
} catch (PromptGetException $e) {
7479
$this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]);
7580

7681
return Error::forInternalError($e->getMessage(), $request->getId());
7782
} catch (PromptNotFoundException $e) {
7883
$this->logger->error('Prompt not found', ['prompt_name' => $promptName, 'exception' => $e]);
7984

80-
return Error::forResourceNotFound($e->getMessage(), $request->getId());
85+
// An unknown prompt name is a bad parameter, not a missing
86+
// resource: -32002 was never the code for this.
87+
return Error::forInvalidParams($e->getMessage(), $request->getId());
8188
} catch (\Throwable $e) {
8289
$this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]);
8390

src/Server/Handler/Request/ReadResourceHandler.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414
use Mcp\Capability\Registry\ReferenceHandlerInterface;
1515
use Mcp\Capability\Registry\ResourceTemplateReference;
1616
use Mcp\Capability\RegistryInterface;
17+
use Mcp\Exception\MissingRequiredClientCapabilityException;
1718
use Mcp\Exception\ResourceNotFoundException;
1819
use Mcp\Exception\ResourceReadException;
1920
use Mcp\Schema\JsonRpc\Error;
2021
use Mcp\Schema\JsonRpc\Request;
2122
use Mcp\Schema\JsonRpc\Response;
2223
use Mcp\Schema\Request\ReadResourceRequest;
2324
use Mcp\Schema\Result\ReadResourceResult;
25+
use Mcp\Server\RequestContext;
2426
use Mcp\Server\Session\SessionInterface;
2527
use Psr\Log\LoggerInterface;
2628
use Psr\Log\NullLogger;
@@ -76,14 +78,23 @@ public function handle(Request $request, SessionInterface $session): Response|Er
7678
}
7779

7880
return new Response($request->getId(), new ReadResourceResult($formatted));
81+
} catch (MissingRequiredClientCapabilityException $e) {
82+
// Not a handler failure — the request was unservable, and the client
83+
// needs to retry declaring the capability. Rendered as -32021.
84+
throw $e;
7985
} catch (ResourceReadException $e) {
8086
$this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]);
8187

8288
return Error::forInternalError($e->getMessage(), $request->getId());
8389
} catch (ResourceNotFoundException $e) {
8490
$this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]);
8591

86-
return Error::forResourceNotFound($e->getMessage(), $request->getId());
92+
// SEP-2164 retired -32002 in favour of the JSON-RPC code that
93+
// already meant this. Older peers still expect the old one, so the
94+
// revision answering the request decides.
95+
return (new RequestContext($session, $request))->getProtocolVersion()->usesInvalidParamsForResourceNotFound()
96+
? Error::forInvalidParams($e->getMessage(), $request->getId(), ['uri' => $uri])
97+
: Error::forResourceNotFound($e->getMessage(), $request->getId());
8798
} catch (\Throwable $e) {
8899
$this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]);
89100

src/Server/Stateless/StandardHeaderValidator.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,18 @@ private function checkName(string $method, ?array $params, array $headers): ?str
8989
return \sprintf('Missing required %s header (body carries "%s").', self::NAME_HEADER, $expected);
9090
}
9191

92-
if ($declared !== $expected) {
93-
return \sprintf('%s header "%s" does not match the body value "%s".', self::NAME_HEADER, $declared, $expected);
92+
// Tool and prompt names are only SHOULD-constrained to header-safe
93+
// characters and a resource URI is not constrained at all, so the
94+
// client wraps anything unsafe — decode before comparing or every
95+
// conformant client carrying a non-ASCII subject is refused.
96+
$decoded = self::decode($declared);
97+
98+
if (null === $decoded) {
99+
return \sprintf('%s header is not a well-formed Base64 wrapper.', self::NAME_HEADER);
100+
}
101+
102+
if ($decoded !== $expected) {
103+
return \sprintf('%s header "%s" does not match the body value "%s".', self::NAME_HEADER, $decoded, $expected);
94104
}
95105

96106
return null;

src/Server/Stateless/StatelessProtocol.php

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
use Mcp\JsonRpc\MessageFactory;
1919
use Mcp\Schema\Enum\ProtocolVersion;
2020
use Mcp\Schema\JsonRpc\Error;
21-
use Mcp\Schema\JsonRpc\Notification;
2221
use Mcp\Schema\JsonRpc\Request;
2322
use Mcp\Schema\JsonRpc\ResultInterface;
2423
use Mcp\Schema\Result\DiscoverResult;
@@ -47,12 +46,22 @@ final class StatelessProtocol
4746
/**
4847
* Methods the modern era deleted. Answered as unknown methods, which is
4948
* what they are to a modern server.
49+
*
50+
* A deny-list rather than an allow-list on purpose: extensions add methods
51+
* this class has never heard of, so an unlisted method has to reach
52+
* dispatch. Every removal named in the 2026-07-28 changelog belongs here —
53+
* the handlers behind them stay registered for the handshake era, which is
54+
* why the era guard, and not the registration, is what turns them off.
5055
*/
5156
public const REMOVED_METHODS = [
5257
'initialize',
5358
'notifications/initialized',
5459
'ping',
5560
'logging/setLevel',
61+
// Replaced by the `resourceSubscriptions` filter of subscriptions/listen.
62+
'resources/subscribe',
63+
'resources/unsubscribe',
64+
'notifications/roots/list_changed',
5665
];
5766

5867
public const DISCOVER_METHOD = 'server/discover';
@@ -85,6 +94,27 @@ public function __construct(
8594
private readonly ?RequestStateCodec $requestStateCodec = null,
8695
) {
8796
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo);
97+
98+
if (null === $this->headerValidator) {
99+
// Not fatal: a transport without a header layer — stdio — has
100+
// nothing to validate. But on HTTP the headers are REQUIRED for
101+
// compliance, so an absent validator there is a silently
102+
// non-conformant server and worth saying out loud once.
103+
$this->logger->warning('No StandardHeaderValidator configured; the SEP-2243 request headers will not be enforced. This is correct only for a transport without a header layer.');
104+
}
105+
}
106+
107+
/**
108+
* Whether the transport carrying this dispatcher has a header layer whose
109+
* required members must be present.
110+
*
111+
* The validator's presence is the signal: it is what a header-bearing
112+
* transport installs, and stdio carries its metadata inline instead
113+
* (see the stdio binding's "Request Metadata").
114+
*/
115+
private function requiresTransportHeaders(): bool
116+
{
117+
return null !== $this->headerValidator;
88118
}
89119

90120
/**
@@ -111,7 +141,13 @@ public function handle(string $body, array $headers = []): StatelessResult
111141
// already knows to omit a null id instead of fabricating one, and
112142
// "id": "" would falsely claim the sender issued a request with an
113143
// empty-string id.
144+
//
145+
// An absent id and an unreadable one are different messages: the first
146+
// is a notification, the second a malformed request. JSON-RPC 2.0
147+
// writes "no id" as an explicit null, so that counts as absent too.
148+
$isNotification = !\array_key_exists('id', $decoded) || null === $decoded['id'];
114149
$id = $decoded['id'] ?? null;
150+
115151
if (!\is_string($id) && !\is_int($id)) {
116152
$id = null;
117153
}
@@ -123,6 +159,19 @@ public function handle(string $body, array $headers = []): StatelessResult
123159

124160
$params = \is_array($decoded['params'] ?? null) ? $decoded['params'] : null;
125161

162+
// No id is a notification. It gets an acknowledgment, never a response
163+
// — answering one with a JSON-RPC message would invent a correlation
164+
// the client has no request to match it against. Checked before the
165+
// `_meta` parse: notification params carry `NotificationMetaObject`,
166+
// which has none of a request's required members.
167+
if ($isNotification) {
168+
return $this->acknowledge($method);
169+
}
170+
171+
if (null === $id) {
172+
return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC request id must be a string or a number.'), 400);
173+
}
174+
126175
try {
127176
$meta = RequestMeta::fromParams($params);
128177
} catch (MissingRequestMetaException $e) {
@@ -140,14 +189,6 @@ public function handle(string $body, array $headers = []): StatelessResult
140189
}
141190

142191
if (self::DISCOVER_METHOD === $method || self::LISTEN_METHOD === $method) {
143-
// Both answer with a single response tied to this request's id,
144-
// unlike a genuine notification — so unlike the general dispatch
145-
// path below, a missing id here is this request being invalid
146-
// rather than this request needing no answer at all.
147-
if (null === $id) {
148-
return StatelessResult::error(Error::forInvalidRequest(\sprintf('Method "%s" requires an "id".', $method)), 400);
149-
}
150-
151192
if (self::DISCOVER_METHOD === $method) {
152193
return $this->encode($method, $id, $this->discover());
153194
}
@@ -165,6 +206,29 @@ public function handle(string $body, array $headers = []): StatelessResult
165206
return $this->dispatch($method, $decoded, $meta, $id);
166207
}
167208

209+
/**
210+
* Answers a notification.
211+
*
212+
* This revision's core defines no client-to-server notification over HTTP —
213+
* `notifications/cancelled` is stdio-only, since closing the response
214+
* stream is the cancellation signal here — so anything arriving is either
215+
* an extension's or a client still speaking an older revision. Accepting
216+
* the former and refusing the latter both come out as a status with no
217+
* body; what must not happen is a JSON-RPC response.
218+
*/
219+
private function acknowledge(string $method): StatelessResult
220+
{
221+
if (\in_array($method, self::REMOVED_METHODS, true)) {
222+
$this->logger->debug('Refused a notification this revision removed.', ['method' => $method]);
223+
224+
return StatelessResult::empty(400);
225+
}
226+
227+
$this->logger->debug('Accepted a notification with no handler to run.', ['method' => $method]);
228+
229+
return StatelessResult::empty(202);
230+
}
231+
168232
/**
169233
* Header and `_meta` must agree before the version can be judged supported:
170234
* when they disagree the server cannot know which the client meant, so a
@@ -176,6 +240,19 @@ private function checkVersion(RequestMeta $meta, array $headers, string|int|null
176240
{
177241
$headerVersion = $this->header($headers, 'MCP-Protocol-Version');
178242

243+
// REQUIRED on every POST. The 2025-03-26 fallback for a header-less
244+
// request exists only for servers choosing to serve pre-2025-06-18
245+
// clients, which a modern-only endpoint is not.
246+
if (null === $headerVersion && $this->requiresTransportHeaders()) {
247+
return StatelessResult::error(
248+
Error::forHeaderMismatch(
249+
\sprintf('Missing required MCP-Protocol-Version header (_meta declares "%s").', $meta->protocolVersion),
250+
$id,
251+
),
252+
400,
253+
);
254+
}
255+
179256
if (null !== $headerVersion && $headerVersion !== $meta->protocolVersion) {
180257
return StatelessResult::error(
181258
Error::forHeaderMismatch(
@@ -273,12 +350,6 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str
273350

274351
$request = $messages[0] ?? null;
275352

276-
// A notification (no id) is never answered, successful or not — this is
277-
// one of the SDK's own registered message classes, just not a Request.
278-
if ($request instanceof Notification) {
279-
return StatelessResult::accepted();
280-
}
281-
282353
// The factory hands back an exception object rather than throwing one,
283354
// distinguishing a genuinely unknown method (-32601, the client should
284355
// stop asking) from a known method the message could not otherwise be
@@ -310,6 +381,12 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str
310381
$session = new Session(new InMemorySessionStore());
311382
$session->set(RequestMeta::class, $meta);
312383

384+
// Under the same keys the handshake era writes, so everything reading
385+
// connection state — ClientGateway's capability probes above all — sees
386+
// this request's declaration instead of an empty session.
387+
$session->set('client_capabilities', $meta->clientCapabilities->jsonSerialize());
388+
$session->set('protocol_version', $meta->protocolVersion);
389+
313390
try {
314391
$input = $this->liftInputContext($decoded['params'] ?? null);
315392
} catch (RequestStateException $e) {

src/Server/Stateless/StatelessResult.php

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ private function __construct(
3434
public readonly ?\Closure $frames = null,
3535
private readonly ?array $body = null,
3636
private readonly string|int|null $id = null,
37+
private readonly bool $bodyless = false,
3738
) {
3839
}
3940

@@ -54,13 +55,12 @@ public static function error(Error $error, int $httpStatus): self
5455
}
5556

5657
/**
57-
* A notification never gets an answer, successful or not — per JSON-RPC, a
58-
* message with no id must never receive a response. This is the "I read
59-
* it, there is nothing to say back" answer: HTTP 202 with no body.
58+
* A status with no body — what a notification gets, since it has no id to
59+
* correlate a JSON-RPC message against.
6060
*/
61-
public static function accepted(): self
61+
public static function empty(int $httpStatus): self
6262
{
63-
return new self(null, 202);
63+
return new self(null, $httpStatus, bodyless: true);
6464
}
6565

6666
/**
@@ -80,13 +80,9 @@ public function isStream(): bool
8080
return null !== $this->frames;
8181
}
8282

83-
/**
84-
* An accepted notification: no message, no body, no frames — just the
85-
* status code.
86-
*/
8783
public function isEmpty(): bool
8884
{
89-
return null === $this->message && null === $this->body && null === $this->frames;
85+
return $this->bodyless;
9086
}
9187

9288
public function isError(): bool
@@ -104,6 +100,10 @@ public function toJson(): string
104100
], \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
105101
}
106102

103+
if ($this->bodyless) {
104+
throw new \LogicException('This result carries no body; send its status alone.');
105+
}
106+
107107
if (null === $this->message) {
108108
throw new \LogicException('A streaming or empty result has no single JSON body; check isStream()/isEmpty() first.');
109109
}

0 commit comments

Comments
 (0)