MCP PHP SDK

StatelessProtocol

FinalYes

Dispatches a single modern-era (SEP-2575) request.

Separate from Protocol because the modern era has no session to resolve, replay or keep a fiber against; the two eras share request handlers, not control flow.

Tags
author

Christopher Hertel mail@christopher-hertel.de

Table of Contents

Constants

ACKNOWLEDGED_NOTIFICATION  : mixed = 'notifications/subscriptions/acknowledged'
DISCOVER_METHOD  : mixed = 'server/discover'
LISTEN_METHOD  : mixed = 'subscriptions/listen'
REMOVED_METHODS  : mixed = [ 'initialize', 'notifications/initialized', 'p...
Methods the modern era deleted. Answered as unknown methods, which is what they are to a modern server.
INTERNAL_ERROR_MESSAGE  : mixed = 'Internal server error.'
What a client is told when a handler fails in a way nothing anticipated.

Properties

$codec  : WireCodecInterface
$configuration  : Configuration
$extensionMethods  : array<string|int, mixed>
$headerValidator  : StandardHeaderValidator|null
$logger  : LoggerInterface
$messageFactory  : MessageFactory
$notificationBus  : NotificationBusInterface|null
$requestHandlers  : iterable<string|int, mixed>
$requestStateCodec  : RequestStateCodec|null
$subscriptionLifetime  : float
$supportedVersions  : array<string|int, mixed>

Methods

__construct()  : mixed
handle()  : StatelessResult
Answers one JSON-RPC request read from an HTTP request body.
supportedVersions()  : array<int, ProtocolVersion>
The modern revisions this dispatcher answers for.
acceptsEventStream()  : bool
Whether the client will read a response stream.
acknowledge()  : StatelessResult
Answers a notification.
checkInputRequests()  : StatelessResult|null
Refuses to send an ask the client cannot answer.
checkVersion()  : StatelessResult|null
Header and `_meta` must agree before the version can be judged supported: when they disagree the server cannot know which the client meant, so a mismatch outranks an unsupported version.
discover()  : DiscoverResult
dispatch()  : StatelessResult
encode()  : StatelessResult
Runs a result through the wire codec. Passed as-is rather than via a json round trip, which would turn a nested `{}` into `[]`.
header()  : string|null
liftInputContext()  : InputContext|null
Reads the multi round-trip material off a retry, verifying the state before any of it reaches a handler. Neither member means a first call, which is what a handler tests to decide whether it still needs to ask.
listen()  : StatelessResult
Opens a `subscriptions/listen` stream. The subscription id is the JSON-RPC id of this request, so there is none to mint.
readElicitation()  : ElicitRequest}|null
One fiber suspension read as an elicitation, or null when it is not one.
readNotification()  : Notification|null
Reads one fiber suspension, or null when it carries nothing to send.
requiresTransportHeaders()  : bool
Whether the transport carrying this dispatcher has a header layer whose required members must be present.
run()  : Generator<null, Response<string|int, ResultInterface>|Error>
Runs a handler, yielding the notifications it emits as it emits them and returning its result.
streamFrames()  : Generator<string|int, mixed>
The frames of a request-scoped response stream: the notifications the handler emits, then the response that ends it.
tagWithSubscription()  : array<string, mixed>
Every message on a listen stream carries the id of the subscription it belongs to, which is how a client demultiplexes them on stdio — where they all share one channel.
toErrorResult()  : StatelessResult
The one place a handler's exception becomes an answer, so the streaming and non-streaming paths cannot disagree about which code it earns.
unknownMethod()  : Error
A method with no handler, said as precisely as the server can.
withTraceContext()  : array<string, mixed>
Puts the request's trace context back onto a notification it caused, so a collector can join the two without the handler carrying it by hand.

Constants

ACKNOWLEDGED_NOTIFICATION

public mixed ACKNOWLEDGED_NOTIFICATION = 'notifications/subscriptions/acknowledged'

REMOVED_METHODS

Methods the modern era deleted. Answered as unknown methods, which is what they are to a modern server.

public mixed REMOVED_METHODS = [ 'initialize', 'notifications/initialized', 'ping', 'logging/setLevel', // Replaced by the `resourceSubscriptions` filter of subscriptions/listen. 'resources/subscribe', 'resources/unsubscribe', 'notifications/roots/list_changed', ]

A deny-list rather than an allow-list on purpose: extensions add methods this class has never heard of, so an unlisted method has to reach dispatch. Every removal named in the 2026-07-28 changelog belongs here — the handlers behind them stay registered for the handshake era, which is why the era guard, and not the registration, is what turns them off.

INTERNAL_ERROR_MESSAGE

What a client is told when a handler fails in a way nothing anticipated.

private mixed INTERNAL_ERROR_MESSAGE = 'Internal server error.'

Deliberately generic rather than Throwable::getMessage(): the real message is logged, not returned, so an internal detail (a connection string, a file path, another library's error text) never reaches the client that triggered it.

Properties

$extensionMethods read-only

private array<string|int, mixed> $extensionMethods = []

$requestHandlers read-only

private iterable<string|int, mixed> $requestHandlers

$supportedVersions read-only

private array<string|int, mixed> $supportedVersions = [ProtocolVersion::V2026_07_28]

Methods

__construct()

public __construct(iterable<string|int, RequestHandlerInterface<string|int, ResultInterface>> $requestHandlers, MessageFactory $messageFactory, Configuration $configuration[, array<int, ProtocolVersion$supportedVersions = [ProtocolVersion::V2026_07_28] ][, LoggerInterface $logger = new NullLogger() ][, float $subscriptionLifetime = 30.0 ][, WireCodecInterface|null $codec = null ][, StandardHeaderValidator|null $headerValidator = null ][, RequestStateCodec|null $requestStateCodec = null ][, CachePolicy|null $cachePolicy = null ][, NotificationBusInterface|null $notificationBus = null ][, array<string, string> $extensionMethods = [] ]) : mixed
Parameters
$requestHandlers : iterable<string|int, RequestHandlerInterface<string|int, ResultInterface>>
$messageFactory : MessageFactory
$configuration : Configuration
$supportedVersions : array<int, ProtocolVersion> = [ProtocolVersion::V2026_07_28]
$logger : LoggerInterface = new NullLogger()
$subscriptionLifetime : float = 30.0
$codec : WireCodecInterface|null = null
$headerValidator : StandardHeaderValidator|null = null
$requestStateCodec : RequestStateCodec|null = null
$cachePolicy : CachePolicy|null = null
$notificationBus : NotificationBusInterface|null = null
$extensionMethods : array<string, string> = []

RPC method to the extension identifier defining it

handle()

Answers one JSON-RPC request read from an HTTP request body.

public handle(string $body[, array<string, string> $headers = [] ]) : StatelessResult
Parameters
$body : string
$headers : array<string, string> = []

request headers, case-insensitively matched

Return values
StatelessResult

acceptsEventStream()

Whether the client will read a response stream.

private static acceptsEventStream(array<string, string> $headers) : bool

Clients MUST offer both content types, so this is normally true; a client that does not gets its notifications dropped rather than a stream it cannot parse.

Parameters
$headers : array<string, string>
Return values
bool

acknowledge()

Answers a notification.

private acknowledge(string $method) : StatelessResult

This revision's core defines no client-to-server notification over HTTP — notifications/cancelled is stdio-only, since closing the response stream is the cancellation signal here — so anything arriving is either an extension's or a client still speaking an older revision. Accepting the former and refusing the latter both come out as a status with no body; what must not happen is a JSON-RPC response.

Parameters
$method : string
Return values
StatelessResult

checkInputRequests()

Refuses to send an ask the client cannot answer.

private checkInputRequests(ResultInterface $result, RequestMeta $meta, string $method, string|int $id) : StatelessResult|null

The handler's mistake rather than the client's, but the client is the one that has to hear about it, and -32021 is precisely the code for "processing this needs a capability you did not declare" — so it is reported as that, and logged as the server-side bug it is.

Parameters
$result : ResultInterface
$meta : RequestMeta
$method : string
$id : string|int
Return values
StatelessResult|null

checkVersion()

Header and `_meta` must agree before the version can be judged supported: when they disagree the server cannot know which the client meant, so a mismatch outranks an unsupported version.

private checkVersion(RequestMeta $meta, array<string, string> $headers, string|int|null $id) : StatelessResult|null
Parameters
$meta : RequestMeta
$headers : array<string, string>
$id : string|int|null
Return values
StatelessResult|null

encode()

Runs a result through the wire codec. Passed as-is rather than via a json round trip, which would turn a nested `{}` into `[]`.

private encode(string $method, string|int $id, ResultInterface $result[, bool $cacheable = true ]) : StatelessResult
Parameters
$method : string
$id : string|int
$result : ResultInterface
$cacheable : bool = true
Return values
StatelessResult

header()

private header(array<string, string> $headers, string $name) : string|null
Parameters
$headers : array<string, string>
$name : string
Return values
string|null

liftInputContext()

Reads the multi round-trip material off a retry, verifying the state before any of it reaches a handler. Neither member means a first call, which is what a handler tests to decide whether it still needs to ask.

private liftInputContext(array<string, mixed>|null $params) : InputContext|null
Parameters
$params : array<string, mixed>|null
Tags
throws
RequestStateException

when a state is present but does not verify

Return values
InputContext|null

listen()

Opens a `subscriptions/listen` stream. The subscription id is the JSON-RPC id of this request, so there is none to mint.

private listen(array<string, mixed>|null $params, string|int $id) : StatelessResult
Parameters
$params : array<string, mixed>|null
$id : string|int
Return values
StatelessResult

readElicitation()

One fiber suspension read as an elicitation, or null when it is not one.

private static readElicitation(mixed $suspended) : ElicitRequest}|null
Parameters
$suspended : mixed

the payload ClientGateway suspended with

Return values
ElicitRequest}|null

the name the handler gave the ask, and the ask

requiresTransportHeaders()

Whether the transport carrying this dispatcher has a header layer whose required members must be present.

private requiresTransportHeaders() : bool

The validator's presence is the signal: it is what a header-bearing transport installs, and stdio carries its metadata inline instead (see the stdio binding's "Request Metadata").

Return values
bool

run()

Runs a handler, yielding the notifications it emits as it emits them and returning its result.

private run(RequestHandlerInterface<string|int, ResultInterface$handler, Request $request, Session $session, RequestMeta $meta) : Generator<null, Response<string|int, ResultInterface>|Error>

The fiber is what makes a handler's $gateway->progress(...) look synchronous while the caller decides where the notification goes. Server -to-client requests are never forwarded: this revision carries what it needs in the result (MRTR), and putting a request on a response stream is something the transport binding forbids outright.

An elicitation is answered here rather than refused. Already answered, it resumes the fiber and the handler runs on; not yet, and the ask becomes the result — abandoning the fiber, since this request has nothing left to say and the client will re-send it. Abandoning unwinds it, so a handler's finally still runs; what does not run is everything after the ask.

That is what lets one handler serve both eras through ClientGateway::elicit(); see ElicitationReplay for what it costs.

Parameters
$handler : RequestHandlerInterface<string|int, ResultInterface>
$request : Request
$session : Session
$meta : RequestMeta
Return values
Generator<null, Response<string|int, ResultInterface>|Error>

streamFrames()

The frames of a request-scoped response stream: the notifications the handler emits, then the response that ends it.

private streamFrames(Generator<null, Response<string|int, ResultInterface>|Error$run, RequestMeta $meta, string $method, string|int $id, bool $cacheable) : Generator<string|int, mixed>
Parameters
$run : Generator<null, Response<string|int, ResultInterface>|Error>
$meta : RequestMeta
$method : string
$id : string|int
$cacheable : bool
Return values
Generator<string|int, mixed>

tagWithSubscription()

Every message on a listen stream carries the id of the subscription it belongs to, which is how a client demultiplexes them on stdio — where they all share one channel.

private static tagWithSubscription(Notification $notification, string|int $id) : array<string, mixed>
Parameters
$notification : Notification
$id : string|int
Return values
array<string, mixed>

toErrorResult()

The one place a handler's exception becomes an answer, so the streaming and non-streaming paths cannot disagree about which code it earns.

private toErrorResult(string $method, string|int $id, Throwable $e) : StatelessResult
Parameters
$method : string
$id : string|int
$e : Throwable
Return values
StatelessResult

unknownMethod()

A method with no handler, said as precisely as the server can.

private unknownMethod(string $method, string|int $id) : Error

An extension's method is still -32601 when the extension is off — the server genuinely does not implement it — but naming the extension turns an opaque refusal into something the caller can act on.

Parameters
$method : string
$id : string|int
Return values
Error

withTraceContext()

Puts the request's trace context back onto a notification it caused, so a collector can join the two without the handler carrying it by hand.

private static withTraceContext(array<string, mixed> $frame, array<string, string> $traceContext) : array<string, mixed>
Parameters
$frame : array<string, mixed>
$traceContext : array<string, string>
Return values
array<string, mixed>
On this page

Search results