laravel/mcp
Laravel MCP lets you rapidly build Model Context Protocol servers for Laravel applications, enabling AI clients to interact with your app through a standardized protocol. This open-source, MIT-licensed package includes official documentation and is maintained by Laravel.
Install and scaffold (5 minutes).
composer require laravel/mcp
php artisan mcp:install # publishes routes/mcp.php (+ config) and scaffolds a first server
Requires PHP 8.2+ (8.1 was dropped in v0.5.4) and a current Laravel release (13 support landed in v0.5.4).
Where to look first, in this order:
routes/mcp.php — the single registration point for every server you expose. This is the file you will edit most.app/Mcp/Servers/*.php — each server class is a thin registry with $tools, $resources, $prompts arrays.app/Mcp/Tools/*.php — generated by php artisan make:mcp-tool. make:mcp-resource, make:mcp-prompt, and make:mcp-server follow the same convention.vendor/laravel/mcp/src — pre-1.0, so the source is often more accurate than blog posts. Pair it with changelog/ (every release has a file), which is where breaking changes are actually documented.First use case: one read-only lookup tool over an existing model.
Do not start with write operations or OAuth. Start with the narrowest thing that proves the loop works end to end:
// app/Mcp/Tools/SearchOrdersTool.php
public function schema(JsonSchema $schema): array
{
return [
'email' => $schema->string()->description('Customer email, exact match')->required(),
];
}
public function handle(Request $request): Response
{
$orders = Order::where('email', $request->string('email'))->latest()->limit(10)->get();
return $orders->isEmpty()
? Response::error('No orders found for that email.')
: Response::text($orders->map->only(['number', 'status', 'total'])->toJson());
}
Then register the server in routes/mcp.php (local/stdio for your editor, web for HTTP clients) and verify with the built-in tooling before touching any AI client:
php artisan mcp:start orders # boots the server over stdio
php artisan mcp:inspector orders # launches MCP Inspector against it (defaults to stdio for local servers)
Only after the inspector shows your tool with a correct schema and a correct response should you wire it into Claude Desktop / Cursor / Claude Code. Most "it doesn't work" reports are client-config problems, and the inspector removes that variable.
Treat the server class as a composition root, not a place for logic. One server per audience or trust boundary (an internal admin server and a public read-only server are different things), with primitives grouped by domain. Keep $tools/$resources/$prompts as declarative lists; everything else belongs in app/Actions or your existing services, injected into handle() via the container.
Pick the right primitive — this is the most common design mistake.
| Primitive | Use when |
|---|---|
| Tool | The model should decide to act (search, mutate, compute) |
| Resource | The user would "attach" or reference addressable context (a record, a report, a file) |
| Resource template | Same, but parameterised by URI (added in v0.4.0) |
| Prompt | A canned, human-initiated workflow with arguments |
Anything read-only and addressable is usually better as a resource — it keeps the tool list short, and a short tool list measurably improves tool-selection accuracy.
Write the schema as a contract, not a form. schema() and outputSchema() must type-hint Illuminate\Contracts\JsonSchema\JsonSchema — the concrete Illuminate\JsonSchema\JsonSchema was removed in v0.4.0. Prefer enums over free text, add ->description() to every property, and mark required() only for genuinely non-optional input. Flat schemas with 1–4 arguments get called correctly far more often than nested ones.
Return responses the model can act on.
Response::text() — narrative results, the safe default.Response::json() / Response::structured() — when you declare an outputSchema (structured content arrived in v0.4.0); assertStructuredContent() exists for tests.Response::error() — expected, recoverable failures (not found, invalid state). The model sees this text and can retry with different input. Reserve thrown exceptions for genuine bugs; they surface as JSON-RPC protocol errors, which clients handle far worse.Scope by the authenticated principal, never by input. Take the user from $request->user() and filter queries accordingly. Never accept a user_id or tenant_id argument from the model. Gate Mcp::web(...) routes behind the same middleware as your API (auth:sanctum, policies), and use the package's OAuth flow for third-party clients rather than inventing a token scheme.
Mirror your HTTP tests. The package ships a testing harness so tools are testable without a transport:
OrdersServer::tool(SearchOrdersTool::class, ['email' => '[email protected]'])
->assertOk()
->assertSee('INV-1001')
->assertDontSee('INV-9999');
Test the unhappy paths explicitly — empty result, unauthorised user, malformed argument — plus the permission boundary (user A cannot see user B's orders). Fake outbound HTTP/services as usual; a tool that hits a live third party is a flaky test suite.
Iterate with the inspector, not the chat client. mcp:inspector supports --host/--port (v0.5.6) for HTTP servers, and defaults to stdio for local ones (v0.2.1). Keep one terminal on the inspector, one on tail -f storage/logs/laravel.log. Watching the actual JSON-RPC params the model sends is how you find out your description is ambiguous.
Use attributes for metadata. Since v0.5.8/v0.5.9 the stubs use attribute syntax for primitive and server configuration rather than protected properties; follow the stubs so you do not end up with a mix of both styles. Annotations (read-only, destructive, idempotent) are cheap to add and clients increasingly surface them as confirmation prompts.
Pin the minor version and read every changelog before upgrading. This is 0.x (v0.9.x as of the latest release). Real breakages already shipped: the JsonSchema concrete→contract switch, Role::ASSISTANT → Role::Assistant, and stub files renamed with an mcp- prefix. Use "laravel/mcp": "^0.9" and treat minor bumps as potentially breaking.
Never write to stdout in a stdio server. A stray dump(), echo, var_dump, or a library printing a banner corrupts the JSON-RPC stream and produces baffling client errors ("unexpected token", silent hangs, truncated responses). Use Log:: or fwrite(STDERR, ...). If a tool works in tests but the client disconnects, this is the first thing to check.
Empty properties is the classic schema bug. A tool with no arguments must still emit an object, not [] — clients validate against JSON Schema and an array where an object is expected fails on their side, not yours. There was an explicit fix for missing properties in v0.3.2; if you hand-roll schema arrays, keep required off optional keys and keep the structure JSON-serialisable (no closures, no objects that won't json_encode).
structuredContent and outputSchema must agree. If you declare an output schema and return something that doesn't match, the failure happens in the client's JSON Schema validator, so your server logs look clean. Declare the schema, then assert it with assertStructuredContent().
php artisan optimize and config caching. Registration in routes/mcp.php must be cache-safe: avoid closures capturing request-time state and anything that reads the current user at boot. optimize was broken and fixed early (v0.2.1) — if you are on an old tag, this bites in production only.
Session handling has sharp edges. The Mcp-Session-Id header name is case-sensitive in practice (corrected in v0.5.1), so don't reinvent it. Use the SessionInitialized event (v0.5.7) for per-session setup, logging, and rate-limit counters instead of doing it inside every tool.
Destructive tools need belt and braces. Annotation flags are advisory — a client may ignore them. Add explicit confirmation-style parameters for irreversible operations (a confirm: true argument, or a separate preview tool), rate-limit the route, and audit-log every write with the authenticated user and the raw arguments. OAuth client registration and redirect handling have been hardened repeatedly (v0.3.1, v0.6.0) — stay current on those patches.
Keep binary payloads out of the context window. Image and audio content types with Storage integration exist (v0.6.0), but returning base64 blobs inflates token usage and often truncates. Prefer a signed URL in a text response for anything larger than a thumbnail.
Debug in this order: inspector → mcp:start with hand-piped JSON-RPC → the server test suite → the client config. A surprising share of issues are the client pointed at a different server handle, a transport mismatch (stdio config for an HTTP server), or a stale php artisan config:clear situation.
Extension points worth knowing: Macroable/Conditionable on core classes (v0.3.0), custom request classes (v0.2.1), _meta passthrough (v0.3.4), completion support for argument autocompletion (v0.5.0), and configurable JSON response formatting (compact by default since v0.5.7). Reach for these before forking the package — pre-1.0, upstream patches move fast, and local forks rot quickly.
How can I help you explore Laravel packages today?