Skip to content

v0.9.0

Latest

Choose a tag to compare

@chr-hertel chr-hertel released this 18 Oct 14:45
· 1 commit to main since this release
be71c78

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. FileSessionLock locks with flock() next to FileSessionStore, whose gc() also removes stale lock files; SymfonyLockSessionLock uses a symfony/lock store for sessions shared across machines; SessionLockInterface takes custom ones. A request that cannot get the lock in time, or hits a failing lock backend, is answered with 503 and a JSON-RPC error (SessionLockTimeoutException and SessionLockException). 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 Psr16SessionStore cache being answered as "session not found" (404): the failure is logged and answered with 503, so clients retry instead of re-initializing. SessionStoreInterface::exists() may throw the new SessionStoreException.
    • 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: StdioTransport keeps 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() and Protocol::handleFiberYield() take and return a Uuid instead of an int, as does the response finder of a transport; handleFiberYield() returns the id of the request it sent. A transport passes the suspended \Fiber as last argument to the pending-requests provider and the fiber yield handler, and StreamableHttpTransport::handleFiberTermination() takes the terminated \Fiber as 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 StreamableHttpTransport bundling the session's queued messages (e.g. notifications/resources/updated) with a request's response into a JSON array, which clients on 2025-06-18 and later reject: such a request is answered as SSE, the queued messages first. A POST of only responses or notifications is accepted with 202 and 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 the Request timed out error, 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 null when the transport fails to store what it sends to the client; ClientGateway::notify() then throws a ClientException.
    • 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.
  • Authorization and HTTP security
    • [BC Break] Narrow authorization to the resource server role (ADR 0002): remove OAuthProxyMiddleware, ClientRegistrationMiddleware, ClientRegistrarInterface, ClientRegistrationException, JwksProvider, JwksProviderInterface, OidcDiscoveryInterface and the OIDC metadata policies; OidcDiscovery is internal.
    • [BC Break] Replace OAuthRequestMetaMiddleware with RequestContext::getAccessToken(), returning the validated Server\Authorization\AccessToken; AuthorizationResult::allow() takes an AccessToken instead of request attributes. Protocol::processInput(), BaseTransport::handleMessage() and StreamableHttpTransport::handlePostRequest() take an ?AccessToken $accessToken parameter, as does the TransportInterface::onMessage() listener as fourth argument.
    • [BC Break] Make JwtTokenValidator final with a single issuer instead of a list of issuer aliases, and a $keys set (e.g. CachedKeySet); JwtTokenValidator::fromIssuer() discovers and caches keys in a PSR-6 pool, refetching on unknown key ids. The alg allowlist is enforced, $tokenType and $leeway are added, requireScopes() is removed in favour of ScopePolicy.
    • [BC Break] Add ScopePolicy as third argument of AuthorizationMiddleware, answering 403 insufficient_scope per method and tool, with scope hierarchies; the resource_metadata challenge URL comes from the configured resource instead of the Host header.
    • [BC Break] ProtectedResourceMetadata requires $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 401 behind AuthorizationMiddleware, which now lets OPTIONS requests pass without a token. The default CorsMiddleware allows Mcp-Method, Mcp-Name and Mcp-Param-* and exposes WWW-Authenticate; an $allowedHeaders entry ending in * allows each requested header with that prefix.
    • [BC Break] DnsRebindingProtectionMiddleware always validates Host (a missing one is rejected) and checks Origin separately against the new allowedOrigins, including scheme and port and empty by default. Same-origin requests are always allowed and both lists accept ['*']. allowedOrigins is the new second constructor argument; pass the factories by name.
    • Add PassthroughMiddleware to opt StreamableHttpTransport out of its default middleware without the warning an empty $middleware list logs.
  • Protocol eras
    • [BC Break] Serve both protocol eras over stdio: StdioTransport settles the era on the client's first request and serves 2026-07-28 requests, subscriptions/listen and notifications/cancelled on the one channel. StatelessAwareTransportInterface declares setHandshakeVersions(), so a server without the modern era names only the revisions it negotiates when refusing a 2026-07-28 request.
    • Answer a bare initialize on a 2026-07-28-only endpoint with -32022 naming 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 listChanged only where list changes are delivered: never in the initialize result, and on server/discover only when the registry publishes them to a notification bus. RegistryInterface declares publishesListChanges(), which Registry answers with true when its dispatcher is a PublishingEventDispatcher; a decorating registry forwards the answer of the one it wraps.
  • Tools and validation
    • [BC Break] Validate a tool result's structuredContent against the tool's outputSchema, which the specification requires the server to honour. A mismatch is answered with a CallToolResult carrying isError: true instead of the non-conforming value, matching the TypeScript, Python and Java SDKs. Skipped when the tool declares no outputSchema, when the result carries no structuredContent, and when the result is already an error. Return new \stdClass() for an empty object, since [] is sent as an array.
    • [BC Break] Add Builder::setSchemaValidator() to configure the validator used for tools/call input and output, e.g. with a resolver for external $ref schemas. SchemaValidator takes an optional Opis\JsonSchema\Validator as its first constructor argument, moving $logger to second (pass logger: by name), and validateAgainstJsonSchema() no longer validates an empty array as an object (pass new \stdClass()).
    • Fix SchemaValidator falling back to opis' wording for camelCase keywords like minLength, multipleOf or additionalProperties, and reporting a pointer relative to the $ref target inside $defs. Clients now see the SDK's messages and the full pointer.
    • [BC Break] Remove the providerClass argument of #[CompletionProvider]. Use provider:, which takes the same class-string and is now the first positional argument.
  • Logging, events and runtime
    • [BC Break] Fix the client's log level being ignored: ClientGateway::log() drops messages below the level set with logging/setLevel, and below warning while the client has set none, and ClientLogger honours a lower level a 2026-07-28 request asks for with io.modelcontextprotocol/logLevel. ClientLogger takes only the ClientGateway, which applies the level itself.
    • Stop the server Protocol from 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, ResponseEvent and ErrorEvent not being dispatched for 2026-07-28 requests, and ResponseEvent and ErrorEvent not when a suspended fiber completes.
    • Add ServerRequestEvent and ClientResponseEvent for handshake-era server-initiated requests (elicitation/create, sampling/createMessage) and the client's replies.
    • Fix memory growing with every tools/call in long-lived processes like FrankenPHP workers or stdio servers.
    • Fix the server's StdioTransport answering a request on an idle connection only after ~50ms: it now waits on the input with stream_select() instead of sleeping.
    • Fix stateless SSE streams holding back frames until close when PHP output buffering is enabled.

