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.
Installation:
composer require mcp/sdk:^0.9
Assessment verified against mcp/sdk v0.9.0. Note that v0.9.0 is a breaking release — pin ^0.9 rather than an exact patch, and read the upgrade checklist at the bottom of this document before bumping from 0.8.x.
First Use Case - Exposing a Laravel Controller as an MCP Tool:
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
use Mcp\Capability\Attribute\McpTool;
class CalculatorController {
#[McpTool]
public function add(int $a, int $b): int {
return $a + $b;
}
}
$server = Server::builder()
->setServerInfo('Laravel MCP Server', '1.0.0')
->setDiscovery(__DIR__, ['App/Http/Controllers'])
->build();
$transport = new StdioTransport();
$server->run($transport);
Since v0.9.0, StdioTransport negotiates both protocol eras on the single channel: it settles the era on the client's first request and will serve 2026-07-28 requests (including subscriptions/listen and notifications/cancelled) as well as handshake-era ones. If you were previously refusing modern clients over stdio, that is now handled for you.
First Use Case - Consuming an MCP Server from Laravel:
use Mcp\Client;
use Mcp\Client\Transport\HttpTransport;
$client = Client::builder()
->setClientInfo('Laravel Client', '1.0.0')
->build();
$transport = new HttpTransport('http://localhost:8000');
$client->connect($transport);
$result = $client->callTool('add', ['a' => 5, 'b' => 3]);
$client->disconnect();
The client now fails fast instead of waiting out its full timeout, so error surfaces in your Laravel logs much sooner than in 0.8.x.
docs/server-builder.md — now also covers setSessionLock() and the middleware defaultsdocs/client.md for API referencedocs/examples.md for Laravel-specific patternsCHANGELOG.md — the SDK is still pre-1.0, so skim it before every minor bump. 0.9.0 is the largest breaking release so far.// app/Providers/McpServiceProvider.php
public function register()
{
$this->app->singleton('mcp.server', function ($app) {
return Server::builder()
->setServerInfo('Laravel MCP Server', '1.0.0')
->setDiscovery(__DIR__.'/../Http/Controllers', ['.php'])
->setSession(new FileSessionStore(storage_path('framework/sessions')))
->setSessionLock(new FileSessionLock(storage_path('framework/mcp-locks')))
->build();
});
}
The session lock is opt-in. Without it, concurrent requests of one session can still interleave; with it, they are serialized so one request cannot overwrite what another saved.
// app/Console/Commands/RunMcpServer.php
public function handle()
{
$server = app('mcp.server');
$transport = new StreamableHttpTransport(
request: $this->laravel->make(Request::class),
responseFactory: $this->laravel->make(ResponseFactory::class),
streamFactory: $this->laravel->make(StreamFactory::class),
);
$response = $server->run($transport);
$response->send();
}
Because 0.9.0 changed transport internals heavily, pass constructor arguments by name where you can — positional argument lists shifted in several middleware and transport constructors.
$server = Server::builder()
->setServerInfo('Dynamic MCP Server', '1.0.0')
->setDiscovery(__DIR__.'/../Http/Controllers')
->build();
foreach (Tool::all() as $tool) {
$server->getRegistry()->addTool(
[$this->app->make($tool->handler), $tool->method],
$tool->name,
$tool->description
);
}
Hydrating an externally loaded registry after build() no longer emits phantom "registry changed" notifications to clients (fixed in 0.8.1). Note that 0.9.0 tightened when listChanged is announced — it is now only announced for capabilities where list changes are actually delivered. If a client in your fleet relies on that hint, verify it still gets the notifications it expects.
$client = Client::builder()
->setClientInfo('Laravel Client', '1.0.0')
->build();
$transport = new HttpTransport(
$this->http->withOptions(['timeout' => 30])
);
$client->connect($transport);
As of 0.9.0 the SDK only does resource server work: it validates bearer tokens; it no longer proxies OAuth flows or registers clients. In Laravel terms, mint your tokens elsewhere (Passport/Sanctum/an IdP) and let the SDK validate them.
public function handle($request, Closure $next)
{
$transport = new StreamableHttpTransport(
$request,
$this->app->make(ResponseFactory::class),
$this->app->make(StreamFactory::class)
);
$server = app('mcp.server');
return $server->run($transport)->send();
}
Downstream, obtain the token context via RequestContext::getAccessToken(), which returns the validated Mcp\Server\Authorization\AccessToken. If you previously read request attributes set by OAuthRequestMetaMiddleware, that class is gone — migrate to RequestContext.
ScopePolicyuse Mcp\Server\Authorization\AuthorizationMiddleware;
use Mcp\Server\Authorization\ScopePolicy;
$middleware = new AuthorizationMiddleware(
$tokenValidator, // JwtTokenValidator
$resourceMetadata, // ProtectedResourceMetadata
new ScopePolicy([ // third argument, new in 0.9.0
'tools/call:report.generate' => 'reports:write',
'tools/call:report.read' => 'reports:read',
])
);
ScopePolicy answers 403 insufficient_scope per method and tool, and supports scope hierarchies. JwtTokenValidator::requireScopes() was removed — anything you did with it belongs in the policy now.
use Mcp\Server\Authorization\JwtTokenValidator;
$validator = JwtTokenValidator::fromIssuer(
issuer: 'https://auth.example.com',
keys: $cachedKeySet, // PSR-6 backed CachedKeySet
// alg allowlist is enforced; $tokenType and $leeway are new
);
JwtTokenValidator is now final, takes a single issuer (not a list of aliases), and a $keys set. fromIssuer() discovers and caches keys in a PSR-6 pool, refetching on an unknown key id — so key rotation no longer requires a redeploy. Issuers with a trailing slash (Authentik, Auth0) are now accepted.
CorsMiddleware now lets OPTIONS preflights through without a token (previously 401 behind AuthorizationMiddleware), allows Mcp-Method, Mcp-Name, Mcp-Param-* by default and exposes WWW-Authenticate. An $allowedHeaders entry ending in * allows any requested header with that prefix.DnsRebindingProtectionMiddleware now always validates Host (a missing one is rejected) and checks Origin separately against allowedOrigins (scheme + port, empty by default; same-origin always allowed; ['*'] accepted). allowedOrigins is the new second constructor argument — pass the response/stream factories by name.StreamableHttpTransport without its default middleware and without the warning an empty $middleware list logs, use PassthroughMiddleware.If your tools elicit input or report progress, 0.9.0 fixes the cross-talk that existed before:
ClientGateway::progress() takes the token from its own request.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.#[McpTool(name: 'deploy.run')]
public function deploy(string $target, ClientGateway $gateway): array
{
foreach ($this->steps($target) as $i => $step) {
$step->run();
$gateway->progress(current: $i + 1, total: count($this->steps($target)));
}
return ['status' => 'ok', 'target' => $target];
}
This is safe to call in parallel from the same session now — previously parallel calls could receive each other's elicitation answers and progress tokens.
#[McpTool(name: 'report.generate')]
public function generate(int $reportId): array
{
GenerateReport::dispatch($reportId);
return ['status' => 'queued', 'report_id' => $reportId];
}
Expose progress via a companion resource (data://reports/{id}) the client polls, or push updates over a streaming transport. Don't block the call on heavy work — and if you do run long work inline, add a FileSessionLock/SymfonyLockSessionLock so two concurrent calls for the same session don't clobber each other's session state.
#[McpResourceTemplate(uri: 'data://users/{id}')]
public function getUserResourceTemplate(int $id): array
{
return [
'title' => 'User Data',
'description' => 'Retrieve user information',
'schema' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string']
]
]
];
}
#[McpResource(uri: 'data://users/{id}')]
public function getUserResource(int $id): array
{
return User::findOrFail($id)->toArray();
}
#[McpTool]
public function sum(int ...$numbers): int
{
return array_sum($numbers);
}
Casting is per element since 0.8.1, so ['numbers' => ['1', '2', '3']] arrives as ints. Keep the element type simple so the cast is unambiguous.
Work through this before deploying 0.9.0:
Protocol::sendRequest(), Protocol::checkResponse() and Protocol::handleFiberYield() take/return a Uuid instead of an int. The response finder of a transport changed too, and handleFiberYield() returns the id of the request it sent. A transport passes the suspended \Fiber as the last argument to the pending-requests provider and the fiber-yield handler; StreamableHttpTransport::handleFiberTermination() takes the terminated \Fiber as its first argument. Any custom transport or protocol subclass needs rewiring.send() semantics: TransportInterface::send() now receives every response of a handler that does not suspend. The session's outgoing queue only carries server-initiated requests and notifications. A custom transport must deliver each message it is given, because a batch may contain several.OAuthProxyMiddleware, ClientRegistrationMiddleware, ClientRegistrarInterface, ClientRegistrationException, JwksProvider, JwksProviderInterface, OidcDiscoveryInterface and the OIDC metadata policies; OidcDiscovery is now internal. If your Laravel app was leaning on the SDK to proxy OAuth or register clients, that logic moves into your app.OAuthRequestMetaMiddleware → RequestContext::getAccessToken(), which returns a validated AccessToken. AuthorizationResult::allow() takes an AccessToken instead of request attributes. Protocol::processInput(), BaseTransport::handleMessage() and StreamableHttpTransport::handlePostRequest() all take an ?AccessToken parameter, as does the TransportInterface::onMessage() listener as its fourth argument.JwtTokenValidator is final: single issuer, $keys set, enforced alg allowlist, new $tokenType/$leeway, and requireScopes() removed in favour of ScopePolicy.ProtectedResourceMetadata requires $resource, serves at the path derived from it (RFC 9728 §3.1), requires https except for loopback hosts, and drops localized fields, policy, ToS, extra fields and $metadataPaths.DnsRebindingProtectionMiddleware rejects a missing Host and gains allowedOrigins as its second constructor argument. Local dev over plain http on a non-loopback hostname will now be rejected — allow it explicitly.listChanged is announced only where list changes are actually delivered. If a client UI auto-refreshes its tool list on that capability, confirm it still receives the signal it relied on.Concurrency Is Opt-In, Not Automatic:
->setSessionLock(new FileSessionLock(storage_path('framework/mcp-locks')))
// or, for sessions shared across machines:
->setSessionLock(new SymfonyLockSessionLock($lockFactory))
FileSessionLock uses flock() next to FileSessionStore, and the store's gc() also removes stale lock files — schedule gc() if you rely on it.503 and a JSON-RPC error (SessionLockTimeoutException / SessionLockException). Clients should retry; don't treat these as fatal in your client code.Protocol::__construct() takes the lock as an optional last argument if you build protocols by hand.Session Writes Are Now Partial:
Session::save() writes only the keys it changed, onto whatever the store holds at that point. That is what fixes concurrent overwrites — but it also means you can no longer rely on a save being a whole-session snapshot. If you cache session state elsewhere, treat the store as authoritative.Psr16SessionStore Failures Are Now 503, Not 404:
503 so clients retry. SessionStoreInterface::exists() may throw the new SessionStoreException — catch it if you implement the interface.Streamable HTTP Response Shape:
notifications/resources/updated) are no longer bundled into a JSON array with a request's response — clients on 2025-06-18 and later reject that. Such a request is answered as SSE, queued messages first.202 and no body; the queued messages stay queued.POST is answered with its own responses rather than pulling them from the session's outgoing queue — this is the fix for lost responses on concurrent requests of one session.Pending Client Requests Expire Properly:
Protocol::checkResponse() expires them with a Request timed out error, entries of a vanished stream are dropped a minute past their timeout, and an answer to a request the server isn't waiting on is dropped instead of stored. If you were relying on late answers being buffered, that no longer happens.Errors After Fiber Resume No Longer Escape:
null when the transport fails to store what it sends to the client; ClientGateway::notify() then throws a ClientException. Wrap notify() in try/catch if a failed notification shouldn't abort the tool.Protocol Era Negotiation:
initialize against a 2026-07-28-only endpoint is answered with -32022 naming the served revisions; a request without a session on the handshake leg is answered with its id. StatelessAwareTransportInterface declares setHandshakeVersions(), so a server without the modern era names only the revisions it actually negotiates when refusing.Mcp-Param-* header whose mirrored argument is absent from the body is now rejected with -32020 (SEP-2243) instead of being accepted. If you hand-craft requests or headers in tests, keep header and body in sync.Attribute Discovery Limitations:
#[McpTool], #[McpResource], etc. in the discovery paths are registered. Use manual registration for dynamic capabilities.Tool Argument Validation:
Handshake-Era SSE Streams:
Enable Verbose Logging:
$server = Server::builder()
->setLogger(new MonologLogger([
new StreamHandler(storage_path('logs/mcp.log'), Logger::DEBUG)
]))
->build();
Inspect Capabilities:
$capabilities = $server->getCapabilities();
dump($capabilities->getTools());
dump($capabilities->getResources());
Tell 503s Apart:
SessionLockTimeoutException → contention; raise the timeout or shorten the handler.SessionLockException → the lock backend itself failed; check the symfony/lock store or directory permissions.SessionStoreException → the session store is unreachable (PSR-16 backend down, disk full).404 session not found should now mean genuinely missing, not "cache hiccup".Validate Protocol Compliance:
composer test:conformance
Check Transport Errors:
try {
$client->connect($transport);
} catch (TransportException $e) {
Log::error('MCP Transport Error', ['error' => $e->getMessage()]);
}
The client now fails fast, so these surface quickly rather than after the timeout window.
Custom Transports:
TransportInterface. Remember send() now receives every response of a non-suspending handler — deliver each message, including each element of a batch.
class CustomTransport implements TransportInterface {
public function send(Response $response): void {
// Deliver this message; a batch arrives as several calls.
}
}
Custom Session Locks:
class RedisSessionLock implements SessionLockInterface {
public function acquire(string $sessionId): void { /* ... */ }
public function release(string $sessionId): void { /* ... */ }
}
Custom Session Storage:
SessionStoreInterface for a database backend; remember exists() may throw SessionStoreException and save() must merge partial writes onto current state rather than replacing the record wholesale.Custom Validation:
$server->getRegistry()->setArgumentValidator(new CustomArgumentValidator());
Protocol Extensions:
$server->getCapabilities()->addExtension('my-extension', [
'version' => '1.0',
'features' => ['custom-feature']
]);
Event Subscribers:
$server->addEventSubscriber(new class implements EventSubscriberInterface {
public function onCapabilitiesDiscovered(CapabilitiesDiscoveredEvent $event) {
// Custom logic
}
});
Service Container Integration:
$this->app->bind(TransportInterface::class, function ($app) {
return new HttpTransport(config('mcp.server_url'));
});
Config Caching:
Locking on Forge / Vapor / Multi-Node:
FileSessionLock only works where every worker sees the same filesystem. On multi-node or serverless deployments, use SymfonyLockSessionLock with a Redis/DB store, and pair it with a shared session store — a per-node file session plus a distributed lock gives you the worst of both.Keep Auth in Your App:
ScopePolicy. Don't try to rebuild the proxy inside AuthorizationMiddleware.Stay Current on Patch Releases:
^0.9 rather than an exact patch, and re-run composer test:conformance after upgrading.How can I help you explore Laravel packages today?