Building a Production MCP Server in Laravel A Laravel developer has built a production-grade Model Context Protocol (MCP) server using HTTP+SSE transport, enabling external AI agents to discover, authenticate against, and invoke Laravel-based tools. The implementation targets MCP specification version 2025-11-25 and uses a custom transport layer rather than the existing `php-mcp/laravel` package, which lags two revisions behind the current stable release. The server handles the full JSON-RPC 2.0 handshake, including protocol version negotiation and tool listing, within a fictional Laravel knowledge base application domain. Spec version notice:This article targets MCP specification version2025-11-25. The protocol has shipped breaking changes between versions, tool schema field names and error codes have shifted in previous releases. Verify the protocolVersion string against the official MCP specification changelog before deployment, and on every spec update. Most Laravel developers encounter the Model Context Protocol from the client side. You added a server to Claude Desktop, your IDE reached out to it, and tools appeared. You were the consumer. This article builds the other side of that relationship: a laravel mcp server that external AI agents can discover, authenticate against, and invoke. This is part of the broader Laravel AI Architecture https://origin-main.com/laravel-ai-architecture/ series covering the infrastructure decisions that make AI integrations maintainable at scale. The code examples use a consistent fictional domain throughout a Laravel-based knowledge base application so every snippet composes into a coherent system rather than a collection of isolated fragments. MCP, the Model Context Protocol, is a JSON-RPC 2.0 protocol. Not an API standard. Not REST. Not a framework. It defines a structured conversation between two parties: a client and a server. The client requests a list of available tools. The server describes them. The client asks the server to execute one. The server validates the input, runs the logic, and returns a result inside a JSON-RPC envelope. The distinction between client and server matters because most Laravel developers have only been on one side. When integrating Laravel Boost into your workflow https://origin-main.com/laravel/laravel-boost-mcp-server-ai-development/ , your AI assistant connects to an MCP server that exposes your Eloquent models, routes, and config. You are the client. Laravel Boost does the responding. This article is the other end of that wire, you build the server. There is a PHP package, php-mcp/laravel , that scaffolds a server for you. At the time of writing it targets spec version 2025-03-26 , which is two revisions behind the current stable release. For production systems where protocol version negotiation and schema correctness matter, building the transport layer yourself gives you full control over what version you advertise and how you handle negotiation failures. That is the approach taken here. MCP supports two transport modes. Choosing the wrong one for your deployment context is the most common early mistake. | Mode | Transport | Use case | Multi-client | Production-appropriate | |---|---|---|---|---| | stdio | stdin / stdout | Local dev, IDE tooling | No | No | | HTTP+SSE | HTTP POST + Server-Sent Events | Remote, hosted, multi-client | Yes | Yes | stdio runs as a child process. The client spawns it, communicates over stdin/stdout, and the process exits when the session ends. Fast, zero network config, appropriate for local tooling only. HTTP+SSE runs as a persistent HTTP process. Clients connect over the network. JSON-RPC calls arrive as POST requests. Streaming responses use Server-Sent Events on a separate channel. Everything in this article targets HTTP+SSE. Every MCP session starts with an initialize handshake. The client declares its protocol version and capabilities; the server responds with its own identity and what it supports. { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": { "roots": { "listChanged": true }, "sampling": {} }, "clientInfo": { "name": "claude-desktop", "version": "1.0.0" } } } The server responds: { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-11-25", "capabilities": { "tools": { "listChanged": false } }, "serverInfo": { "name": "knowledge-base-mcp", "version": "1.0.0" } } } After initialize , the client sends a notifications/initialized notification no id , no response expected , and the session is live. The protocolVersion string is not cosmetic. Mismatched versions cause silent failures in some clients; always echo back exactly what you support. Drive both values from config: js // config/mcp.php return 'protocol version' = env 'MCP PROTOCOL VERSION', '2025-11-25' , 'server name' = env 'MCP SERVER NAME', 'knowledge-base-mcp' , ; php // app/MCP/Handlers/InitializeHandler.php namespace App\MCP\Handlers; class InitializeHandler { public function handle array $params : array { return 'protocolVersion' = config 'mcp.protocol version' , 'capabilities' = 'tools' = 'listChanged' = false , , 'serverInfo' = 'name' = config 'mcp.server name' , 'version' = config 'app.version', '1.0.0' , , ; } } listChanged: false tells the client your tool list is static for the session. Set it to true only if you implement the corresponding push notification mechanism, otherwise you are advertising a capability you cannot fulfil. All JSON-RPC calls arrive as a POST to a single endpoint. This is the MCP transport contract: one URL, all method dispatch handled by the server. php // routes/api.php use App\Http\Controllers\McpController; Route::middleware 'auth:sanctum', 'ability:mcp:connect', 'throttle:mcp' - group function { Route::post '/mcp', McpController::class, 'handle' ; } ; A closure here is tempting and wrong. The dedicated controller gives you constructor injection, testability, and a clean extension point when your method list grows. php // app/Http/Controllers/McpController.php namespace App\Http\Controllers; use App\MCP\Exceptions\McpException; use App\MCP\Handlers\InitializeHandler; use App\MCP\Handlers\ToolsCallHandler; use App\MCP\Handlers\ToolsListHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; class McpController extends Controller { public function construct private InitializeHandler $initialize, private ToolsListHandler $toolsList, private ToolsCallHandler $toolsCall, {} public function handle Request $request : JsonResponse|Response { $payload = $request- json - all ; $id = $payload 'id' ?? null; $method = $payload 'method' ?? null; $params = $payload 'params' ?? ; // Notifications carry no id and expect no response body. if $id === null && str starts with string $method, 'notifications/' { return response - noContent ; } try { $result = match $method { 'initialize' = $this- initialize- handle $params , 'tools/list' = $this- toolsList- handle $params , 'tools/call' = $this- toolsCall- handle $params , default = throw new McpException -32601, 'Method not found' , }; } catch McpException $e { return response - json 'jsonrpc' = '2.0', 'id' = $id, 'error' = 'code' = $e- getCode , 'message' = $e- getMessage , ; } return response - json 'jsonrpc' = '2.0', 'id' = $id, 'result' = $result, ; } } php // app/MCP/Exceptions/McpException.php namespace App\MCP\Exceptions; use RuntimeException; class McpException extends RuntimeException { public function construct int $code, string $message { parent:: construct $message, $code ; } } One point worth making explicit: MCP errors travel inside the JSON-RPC envelope over HTTP 200. Do not return 4xx or 5xx at the HTTP layer for protocol-level failures. Your endpoint returns non-200 responses only for genuine transport failures, auth rejection by middleware, server crash , never for unknown methods or invalid parameters. The MCP error code set -32601 , -32602 , and so on is entirely separate from HTTP status codes. The tools/list response is the contract your server publishes to every connecting agent. A well-typed tool definition is also your primary defence against an agent sending garbage input. php // app/MCP/Handlers/ToolsListHandler.php namespace App\MCP\Handlers; class ToolsListHandler { public function handle array $params : array { return 'tools' = 'name' = 'v1 search articles', 'description' = 'Search the knowledge base for articles matching a query. Returns up to 10 results with title, excerpt, and URL. v1 — stable.', 'inputSchema' = '$schema' = 'http://json-schema.org/draft-07/schema ', 'type' = 'object', 'required' = 'query' , 'additionalProperties' = false, 'properties' = 'query' = 'type' = 'string', 'description' = 'Full-text search query.', 'minLength' = 1, 'maxLength' = 500, , 'limit' = 'type' = 'integer', 'default' = 10, 'minimum' = 1, 'maximum' = 50, , , , , 'name' = 'v1 get article', 'description' = 'Retrieve the full content of a single article by its slug. v1 — stable.', 'inputSchema' = '$schema' = 'http://json-schema.org/draft-07/schema ', 'type' = 'object', 'required' = 'slug' , 'additionalProperties' = false, 'properties' = 'slug' = 'type' = 'string', 'description' = 'The article slug, e.g. laravel-scout-meilisearch.', 'pattern' = '^ a-z0-9- +$', , , , , , ; } } additionalProperties: false rejects fields the schema does not declare. Agents occasionally send undeclared properties: from hallucination, caching a stale tool definition, or a client implementation bug. Explicit rejection surfaces these failures cleanly. The same discipline that drives input schema validation against hallucinated tool calls https://origin-main.com/laravel-architecture/laravel-agentic-workflow-schema-validation/ in your own agentic workflows applies equally to the schemas you publish. Architect’s Note Do not embed tool definitions as inline PHP arrays in production code with a large tool surface. Build a ToolRegistry class that loads definitions from config files or a dedicated directory. The inline approach here is for readability, not architecture. The JSON Schema validator wraps justinrainbow/json-schema , which supports Draft 7 out of the box: composer require justinrainbow/json-schema php // app/MCP/JsonSchema/Validator.php namespace App\MCP\JsonSchema; use JsonSchema\Validator as JsonSchemaValidator; class Validator { public function validate array $data, array $schema : array { $validator = new JsonSchemaValidator ; $dataObj = json decode json encode $data ; $schemaObj = json decode json encode $schema ; $validator- validate $dataObj, $schemaObj ; if $validator- isValid { return ; } return array map fn $e = " {$e 'property' } {$e 'message' }", $validator- getErrors ; } } php // app/MCP/Handlers/ToolsCallHandler.php namespace App\MCP\Handlers; use App\MCP\Exceptions\McpException; use App\MCP\JsonSchema\Validator; use App\MCP\Tools\GetArticleTool; use App\MCP\Tools\SearchArticlesTool; class ToolsCallHandler { private array $tools; public function construct private Validator $validator, SearchArticlesTool $searchArticles, GetArticleTool $getArticle, { $this- tools = 'v1 search articles' = $searchArticles, 'v1 get article' = $getArticle, ; } public function handle array $params : array { $toolName = $params 'name' ?? null; $arguments = $params 'arguments' ?? ; if $toolName === null || isset $this- tools $toolName { throw new McpException -32602, "Unknown tool: {$toolName}" ; } $tool = $this- tools $toolName ; $errors = $this- validator- validate $arguments, $tool- schema ; if empty $errors { throw new McpException -32602, 'Invalid arguments: ' . implode ', ', $errors ; } try { $result = $tool- execute $arguments ; } catch \Throwable $e { // Tool execution errors use isError: true in the result envelope. // This is NOT a JSON-RPC error object — the agent receives it and // can reason about the failure without the protocol layer breaking. return 'content' = 'type' = 'text', 'text' = $e- getMessage , 'isError' = true, ; } return 'content' = 'type' = 'text', 'text' = json encode $result , 'isError' = false, ; } } The isError field belongs to the MCP tool result schema, not to the JSON-RPC layer. A JSON-RPC error object means the protocol call itself failed. isError: true in the tool result means the tool ran but the business logic failed. Claude receives both and can reason about them differently. Do not conflate them. A concrete tool implementation for the knowledge base domain: php // app/MCP/Tools/SearchArticlesTool.php namespace App\MCP\Tools; use App\Models\Article; class SearchArticlesTool { public function schema : array { return '$schema' = 'http://json-schema.org/draft-07/schema ', 'type' = 'object', 'required' = 'query' , 'additionalProperties' = false, 'properties' = 'query' = 'type' = 'string', 'minLength' = 1, 'maxLength' = 500 , 'limit' = 'type' = 'integer', 'default' = 10, 'minimum' = 1, 'maximum' = 50 , , ; } public function execute array $arguments : array { $limit = min int $arguments 'limit' ?? 10 , 50 ; return Article::where 'status', 'published' - where function $q use $arguments { $q- where 'title', 'like', "%{$arguments 'query' }%" - orWhere 'content', 'like', "%{$arguments 'query' }%" ; } - orderBy 'published at', 'desc' - limit $limit - get 'title', 'slug', 'excerpt', 'published at' - map fn $a = 'title' = $a- title, 'slug' = $a- slug, 'excerpt' = $a- excerpt, 'url' = url "/articles/{$a- slug}" , - toArray ; } } Production Pitfall LIKE queries against large article tables will not hold under load. At a few hundred thousand rows, a LIKE on the content column produces full table scans. Laravel Scout with Meilisearch is the production-appropriate replacement. The LIKE query here keeps the example self-contained and focused on the MCP layer, not the search layer. MCP over HTTP requires authentication. Every connecting agent should present a Sanctum API token scoped to a client identifier. Provision tokens with an ability that identifies the MCP client type: php // A controller or Artisan command that provisions client tokens: $token = $user- createToken 'claude-desktop', 'mcp:connect' - plainTextToken; Define the named rate limiter in AppServiceProvider::boot : php // app/Providers/AppServiceProvider.php use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; public function boot : void { RateLimiter::for 'mcp', function Request $request { $tokenId = $request- user ?- currentAccessToken ?- id; return $tokenId ? Limit::perMinute 60 - by "mcp token:{$tokenId}" : Limit::perMinute 10 - by $request- ip ; } ; } For Redis-backed enforcement across distributed workers, swap the throttle middleware alias in bootstrap/app.php : php // bootstrap/app.php - withMiddleware function Middleware $middleware { $middleware- alias 'throttle' = \Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class, ; } The throttle:mcp reference in your route group now uses this named limiter, keyed at the token level. Keying on currentAccessToken - id rather than the user ID matters in multi-client setups. A user might provision tokens for Claude Desktop, Cursor, and a custom agent simultaneously, independent token-level keys give each client its own bucket. This section stays deliberately narrow. The token tracking middleware https://origin-main.com/laravel-architecture/laravel-ai-middleware-token-tracking/ patterns extend cleanly to MCP contexts, giving you a unified usage ledger across both LLM API calls and MCP tool invocations once your auth layer is in place. Word to the Wise Never share one Sanctum token across multiple MCP clients. Provision one token per client identifier. Revoking a single compromised client must not affect others. The provisioning cost is negligible; the blast-radius reduction is not. Production Pitfall Without Redis-backed throttling, distributed deployments multiple PHP-FPM workers or Octane workers , will not enforce rate limits consistently. The in-memory driver sees only its own process. This is not a “good enough for now” situation: a slow rollout of clients against an under-guarded endpoint will surface this gap at the worst possible time. Two test layers are required. The first is deterministic PHPUnit coverage of the JSON-RPC interface. The second is integration against Claude Desktop. Neither is optional if this server runs in production. PHPUnit treats the MCP endpoint like any other HTTP endpoint in a Laravel application: php // tests/Feature/McpTest.php namespace Tests\Feature; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Testing\TestResponse; use Tests\TestCase; class McpTest extends TestCase { use RefreshDatabase; private function mcpPost array $payload, ?User $user = null : TestResponse { $user ??= User::factory - create ; $token = $user- createToken 'test-client', 'mcp:connect' - plainTextToken; return $this- withToken $token - postJson '/api/mcp', $payload ; } public function test initialize returns server info : void { $response = $this- mcpPost 'jsonrpc' = '2.0', 'id' = 1, 'method' = 'initialize', 'params' = 'protocolVersion' = '2025-11-25', 'capabilities' = , 'clientInfo' = 'name' = 'test', 'version' = '1.0' , , ; $response- assertOk - assertJsonPath 'jsonrpc', '2.0' - assertJsonPath 'result.protocolVersion', '2025-11-25' - assertJsonStructure 'result' = 'serverInfo', 'capabilities' ; } public function test tools list returns expected tools : void { $response = $this- mcpPost 'jsonrpc' = '2.0', 'id' = 2, 'method' = 'tools/list', 'params' = , ; $response- assertOk - assertJsonPath 'result.tools.0.name', 'v1 search articles' ; } public function test tools call rejects unknown tool : void { $response = $this- mcpPost 'jsonrpc' = '2.0', 'id' = 3, 'method' = 'tools/call', 'params' = 'name' = 'nonexistent tool', 'arguments' = , ; // MCP errors return HTTP 200 with the error inside the JSON-RPC envelope. $response- assertOk - assertJsonPath 'error.code', -32602 ; } public function test unknown method returns method not found : void { $response = $this- mcpPost 'jsonrpc' = '2.0', 'id' = 4, 'method' = 'resources/list', 'params' = , ; $response- assertOk - assertJsonPath 'error.code', -32601 ; } public function test unauthenticated request is rejected at http layer : void { $response = $this- postJson '/api/mcp', 'jsonrpc' = '2.0', 'id' = 5, 'method' = 'initialize', 'params' = , ; // Auth failures are the one legitimate case for non-200 at the HTTP layer. $response- assertUnauthorized ; } } The Claude Desktop integration uses claude desktop config.json . For an HTTP+SSE server with Sanctum, point at your endpoint directly: { "mcpServers": { "knowledge-base": { "url": "https://your-app.test/api/mcp", "headers": { "Authorization": "Bearer YOUR SANCTUM TOKEN" } } } } For local development under Laravel Herd or Sail, use your .test domain. Claude Desktop supports the HTTP transport directly, no stdio proxy required. The testability of a JSON-RPC endpoint is not accidental. It is a direct consequence of keeping the MCP transport layer thin and pushing business logic into typed tool handlers. This is the same principle that underpins the production AI architecture: governance and telemetry https://origin-main.com/laravel-architecture/production-grade-ai-architecture-in-laravel/ approach: observable, bounded, independently testable components. An MCP server built without that discipline will give you a server you can demo, not one you can operate. Getting the server to respond correctly in development is an afternoon’s work. Keeping it reliable under concurrent client load, through spec updates, and across deployment boundaries is the actual engineering problem. The named rate limiter defined in AppServiceProvider already keys on the Sanctum token ID. For deployments with mixed client trust levels, extend it with role-based tiering: // app/Providers/AppServiceProvider.php RateLimiter::for 'mcp', function Request $request { $user = $request- user ; $tokenId = $user?- currentAccessToken ?- id; if $tokenId { return Limit::perMinute 10 - by $request- ip ; } $limit = $user- hasRole 'trusted-agent' ? 300 : 60; return Limit::perMinute $limit - by "mcp token:{$tokenId}" ; } ; Teams running this at volume have found that the 60 req/min default is generous for interactive use but tight for agents that batch tool calls during complex reasoning loops. Profile your actual agent traffic before locking in limits. Tool definitions will change. The question is not whether you will need a breaking change, but when and how you communicate it to agents that have already cached your tool list. The v1 naming prefix is the versioning strategy. Keep it from day one: v1 search articles → stable, in production v2 search articles → new parameter set, under parallel deployment During a transition window, both versions appear in your tools/list response. Agents that cached v1 search articles continue working. Agents that fetch the current tool list pick up v2 search articles . You retire v1 search articles once your observability data confirms no active clients are still calling it. Signal deprecation inside the description field, since that is what agents read: js 'name' = 'v1 search articles', 'description' = ' DEPRECATED — use v2 search articles — removal: 2026-09-01 Search the knowledge base...', Claude reads tool descriptions and will propagate this signal in its reasoning. It is not a guarantee, but it costs nothing and occasionally surfaces the deprecation in agent output where a human can act on it. Word to the Wise Build the versioning convention into your ToolRegistry before you need your first breaking change. Retrofitting namespace prefixes across a production server with multiple active clients requires a coordinated rollout and a maintenance window. Do it on day one. Every tools/call invocation should produce a structured log entry. Add timing and logging directly into ToolsCallHandler : // app/MCP/Handlers/ToolsCallHandler.php — updated handle method public function handle array $params : array { $toolName = $params 'name' ?? null; $arguments = $params 'arguments' ?? ; if $toolName === null || isset $this- tools $toolName { throw new McpException -32602, "Unknown tool: {$toolName}" ; } $tool = $this- tools $toolName ; $errors = $this- validator- validate $arguments, $tool- schema ; if empty $errors { throw new McpException -32602, 'Invalid arguments: ' . implode ', ', $errors ; } $clientId = auth - user ?- id; $inputHash = hash 'xxh3', json encode $arguments ; $start = hrtime true ; try { $result = $tool- execute $arguments ; $elapsed = hrtime true - $start / 1e6; Log::channel 'mcp' - info 'tool.call', 'client id' = $clientId, 'tool' = $toolName, 'input hash' = $inputHash, 'elapsed ms' = round $elapsed, 2 , 'is error' = false, ; return 'content' = 'type' = 'text', 'text' = json encode $result , 'isError' = false, ; } catch \Throwable $e { $elapsed = hrtime true - $start / 1e6; Log::channel 'mcp' - warning 'tool.call.error', 'client id' = $clientId, 'tool' = $toolName, 'input hash' = $inputHash, 'elapsed ms' = round $elapsed, 2 , 'error' = $e- getMessage , ; return 'content' = 'type' = 'text', 'text' = $e- getMessage , 'isError' = true, ; } } Add the dedicated log channel in config/logging.php : js // config/logging.php — add to 'channels': 'mcp' = 'driver' = 'daily', 'path' = storage path 'logs/mcp.log' , 'level' = 'debug', 'days' = 30, , Log the input hash rather than the raw input. MCP tool arguments regularly contain user-supplied text. Logging raw content creates PII exposure risk and bloats log files under any real call volume. The xxh3 algorithm is substantially faster than sha256 for short payloads, and the collision resistance is sufficient for log correlation, not cryptographic use. These structured log entries pipe naturally into an admin interface. The Filament AI admin dashboard https://origin-main.com/laravel-architecture/laravel-filament-admin-dashboard-ai-applications/ pattern covers exactly this surface: agent invocation events queryable by client ID, tool name, and time range, giving you a full audit trail for every tool your server has executed. Efficiency Gain Route the MCP log channel to its own daily file from day one. Mixing structured MCP events into your main application log makes both harder to parse and complicates log shipping configurations downstream. Your Laravel application can simultaneously run an MCP server for inbound agents and act as an MCP client consuming other MCP servers through Prism. These are complementary roles. Prism’s MCP client support ships as a separate companion package, Relay: composer require prism-php/relay php artisan vendor:publish --tag="relay-config" Define your server connection in config/relay.php : php // config/relay.php use Prism\Relay\Enums\Transport; return 'servers' = 'knowledge-base' = 'url' = env 'MCP INTERNAL SERVER URL', 'http://localhost/api/mcp' , 'transport' = Transport::Http, 'timeout' = 30, 'headers' = 'Authorization' = 'Bearer ' . env 'MCP INTERNAL TOKEN' , , , , 'cache duration' = env 'RELAY TOOLS CACHE DURATION', 60 , ; Then use Relay::tools in your Prism agent chain: php use Prism\Prism\Enums\Provider; use Prism\Prism\Prism; use Prism\Relay\Facades\Relay; $response = Prism::text - using Provider::Anthropic, 'claude-sonnet-4-6' - withPrompt 'Search the knowledge base for articles about Laravel queues and summarise the top three results.' - withTools Relay::tools 'knowledge-base' - asText ; Relay::tools fetches tools/list from your MCP server, translates the JSON Schema definitions into Prism tool objects, and wires them into the agent loop. When Claude decides to call v1 search articles , Relay dispatches the tools/call request to your MCP server and returns the result back into the reasoning loop. The dual-role architecture, MCP server for external agents, Prism client for outbound agent workflows, is the natural end state of a fully integrated Laravel application. For the full picture of what Prism brings to building agentic Laravel apps with Prism PHP https://origin-main.com/ai-agents/laravel-prism-php-agentic-apps/ beyond MCP client support, including multi-step reasoning loops and provider-agnostic pipelines, that article covers the complete agentic layer. Your MCP server gives external agents a stable, authenticated interface into your Laravel application. The natural next step is the Claude integration that powers what your tools actually do. If you have not yet built the Claude layer inside your application streaming responses, conversation memory, production error handling , the complete Laravel Claude API integration guide https://origin-main.com/guides/laravel-claude-api-integration-guide/ is where to start. The MCP server becomes significantly more useful when the model connecting to it is already wired correctly into your application. Build that layer first, then expose it through tools.