Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
0.8.0
-----

* 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.
* 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.
* 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".
* 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.
Expand Down
7 changes: 7 additions & 0 deletions examples/server/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
});

/**
* 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.
*
* @return TransportInterface<int>|TransportInterface<ResponseInterface>
*/
function transport(): TransportInterface
Expand Down
14 changes: 14 additions & 0 deletions src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

use Mcp\Server\Builder;
use Mcp\Server\Protocol;
use Mcp\Server\Stateless\StatelessProtocol;
use Mcp\Server\Transport\StatelessAwareTransportInterface;
use Mcp\Server\Transport\TransportInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
Expand All @@ -23,9 +25,14 @@
*/
final class Server
{
/**
* @param StatelessProtocol|null $statelessProtocol the modern-era (SEP-2575) dispatcher, absent on a
* server that serves the handshake era alone
*/
public function __construct(
private readonly Protocol $protocol,
private readonly LoggerInterface $logger = new NullLogger(),
private readonly ?StatelessProtocol $statelessProtocol = null,
) {
}

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

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

// 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);
}

$this->logger->info('Running server...');

try {
Expand Down
177 changes: 154 additions & 23 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@
/**
* @phpstan-import-type Handler from ElementReference
*
* @phpstan-type AssembledParts array{
* logger: LoggerInterface,
* eventDispatcher: ?EventDispatcherInterface,
* configuration: Configuration,
* messageFactory: MessageFactory,
* sessionManager: SessionManagerInterface,
* registry: RegistryInterface,
* requestHandlers: list<RequestHandlerInterface<mixed>>,
* notificationHandlers: list<NotificationHandlerInterface>,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
final class Builder
Expand Down Expand Up @@ -118,6 +129,12 @@ final class Builder

private float $subscriptionLifetime = 30.0;

/** @var array<string, string> RPC method to the extension identifier defining it */
private array $extensionMethods = [];

/** @var list<class-string<\Mcp\Schema\JsonRpc\Request>|class-string<\Mcp\Schema\JsonRpc\Notification>> */
private array $extensionMessages = [];

private ?string $requestStateKey = null;

private int $requestStateTtl = 600;
Expand Down Expand Up @@ -233,12 +250,6 @@ final class Builder
*/
private array $extensions = [];

/** @var list<class-string<\Mcp\Schema\JsonRpc\Request>|class-string<\Mcp\Schema\JsonRpc\Notification>> */
private array $extensionMessages = [];

/** @var array<string, string> RPC method to the extension identifier defining it */
private array $extensionMethods = [];

/**
* @var LoaderInterface[]
*/
Expand All @@ -250,6 +261,22 @@ final class Builder

private bool $headerValidation = true;

/** @var list<ProtocolVersion>|null null defaults to every modern revision, [] serves none */
private ?array $modernVersions = null;

private bool $inputRequiredShim = true;

private int $inputRequiredRounds = InputRequiredShim::DEFAULT_MAX_ROUNDS;

private int $inputRequiredTimeout = InputRequiredShim::DEFAULT_ROUND_TIMEOUT;

/**
* @see self::assemble() for why this is memoized
*
* @var AssembledParts|null
*/
private ?array $parts = null;

/**
* Sets the server's identity. Required.
*
Expand Down Expand Up @@ -807,8 +834,100 @@ public function addLoaders(iterable $loaders): self
return $this;
}

/**
* Stop serving multi round-trip handlers to handshake-era clients.
*
* A handler that returns an {@see \Mcp\Schema\Result\InputRequiredResult}
* is written for the modern era, where the client answers the embedded
* requests and retries the call. On a handshake-era connection the SDK
* fulfils it instead, by sending those requests over that connection's own
* channel and re-entering the handler with the answers — so one handler
* serves both eras. See {@see InputRequiredShim} for what re-entry costs.
*
* Turn it off to have such a handler fail on a handshake-era connection
* rather than be fulfilled behind your back.
*/
public function withoutInputRequiredShim(): self
{
$this->inputRequiredShim = false;

return $this;
}

/**
* Bounds on the shim's loop: how many times a handler may be re-entered for
* one request, and how long one answer is waited for.
*
* The wait holds the originating request open, so on a process-per-request
* runtime it holds a worker too. Size it against your pool, not against a
* user's patience.
*/
public function setInputRequiredLimits(int $maxRounds, int $roundTimeout): self
{
if ($maxRounds < 1) {
throw new InvalidArgumentException('maxRounds must be at least 1.');
}

if ($roundTimeout < 1) {
throw new InvalidArgumentException('roundTimeout must be at least 1 second.');
}

$this->inputRequiredRounds = $maxRounds;
$this->inputRequiredTimeout = $roundTimeout;

return $this;
}

private function requestStateCodec(): ?RequestStateCodec
{
return null !== $this->requestStateKey
? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl)
: null;
}

/**
* Serve only the handshake era, refusing modern-era traffic.
*
* The default is to serve both from whatever the server is run on, because
* an endpoint that turns a client away for speaking the newer revision is
* almost never what anyone wants. Call this when it is: a deployment that
* has to stay on the handshake wire, or one whose tools call back into the
* client and would fail the modern half anyway.
*/
public function withoutModernEra(): self
{
$this->modernVersions = [];

return $this;
}

/**
* Revisions the modern-era leg answers for. Defaults to every modern
* revision this SDK knows.
*
* @param list<ProtocolVersion> $versions
*
* @throws InvalidArgumentException if a version is not one {@see Wire\InboundClassifier} routes to this leg
*/
public function setModernVersions(array $versions): self
{
foreach ($versions as $version) {
if (!$version->isModern()) {
throw new InvalidArgumentException(\sprintf('"%s" is a handshake-era revision; a request claiming it never reaches the modern leg to be served.', $version->value));
}
}

$this->modernVersions = $versions;

return $this;
}

/**
* Builds the fully configured Server instance.
*
* The result carries a dispatcher for each era. Which one answers is a
* per-request decision the transport makes, so one server object — and one
* endpoint — serves handshake-era and modern-era clients alike.
*/
public function build(): Server
{
Expand All @@ -821,17 +940,28 @@ public function build(): Server
sessionManager: $parts['sessionManager'],
logger: $parts['logger'],
eventDispatcher: $parts['eventDispatcher'],
inputRequiredShim: $this->inputRequiredShim
? new InputRequiredShim($this->inputRequiredRounds, $this->inputRequiredTimeout, $parts['logger'])
: null,
requestStateCodec: $this->requestStateCodec(),
);

return new Server($protocol, $parts['logger']);
$modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions();

return new Server(
$protocol,
$parts['logger'],
[] === $modernVersions ? null : $this->buildStateless($modernVersions),
);
}

/**
* Builds a dispatcher for the modern (SEP-2575) lifecycle.
* Builds a dispatcher for the modern (SEP-2575) lifecycle on its own.
*
* Tools, prompts, resources and their handlers are era-independent, so one
* builder configuration drives either lifecycle and a server can offer both
* by mounting each on its own endpoint.
* builder configuration drives either lifecycle. {@see self::build()} wires
* both together; this is the modern era by itself, for an endpoint that
* serves nothing else.
*
* @param list<ProtocolVersion> $supportedVersions revisions this dispatcher will answer for
*/
Expand All @@ -847,9 +977,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
logger: $parts['logger'],
subscriptionLifetime: $this->subscriptionLifetime,
headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null,
requestStateCodec: null !== $this->requestStateKey
? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl)
: null,
requestStateCodec: $this->requestStateCodec(),
cachePolicy: $this->cachePolicy,
notificationBus: $this->notificationBus,
extensionMethods: $this->extensionMethods,
Expand All @@ -859,18 +987,21 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
/**
* Resolves the builder's configuration into the parts both lifecycles need.
*
* @return array{
* logger: LoggerInterface,
* eventDispatcher: ?EventDispatcherInterface,
* configuration: Configuration,
* messageFactory: MessageFactory,
* sessionManager: SessionManagerInterface,
* registry: RegistryInterface,
* requestHandlers: list<RequestHandlerInterface<mixed>>,
* notificationHandlers: list<NotificationHandlerInterface>,
* }
* Memoized: the two eras share one registry, one session manager and one
* set of handler instances, so they answer for the same server rather than
* for two that merely started from the same configuration.
*
* @return AssembledParts
*/
private function assemble(): array
{
return $this->parts ??= $this->resolve();
}

/**
* @return AssembledParts
*/
private function resolve(): array
{
$logger = $this->logger ?? new NullLogger();
$container = $this->container ?? new Container();
Expand Down
8 changes: 7 additions & 1 deletion src/Server/ClientGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,14 +403,20 @@ private function sendElicitation(ElicitRequest $request, int $timeout): ElicitRe
* This suspends the Fiber and waits for the client to respond. The transport
* handles polling the session for the response and resuming the Fiber when ready.
*
* Public for {@see InputRequiredShim}, which sends the requests a handler
* embedded in an {@see \Mcp\Schema\Result\InputRequiredResult} and knows
* nothing about their kinds. Prefer the typed methods above.
*
* @param Request $request The request to send
* @param int $timeout Maximum time to wait for response (seconds)
*
* @return Response<array<string, mixed>>|Error The client's response message
*
* @throws RuntimeException If Fiber support is not available
*
* @internal
*/
private function request(Request $request, int $timeout = 120): Response|Error
public function request(Request $request, int $timeout = 120): Response|Error
{
$response = \Fiber::suspend([
'type' => 'request',
Expand Down
Loading