Skip to content

v0.10.0

Latest

Choose a tag to compare

@chr-hertel chr-hertel released this 10 Nov 17:17
5701e69

What's Changed

TL;DR

  • The SDK is split into the Mcp\Schema, Mcp\Client and Mcp\Server namespaces, ahead of separate packages: most imports change
  • PHP 8.2 or higher is required, and JSON-RPC batches are gone
  • The client authorizes with OAuth: authorization code, client credentials, private_key_jwt and experimental cross-app access
  • Handshake-era servers over HTTP run without sessions unless Builder::setSession() is called, and sessions belong to the principal that opened them
  • Request handlers can run under an event loop (Revolt, ReactPHP) with the opt-in Builder::setFiberScheduler(), and stream their responses
  • Tool arguments keep their JSON types, and arguments that don't fit are answered with -32602
  • stdio works on Windows, in the server and the client

Namespaces

  • [BC Break] Split the root namespace into Mcp\Schema, Mcp\Client and Mcp\Server to prepare slicing them into separate packages; update your imports. Mcp\Schema depends on nothing, Mcp\Client and Mcp\Server depend on Mcp\Schema only — enforced by Deptrac (make deptrac).
    • Mcp\Server => Mcp\Server\Server
    • Mcp\Client => Mcp\Client\Client
    • Mcp\Capability\* => Mcp\Server\Capability\* (Attribute, Completion, Discovery, Formatter, Logger, Registry, Tool, plus Registry and RegistryInterface)
    • Mcp\Event\* => Mcp\Server\Event\*
    • Mcp\JsonRpc\MessageFactory => Mcp\Schema\MessageFactory
    • Mcp\Server\Stateless\RequestMeta => Mcp\Schema\RequestMeta
  • [BC Break] Distribute Mcp\Exception\* across the three namespaces.
    • Mcp\Exception\{ExceptionInterface,InvalidArgumentException,LogicException,RuntimeException} => the same names under Mcp\Schema\Exception\, Mcp\Client\Exception\ and Mcp\Server\Exception\ — pick the one matching the package you catch around
    • Mcp\Exception\Exception => Mcp\Client\Exception\Exception and Mcp\Server\Exception\Exception
    • Mcp\Exception\{InvalidInputMessageException,MissingRequestMetaException} => Mcp\Schema\Exception\*
    • Mcp\Exception\{ConnectionException,ElicitationException,RequestCancelledException,RequestException,RootsException,SamplingException,TimeoutException} => Mcp\Client\Exception\*
    • Mcp\Exception\{BadMethodCallException,ClientException,ConfigurationException,ContainerException,InvalidCursorException,MissingRequiredClientCapabilityException,NotFoundExceptionInterface,PromptGetException,PromptNotFoundException,RegistryException,RequestStateException,ResourceNotFoundException,ResourceReadException,ServiceNotFoundException,SessionLockException,SessionLockTimeoutException,SessionStoreException,ToolCallException,ToolNotFoundException} => Mcp\Server\Exception\*
    • Mcp\Exception\HandlerNotFoundException is removed; it was unused.
    • Mcp\Server\Exception\ExceptionInterface and Mcp\Client\Exception\ExceptionInterface extend Mcp\Schema\Exception\ExceptionInterface, so catching the latter still catches any SDK exception.
    • The named exceptions extend their package's RuntimeException/InvalidArgumentException instead of the global ones, so catch (Mcp\Server\Exception\RuntimeException) reaches ToolCallException, PromptGetException and friends.
  • [BC Break] Move ExtensionInterface::getRequestHandlers() to the new Mcp\Server\Extension\ServerExtensionInterface, so Mcp\Schema does not reference Mcp\Server. AbstractExtension no longer defines it. An extension that serves its methods must implement the new interface, or Builder::enableExtension() does not register its handlers.

General

  • [BC Break] Require PHP 8.2 or higher, as PHP 8.1 reached its end of life.
  • [BC Break] Remove JSON-RPC batch support, as MCP dropped batching in 2025-06-18. The server refuses a JSON array with -32600, and HTTP status 400, on every revision, including 2025-03-26, which requires batches and which the server still negotiates. The client drops one with a warning.
  • [BC Break] Refuse OAuth URLs, in the server and the client, whose host is 0.0.0.0, ::, an IPv4 address within IPv6 or an IPv4 address in a non-standard form (e.g. 127.1), or that hold percent-encoding or non-ASCII characters (give an internationalized name in punycode). In the server, this applies to the issuer, the jwks_uri and the resource.
  • [BC Break] Require phpdocumentor/reflection-docblock ^6.0, with phpdocumentor/type-resolver ^2.0 and phpstan/phpdoc-parser ^2.0: older versions cannot parse types like non-empty-array<T>, and the SDK silently ignored such a @param. Require psr/container ^1.1, as Container is not compatible with 1.0.
  • ext-fileinfo is optional: without it, creating content from a file without a MIME type throws a LogicException.