Client

  • Protocol eras
    • [BC Break] Speak both protocol eras by default: the client's default protocol version is 2026-07-28, and connect() probes with server/discover and falls back to the initialize handshake on 2025-11-25 when the server does not speak the modern era. Builder::setFallbackProtocolVersion() picks the fallback revision, or null for a modern-only client. Against a server that speaks both, a default client now settles on 2026-07-28, where a server can no longer sample or list roots through ClientGateway; return those asks as an InputRequiredResult instead, or call Builder::setProtocolVersion(ProtocolVersion::V2025_11_25) on the client.
    • On a 2026-07-28 connection, Client::setLoggingLevel() stamps the level on every following request, Client::ping() sends server/discover and Client::sendRootsListChanged() sends nothing.
    • Add a listen option to the client's HttpTransport, opening the standalone GET stream on which a 2025-era server sends requests and notifications outside of a client request, like roots/list. Needs a PSR-18 client that streams response bodies, such as symfony/http-client.
  • 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 throws TimeoutException and sends notifications/cancelled for the abandoned request. TimeoutException extends RequestException instead of Exception, so a catch (RequestException) now also catches timeouts. ClientStateInterface::getPendingRequests() reports timestamp as a float from microtime(true) instead of an int.
    • Resume an interrupted SSE response in HttpTransport with a GET carrying Last-Event-ID, honouring the server's retry interval. Configure attempts and fallback backoff with ReconnectPolicy, the scheduling clock with any PSR-20 ClockInterface (default: a monotonic clock). Protocol version 2026-07-28 does 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 onProgress callback receiving progress for an earlier request that timed out: progress now only reaches the callback of the request whose progressToken it carries.
    • Fix Client::getServerInfo(), getInstructions() and getProtocolVersion() reporting the previous server's values after a reconnect, whether it failed or reached a 2026-07-28 server whose server/discover leaves them out.
    • Fix Client::connect() rejecting an empty serverInfo.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 a ConnectionException.
    • Fix the client's HttpTransport failing to connect through an amphp-based PSR-18 client, like Symfony's Psr18Client(new AmpHttpClient()), where connect() failed at the init timeout with FutureIterator::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 in initialize or, from 2026-07-28 on, in server/discover. ClientStateInterface declares setServerCapabilities() and getServerCapabilities(), and its setProtocolVersion() and setServerInfo() accept null, which a custom implementation has to follow.
    • Add HttpTransport::getSessionId() to read the server-minted Mcp-Session-Id: a request-scoped caller can persist it and pass it back through the constructor's $headers on a later transport. Always null on 2026-07-28, which removed protocol-level sessions.
    • Fix HttpTransport omitting the MCP-Protocol-Version header on requests after the initialize handshake, including the closing DELETE.
    • 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 \JsonException for request params it cannot encode (e.g. invalid UTF-8): it now throws InvalidArgumentException, and a request handler result it cannot encode is answered with an internal error instead of no answer.

Schema

  • [BC Break] Bump MessageInterface::PROTOCOL_VERSION to 2026-07-28. Use ProtocolVersion::latestHandshake() where a handshake revision is needed, e.g. in an initialize answer.
  • Add Tool::$execution (ToolExecution with a TaskSupport enum) and ServerCapabilities::$tasks, which were dropped when parsing a 2025-11-25 server's tools/list and initialize results.
  • [BC Break] Reject a Tool input schema whose properties is not an object or whose required is neither a list nor null, instead of silently replacing the member. Reject a completion/complete whose argument is missing name or value, instead of completing against an empty prefix.
  • [BC Break] Add ElicitationSchema::getDefaults(), returning the declared default of each field to accept a form elicitation with. AbstractSchemaDefinition declares an abstract getDefault(), which a custom schema definition has to implement.

New Contributors

Full Changelog: v0.8.1...v0.9.0