Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Sdk Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup for a Laravel Developer

  1. 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.

  2. 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.

  3. 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.

Where to Look First

  • Server SDK: docs/server-builder.md — now also covers setSessionLock() and the middleware defaults
  • Client SDK: docs/client.md for API reference
  • Authorization: read ADR 0002 (resource server role) before touching any auth middleware — the surface changed substantially in 0.9.0
  • Examples: docs/examples.md for Laravel-specific patterns
  • Changelog: CHANGELOG.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.

Implementation Patterns

Laravel-Specific Integration Patterns

1. Service Provider Integration

// 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.

2. Laravel Command for MCP Server

// 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.

3. Dynamic Tool Registration from Database

$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.

4. Client Integration with Laravel HTTP Client

$client = Client::builder()
    ->setClientInfo('Laravel Client', '1.0.0')
    ->build();

$transport = new HttpTransport(
    $this->http->withOptions(['timeout' => 30])
);

$client->connect($transport);

5. Middleware for Authentication (resource server role)

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.

6. Per-Tool Scopes with ScopePolicy

use 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.

7. JWT Validation (Passport / IdP)

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.

8. HTTP Security Middleware Defaults (CORS / DNS rebinding)

  • 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.
  • To run StreamableHttpTransport without its default middleware and without the warning an empty $middleware list logs, use PassthroughMiddleware.

9. Parallel Tool Calls, Elicitation and Progress

If your tools elicit input or report progress, 0.9.0 fixes the cross-talk that existed before:

  • Requests to the client now use UUID ids instead of a session counter, and each stream polls only the client request its own fiber sent.
  • ClientGateway::progress() takes the token from its own request.
  • Over stdio, 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.

10. Queue Jobs for Long-Running Tools

#[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.

11. Resource Templates with Eloquent Models

#[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();
}

12. Variadic Tool Parameters

#[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.


Gotchas and Tips

Upgrading from 0.8.x — Breaking Changes Checklist

Work through this before deploying 0.9.0:

  1. Requests to the client are UUIDs: 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.
  2. Transport 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.
  3. Authorization narrowed to the resource server role (ADR 0002): removed 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.
  4. 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.
  5. JwtTokenValidator is final: single issuer, $keys set, enforced alg allowlist, new $tokenType/$leeway, and requireScopes() removed in favour of ScopePolicy.
  6. 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.
  7. 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.
  8. 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.

Common Pitfalls

  1. Concurrency Is Opt-In, Not Automatic:

    • The SDK is still not thread-safe by default. Serialize a session's concurrent requests explicitly:
      ->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.
    • 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 / 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.
  2. 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.
    • Writes made by a handler after it resumed from an elicitation, sampling request or notification are now saved too. A session deleted while one of its requests still ran no longer resurrects itself.
  3. Psr16SessionStore Failures Are Now 503, Not 404:

    • An unreachable cache backend used to look like "session not found" and made clients re-initialize. Now the failure is logged and answered 503 so clients retry. SessionStoreInterface::exists() may throw the new SessionStoreException — catch it if you implement the interface.
  4. Streamable HTTP Response Shape:

    • Queued messages (e.g. 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.
    • A POST containing only responses or notifications is accepted with 202 and no body; the queued messages stay queued.
    • A 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.
  5. Pending Client Requests Expire Properly:

    • Requests to the client (elicitation, sampling) no longer linger after a timeout or a vanished stream: 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.
  6. Errors After Fiber Resume No Longer Escape:

    • A handler throwing after its fiber resumed 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. Wrap notify() in try/catch if a failed notification shouldn't abort the tool.
  7. Protocol Era Negotiation:

    • A bare 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.
    • A recognized 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.
  8. Attribute Discovery Limitations:

    • Only classes with #[McpTool], #[McpResource], etc. in the discovery paths are registered. Use manual registration for dynamic capabilities.
  9. Tool Argument Validation:

    • The SDK validates arguments against the method signature; complex custom objects may fail. Prefer simple types or JSON-serializable structures.
  10. Handshake-Era SSE Streams:

    • An idle stream now sends a keep-alive comment and stops once the connection is aborted, instead of pinning the PHP worker until a client request times out. Confirm your web server / proxy doesn't buffer those comments away.

Debugging Tips

  1. Enable Verbose Logging:

    $server = Server::builder()
        ->setLogger(new MonologLogger([
            new StreamHandler(storage_path('logs/mcp.log'), Logger::DEBUG)
        ]))
        ->build();
    
  2. Inspect Capabilities:

    $capabilities = $server->getCapabilities();
    dump($capabilities->getTools());
    dump($capabilities->getResources());
    
  3. 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).
    • A plain 404 session not found should now mean genuinely missing, not "cache hiccup".
  4. Validate Protocol Compliance:

    composer test:conformance
    
  5. 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.

Extension Points

  1. Custom Transports:

    • Implement 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.
          }
      }
      
  2. Custom Session Locks:

    class RedisSessionLock implements SessionLockInterface {
        public function acquire(string $sessionId): void { /* ... */ }
        public function release(string $sessionId): void { /* ... */ }
    }
    
  3. Custom Session Storage:

    • Implement 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.
  4. Custom Validation:

    $server->getRegistry()->setArgumentValidator(new CustomArgumentValidator());
    
  5. Protocol Extensions:

    $server->getCapabilities()->addExtension('my-extension', [
        'version' => '1.0',
        'features' => ['custom-feature']
    ]);
    
  6. Event Subscribers:

    $server->addEventSubscriber(new class implements EventSubscriberInterface {
        public function onCapabilitiesDiscovered(CapabilitiesDiscoveredEvent $event) {
            // Custom logic
        }
    });
    

Laravel-Specific Tips

  1. Service Container Integration:

    $this->app->bind(TransportInterface::class, function ($app) {
        return new HttpTransport(config('mcp.server_url'));
    });
    
  2. Config Caching:

    • Tool discovery walks the filesystem at boot. If you register tools dynamically in a service provider, resolve the server as a singleton so discovery isn't repeated per request.
  3. 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.
  4. Keep Auth in Your App:

    • With the OAuth proxy and client registration removed, the clean Laravel split is: your app (or IdP) issues tokens, the SDK validates them and enforces ScopePolicy. Don't try to rebuild the proxy inside AuthorizationMiddleware.
  5. Stay Current on Patch Releases:

    • The SDK ships frequent patches fixing capability and serialization edge cases. Pin to ^0.9 rather than an exact patch, and re-run composer test:conformance after upgrading.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table