Server

  • Sessions
    • [BC Break] Serve handshake-era clients over Streamable HTTP without sessions unless Builder::setSession() is called, instead of falling back to an InMemorySessionStore that PHP-FPM and php -S lose between requests. initialize is answered without Mcp-Session-Id, later requests run on an EphemeralSessionInterface that is never stored, and GET and DELETE get 405, so a scaled-out server needs no shared session store or lock. The protocol version comes from the MCP-Protocol-Version header. Stdio and the 2026-07-28 era are not affected. Without a session, requests to the client (elicitation, sampling, roots), Protocol::sendRequest(), Protocol::sendNotification() and saving the session throw a SessionRequiredException, answered with -32603 if a handler does not catch it. Input-required results (unless withoutInputRequiredShim() is used), resources/subscribe, resources/unsubscribe and logging/setLevel get -32603, initialize offers neither resource subscriptions nor logging, log messages are dropped and the client capability checks of ClientGateway return false. Keep sessions with e.g. setSession(new InMemorySessionStore()) in a long-running process.
    • [BC Break] Bind handshake-era sessions to the principal that opened them: AccessToken::getPrincipal(), or the StreamableHttpTransport::PRINCIPAL_ATTRIBUTE request attribute when set. A request of another principal, or to a session opened before the upgrade, gets 404. Protocol::processInput(), the transports' handleMessage(), handleSessionEnd(), handlePostRequest() and handleDeleteRequest(), and the onMessage() and onSessionEnd() listeners take a ?string $principal as last argument.
    • [BC Break] Add SessionInterface::isUnreadable(), which a custom session implements. A session the store holds without attributes, because a handler cleared it or its data is corrupt, gets 404 so the client opens a new one; only a session the store fails to return gets 503. FileSessionStore::exists() no longer reports a zero-byte file.
    • [BC Break] Answer a DELETE of an unknown or ended session with 404 instead of 200, as the spec requires: Protocol::destroySession(), BaseTransport::handleSessionEnd() and the listener of TransportInterface::onSessionEnd() return false for it. A listener that returns nothing keeps the 200.
    • [BC Break] Answer malformed JSON and an initialize sent with a session id with HTTP 400, and a failure before the session is checked with 500, instead of 200. Such an answer no longer carries, or removes, the messages queued for the session in Mcp-Session-Id.
    • Add Psr6SessionStore and Psr6NotificationBus, the PSR-6 counterparts of Psr16SessionStore and Psr16NotificationBus. Builder::setDiscovery() also accepts a PSR-6 pool as $cache.
    • Fix Session::save() wiping the other keys of a session the store failed to read, e.g. on a Psr16SessionStore cache error: it returns false instead.
    • Fix FileSessionStore serving a partial session on Windows when a write could not replace the session file: it no longer falls back to copy(), retries the move for up to a second while another process holds the file, and write() returns false if the move fails.
    • Fix InMemorySessionStore::exists() reporting a session past its TTL as existing.
    • Fix Psr16NotificationBus subscribers missing notifications published after the bus stayed quiet for longer than its TTL.
  • Authorization and HTTP security
    • [BC Break] Answer 503 with Retry-After and without a Bearer challenge when JwtTokenValidator cannot fetch the signing keys or reaches the limit of the new RateLimitedJwksClient, which replaces the rateLimit of CachedKeySet. CorsMiddleware exposes Retry-After by default.
    • [BC Break] AuthorizationMiddleware answers a body over its maxBodyBytes with 413 and a JSON-RPC error, like the transports, instead of 400 with a WWW-Authenticate challenge, and logs a warning to its new $logger.
    • [BC Break] ScopePolicy::requiredFor() takes one decoded message, renames its parameter from $payload to $message and returns only the default scopes for a list.
    • Fix JwtTokenValidator throwing a TypeError, or answering 503, on a token naming a JWKS key whose type or curve firebase/php-jwt does not support (e.g. AKP, P-521 since its v7.1, X25519): the token is rejected with 401.
    • Fix SecureUrl rejecting plain http on *.localhost hosts (RFC 6761) and IPv4 loopback addresses other than 127.0.0.1, which broke local setups like http://shop.localhost:8080. Plain http is accepted for localhost, *.localhost, 127.0.0.0/8 and ::1; every other host still requires https.
    • Fix SecureUrl accepting a URL with a backslash, whitespace or control character, so http://evil.example\@localhost passed as loopback although browsers read evil.example as the host.
  • Tools, arguments and results
    • [BC Break] Keep the JSON types of tools/call arguments, so validation sees what the client sent: {} and {"0":"x"} are objects, [] and ["x"] are arrays. Nested objects in CallToolRequest::$arguments and in the arguments of ToolHandlerInterface::execute() are \stdClass, as they are for parameters without a type or typed mixed, object or \stdClass; parameters typed array still receive arrays at every level. array<string, T> parameters generate {"type": "object"}, and array<int, T> parameters accept a JSON array or object.
    • [BC Break] Reject tools/call whose arguments is not a JSON object, including [], with -32602, and one whose message has a member name starting with a NUL byte with -32600.
    • [BC Break] Answer params that do not fit a known method (e.g. tools/call without name) with -32602 instead of -32600, as JSON-RPC requires. Request::fromArray() throws the new InvalidParamsException, a subclass of InvalidArgumentException, and InvalidInputMessageException::isInvalidParams() tells the protocols which code to send.
    • [BC Break] Answer an argument that cannot be cast to its parameter type with -32602 instead of -32603, for tools, prompts and resources. The message names the parameter. A string parameter refuses a list or an object instead of receiving "Array", and an int-backed enum refuses a value with a fraction instead of truncating it. A missing required argument of a prompt, or of a tool that gets past schema validation, gets -32602 too.
    • [BC Break] Modern tools with an outputSchema send and validate a null result as structured content, with JSON text null alongside it. A tool whose schema does not allow null, including a void tool, returns a tool error instead of the text (null). ToolReference::extractStructuredContent() returns StructuredContent::Absent when there is nothing to send.
    • [BC Break] ReferenceHandler resolves a string handler that is not an invokable class from the container by service id. The container goes first, so a function handler no longer runs if the container has an entry of that name.
    • Fix an int parameter receiving a wrong value: a number beyond the integer range wrapped around, also past the schema's minimum or maximum, a numeric string beyond it became PHP_INT_MAX or PHP_INT_MIN, and "--5" became 0. They are refused now.
    • Fix a nullable parameter with a default (e.g. ?string $category = 'books') receiving the default when the client sends null.
    • Fix DocBlock types producing wrong schemas, so valid arguments were rejected with -32602: PHPStan/Psalm pseudo-types and literal types (non-empty-string, positive-int, 'asc'|'desc', array<array-key, T>) get the JSON Schema type of their base type, nullable and union keys of an array shape (array{nickname: string|null}) and union elements of a generic (array<string|null>) get their own types instead of object, and a null inside them, or in a class name, no longer makes the parameter nullable.
    • Fix tool name validation to follow the spec (1 to 128 characters of A-Z, a-z, 0-9, _, - and .): names of 65 to 128 characters no longer log a warning, names with a / or a trailing newline do.
    • Fix content, messages and contents being sent as a JSON object when the handler returned a non-list array, e.g. from array_filter(), and named keys in nested mixed arrays overwriting each other.
    • Fix a scalar structuredContent on 2026-07-28 going out as raw text in content: 'sunny' is sent as the JSON "sunny", as SEP-2106 requires.
    • Fix a resource whose text is not valid UTF-8 failing resources/read: ResourceResultFormatter sends it as a blob with the same MIME type.
    • Encode arrays and objects in results and TextContent as compact JSON instead of pretty-printed JSON.
    • Fix EnumCompletionProvider and ListCompletionProvider offering all values for the prefix "0".
    • Log the name of a called tool at info level; its arguments stay at debug level.
  • Discovery and registration
    • [BC Break] Builder::build() and Builder::buildStateless() throw a RuntimeException when setDiscovery() is used without symfony/finder and without a custom discoverer, instead of logging a warning and discovering nothing.
    • [BC Break] DocBlockParser::parseDocBlock() no longer accepts null; pass ReflectionMethod::getDocComment() as is.
    • Fix CachedDiscoverer returning the elements of a discovery that only differs in its name patterns, and clearCache() wiping the whole cache instead of its own entries.
    • Fix discovery skipping a class whose file declares an enum before it, once that file has been loaded.
    • Fix a manually registered element with a bad handler answering every list and call with -32602 and the internal message: the client gets -32603 and the ConfigurationException is logged.
  • Protocol 2026-07-28
    • [BC Break] Answer server/discover with DiscoverHandler, which Builder::addRequestHandler() can replace, and dispatch RequestEvent, ResponseEvent and ErrorEvent for it. A StatelessProtocol built without the Builder needs DiscoverHandler and a MessageFactory with DiscoverRequest.
    • Add a binder argument to Builder::setRequestState() that binds requestState to the request it was minted for, as the spec recommends: a state echoed on a request that binds to another value is refused like a forged one. OriginatingRequestBinder binds the principal, the method, the tool, prompt or resource and a digest of the arguments; A token without a subject fails closed, and RequestStateBinderInterface takes custom bindings. Without a binder, nothing changes.
    • Rotate the requestState signing key without failing calls in flight: Builder::setRequestState() and RequestStateCodec take a list of keys, the first signing and all of them verifying. The wire format is unchanged.
    • Fix a subscriptions/listen stream that can carry nothing, or that asks for resource subscriptions without a notification bus, being held open for its whole lifetime and pinning a PHP-FPM worker: it is acknowledged with an empty filter and closed right away. server/discover no longer announces resources.subscribe without a bus, unless setCapabilities() sets it. Adds NotificationFilter::isEmpty().
    • Fix a retried resources/read leaving out the required ttlMs and cacheScope, which the TypeScript client rejects: it always carries ttlMs: 0 and cacheScope: private. A ReadResourceResult no longer sends its own hints to handshake-era clients.
    • Fix StandardHeaderValidator accepting an Mcp-Param-* header that does not match the body: a fraction that rounds to an integer argument, object and array arguments, and integers outside ±(2^53-1) are refused with -32020.
    • Fix a request with "id": null being taken for a notification and left unanswered: it is refused with -32600.
    • Fix a result that cannot be encoded as JSON escaping as an uncaught JsonException or ending a stream without an answer: the client gets -32603, with HTTP status 500 or as the last frame of a stream, and the failure is logged and dispatched as an ErrorEvent.
    • Fix messages with a jsonrpc other than "2.0" being served: a 2026-07-28 request that skips MessageFactory, like subscriptions/listen, is refused with -32600, and Notification::fromArray() throws, as Request::fromArray() already did.
    • [BC Break] Remove Rev2025Codec, which nothing used.
  • Event loops and fibers
    • Add Builder::setFiberScheduler() and FiberSchedulerInterface: under an event loop (Revolt, ReactPHP's async), a handler awaiting I/O suspends its fiber for the loop, and the server waits through the scheduler until the handler suspends for the server or returns. The scheduler must wake the server on a later turn of the loop; the guide shows one for Revolt and one for ReactPHP.
    • Add CallbackStream::chunks(), which hands the SSE text of a streamed response, in both protocol eras, to a server that writes the body itself (e.g. amphp/http-server, react/http). On an empty chunk, the server waits on its loop before it asks for the next one, and dropping the generator ends the stream. While a handshake-era handler waits for the client, the server keeps serving its loop. The Revolt and ReactPHP examples stream these responses instead of answering 501.
    • Add Builder::setFiberContext() and FiberContextInterface to carry execution context, e.g. the active OpenTelemetry context, into handler fibers.
    • [BC Break] A subscriptions/listen stream read from StatelessProtocol::handle() no longer waits between polls or checks connection_aborted(): its consumer paces it, as StatelessResponder does.
    • [BC Break] StreamableHttpTransport no longer has the protected flushOutgoingMessages() and handleFiberTermination().
    • [BC Break] A handler calling \Fiber::suspend() itself with anything but a NotificationSuspension or RequestSuspension fails the request without a scheduler, instead of being resumed with null.
    • Fix the server resuming a fiber the runtime had suspended, which made the handler's await return early or throw and could terminate a Revolt loop. Without a scheduler, such a handler fails the request with a LogicException naming Builder::setFiberScheduler(). Protocol and StatelessProtocol take the scheduler as optional last constructor argument.
    • Fix what a handler throws while the runtime drives its fiber escaping to the runtime: it reaches the server, and what it throws while its fiber is discarded is logged.
    • Fix a request failing with 503, or a resumed one losing its session writes, while another request of the session holds the lock across an await: both wait for the lock on the loop.
    • Fix a handler still running after its request was answered firing ResponseEvent or ErrorEvent: what it throws is logged, and calling the ClientGateway throws.
  • stdio
    • Fix a stdio server on Windows ending by itself after about 13.5 hours without input: StdioTransport reads only once stream_select() reports input. Requires PHP 8.5 on Windows.
    • Fix a stdio server never answering a message that reaches it in several reads, such as a tool call with large arguments.
    • Fix a stdio server answering a notification it refuses, e.g. one sent before initialize: the refusal is only logged.
    • Fix maxLineBytes of StdioTransport discarding lines two bytes shorter than the limit, and StdioTransport throwing a ValueError on an input that cannot be selected, like php://memory.

Client

  • Authorization
    • Add OAuth authorization: pass OAuth::forApplication('My App')->build() as auth and the client answers a 401 with discovery, dynamic or pre-registered client identity, the authorization code flow with PKCE and a retry. It refreshes tokens, handles step-up challenges and treats the server's metadata as untrusted input. See Authorization.
    • Add OAuth::forServiceAccount() for the client credentials grant, setPrivateKeyJwt() for private_key_jwt client authentication, the experimental setCrossAppAccess() to reach a server through an enterprise identity, and setClientMetadataUrl() for a client ID metadata document. setExpectedIssuer() is required with setClientCredentials(), so a hostile MCP server can't receive the secret or an authorization code.
    • Add LoopbackAuthorizationHandler, HeadlessAuthorizationHandler, ConsoleAuthorizationHandler for a shell without a browser, and RedirectAuthorizationHandler with OAuthAuthenticator::finishAuthorization() to finish in a later request, such as a web callback. Credentials go to InMemoryCredentialStorage or FileCredentialStorage, which also implements PendingAuthorizationStorageInterface.
    • Add the auth parameter of HttpTransport and AuthenticatorInterface, to authenticate every request and retry the ones the server refuses. BearerToken sends a token you already have, and AuthenticatingHttpClient offers the same as a PSR-18 decorator.
    • [BC Break] A 401 or 403 answer to a request, a notification or a stream resumption fails with an AuthorizationException holding the WWW-Authenticate header, instead of a RequestException. Client::connect() wraps it in a ConnectionException, without a retry.
  • HTTP transport
    • [BC Break] Turn off redirects on a symfony/http-client client, discovered or passed, in HttpTransport, AuthenticatingHttpClient and OAuth. HttpTransport fails on every 3xx answer with an error that names the status and target, while AuthenticatingHttpClient returns it unchanged. A redirect to another origin used to receive the configured headers, like an API key, and on a 307 or 308 the body: rotate those credentials if an endpoint of yours may have redirected to a host you don't control. Configure the final URL of an endpoint reached through a redirect. A client you pass must not follow redirects to another origin.
    • [BC Break] Limit what HttpTransport reads of a response that is not an SSE stream to the new maxResponseBodyBytes (default: 8 MiB): a larger successful response fails the request, and an error body is cut at the limit.
    • Fix HttpTransport taking an Mcp-Session-Id from any response instead of only the answer to initialize, which replaced the session mid-way and, on 2026-07-28, made close() send a DELETE.
    • Fix HttpTransport not reading the listening stream during a request answered with plain JSON.
  • stdio on Windows
    • [BC Break] Start a stdio server without cmd.exe, which corrupted arguments containing ", % or !. A batch file like npx needs command: 'cmd' and args: ['/c', 'npx', ...].
    • Fix the stdio client hanging while the server stays silent, ignoring its timeouts. Requires PHP 8.5.
  • Fix Client::connect() leaving the previous transport open when called again.

Schema

  • [BC Break] MessageFactory::create() returns one MessageInterface and throws InvalidInputMessageException for invalid input, instead of returning a list of messages and exceptions.
  • [BC Break] Remove MessageFactory::DEFAULT_MAX_BATCH_SIZE and the $maxBatchSize parameter of MessageFactory::__construct() and MessageFactory::make(), whose first parameter is now $additional.
  • Add UnknownMethodException, a subclass of InvalidInputMessageException for a method the factory has not registered.
  • [BC Break] An x-mcp-header annotation that is not on a property reached through properties alone, e.g. under items, anyOf or $defs, or that is null, makes the tool definition invalid: Tool throws an InvalidArgumentException, and the client drops such a tool from tools/list and refuses a sampling request carrying one.
  • [BC Break] CallToolResult keeps an explicit structuredContent: null when constructed and decoded. Code that passed null to omit it must leave the argument out (use meta: $meta for metadata) or pass StructuredContent::Absent. An explicit null that violates the output schema becomes a tool error, and strict handshake-era clients can reject it. hasStructuredContent() tells absent content from null; success() and error() stay without structured content.

New Contributors

Full Changelog: v0.9.0...v0.10.0