Repository navigation
What's Changed
TL;DR
- The SDK is split into the
Mcp\Schema,Mcp\ClientandMcp\Servernamespaces, 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_jwtand 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\ClientandMcp\Serverto prepare slicing them into separate packages; update your imports.Mcp\Schemadepends on nothing,Mcp\ClientandMcp\Serverdepend onMcp\Schemaonly — enforced by Deptrac (make deptrac).Mcp\Server=>Mcp\Server\ServerMcp\Client=>Mcp\Client\ClientMcp\Capability\*=>Mcp\Server\Capability\*(Attribute,Completion,Discovery,Formatter,Logger,Registry,Tool, plusRegistryandRegistryInterface)Mcp\Event\*=>Mcp\Server\Event\*Mcp\JsonRpc\MessageFactory=>Mcp\Schema\MessageFactoryMcp\Server\Stateless\RequestMeta=>Mcp\Schema\RequestMeta
- [BC Break] Distribute
Mcp\Exception\*across the three namespaces.Mcp\Exception\{ExceptionInterface,InvalidArgumentException,LogicException,RuntimeException}=> the same names underMcp\Schema\Exception\,Mcp\Client\Exception\andMcp\Server\Exception\— pick the one matching the package you catch aroundMcp\Exception\Exception=>Mcp\Client\Exception\ExceptionandMcp\Server\Exception\ExceptionMcp\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\HandlerNotFoundExceptionis removed; it was unused.Mcp\Server\Exception\ExceptionInterfaceandMcp\Client\Exception\ExceptionInterfaceextendMcp\Schema\Exception\ExceptionInterface, so catching the latter still catches any SDK exception.- The named exceptions extend their package's
RuntimeException/InvalidArgumentExceptioninstead of the global ones, socatch (Mcp\Server\Exception\RuntimeException)reachesToolCallException,PromptGetExceptionand friends.
- [BC Break] Move
ExtensionInterface::getRequestHandlers()to the newMcp\Server\Extension\ServerExtensionInterface, soMcp\Schemadoes not referenceMcp\Server.AbstractExtensionno longer defines it. An extension that serves its methods must implement the new interface, orBuilder::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 status400, on every revision, including2025-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, thejwks_uriand the resource. - [BC Break] Require
phpdocumentor/reflection-docblock^6.0, withphpdocumentor/type-resolver^2.0andphpstan/phpdoc-parser^2.0: older versions cannot parse types likenon-empty-array<T>, and the SDK silently ignored such a@param. Requirepsr/container^1.1, asContaineris not compatible with1.0. ext-fileinfois optional: without it, creating content from a file without a MIME type throws aLogicException.
Server
- Sessions
- [BC Break] Serve handshake-era clients over Streamable HTTP without sessions unless
Builder::setSession()is called, instead of falling back to anInMemorySessionStorethat PHP-FPM andphp -Slose between requests.initializeis answered withoutMcp-Session-Id, later requests run on anEphemeralSessionInterfacethat is never stored, andGETandDELETEget405, so a scaled-out server needs no shared session store or lock. The protocol version comes from theMCP-Protocol-Versionheader. Stdio and the2026-07-28era are not affected. Without a session, requests to the client (elicitation, sampling, roots),Protocol::sendRequest(),Protocol::sendNotification()and saving the session throw aSessionRequiredException, answered with-32603if a handler does not catch it. Input-required results (unlesswithoutInputRequiredShim()is used),resources/subscribe,resources/unsubscribeandlogging/setLevelget-32603,initializeoffers neither resource subscriptions nor logging, log messages are dropped and the client capability checks ofClientGatewayreturnfalse. 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 theStreamableHttpTransport::PRINCIPAL_ATTRIBUTErequest attribute when set. A request of another principal, or to a session opened before the upgrade, gets404.Protocol::processInput(), the transports'handleMessage(),handleSessionEnd(),handlePostRequest()andhandleDeleteRequest(), and theonMessage()andonSessionEnd()listeners take a?string $principalas 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, gets404so the client opens a new one; only a session the store fails to return gets503.FileSessionStore::exists()no longer reports a zero-byte file. - [BC Break] Answer a
DELETEof an unknown or ended session with404instead of200, as the spec requires:Protocol::destroySession(),BaseTransport::handleSessionEnd()and the listener ofTransportInterface::onSessionEnd()returnfalsefor it. A listener that returns nothing keeps the200. - [BC Break] Answer malformed JSON and an
initializesent with a session id with HTTP400, and a failure before the session is checked with500, instead of200. Such an answer no longer carries, or removes, the messages queued for the session inMcp-Session-Id. - Add
Psr6SessionStoreandPsr6NotificationBus, the PSR-6 counterparts ofPsr16SessionStoreandPsr16NotificationBus.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 aPsr16SessionStorecache error: it returnsfalseinstead. - Fix
FileSessionStoreserving a partial session on Windows when a write could not replace the session file: it no longer falls back tocopy(), retries the move for up to a second while another process holds the file, andwrite()returnsfalseif the move fails. - Fix
InMemorySessionStore::exists()reporting a session past its TTL as existing. - Fix
Psr16NotificationBussubscribers missing notifications published after the bus stayed quiet for longer than its TTL.
- [BC Break] Serve handshake-era clients over Streamable HTTP without sessions unless
- Authorization and HTTP security
- [BC Break] Answer
503withRetry-Afterand without a Bearer challenge whenJwtTokenValidatorcannot fetch the signing keys or reaches the limit of the newRateLimitedJwksClient, which replaces therateLimitofCachedKeySet.CorsMiddlewareexposesRetry-Afterby default. - [BC Break]
AuthorizationMiddlewareanswers a body over itsmaxBodyByteswith413and a JSON-RPC error, like the transports, instead of400with aWWW-Authenticatechallenge, and logs a warning to its new$logger. - [BC Break]
ScopePolicy::requiredFor()takes one decoded message, renames its parameter from$payloadto$messageand returns only the default scopes for a list. - Fix
JwtTokenValidatorthrowing aTypeError, or answering503, on a token naming a JWKS key whose type or curvefirebase/php-jwtdoes not support (e.g.AKP,P-521since its v7.1, X25519): the token is rejected with401. - Fix
SecureUrlrejecting plain http on*.localhosthosts (RFC 6761) and IPv4 loopback addresses other than127.0.0.1, which broke local setups likehttp://shop.localhost:8080. Plain http is accepted forlocalhost,*.localhost,127.0.0.0/8and::1; every other host still requires https. - Fix
SecureUrlaccepting a URL with a backslash, whitespace or control character, sohttp://evil.example\@localhostpassed as loopback although browsers readevil.exampleas the host.
- [BC Break] Answer
- Tools, arguments and results
- [BC Break] Keep the JSON types of
tools/callarguments, so validation sees what the client sent:{}and{"0":"x"}are objects,[]and["x"]are arrays. Nested objects inCallToolRequest::$argumentsand in the arguments ofToolHandlerInterface::execute()are\stdClass, as they are for parameters without a type or typedmixed,objector\stdClass; parameters typedarraystill receive arrays at every level.array<string, T>parameters generate{"type": "object"}, andarray<int, T>parameters accept a JSON array or object. - [BC Break] Reject
tools/callwhoseargumentsis 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/callwithoutname) with-32602instead of-32600, as JSON-RPC requires.Request::fromArray()throws the newInvalidParamsException, a subclass ofInvalidArgumentException, andInvalidInputMessageException::isInvalidParams()tells the protocols which code to send. - [BC Break] Answer an argument that cannot be cast to its parameter type with
-32602instead of-32603, for tools, prompts and resources. The message names the parameter. Astringparameter 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-32602too. - [BC Break] Modern tools with an
outputSchemasend and validate a null result as structured content, with JSON textnullalongside it. A tool whose schema does not allow null, including avoidtool, returns a tool error instead of the text(null).ToolReference::extractStructuredContent()returnsStructuredContent::Absentwhen there is nothing to send. - [BC Break]
ReferenceHandlerresolves 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
intparameter receiving a wrong value: a number beyond the integer range wrapped around, also past the schema'sminimumormaximum, a numeric string beyond it becamePHP_INT_MAXorPHP_INT_MIN, and"--5"became0. They are refused now. - Fix a nullable parameter with a default (e.g.
?string $category = 'books') receiving the default when the client sendsnull. - 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 ofobject, and anullinside 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,messagesandcontentsbeing sent as a JSON object when the handler returned a non-list array, e.g. fromarray_filter(), and named keys in nested mixed arrays overwriting each other. - Fix a scalar
structuredContenton2026-07-28going out as raw text incontent:'sunny'is sent as the JSON"sunny", as SEP-2106 requires. - Fix a resource whose text is not valid UTF-8 failing
resources/read:ResourceResultFormattersends it as a blob with the same MIME type. - Encode arrays and objects in results and
TextContentas compact JSON instead of pretty-printed JSON. - Fix
EnumCompletionProviderandListCompletionProvideroffering all values for the prefix"0". - Log the name of a called tool at info level; its arguments stay at debug level.
- [BC Break] Keep the JSON types of
- Discovery and registration
- [BC Break]
Builder::build()andBuilder::buildStateless()throw aRuntimeExceptionwhensetDiscovery()is used withoutsymfony/finderand without a custom discoverer, instead of logging a warning and discovering nothing. - [BC Break]
DocBlockParser::parseDocBlock()no longer acceptsnull; passReflectionMethod::getDocComment()as is. - Fix
CachedDiscovererreturning the elements of a discovery that only differs in its name patterns, andclearCache()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
-32602and the internal message: the client gets-32603and theConfigurationExceptionis logged.
- [BC Break]
- Protocol
2026-07-28- [BC Break] Answer
server/discoverwithDiscoverHandler, whichBuilder::addRequestHandler()can replace, and dispatchRequestEvent,ResponseEventandErrorEventfor it. AStatelessProtocolbuilt without theBuilderneedsDiscoverHandlerand aMessageFactorywithDiscoverRequest. - Add a
binderargument toBuilder::setRequestState()that bindsrequestStateto 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.OriginatingRequestBinderbinds the principal, the method, the tool, prompt or resource and a digest of the arguments; A token without a subject fails closed, andRequestStateBinderInterfacetakes custom bindings. Without a binder, nothing changes. - Rotate the
requestStatesigning key without failing calls in flight:Builder::setRequestState()andRequestStateCodectake a list of keys, the first signing and all of them verifying. The wire format is unchanged. - Fix a
subscriptions/listenstream 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/discoverno longer announcesresources.subscribewithout a bus, unlesssetCapabilities()sets it. AddsNotificationFilter::isEmpty(). - Fix a retried
resources/readleaving out the requiredttlMsandcacheScope, which the TypeScript client rejects: it always carriesttlMs: 0andcacheScope: private. AReadResourceResultno longer sends its own hints to handshake-era clients. - Fix
StandardHeaderValidatoraccepting anMcp-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": nullbeing 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
JsonExceptionor ending a stream without an answer: the client gets-32603, with HTTP status500or as the last frame of a stream, and the failure is logged and dispatched as anErrorEvent. - Fix messages with a
jsonrpcother than"2.0"being served: a2026-07-28request that skipsMessageFactory, likesubscriptions/listen, is refused with-32600, andNotification::fromArray()throws, asRequest::fromArray()already did. - [BC Break] Remove
Rev2025Codec, which nothing used.
- [BC Break] Answer
- Event loops and fibers
- Add
Builder::setFiberScheduler()andFiberSchedulerInterface: 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 answering501. - Add
Builder::setFiberContext()andFiberContextInterfaceto carry execution context, e.g. the active OpenTelemetry context, into handler fibers. - [BC Break] A
subscriptions/listenstream read fromStatelessProtocol::handle()no longer waits between polls or checksconnection_aborted(): its consumer paces it, asStatelessResponderdoes. - [BC Break]
StreamableHttpTransportno longer has the protectedflushOutgoingMessages()andhandleFiberTermination(). - [BC Break] A handler calling
\Fiber::suspend()itself with anything but aNotificationSuspensionorRequestSuspensionfails the request without a scheduler, instead of being resumed withnull. - 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
LogicExceptionnamingBuilder::setFiberScheduler().ProtocolandStatelessProtocoltake 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
ResponseEventorErrorEvent: what it throws is logged, and calling theClientGatewaythrows.
- Add
- stdio
- Fix a stdio server on Windows ending by itself after about 13.5 hours without input:
StdioTransportreads only oncestream_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
maxLineBytesofStdioTransportdiscarding lines two bytes shorter than the limit, andStdioTransportthrowing aValueErroron an input that cannot be selected, likephp://memory.
- Fix a stdio server on Windows ending by itself after about 13.5 hours without input:
Client
- Authorization
- Add OAuth authorization: pass
OAuth::forApplication('My App')->build()asauthand the client answers a401with 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()forprivate_key_jwtclient authentication, the experimentalsetCrossAppAccess()to reach a server through an enterprise identity, andsetClientMetadataUrl()for a client ID metadata document.setExpectedIssuer()is required withsetClientCredentials(), so a hostile MCP server can't receive the secret or an authorization code. - Add
LoopbackAuthorizationHandler,HeadlessAuthorizationHandler,ConsoleAuthorizationHandlerfor a shell without a browser, andRedirectAuthorizationHandlerwithOAuthAuthenticator::finishAuthorization()to finish in a later request, such as a web callback. Credentials go toInMemoryCredentialStorageorFileCredentialStorage, which also implementsPendingAuthorizationStorageInterface. - Add the
authparameter ofHttpTransportandAuthenticatorInterface, to authenticate every request and retry the ones the server refuses.BearerTokensends a token you already have, andAuthenticatingHttpClientoffers the same as a PSR-18 decorator. - [BC Break] A
401or403answer to a request, a notification or a stream resumption fails with anAuthorizationExceptionholding theWWW-Authenticateheader, instead of aRequestException.Client::connect()wraps it in aConnectionException, without a retry.
- Add OAuth authorization: pass
- HTTP transport
- [BC Break] Turn off redirects on a
symfony/http-clientclient, discovered or passed, inHttpTransport,AuthenticatingHttpClientandOAuth.HttpTransportfails on every3xxanswer with an error that names the status and target, whileAuthenticatingHttpClientreturns it unchanged. A redirect to another origin used to receive the configured headers, like an API key, and on a307or308the 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
HttpTransportreads of a response that is not an SSE stream to the newmaxResponseBodyBytes(default: 8 MiB): a larger successful response fails the request, and an error body is cut at the limit. - Fix
HttpTransporttaking anMcp-Session-Idfrom any response instead of only the answer toinitialize, which replaced the session mid-way and, on2026-07-28, madeclose()send aDELETE. - Fix
HttpTransportnot reading the listening stream during a request answered with plain JSON.
- [BC Break] Turn off redirects on a
- stdio on Windows
- [BC Break] Start a stdio server without
cmd.exe, which corrupted arguments containing",%or!. A batch file likenpxneedscommand: 'cmd'andargs: ['/c', 'npx', ...]. - Fix the stdio client hanging while the server stays silent, ignoring its timeouts. Requires PHP 8.5.
- [BC Break] Start a stdio server without
- Fix
Client::connect()leaving the previous transport open when called again.
Schema
- [BC Break]
MessageFactory::create()returns oneMessageInterfaceand throwsInvalidInputMessageExceptionfor invalid input, instead of returning a list of messages and exceptions. - [BC Break] Remove
MessageFactory::DEFAULT_MAX_BATCH_SIZEand the$maxBatchSizeparameter ofMessageFactory::__construct()andMessageFactory::make(), whose first parameter is now$additional. - Add
UnknownMethodException, a subclass ofInvalidInputMessageExceptionfor a method the factory has not registered. - [BC Break] An
x-mcp-headerannotation that is not on a property reached throughpropertiesalone, e.g. underitems,anyOfor$defs, or that is null, makes the tool definition invalid:Toolthrows anInvalidArgumentException, and the client drops such a tool fromtools/listand refuses a sampling request carrying one. - [BC Break]
CallToolResultkeeps an explicitstructuredContent: nullwhen constructed and decoded. Code that passed null to omit it must leave the argument out (usemeta: $metafor metadata) or passStructuredContent::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()anderror()stay without structured content.
New Contributors
- @Revinand made their first contribution in #613
- @andreybolonin made their first contribution in #654
- @sarankumar1325 made their first contribution in #643
- @ruudk made their first contribution in #658
- @jaeminbird made their first contribution in #639
- @euvoor made their first contribution in #662
- @webagil-kevin made their first contribution in #616
- @bbdu3 made their first contribution in #670
- @imorland made their first contribution in #674
- @dg made their first contribution in #671
- @yusuf57rl made their first contribution in #689
- @ErikThiart made their first contribution in #700
- @skybleak333 made their first contribution in #707
Full Changelog: v0.9.0...v0.10.0