Repository navigation
What's Changed
TL;DR
- Concurrent requests of one session keep their own answers, session changes and fibers
- The client fails fast instead of waiting out its timeout
- Authorization narrows to the resource server role
- stdio and the client speak both protocol eras
Server
- Concurrent requests and sessions
- Add an opt-in per-session lock,
Builder::setSessionLock(), serializing the concurrent requests of one session so none of them overwrites what another saved.FileSessionLocklocks withflock()next toFileSessionStore, whosegc()also removes stale lock files;SymfonyLockSessionLockuses a symfony/lock store for sessions shared across machines;SessionLockInterfacetakes custom ones. A request that cannot get the lock in time, or hits a failing lock backend, is answered with503and a JSON-RPC error (SessionLockTimeoutExceptionandSessionLockException).Protocol::__construct()takes the lock as optional last argument. - Fix concurrent requests of one session overwriting each other's session changes:
Session::save()writes only the keys it changed, onto what the store holds at that point. Writes of a handler after it resumed from an elicitation, sampling request or notification are saved too, and a session deleted while one of its requests still ran no longer comes back when that request saves it. - Fix an unreachable
Psr16SessionStorecache being answered as "session not found" (404): the failure is logged and answered with503, so clients retry instead of re-initializing.SessionStoreInterface::exists()may throw the newSessionStoreException. - Fix parallel calls of one session getting each other's elicitation answers and progress tokens: each stream polls only the client request its own fiber sent, requests to the client get UUID ids instead of a session counter, and
ClientGateway::progress()takes the token from its own request. - Fix concurrent stdio tool calls that report progress, log, elicit or sample never finishing:
StdioTransportkeeps every suspended fiber and resumes each with the answer to its own request. A call's notifications now go out before its response. - [BC Break] Ids of requests to the client are UUIDs:
Protocol::sendRequest(),Protocol::checkResponse()andProtocol::handleFiberYield()take and return aUuidinstead of anint, as does the response finder of a transport;handleFiberYield()returns the id of the request it sent. A transport passes the suspended\Fiberas last argument to the pending-requests provider and the fiber yield handler, andStreamableHttpTransport::handleFiberTermination()takes the terminated\Fiberas its first argument. - [BC Break] 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.
TransportInterface::send()receives every response of a handler that does not suspend, and the outgoing queue only carries server-initiated requests and notifications; a custom transport must deliver each message it gets, as a batch gets several. - Fix
StreamableHttpTransportbundling the session's queued messages (e.g.notifications/resources/updated) with a request's response into a JSON array, which clients on2025-06-18and later reject: such a request is answered as SSE, the queued messages first. A POST of only responses or notifications is accepted with202and no body, and the queued messages stay. - Fix requests to the client (elicitation, sampling) staying in the session's pending requests after they timed out or their stream vanished:
Protocol::checkResponse()expires them with theRequest timed outerror, entries of a vanished stream are dropped a minute past their timeout, and an answer to a request the server is not waiting on is dropped instead of stored. - Fix a handler throwing after its fiber resumed escaping to the transport: it is answered with an error response, like one throwing before suspending. A suspended handler is resumed with an error instead of
nullwhen the transport fails to store what it sends to the client;ClientGateway::notify()then throws aClientException. - Fix a handshake-era Streamable HTTP stream pinning its PHP worker until the elicitation (or other client request) times out after the client disconnected: the idle stream sends a keep-alive comment and stops once the connection is aborted.
- Add an opt-in per-session lock,
- Authorization and HTTP security
- [BC Break] Narrow authorization to the resource server role (ADR 0002): remove
OAuthProxyMiddleware,ClientRegistrationMiddleware,ClientRegistrarInterface,ClientRegistrationException,JwksProvider,JwksProviderInterface,OidcDiscoveryInterfaceand the OIDC metadata policies;OidcDiscoveryis internal. - [BC Break] Replace
OAuthRequestMetaMiddlewarewithRequestContext::getAccessToken(), returning the validatedServer\Authorization\AccessToken;AuthorizationResult::allow()takes anAccessTokeninstead of request attributes.Protocol::processInput(),BaseTransport::handleMessage()andStreamableHttpTransport::handlePostRequest()take an?AccessToken $accessTokenparameter, as does theTransportInterface::onMessage()listener as fourth argument. - [BC Break] Make
JwtTokenValidatorfinal with a single issuer instead of a list of issuer aliases, and a$keysset (e.g.CachedKeySet);JwtTokenValidator::fromIssuer()discovers and caches keys in a PSR-6 pool, refetching on unknown key ids. Thealgallowlist is enforced,$tokenTypeand$leewayare added,requireScopes()is removed in favour ofScopePolicy. - [BC Break] Add
ScopePolicyas third argument ofAuthorizationMiddleware, answering403 insufficient_scopeper method and tool, with scope hierarchies; theresource_metadatachallenge URL comes from the configured resource instead of theHostheader. - [BC Break]
ProtectedResourceMetadatarequires$resource, serves at the path derived from it (RFC 9728 §3.1) and requires https except for loopback hosts; drops localized, policy, ToS, extra fields and$metadataPaths. - Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0).
- Fix CORS preflight requests failing with
401behindAuthorizationMiddleware, which now letsOPTIONSrequests pass without a token. The defaultCorsMiddlewareallowsMcp-Method,Mcp-NameandMcp-Param-*and exposesWWW-Authenticate; an$allowedHeadersentry ending in*allows each requested header with that prefix. - [BC Break]
DnsRebindingProtectionMiddlewarealways validatesHost(a missing one is rejected) and checksOriginseparately against the newallowedOrigins, including scheme and port and empty by default. Same-origin requests are always allowed and both lists accept['*'].allowedOriginsis the new second constructor argument; pass the factories by name. - Add
PassthroughMiddlewareto optStreamableHttpTransportout of its default middleware without the warning an empty$middlewarelist logs.
- [BC Break] Narrow authorization to the resource server role (ADR 0002): remove
- Protocol eras
- [BC Break] Serve both protocol eras over stdio:
StdioTransportsettles the era on the client's first request and serves2026-07-28requests,subscriptions/listenandnotifications/cancelledon the one channel.StatelessAwareTransportInterfacedeclaressetHandshakeVersions(), so a server without the modern era names only the revisions it negotiates when refusing a2026-07-28request. - Answer a bare
initializeon a2026-07-28-only endpoint with-32022naming the served revisions, and a request without a session on the handshake leg with its id. - Reject a recognized
Mcp-Param-*header whose mirrored argument is absent from the body with-32020, instead of accepting the request (SEP-2243). - [BC Break] Announce
listChangedonly where list changes are delivered: never in theinitializeresult, and onserver/discoveronly when the registry publishes them to a notification bus.RegistryInterfacedeclarespublishesListChanges(), whichRegistryanswers withtruewhen its dispatcher is aPublishingEventDispatcher; a decorating registry forwards the answer of the one it wraps.
- [BC Break] Serve both protocol eras over stdio:
- Tools and validation
- [BC Break] Validate a tool result's
structuredContentagainst the tool'soutputSchema, which the specification requires the server to honour. A mismatch is answered with aCallToolResultcarryingisError: trueinstead of the non-conforming value, matching the TypeScript, Python and Java SDKs. Skipped when the tool declares nooutputSchema, when the result carries nostructuredContent, and when the result is already an error. Returnnew \stdClass()for an empty object, since[]is sent as an array. - [BC Break] Add
Builder::setSchemaValidator()to configure the validator used fortools/callinput and output, e.g. with a resolver for external$refschemas.SchemaValidatortakes an optionalOpis\JsonSchema\Validatoras its first constructor argument, moving$loggerto second (passlogger:by name), andvalidateAgainstJsonSchema()no longer validates an empty array as an object (passnew \stdClass()). - Fix
SchemaValidatorfalling back to opis' wording for camelCase keywords likeminLength,multipleOforadditionalProperties, and reporting a pointer relative to the$reftarget inside$defs. Clients now see the SDK's messages and the full pointer. - [BC Break] Remove the
providerClassargument of#[CompletionProvider]. Useprovider:, which takes the same class-string and is now the first positional argument.
- [BC Break] Validate a tool result's
- Logging, events and runtime
- [BC Break] Fix the client's log level being ignored:
ClientGateway::log()drops messages below the level set withlogging/setLevel, and belowwarningwhile the client has set none, andClientLoggerhonours a lower level a2026-07-28request asks for withio.modelcontextprotocol/logLevel.ClientLoggertakes only theClientGateway, which applies the level itself. - Stop the server
Protocolfrom logging full JSON-RPC payloads (tool arguments, client replies) at info level: info records now carry only the method and id, the raw message is logged at debug level. - Fix
RequestEvent,ResponseEventandErrorEventnot being dispatched for2026-07-28requests, andResponseEventandErrorEventnot when a suspended fiber completes. - Add
ServerRequestEventandClientResponseEventfor handshake-era server-initiated requests (elicitation/create,sampling/createMessage) and the client's replies. - Fix memory growing with every
tools/callin long-lived processes like FrankenPHP workers or stdio servers. - Fix the server's
StdioTransportanswering a request on an idle connection only after ~50ms: it now waits on the input withstream_select()instead of sleeping. - Fix stateless SSE streams holding back frames until close when PHP output buffering is enabled.
- [BC Break] Fix the client's log level being ignored:
Client
- Protocol eras
- [BC Break] Speak both protocol eras by default: the client's default protocol version is
2026-07-28, andconnect()probes withserver/discoverand falls back to theinitializehandshake on2025-11-25when the server does not speak the modern era.Builder::setFallbackProtocolVersion()picks the fallback revision, ornullfor a modern-only client. Against a server that speaks both, a default client now settles on2026-07-28, where a server can no longer sample or list roots throughClientGateway; return those asks as anInputRequiredResultinstead, or callBuilder::setProtocolVersion(ProtocolVersion::V2025_11_25)on the client. - On a
2026-07-28connection,Client::setLoggingLevel()stamps the level on every following request,Client::ping()sendsserver/discoverandClient::sendRootsListChanged()sends nothing. - Add a
listenoption to the client'sHttpTransport, opening the standalone GET stream on which a 2025-era server sends requests and notifications outside of a client request, likeroots/list. Needs a PSR-18 client that streams response bodies, such assymfony/http-client.
- [BC Break] Speak both protocol eras by default: the client's default protocol version is
- Failing fast and recovering
- Fail a client request at once instead of waiting out the timeout when the HTTP server refuses it with an error status, or a non-streamed response ends without its answer (truncated, empty, unreadable or not JSON), also when the body of an error status fails to read.
- Fix the client after the stdio server process exited: requests fail at once with the exit code and the end of stderr, and
Client::isConnected()turns false. - [BC Break] Fix the client request timeout firing up to a second early and throwing a generic
RequestException: it now throwsTimeoutExceptionand sendsnotifications/cancelledfor the abandoned request.TimeoutExceptionextendsRequestExceptioninstead ofException, so acatch (RequestException)now also catches timeouts.ClientStateInterface::getPendingRequests()reportstimestampas afloatfrommicrotime(true)instead of anint. - Resume an interrupted SSE response in
HttpTransportwith a GET carryingLast-Event-ID, honouring the server'sretryinterval. Configure attempts and fallback backoff withReconnectPolicy, the scheduling clock with any PSR-20ClockInterface(default: a monotonic clock). Protocol version2026-07-28does not resume streams. A stream that ends without its response and without an event ID now fails the request instead of waiting for its timeout. - Fix a client
onProgresscallback receiving progress for an earlier request that timed out: progress now only reaches the callback of the request whoseprogressTokenit carries. - Fix
Client::getServerInfo(),getInstructions()andgetProtocolVersion()reporting the previous server's values after a reconnect, whether it failed or reached a2026-07-28server whoseserver/discoverleaves them out. - Fix
Client::connect()rejecting an emptyserverInfo.version, as the Python SDK sends, and leaving the transport open when initialization fails on a malformed result: the transport is now closed and the failure wrapped in aConnectionException. - Fix the client's
HttpTransportfailing to connect through an amphp-based PSR-18 client, like Symfony'sPsr18Client(new AmpHttpClient()), whereconnect()failed at the init timeout withFutureIterator::consume(): Return value must be of type ?array: requests are now sent from outside the request fiber, where the event loop runs.
- API
- [BC Break] Add
Client::getServerCapabilities(), returning what the server declared ininitializeor, from2026-07-28on, inserver/discover.ClientStateInterfacedeclaressetServerCapabilities()andgetServerCapabilities(), and itssetProtocolVersion()andsetServerInfo()acceptnull, which a custom implementation has to follow. - Add
HttpTransport::getSessionId()to read the server-mintedMcp-Session-Id: a request-scoped caller can persist it and pass it back through the constructor's$headerson a later transport. Alwaysnullon2026-07-28, which removed protocol-level sessions. - Fix
HttpTransportomitting theMCP-Protocol-Versionheader on requests after theinitializehandshake, including the closingDELETE. - Fix
Client::getPrompt()without arguments sending"arguments": [], which servers validating the spec's object type (e.g. the TypeScript SDK) reject: empty arguments are now omitted. - Fix the client throwing a native
\JsonExceptionfor request params it cannot encode (e.g. invalid UTF-8): it now throwsInvalidArgumentException, and a request handler result it cannot encode is answered with an internal error instead of no answer.
- [BC Break] Add
Schema
- [BC Break] Bump
MessageInterface::PROTOCOL_VERSIONto2026-07-28. UseProtocolVersion::latestHandshake()where a handshake revision is needed, e.g. in aninitializeanswer. - Add
Tool::$execution(ToolExecutionwith aTaskSupportenum) andServerCapabilities::$tasks, which were dropped when parsing a 2025-11-25 server'stools/listandinitializeresults. - [BC Break] Reject a
Toolinput schema whosepropertiesis not an object or whoserequiredis neither a list nornull, instead of silently replacing the member. Reject acompletion/completewhoseargumentis missingnameorvalue, instead of completing against an empty prefix. - [BC Break] Add
ElicitationSchema::getDefaults(), returning the declareddefaultof each field to accept a form elicitation with.AbstractSchemaDefinitiondeclares an abstractgetDefault(), which a custom schema definition has to implement.
New Contributors
- @RobLoach made their first contribution in #497
- @karedum made their first contribution in #506
- @SammyTourani made their first contribution in #520
- @steffans made their first contribution in #509
- @aton-of-data made their first contribution in #525
- @mglaman made their first contribution in #526
- @guillaume-sainthillier made their first contribution in #535
- @ez-lbz made their first contribution in #425
- @Arslan-TR made their first contribution in #585
- @ausi made their first contribution in #589
- @vbcherepanov made their first contribution in #584
Full Changelog: v0.8.1...v0.9.0