php-llm/mcp-sdk
A PHP SDK for the Model Context Protocol (MCP), helping developers build MCP servers and clients with tools, resources, prompts, and transports for connecting LLM applications to external data and workflows. Framework-agnostic and suitable for Laravel or standalone PHP projects.
This SDK is archived (last release May 2025). Treat it as a reference implementation or a base to fork, not a dependency you roll out blindly. Pin the exact version in composer.json and audit before shipping.
Minimal setup:
composer require php-llm/mcp-sdk
Explore the two top-level namespaces first — that tells you the whole shape of the package:
Client\ — connect to an MCP server, negotiate capabilities, list and call tools, read resources, fetch prompts.Server\ — expose your own tools/resources/prompts over a transport (stdio for local, SSE/HTTP for remote).Pick your lane based on what you're building:
Client transport classes and the initialize → tools/list → tools/call roundtrip.Server classes, register one tool, run it over stdio, and point the MCP Inspector at it.The fastest first win: create a Laravel artisan command that boots an MCP Server over stdio, registers a single tool (e.g., lookup_order), and validate it against the official Inspector UI. That single end-to-end loop surfaces 90% of the protocol and transport quirks before you commit to a design.
Server as an artisan command (stdio transport). The idiomatic PHP/Laravel shape is a long-running artisan command that boots the server and blocks on stdio:
// app/Console/Commands/McpServe.php
public function handle(Server $server): int
{
$server->tool('lookup_order', 'Fetch an order by ID', [
'order_id' => ['type' => 'string', 'required' => true],
], function (array $args) {
$order = Order::findOrFail($args['order_id']);
return ['content' => [['type' => 'text', 'text' => $order->toJson()]]];
});
return $server->run(new StdioTransport());
}
Then wire it into an MCP host config (claude_desktop_config.json or equivalent) as php artisan mcp:serve.
Tool registry via a service provider. Don't hand-register tools inside the command. Resolve them from a tagged container collection so tools are discoverable, testable, and reusable across transports:
$this->app->tag([LookupOrderTool::class, SearchProductsTool::class], 'mcp.tools');
// in the server bootstrap:
foreach ($this->app->tagged('mcp.tools') as $tool) { $server->tool(...$tool->definition()); }
This keeps your tool classes as plain services you can unit-test without spinning up a transport.
Client usage for multi-step flows. The client is a request/response loop, not an agent. You own the orchestration:
initialize → capture server capabilities.tools/list → build a tool schema to hand to your LLM (usually as JSON Schema passed straight into the model's tools parameter).tools/call, execute, append results, loop.Persist the initialize result and tool list for the session; don't re-list on every turn — tool catalogs on real servers get expensive.
Multiple transports, one interface. Keep your application code transport-agnostic. The same registered server should run over stdio in dev (fast, no ports) and over SSE/HTTP in a container. If your business logic leaks transport assumptions (e.g., writing to STDOUT directly instead of through the transport), you'll discover it when you try to run this behind nginx.
Resource vs tool split. Tools are for actions with side effects; resources are for read-only URI-addressed data (files, records). Model resources as yourdomain://... URIs so clients can subscribe to changes and cache. Don't funnel reads through tools just because it's easier — you lose the client's ability to reason about what's fetchable.
Archived = no security updates. The MCP spec is still churning (protocol version negotiation, auth on remote transports, annotation semantics). If you use this in production, budget for a fork and track upstream spec changes yourself. The initialize handshake pins a protocol version — mismatches with newer clients will drop connections silently.
Stdio is a footgun in web contexts. Never invoke the stdio transport from a web request. It reads/writes STDIN/STDOUT, and in FPM that's a fast route to corrupted responses or a hung worker. Use a separate long-running process (supervisor + artisan command) and a socket/SSE transport for anything request-driven.
Don't echo from tool handlers. Anything written to STDOUT corrupts the JSON-RPC framing on stdio. Route all logging to STDERR or a channel (Log::channel('stderr')), and wrap any library that prints (some DB drivers and var_dump debuggers will bite you here).
Schema validation is not automatic. The SDK passes your declared input schema to the client, but enforcing it inside the handler is your job. Validate with Laravel's Validator (or opis/json-schema) before touching domain logic — an LLM will absolutely send you strings where you declared integers.
Errors have two channels. Throwing from a handler typically becomes a tool result with isError: true; a malformed JSON-RPC request becomes a protocol error. Clients behave very differently for these — the former is fed back to the model for recovery, the latter kills the session. Surface expected failures as tool errors, not exceptions.
Result shape is strict. Tool results are content arrays of typed blocks (text, image, resource). For structured output, return JSON as a text block and document the shape; don't invent fields at the top level.
Debug with the Inspector, not curl. The handshake ordering (initialize → initialized notification → tools/list) is easy to get wrong by hand. Point the MCP Inspector at your server and use it as your regression harness.
Capability negotiation matters. Only advertise capabilities your server actually implements. Over-advertising causes clients to call handlers you never registered, and you'll get cryptic "method not found" errors far from the cause.
Extension point worth knowing: transports and (in this SDK) the dispatcher/handler resolution are the seams. If you need auth, rate limiting, or request tracing on remote transports, wrap them at the transport layer or via middleware rather than editing tool handlers — that keeps your tool classes portable if you swap SDKs later.
How can I help you explore Laravel packages today?