Skip to content

Commit ab32699

Browse files
committed
[Server] Serve both protocol eras from one endpoint
The two lifecycles share a transport, not a dispatcher. StreamableHttpTransport classifies each request - a 2026-07-28 envelope, an initialize handshake, or a session-bound follow-up - through InboundClassifier and routes it to the dispatcher that owns it, so one URL answers a modern client and a handshake-era one alike. Builder::build() carries both; withoutModernEra() opts out and setModernVersions() narrows what the modern leg answers for. InputRequiredShim lets a handler written for multi round-trip requests also serve a handshake-era client, by turning each ask into the request/response exchange that era has - so a handler is written once rather than twice. The conformance fixture collapses into one server for the same reason: both legs now hit the same URL.
1 parent b973893 commit ab32699

23 files changed

Lines changed: 2022 additions & 317 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+
* Serve both protocol eras from one endpoint: `StreamableHttpTransport` classifies each request — a `2026-07-28` envelope, an `initialize` handshake, or a session-bound follow-up — through the new `Mcp\Server\Wire\InboundClassifier` and routes it to the dispatcher that owns it, so a single URL answers a modern client and a handshake-era one alike. `Server::builder()->build()` now carries both dispatchers; `Builder::withoutModernEra()` opts out and `Builder::setModernVersions()` narrows what the modern leg answers for. `Mcp\Server\InputRequiredShim` lets a handler written for multi round-trip requests also serve a handshake-era client, by turning each ask into the request/response exchange that era has.
89
* Carry W3C trace context through a request (SEP-414): `traceparent`, `tracestate` and `baggage` in a request's `_meta` are exposed to handlers as `RequestContext::getTraceContext()` and echoed onto the notifications that request causes, so a span stays joined across the response stream. Values pass through exactly as they arrived, and no OpenTelemetry dependency is added.
910
* Deliver notifications on a `subscriptions/listen` stream (SEP-2575), which previously acknowledged and then carried nothing for the rest of its life. New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations — `InMemoryNotificationBus` for stdio and persistent runtimes, `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes — set with `Builder::setNotificationBus()`. Registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured. `Builder::setSubscriptionLifetime()` replaces the hard-coded 30-second ceiling, where `0` means "until the client or the runtime ends it".
1011
* Add `Mcp\Server\Wire\CachePolicy`, set with `Builder::setCachePolicy()`, to configure the SEP-2549 caching hints the 2026-07-28 lifecycle stamps on a cacheable result. The conservative `ttlMs: 0, cacheScope: private` stays the default, since `public` lets a shared proxy serve one caller's answer to another and only the operator can make that call. A `ReadResourceResult` may also carry its own `ttlMs`/`cacheScope`, which win over the policy.

examples/server/bootstrap.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828
});
2929

3030
/**
31+
* The transport every example runs on.
32+
*
33+
* Over HTTP that is one endpoint serving both protocol eras: `StreamableHttpTransport`
34+
* classifies each request and routes it to the lifecycle it belongs to, so every
35+
* example here answers an `initialize` handshake and a 2026-07-28 envelope alike.
36+
* Over stdio there is no such choice to make — that binding carries the handshake era.
37+
*
3138
* @return TransportInterface<int>|TransportInterface<ResponseInterface>
3239
*/
3340
function transport(): TransportInterface

src/Server.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
use Mcp\Server\Builder;
1515
use Mcp\Server\Protocol;
16+
use Mcp\Server\Stateless\StatelessProtocol;
17+
use Mcp\Server\Transport\StatelessAwareTransportInterface;
1618
use Mcp\Server\Transport\TransportInterface;
1719
use Psr\Log\LoggerInterface;
1820
use Psr\Log\NullLogger;
@@ -23,9 +25,14 @@
2325
*/
2426
final class Server
2527
{
28+
/**
29+
* @param StatelessProtocol|null $statelessProtocol the modern-era (SEP-2575) dispatcher, absent on a
30+
* server that serves the handshake era alone
31+
*/
2632
public function __construct(
2733
private readonly Protocol $protocol,
2834
private readonly LoggerInterface $logger = new NullLogger(),
35+
private readonly ?StatelessProtocol $statelessProtocol = null,
2936
) {
3037
}
3138

@@ -47,6 +54,13 @@ public function run(TransportInterface $transport): mixed
4754

4855
$this->protocol->connect($transport);
4956

57+
// The eras share the transport, not the dispatcher: a transport that
58+
// can tell them apart takes both and picks per request. One that
59+
// cannot — stdio — carries the handshake era alone.
60+
if (null !== $this->statelessProtocol && $transport instanceof StatelessAwareTransportInterface) {
61+
$transport->connectStateless($this->statelessProtocol);
62+
}
63+
5064
$this->logger->info('Running server...');
5165

5266
try {

src/Server/Builder.php

Lines changed: 151 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@
7474
/**
7575
* @phpstan-import-type Handler from ElementReference
7676
*
77+
* @phpstan-type AssembledParts array{
78+
* logger: LoggerInterface,
79+
* eventDispatcher: ?EventDispatcherInterface,
80+
* configuration: Configuration,
81+
* messageFactory: MessageFactory,
82+
* sessionManager: SessionManagerInterface,
83+
* registry: RegistryInterface,
84+
* requestHandlers: list<RequestHandlerInterface<mixed>>,
85+
* notificationHandlers: list<NotificationHandlerInterface>,
86+
* }
87+
*
7788
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
7889
*/
7990
final class Builder
@@ -118,6 +129,12 @@ final class Builder
118129

119130
private float $subscriptionLifetime = 30.0;
120131

132+
/** @var array<string, string> RPC method to the extension identifier defining it */
133+
private array $extensionMethods = [];
134+
135+
/** @var list<class-string<\Mcp\Schema\JsonRpc\Request>|class-string<\Mcp\Schema\JsonRpc\Notification>> */
136+
private array $extensionMessages = [];
137+
121138
private ?string $requestStateKey = null;
122139

123140
private int $requestStateTtl = 600;
@@ -233,12 +250,6 @@ final class Builder
233250
*/
234251
private array $extensions = [];
235252

236-
/** @var list<class-string<\Mcp\Schema\JsonRpc\Request>|class-string<\Mcp\Schema\JsonRpc\Notification>> */
237-
private array $extensionMessages = [];
238-
239-
/** @var array<string, string> RPC method to the extension identifier defining it */
240-
private array $extensionMethods = [];
241-
242253
/**
243254
* @var LoaderInterface[]
244255
*/
@@ -250,6 +261,27 @@ final class Builder
250261

251262
private bool $headerValidation = true;
252263

264+
/** @var list<ProtocolVersion>|null null defaults to every modern revision, [] serves none */
265+
private ?array $modernVersions = null;
266+
267+
private bool $inputRequiredShim = true;
268+
269+
private int $inputRequiredRounds = InputRequiredShim::DEFAULT_MAX_ROUNDS;
270+
271+
private int $inputRequiredTimeout = InputRequiredShim::DEFAULT_ROUND_TIMEOUT;
272+
273+
/**
274+
* Resolved once and shared by every dispatcher this builder produces.
275+
*
276+
* Both eras run the same tools over the same registry, so assembling twice
277+
* would mean two registries, two discovery passes and two session managers
278+
* behind one endpoint — and a change made through one of them invisible to
279+
* the other.
280+
*
281+
* @var AssembledParts|null
282+
*/
283+
private ?array $parts = null;
284+
253285
/**
254286
* Sets the server's identity. Required.
255287
*
@@ -807,8 +839,92 @@ public function addLoaders(iterable $loaders): self
807839
return $this;
808840
}
809841

842+
/**
843+
* Stop serving multi round-trip handlers to handshake-era clients.
844+
*
845+
* A handler that returns an {@see \Mcp\Schema\Result\InputRequiredResult}
846+
* is written for the modern era, where the client answers the embedded
847+
* requests and retries the call. On a handshake-era connection the SDK
848+
* fulfils it instead, by sending those requests over that connection's own
849+
* channel and re-entering the handler with the answers — so one handler
850+
* serves both eras. See {@see InputRequiredShim} for what re-entry costs.
851+
*
852+
* Turn it off to have such a handler fail on a handshake-era connection
853+
* rather than be fulfilled behind your back.
854+
*/
855+
public function withoutInputRequiredShim(): self
856+
{
857+
$this->inputRequiredShim = false;
858+
859+
return $this;
860+
}
861+
862+
/**
863+
* Bounds on the shim's loop: how many times a handler may be re-entered for
864+
* one request, and how long one answer is waited for.
865+
*
866+
* The wait holds the originating request open, so on a process-per-request
867+
* runtime it holds a worker too. Size it against your pool, not against a
868+
* user's patience.
869+
*/
870+
public function setInputRequiredLimits(int $maxRounds, int $roundTimeout): self
871+
{
872+
if ($maxRounds < 1) {
873+
throw new InvalidArgumentException('maxRounds must be at least 1.');
874+
}
875+
876+
if ($roundTimeout < 1) {
877+
throw new InvalidArgumentException('roundTimeout must be at least 1 second.');
878+
}
879+
880+
$this->inputRequiredRounds = $maxRounds;
881+
$this->inputRequiredTimeout = $roundTimeout;
882+
883+
return $this;
884+
}
885+
886+
private function requestStateCodec(): ?RequestStateCodec
887+
{
888+
return null !== $this->requestStateKey
889+
? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl)
890+
: null;
891+
}
892+
893+
/**
894+
* Serve only the handshake era, refusing modern-era traffic.
895+
*
896+
* The default is to serve both from whatever the server is run on, because
897+
* an endpoint that turns a client away for speaking the newer revision is
898+
* almost never what anyone wants. Call this when it is: a deployment that
899+
* has to stay on the handshake wire, or one whose tools call back into the
900+
* client and would fail the modern half anyway.
901+
*/
902+
public function withoutModernEra(): self
903+
{
904+
$this->modernVersions = [];
905+
906+
return $this;
907+
}
908+
909+
/**
910+
* Revisions the modern-era leg answers for. Defaults to every modern
911+
* revision this SDK knows.
912+
*
913+
* @param list<ProtocolVersion> $versions
914+
*/
915+
public function setModernVersions(array $versions): self
916+
{
917+
$this->modernVersions = $versions;
918+
919+
return $this;
920+
}
921+
810922
/**
811923
* Builds the fully configured Server instance.
924+
*
925+
* The result carries a dispatcher for each era. Which one answers is a
926+
* per-request decision the transport makes, so one server object — and one
927+
* endpoint — serves handshake-era and modern-era clients alike.
812928
*/
813929
public function build(): Server
814930
{
@@ -821,17 +937,28 @@ public function build(): Server
821937
sessionManager: $parts['sessionManager'],
822938
logger: $parts['logger'],
823939
eventDispatcher: $parts['eventDispatcher'],
940+
inputRequiredShim: $this->inputRequiredShim
941+
? new InputRequiredShim($this->inputRequiredRounds, $this->inputRequiredTimeout, $parts['logger'])
942+
: null,
943+
requestStateCodec: $this->requestStateCodec(),
824944
);
825945

826-
return new Server($protocol, $parts['logger']);
946+
$modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions();
947+
948+
return new Server(
949+
$protocol,
950+
$parts['logger'],
951+
[] === $modernVersions ? null : $this->buildStateless($modernVersions),
952+
);
827953
}
828954

829955
/**
830-
* Builds a dispatcher for the modern (SEP-2575) lifecycle.
956+
* Builds a dispatcher for the modern (SEP-2575) lifecycle on its own.
831957
*
832958
* Tools, prompts, resources and their handlers are era-independent, so one
833-
* builder configuration drives either lifecycle and a server can offer both
834-
* by mounting each on its own endpoint.
959+
* builder configuration drives either lifecycle. {@see self::build()} wires
960+
* both together; this is the modern era by itself, for an endpoint that
961+
* serves nothing else.
835962
*
836963
* @param list<ProtocolVersion> $supportedVersions revisions this dispatcher will answer for
837964
*/
@@ -847,9 +974,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
847974
logger: $parts['logger'],
848975
subscriptionLifetime: $this->subscriptionLifetime,
849976
headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null,
850-
requestStateCodec: null !== $this->requestStateKey
851-
? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl)
852-
: null,
977+
requestStateCodec: $this->requestStateCodec(),
853978
cachePolicy: $this->cachePolicy,
854979
notificationBus: $this->notificationBus,
855980
extensionMethods: $this->extensionMethods,
@@ -859,18 +984,21 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
859984
/**
860985
* Resolves the builder's configuration into the parts both lifecycles need.
861986
*
862-
* @return array{
863-
* logger: LoggerInterface,
864-
* eventDispatcher: ?EventDispatcherInterface,
865-
* configuration: Configuration,
866-
* messageFactory: MessageFactory,
867-
* sessionManager: SessionManagerInterface,
868-
* registry: RegistryInterface,
869-
* requestHandlers: list<RequestHandlerInterface<mixed>>,
870-
* notificationHandlers: list<NotificationHandlerInterface>,
871-
* }
987+
* Memoized: the two eras share one registry, one session manager and one
988+
* set of handler instances, so they answer for the same server rather than
989+
* for two that merely started from the same configuration.
990+
*
991+
* @return AssembledParts
872992
*/
873993
private function assemble(): array
994+
{
995+
return $this->parts ??= $this->resolve();
996+
}
997+
998+
/**
999+
* @return AssembledParts
1000+
*/
1001+
private function resolve(): array
8741002
{
8751003
$logger = $this->logger ?? new NullLogger();
8761004
$container = $this->container ?? new Container();

src/Server/ClientGateway.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,14 +403,20 @@ private function sendElicitation(ElicitRequest $request, int $timeout): ElicitRe
403403
* This suspends the Fiber and waits for the client to respond. The transport
404404
* handles polling the session for the response and resuming the Fiber when ready.
405405
*
406+
* Public for {@see InputRequiredShim}, which sends the requests a handler
407+
* embedded in an {@see \Mcp\Schema\Result\InputRequiredResult} and knows
408+
* nothing about their kinds. Prefer the typed methods above.
409+
*
406410
* @param Request $request The request to send
407411
* @param int $timeout Maximum time to wait for response (seconds)
408412
*
409413
* @return Response<array<string, mixed>>|Error The client's response message
410414
*
411415
* @throws RuntimeException If Fiber support is not available
416+
*
417+
* @internal
412418
*/
413-
private function request(Request $request, int $timeout = 120): Response|Error
419+
public function request(Request $request, int $timeout = 120): Response|Error
414420
{
415421
$response = \Fiber::suspend([
416422
'type' => 'request',

0 commit comments

Comments
 (0)