mcp/sdk
The official PHP SDK for the Model Context Protocol: a framework-agnostic API for building MCP servers and clients in PHP, with tools, resources, prompts, STDIO and HTTP transports, sessions, authorization, and support for both protocol eras.
JwtTokenValidator::fromIssuer() caches keys in a PSR-6 pool, while sessions use PSR-16. Laravel ships PSR-16 natively (Cache) but not PSR-6 — a symfony/cache adapter bridged to a Laravel store becomes required infrastructure, not an optional detail.PassthroughMiddleware is a small but useful escape hatch for teams that want to own the HTTP middleware stack entirely rather than fight the SDK's defaults.listChanged announcements. This is the release where the SDK stops being "single-request-at-a-time only" and becomes defensible behind a real web server.StdioTransport and the client now speak both protocol eras (2026-07-28 alongside the handshake era), with -32022 naming served revisions when a request can't be negotiated. This is architecturally significant: it lets a Laravel deployment serve heterogeneous AI clients without a parallel deployment.Mcp\Server/Mcp\Client in a provider. The upgrade touches the internals of that provider, not the pattern.StreamableHttpTransport now behaves materially differently on POST: a POST is answered with its own responses, queued server-initiated messages are sent as SSE (no longer bundled into a JSON array, which 2025-06-18+ clients reject), and a POST containing only responses/notifications returns 202 with no body. Laravel routes and any Nginx/Octane layer must be configured for streaming and must not buffer.Builder::setSessionLock() is opt-in and is the single most Laravel-relevant new feature. FileSessionLock (flock) is fine for single-node; SymfonyLockSessionLock maps cleanly onto symfony/lock's Redis/PDO/Postgres stores, which aligns with an existing Laravel Redis or database backend.OAuthProxyMiddleware, ClientRegistrationMiddleware, ClientRegistrarInterface, JwksProvider*, OidcDiscoveryInterface and the OIDC metadata policies are removed. Laravel Passport/Sanctum integration can no longer lean on the SDK to act as an OAuth proxy or dynamic client registration endpoint — the app must be a pure resource server validating bearer tokens and enforcing scopes.ScopePolicy replaces requireScopes() and answers 403 insufficient_scope per method and per tool, with scope hierarchies. This is a natural fit for Laravel Gates/Policies and is a net improvement over string comparisons scattered in tool code.Mcp-Param-* headers whose mirrored argument is absent from the body are now rejected with -32020 (SEP-2243). Any proxy, CDN, or Laravel middleware that strips unknown Mcp-* headers will turn valid calls into errors.TransportInterface::send() now receives every response of a non-suspending handler (a batch may yield several), and Protocol::sendRequest()/checkResponse()/handleFiberYield() exchange Uuid instead of int. handleFiberYield() returns the id of the request it sent. Any bespoke Laravel transport (broadcast/queue/websocket bridge) is a rewrite, not a patch.[BC Break] items across transports, protocol ids, authorization, and middleware. This is not a drop-in bump.^0.8.1 resolves to >=0.8.1 <0.9.0, so the upgrade is explicitly opt-in. That is protective, but it also means the team will silently sit on 0.8.x until someone deliberately bumps — schedule the decision rather than waiting for a security-driven scramble.JwtTokenValidator is now final, takes a single issuer (not an alias list), a $keys set (e.g. CachedKeySet), an alg allowlist, $tokenType, and $leeway. Multi-tenant Laravel apps validating several issuers must construct multiple validators and dispatch between them.AuthorizationResult::allow() takes an AccessToken; OAuthRequestMetaMiddleware is replaced by RequestContext::getAccessToken().ProtectedResourceMetadata now requires $resource, serves at the RFC 9728 §3.1 derived path, and requires https except loopback — a misconfigured APP_URL breaks discovery in staging.DnsRebindingProtectionMiddleware always validates Host (missing Host is rejected) and checks Origin against a new allowedOrigins list (scheme + port, empty by default; same-origin always allowed). Behind a load balancer that rewrites or drops Host, or for browser-based clients, this is a production-breaking default if unconfigured.CorsMiddleware now lets OPTIONS through without a token (fixing a real preflight 401), allows Mcp-Method, Mcp-Name, Mcp-Param-* and wildcard-prefixed headers, and exposes WWW-Authenticate. Laravel's own HandleCors must not shadow or contradict this.setSessionLock(), the session-overwrite fix (Session::save() writes only changed keys) mitigates but does not eliminate interleaving. Per-session serialization trades throughput on hot sessions for correctness — a design decision, not a config toggle.Psr16SessionStore now returns 503 instead of 404 (previously clients would silently re-initialize), and lock failures/timeouts return 503 with JSON-RPC errors (SessionLockTimeoutException, SessionLockException). Monitoring, alerting, and client retry logic that keyed off 404 must be updated.listChanged item ("…neve…"). Before committing, obtain the complete release body plus any UPGRADING.md and ADR 0002 text — an incomplete BC list is itself a risk.$resource value?JwtTokenValidator is final with a single issuer?FileSessionLock, or SymfonyLockSessionLock over Redis/PDO? What is the acceptable p95 added latency from serializing one session's concurrent requests?TransportInterface implementation? If so, the Uuid id and send()-receives-all-responses changes are a scheduled rewrite.2026-07-28 vs. handshake era)? Does any endpoint intentionally serve a single era, and is -32022 handled by clients?202 correctly for notification-only POSTs?Host, Origin, or Mcp-Param-*? Both DNS-rebinding protection and SEP-2243 depend on those surviving intact.McpServerServiceProvider now also binds SessionLockInterface, the PSR-6 key-set pool, and ScopePolicy implementations — not just the server/client.PassthroughMiddleware if the team prefers to own the stack; otherwise configure SDK CorsMiddleware ($allowedHeaders, allowedOrigins) deliberately and ensure Laravel's HandleCors does not conflict.ScopePolicy on top of Laravel's authorization layer so MCP scope checks and app-level checks share one definition.symfony/cache over a Laravel-backed store; document the two-pool split or the team will conflate them.SymfonyLockSessionLock over symfony/lock Redis/PDO for any multi-node deployment.-32022/-32020/503 responses create retryable vs. non-retryable classes that job wrappers should distinguish.^0.8.1. Create an upgrade branch pinned to ^0.9.0; do not widen a caret on 0.x expecting a smooth ride.UPGRADING.md, and ADR 0002. The provided notes are truncated — treat the authoritative list as a prerequisite gate.OAuthProxyMiddleware/ClientRegistrationMiddleware/JwksProvider*; move token validation to JwtTokenValidator::fromIssuer() with CachedKeySet; migrate requireScopes() calls to ScopePolicy; fix ProtectedResourceMetadata $resource and https.Uuid ids and the new send() contract; verify SSE response path and 202 handling; re-test any Mcp-Param-* mirroring.setSessionLock() with SymfonyLockSessionLock; load-test one hot session to quantify serialization cost.allowedOrigins (empty by default!) and confirm Host survives every proxy hop before enabling in staging.^0.9.0 deliberately. ^0.8.1 will not pull 0.9.0 — that constraint is now a deliberate hold, not a safety net to forget about.symfony/lock (new, for SymfonyLockSessionLock) and a PSR-6 pool (new, for JWKS caching) enter the dependency graph. Confirm they don't collide with existing symfony/* pins.OAuthRequestMetaMiddleware, ClientRegistrarInterface, ClientRegistrationException, JwksProvider(Interface), OidcDiscoveryInterface, or AuthorizationResult::allow() with request attributes will fatal — grep the codebase before upgrading.^0.9.0, publish config, fix service provider bindings until the app boots.JwtTokenValidator, ScopePolicy, ProtectedResourceMetadata, RequestContext::getAccessToken().Uuid ids, send() semantics, SSE and 202 routes, middleware ordering (PassthroughMiddleware or explicit config).setSessionLock(), session store exists() exception handling, retry-on-503 client policy.Mcp-Param-* rejection.^0.8 constraint is an invisible deferral. Track the pinned MCP version in a visible place (README/ADR) and alert on new minor releases.404 no longer means "session gone" in the cache-outage case; 503 now covers store outages and lock failures. Update log-based dashboards, alerts, and runbooks accordingly.Host/Origin rejections are the first thing to check on "works locally, 4xx in staging."503 + lock error: lock backend unhealthy or contention — check Redis/PDO lock store, then session fan-out.503 + session store error: PSR-16 store unreachable — previously a silent 404 re-init; clients should now retry, not re-initialize.403 insufficient_scope: ScopePolicy mapping or hierarchy misconfigured per method/tool.-32020: Mcp-Param-* header present but mirrored argument missing — usually a client/proxy contract mismatch.-32022: client speaks a revision this endpoint doesn't serve — check the endpoint's served revisions.401 on preflight: should no longer occur behind AuthorizationMiddleware; if it does, a conflicting CORS layer is in front.setSessionLock() converts intra-session concurrency into a queue. Design for many sessions, low per-session concurrency; a single hot session (one agent doing heavy parallel tool calls) will see p95 rise and eventually 503. Measure before enabling broadly.flock is single-node only. Any autoscaled or multi-AZ Laravel deployment needs SymfonyLockSessionLock over a shared store; the lock store inherits the availability profile of Redis/DB.throttle on MCP endpoints; also rate-limit per session, since locking makes one session's throughput a shared resource.| Component | Failure Scenario | Mitigation |
|---|---|---|
| Session Lock | Lock timeout or failing lock backend → 503 |
Choose a redundant shared lock store; client retry with backoff; alert on SessionLockTimeoutException rate. |
| Session Store | PSR-16 unreachable → 503 (was 404) |
Health-check the store; ensure clients retry rather than re-initialize; handle SessionStoreException from exists(). |
| HTTP Streaming | Proxy/Octane buffers SSE; queued messages never delivered | Disable buffering end-to-end; verify POST returns SSE for queued messages and 202 for notification-only POSTs. |
| DNS Rebinding Protection | Missing/rewritten Host rejected; cross-origin browser client blocked by empty allowedOrigins |
Preserve Host through every hop; explicitly configure allowedOrigins (scheme + port) or ['*'] where appropriate. |
| CORS | Mcp-* headers or WWW-Authenticate not allowed/exposed |
Configure SDK CorsMiddleware (allowedHeaders, wildcard prefixes) and remove conflicting Laravel HandleCors rules. |
| Authorization | Removed OAuth proxy/registration classes referenced; multi-issuer token rejected | Inventory removed APIs pre-upgrade; one JwtTokenValidator per issuer with explicit dispatch. |
| Resource Metadata | $resource missing or non-https → discovery fails |
Set https APP_URL/resource value; use loopback allowance only in local dev. |
| Scope Enforcement | ScopePolicy misconfigured → blanket 403 or over-permissive tool access |
Map scopes per method/tool; test hierarchies explicitly; add negative tests. |
| Custom Transport | Uuid ids or new send() semantics unhandled → dropped/misrouted responses |
Rewrite against the new contract; assert multi-response batches. |
| Mirrored Params | Mcp-Param-* header stripped by proxy → -32020 |
Allowlist Mcp-* headers at CDN/LB; validate end-to-end. |
| Protocol Era | Client revision unsupported → -32022 |
Publish served revisions; test both eras; handle negotiation errors client-side. |
| Variadic Tools | Array/spread args coerced incorrectly | Pin ≥0.8.1 (satisfied by 0.9.0); assert element typing in tool tests. |
| Registry/List Changes | Spurious capability-change announcements | Satisfied by 0.8.1 + narrowed listChanged in 0.9.0; verify composed registries. |
^0.9.0 with SymfonyLockSessionLock and a PSR-6 JWKS pool preconfigured.ScopePolicy + Laravel Gates as one authorization model; attribute-based tool discovery unchanged.Host/Origin validation through proxies, lock-store health, and the new 503 semantics.How can I help you explore Laravel packages today?