[Server] Serve both protocol eras from one endpoint (#455)
* [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. * Trim redundant comments, fix stale in-memory claim in conformance fixture * Drop stale json-schema-2020-12 baseline entries The new fixture tool makes this scenario pass in both eras now. * Reject handshake revisions in setModernVersions(), warn on unsafe custom middleware Copilot review: setModernVersions() accepted revisions InboundClassifier would never route to the modern leg. StreamableHttpTransport now warns when a custom middleware list carries ProtocolVersionMiddleware, since it runs before era classification and rejects modern-era traffic by default. Also fixed a misleading docblock claim on the same middleware.
This commit is contained in:
parent
b97389325f
commit
cbceef06eb
1 changed files with 2060 additions and 323 deletions
|
|
@ -27,6 +27,8 @@ use Mcp\Server\Handler\Notification\NotificationHandlerInterface;
|
|||
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
use Mcp\Server\Session\SessionManagerInterface;
|
||||
use Mcp\Server\Stateless\InputContext;
|
||||
use Mcp\Server\Stateless\RequestStateCodec;
|
||||
use Mcp\Server\Transport\TransportInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
|
@ -78,6 +80,8 @@ class Protocol
|
|||
private readonly SessionManagerInterface $sessionManager,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
private readonly ?EventDispatcherInterface $eventDispatcher = null,
|
||||
private readonly ?InputRequiredShim $inputRequiredShim = null,
|
||||
private readonly ?RequestStateCodec $requestStateCodec = null,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -257,6 +261,15 @@ class Protocol
|
|||
|
||||
$session->set(self::SESSION_ACTIVE_REQUEST_META, $request->getMeta());
|
||||
|
||||
// A request starts with nothing behind it: the shim fills this in as it
|
||||
// collects answers, and clearing it here is what keeps one request's
|
||||
// round from being read as another's.
|
||||
$session->set(InputContext::class, null);
|
||||
|
||||
if (null !== $this->requestStateCodec) {
|
||||
$session->set(RequestStateCodec::class, $this->requestStateCodec);
|
||||
}
|
||||
|
||||
$event = $this->dispatchEvent(new RequestEvent($request, $session));
|
||||
$request = $event->getRequest();
|
||||
|
||||
|
|
@ -270,8 +283,17 @@ class Protocol
|
|||
$handlerFound = true;
|
||||
|
||||
try {
|
||||
$shim = $this->inputRequiredShim;
|
||||
$codec = $this->requestStateCodec;
|
||||
|
||||
// One fiber for the whole exchange: with the shim, the handler
|
||||
// re-enters inside it each round rather than needing a new one.
|
||||
/** @var McpFiber $fiber */
|
||||
$fiber = new \Fiber(static fn () => $handler->handle($request, $session));
|
||||
$fiber = new \Fiber(static function () use ($handler, $request, $session, $shim, $codec): Response|Error {
|
||||
$result = $handler->handle($request, $session);
|
||||
|
||||
return $shim?->fulfill($result, $handler, $request, $session, $codec) ?? $result;
|
||||
});
|
||||
|
||||
$result = $fiber->start();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue