Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
* [BC Break] `ProtectedResourceMetadata` requires `$resource`, serves at the path derived from it (RFC 9728 §3.1) and requires https except for loopback hosts; drops localized, policy, ToS, extra fields and `$metadataPaths`.
* [BC Break] Add `ScopePolicy` as third argument of `AuthorizationMiddleware`, answering `403 insufficient_scope` per method and tool, with scope hierarchies; the `resource_metadata` challenge URL comes from the configured resource instead of the `Host` header.
* Expose `WWW-Authenticate` in the default `CorsMiddleware`.
* Serve both protocol eras over stdio: `StdioTransport` settles the era on the client's first request and serves `2026-07-28` requests, `subscriptions/listen` and `notifications/cancelled` on the one channel.
* Answer a bare `initialize` on a `2026-07-28`-only endpoint with `-32022` naming the served revisions, and a request without a session on the handshake leg with its id.

0.8.0
-----
Expand Down
5 changes: 4 additions & 1 deletion docs/protocol-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ map; the mechanics live with the task they belong to.
| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` |
| Dispatcher | `Protocol` | `StatelessProtocol` |
| HTTP entry | `StreamableHttpTransport` — the same one, for both |
| stdio entry | `StdioTransport` — the same one, settled by the client's first request |

`ProtocolVersion::isModern()` tells the two apart, and
`Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits.
Expand Down Expand Up @@ -150,7 +151,9 @@ for a runnable version, described in [Examples](examples.md#modern-era-client).

## What was removed

Answered with `404` and `-32601` by a modern server:
Answered with `404` and `-32601` by a modern server — except a bare `initialize`, which is how
a client from before the modern era opens, and is refused with `-32022` naming the revisions
the server does speak:

- `initialize`, `notifications/initialized`
- `ping`
Expand Down
26 changes: 26 additions & 0 deletions docs/run/protocol-eras.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,32 @@ Both legs come from **one** builder configuration — one registry, one set of h
instances, one session manager. A tool registered once is reachable from both, and a change
made through one is visible to the other.

## Over stdio

`StdioTransport` serves both eras too, but stdio carries one client per process, so the era is
settled once rather than per request: the client's **first request** decides it, by the same
body-primary rule as above.

| Opening request | The connection |
| --- | --- |
| carries a modern revision in `params._meta` | is served by the modern dispatcher from then on |
| anything else — `initialize` above all | runs the handshake, as before `2026-07-28` |

A request from the other era after that is refused rather than served: `initialize` on a modern
connection gets `-32022` naming the modern revisions, an enveloped request on a handshake one
gets `-32600`. That is what a client that probed, gave up waiting and fell back to the handshake
needs to learn that the server settled on the modern era after all.

On a modern connection everything shares the one channel, and requests are served one at a
time: a request's progress and log messages are written as its handler emits them, ahead of its
result, and the next message is read once that result is out. A `subscriptions/listen` is the
long-lived exception: it stays open alongside other requests, each of its messages tagged with
the subscription id, until the client sends `notifications/cancelled` for it, since there is no
stream to close. stdio has no headers, so none of the `Mcp-*` header rules apply.

A server built `withoutModernEra()` refuses a modern opening with `-32022` naming the handshake
revisions, and still accepts the handshake that follows.

## Middleware

The [default middleware stack](http.md#default-middleware) runs at the edge, before the
Expand Down
8 changes: 4 additions & 4 deletions examples/server/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@
/**
* The transport every example runs on.
*
* Over HTTP that is one endpoint serving both protocol eras: `StreamableHttpTransport`
* classifies each request and routes it to the lifecycle it belongs to, so every
* example here answers an `initialize` handshake and a 2026-07-28 envelope alike.
* Over stdio there is no such choice to make — that binding carries the handshake era.
* Either way it serves both protocol eras: over HTTP, `StreamableHttpTransport`
* classifies each request and routes it to the lifecycle it belongs to; over stdio,
* `StdioTransport` settles the era on the client's first request. So every example
* here answers an `initialize` handshake and a 2026-07-28 envelope alike.
*
* @return TransportInterface<int>|TransportInterface<ResponseInterface>
*/
Expand Down
11 changes: 10 additions & 1 deletion src/Server/Protocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,16 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
}

if (!$sessionId) {
$error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.');
// Echoes the request's id: a client probing for the modern era sends
// `server/discover` before any handshake, and an error it cannot
// correlate would leave it waiting out its timeout to fall back.
$id = match (true) {
1 !== \count($messages) => null,
$messages[0] instanceof Request => $messages[0]->getId(),
$messages[0] instanceof InvalidInputMessageException => $messages[0]->getRequestId(),
default => null,
};
$error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.', $id);
$this->sendResponse($transport, $error, null, ['status_code' => 400]);

return null;
Expand Down
55 changes: 47 additions & 8 deletions src/Server/Stateless/StatelessProtocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,28 @@ private function requiresTransportHeaders(): bool
* @param AccessToken|null $accessToken the token the request was authorized with, if the transport authorizes
*/
public function handle(string $body, array $headers = [], ?AccessToken $accessToken = null): StatelessResult
{
return $this->answer($body, $headers, true, $accessToken);
}

/**
* Answers one JSON-RPC message read off a transport without a header layer.
*
* stdio carries the request metadata inline (see the stdio binding's
* "Request Metadata"), so there is no header to require or cross-check, and
* its one channel always carries a request's notifications. A long-lived
* stream is left for the caller to pace, since it interleaves it with
* everything else arriving on that channel.
*/
public function handleInline(string $message): StatelessResult
{
return $this->answer($message, [], false);
}

/**
* @param array<string, string> $headers
*/
private function answer(string $body, array $headers, bool $headerLayer, ?AccessToken $accessToken = null): StatelessResult
{
try {
/** @var array<string, mixed>|null $decoded */
Expand Down Expand Up @@ -203,19 +225,30 @@ public function handle(string $body, array $headers = [], ?AccessToken $accessTo
return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC request id must be a string or a number.'), 400);
}

// How a client from before the modern era opens. It has no way to move
// forward to this one, so the refusal is the only thing it can show its
// user: it names the revisions served rather than the envelope missing.
// One stamped with the envelope is a modern client, told further down
// that its revision has no such method.
if ('initialize' === $method && !isset($params['_meta'][RequestMeta::PROTOCOL_VERSION])) {
$offered = $params['protocolVersion'] ?? null;

return StatelessResult::error(Error::forUnsupportedProtocolVersion(\is_string($offered) ? $offered : '', $this->supportedVersions, $id), 400);
}

try {
$meta = RequestMeta::fromParams($params, $headers);
} catch (MissingRequestMetaException $e) {
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
}

if (null !== $versionError = $this->checkVersion($meta, $headers, $id)) {
if (null !== $versionError = $this->checkVersion($meta, $headers, $id, $headerLayer)) {
return $versionError;
}

// After the version check: a peer on the wrong revision has a more
// fundamental problem than headers that disagree with its body.
if (null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) {
if ($headerLayer && null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) {
return StatelessResult::error(Error::forHeaderMismatch($headerError, $id), 400);
}

Expand All @@ -224,7 +257,7 @@ public function handle(string $body, array $headers = [], ?AccessToken $accessTo
return $this->encode($method, $id, $this->discover());
}

return $this->listen($params, $id);
return $this->listen($params, $id, $headerLayer);
}

if (\in_array($method, self::REMOVED_METHODS, true)) {
Expand All @@ -234,7 +267,7 @@ public function handle(string $body, array $headers = [], ?AccessToken $accessTo
);
}

return $this->dispatch($method, $decoded, $meta, $id, self::acceptsEventStream($headers), $accessToken);
return $this->dispatch($method, $decoded, $meta, $id, !$headerLayer || self::acceptsEventStream($headers), $accessToken);
}

/**
Expand Down Expand Up @@ -267,14 +300,14 @@ private function acknowledge(string $method): StatelessResult
*
* @param array<string, string> $headers
*/
private function checkVersion(RequestMeta $meta, array $headers, string|int|null $id): ?StatelessResult
private function checkVersion(RequestMeta $meta, array $headers, string|int|null $id, bool $headerLayer = true): ?StatelessResult
{
$headerVersion = $this->header($headers, 'MCP-Protocol-Version');

// REQUIRED on every POST. The 2025-03-26 fallback for a header-less
// request exists only for servers choosing to serve pre-2025-06-18
// clients, which a modern-only endpoint is not.
if (null === $headerVersion && $this->requiresTransportHeaders()) {
if (null === $headerVersion && $headerLayer && $this->requiresTransportHeaders()) {
return StatelessResult::error(
Error::forHeaderMismatch(
\sprintf('Missing required MCP-Protocol-Version header (_meta declares "%s").', $meta->protocolVersion),
Expand Down Expand Up @@ -307,8 +340,10 @@ private function checkVersion(RequestMeta $meta, array $headers, string|int|null
* JSON-RPC id of this request, so there is none to mint.
*
* @param array<string, mixed>|null $params
* @param bool $paced whether the stream sleeps between polls itself, or its consumer
* paces it by how often it asks for the next frame
*/
private function listen(?array $params, string|int $id): StatelessResult
private function listen(?array $params, string|int $id, bool $paced = true): StatelessResult
{
$notifications = \is_array($params['notifications'] ?? null) ? $params['notifications'] : null;
$agreed = NotificationFilter::fromParams($notifications)->intersect($this->configuration->capabilities);
Expand All @@ -317,7 +352,7 @@ private function listen(?array $params, string|int $id): StatelessResult
$bus = $this->notificationBus;
$codec = $this->codec;

return StatelessResult::stream(static function () use ($agreed, $id, $lifetime, $bus, $codec): \Generator {
return StatelessResult::stream(static function () use ($agreed, $id, $lifetime, $bus, $codec, $paced): \Generator {
// MUST be the first message carrying this subscription's id, and
// MUST precede any notification on it.
yield [
Expand Down Expand Up @@ -352,6 +387,10 @@ private function listen(?array $params, string|int $id): StatelessResult

yield null;

if (!$paced) {
continue;
}

if (connection_aborted()) {
return;
}
Expand Down
Loading
Loading