Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
49e6ad6
[Server] Echo the request id when refusing a request without a session
chr-hertel Oct 6, 2026
db6580d
[Server] Answer modern-era messages from a transport without headers
chr-hertel Oct 6, 2026
d14adc4
[Server] Serve both protocol eras over stdio
chr-hertel Oct 6, 2026
816fec7
[Server] Name the served revisions when refusing a bare initialize
chr-hertel Oct 6, 2026
013ab6c
[Server] Cover subscriptions/listen over stdio
chr-hertel Oct 6, 2026
1668212
[Server] Keep string and integer request ids apart on stdio
chr-hertel Oct 7, 2026
72302fa
[Docs] Document both protocol eras over stdio
chr-hertel Oct 7, 2026
04bd9cf
[Server] Write a stream frame before resuming its handler on stdio
chr-hertel Oct 7, 2026
35405f9
[Server] Leave the stdio era open after refusing an unserved revision
chr-hertel Oct 7, 2026
445d8af
[Server] Settle the stdio era on a request, not a response
chr-hertel Oct 7, 2026
06a6869
[Docs] Say what notifications/cancelled stops over stdio
chr-hertel Oct 7, 2026
7fa9b5d
[Server] Refuse a request with a malformed id on stdio without settli…
chr-hertel Oct 9, 2026
ccb1ef0
[Server] Keep a stdio listen stream open until it is cancelled
chr-hertel Oct 9, 2026
8486354
[Server] Cover listen notifications over stdio
chr-hertel Oct 9, 2026
ad16885
[Server] Ignore a response on stdio before the era is settled
chr-hertel Oct 9, 2026
e9002ba
[Server] Ignore a malformed cancellation of a stdio stream
chr-hertel Oct 9, 2026
8324afb
[Server] Name only the negotiated handshake revisions when refusing a…
chr-hertel Oct 9, 2026
aad7dce
[Server] Ignore a response on a modern stdio connection
chr-hertel Oct 9, 2026
a61a5c9
[Docs] Say the subscription lifetime does not apply over stdio
chr-hertel Oct 9, 2026
7b9623b
[Server] Trim the stdio comments to one line each
chr-hertel Oct 9, 2026
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Expose `WWW-Authenticate` in the default `CorsMiddleware`.
* [BC Break] Fix concurrent Streamable HTTP streams on one session resuming each other's fibers: each stream now polls only the client request its own fiber sent, so an elicitation answer reaches the tool call that asked for it. `Protocol::handleFiberYield()` returns the ID of the request it sent.
* Fix lost responses on concurrent requests of one session over Streamable HTTP: a POST is answered with its own responses instead of taking them from the session's outgoing queue. Adds `InlineResponseTransportInterface` for transports that answer each request on the exchange that carried it.
* 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.
* [BC Break] `StatelessAwareTransportInterface` declares `setHandshakeVersions()`, so a server without the modern era names only the revisions it negotiates when refusing a `2026-07-28` request, e.g. the one set with `Builder::setProtocolVersion()`.
* 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
27 changes: 27 additions & 0 deletions docs/run/protocol-eras.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,33 @@ 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. `setSubscriptionLifetime()` does not apply here. 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
2 changes: 1 addition & 1 deletion docs/run/server-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ $server = Server::builder()
| `withoutInputRequiredShim()` | - | Do not fulfil an `InputRequiredResult` over a handshake-era connection |
| `setCachePolicy()` | policy | Set the `ttlMs`/`cacheScope` hints on cacheable results |
| `setNotificationBus()` | bus | Delivery for `subscriptions/listen` streams |
| `setSubscriptionLifetime()` | seconds | How long a subscription stream is held open (`0` = unbounded) |
| `setSubscriptionLifetime()` | seconds | How long a subscription stream is held open over HTTP (`0` = unbounded) |
| `setHeaderValidator()` | enabled | Toggle the SEP-2243 standard-header check on `buildStateless()` |
| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery |
| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management |
Expand Down
3 changes: 3 additions & 0 deletions docs/run/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ $bus->publish(new ResourceUpdatedNotification('file:///project/config.json'));
`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server
closes it gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot
outlive `max_execution_time`. Pass `0` for "until the client or the runtime ends it".

Over stdio the lifetime does not apply: a stream stays open until the client sends
`notifications/cancelled` for it, see [Over stdio](protocol-eras.md#over-stdio).
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
17 changes: 12 additions & 5 deletions src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Mcp;

use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Server\Builder;
use Mcp\Server\Protocol;
use Mcp\Server\Stateless\StatelessProtocol;
Expand All @@ -26,13 +27,15 @@
final class Server
{
/**
* @param StatelessProtocol|null $statelessProtocol the modern-era (SEP-2575) dispatcher, absent on a
* server that serves the handshake era alone
* @param StatelessProtocol|null $statelessProtocol the modern-era (SEP-2575) dispatcher, absent on a
* server that serves the handshake era alone
* @param non-empty-list<ProtocolVersion>|null $handshakeVersions revisions `initialize` negotiates, all of them by default
*/
public function __construct(
private readonly Protocol $protocol,
private readonly LoggerInterface $logger = new NullLogger(),
private readonly ?StatelessProtocol $statelessProtocol = null,
private readonly ?array $handshakeVersions = null,
) {
}

Expand All @@ -56,9 +59,13 @@ public function run(TransportInterface $transport): mixed

// The eras share the transport, not the dispatcher: a transport that
// can tell them apart takes both and picks per request. One that
// cannot — stdio — carries the handshake era alone.
if (null !== $this->statelessProtocol && $transport instanceof StatelessAwareTransportInterface) {
$transport->connectStateless($this->statelessProtocol);
// cannot carries the handshake era alone.
if ($transport instanceof StatelessAwareTransportInterface) {
$transport->setHandshakeVersions($this->handshakeVersions ?? ProtocolVersion::handshakeVersions());

if (null !== $this->statelessProtocol) {
$transport->connectStateless($this->statelessProtocol);
}
}

$this->logger->info('Running server...');
Expand Down
1 change: 1 addition & 0 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,7 @@ public function build(): Server
$protocol,
$parts['logger'],
[] === $modernVersions ? null : $this->buildStateless($modernVersions),
$parts['configuration']->handshakeVersions(),
);
}

Expand Down
18 changes: 18 additions & 0 deletions src/Server/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,22 @@ public function __construct(
public readonly ?ProtocolVersion $protocolVersion = null,
) {
}

/**
* Versions this server is willing to negotiate over `initialize`.
*
* A configured version pins the handshake to exactly that revision. Modern
* revisions are never offered here: they have no `initialize` at all, so a
* client negotiating one could not use the connection.
*
* @return non-empty-list<ProtocolVersion>
*/
public function handshakeVersions(): array
{
if (null !== $this->protocolVersion && !$this->protocolVersion->isModern()) {
return [$this->protocolVersion];
}

return ProtocolVersion::handshakeVersions();
}
}
15 changes: 1 addition & 14 deletions src/Server/Handler/Request/InitializeHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,23 +84,10 @@ private function negotiate(string $requested): ProtocolVersion
}

/**
* Versions this server is willing to negotiate over `initialize`.
*
* A version configured on the server pins the handshake to exactly that
* revision. Modern revisions are never offered here: they have no
* `initialize` at all, so a client that reached this handler cannot speak
* one, and answering with it would leave the connection unusable.
*
* @return non-empty-list<ProtocolVersion>
*/
private function supportedVersions(): array
{
$configured = $this->configuration?->protocolVersion;

if (null !== $configured && !$configured->isModern()) {
return [$configured];
}

return ProtocolVersion::handshakeVersions();
return $this->configuration?->handshakeVersions() ?? ProtocolVersion::handshakeVersions();
}
}
9 changes: 8 additions & 1 deletion src/Server/Protocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,14 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
}

if (!$sessionId) {
$error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.');
// Echo the id so a client probing with `server/discover` can correlate the refusal.
$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: 46 additions & 9 deletions src/Server/Stateless/StatelessProtocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,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 @@ -205,19 +227,26 @@ 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);
}

// A pre-modern client opens with a bare initialize: name the served revisions, not the missing envelope.
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 @@ -226,7 +255,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, paced: $headerLayer);
}

if (\in_array($method, self::REMOVED_METHODS, true)) {
Expand All @@ -236,7 +265,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 @@ -269,14 +298,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 @@ -309,8 +338,11 @@ 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 and ends after
* the subscription lifetime, or its consumer paces it by how often
* it asks for the next frame and ends it by dropping it
*/
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 @@ -319,7 +351,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 All @@ -337,7 +369,8 @@ private function listen(?array $params, string|int $id): StatelessResult

// The tick is not optional: PHP spots a dropped peer by writing,
// and a sleeping loop would pin an FPM worker for the full lifetime.
$deadline = 0.0 >= $lifetime ? \INF : microtime(true) + $lifetime;
// An unpaced stream is ended by its consumer, so it is not bounded.
$deadline = !$paced || 0.0 >= $lifetime ? \INF : microtime(true) + $lifetime;

while (microtime(true) < $deadline) {
if (null !== $bus) {
Expand All @@ -354,6 +387,10 @@ private function listen(?array $params, string|int $id): StatelessResult

yield null;

if (!$paced) {
continue;
}

if (connection_aborted()) {
return;
}
Expand Down
9 changes: 9 additions & 0 deletions src/Server/Transport/StatelessAwareTransportInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Mcp\Server\Transport;

use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Server\Stateless\StatelessProtocol;

/**
Expand All @@ -26,4 +27,12 @@
interface StatelessAwareTransportInterface
{
public function connectStateless(StatelessProtocol $protocol): void;

/**
* Revisions the handshake dispatcher negotiates, named when a modern-era
* request reaches a server without the modern era.
*
* @param non-empty-list<ProtocolVersion> $versions
*/
public function setHandshakeVersions(array $versions): void;
}
Loading
Loading