{"slug": "building-a-production-mcp-server-in-laravel", "title": "Building a Production MCP Server in Laravel", "summary": "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.", "body_md": "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`\n\nstring against the[official MCP specification changelog]before deployment, and on every spec update.\n\nMost 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.\n\nThis 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.\n\nMCP, 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.\n\nThe 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.\n\nThere is a PHP package, `php-mcp/laravel`\n\n, that scaffolds a server for you. At the time of writing it targets spec version `2025-03-26`\n\n, 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.\n\nMCP supports two transport modes. Choosing the wrong one for your deployment context is the most common early mistake.\n\n| Mode | Transport | Use case | Multi-client | Production-appropriate |\n|---|---|---|---|---|\n| stdio | stdin / stdout | Local dev, IDE tooling | No | No |\n| HTTP+SSE | HTTP POST + Server-Sent Events | Remote, hosted, multi-client | Yes | Yes |\n\n**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.\n\n**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.\n\nEvery MCP session starts with an `initialize`\n\nhandshake. The client declares its protocol version and capabilities; the server responds with its own identity and what it supports.\n\n```\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"method\": \"initialize\",\n  \"params\": {\n    \"protocolVersion\": \"2025-11-25\",\n    \"capabilities\": {\n      \"roots\": { \"listChanged\": true },\n      \"sampling\": {}\n    },\n    \"clientInfo\": {\n      \"name\": \"claude-desktop\",\n      \"version\": \"1.0.0\"\n    }\n  }\n}\n```\n\nThe server responds:\n\n```\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"result\": {\n    \"protocolVersion\": \"2025-11-25\",\n    \"capabilities\": {\n      \"tools\": { \"listChanged\": false }\n    },\n    \"serverInfo\": {\n      \"name\": \"knowledge-base-mcp\",\n      \"version\": \"1.0.0\"\n    }\n  }\n}\n```\n\nAfter `initialize`\n\n, the client sends a `notifications/initialized`\n\nnotification (no `id`\n\n, no response expected), and the session is live. The `protocolVersion`\n\nstring is not cosmetic. Mismatched versions cause silent failures in some clients; always echo back exactly what you support.\n\nDrive both values from config:\n\n``` js\n// config/mcp.php\n\nreturn [\n    'protocol_version' => env('MCP_PROTOCOL_VERSION', '2025-11-25'),\n    'server_name'      => env('MCP_SERVER_NAME', 'knowledge-base-mcp'),\n];\nphp\n// app/MCP/Handlers/InitializeHandler.php\n\nnamespace App\\MCP\\Handlers;\n\nclass InitializeHandler\n{\n    public function handle(array $params): array\n    {\n        return [\n            'protocolVersion' => config('mcp.protocol_version'),\n            'capabilities'    => [\n                'tools' => ['listChanged' => false],\n            ],\n            'serverInfo' => [\n                'name'    => config('mcp.server_name'),\n                'version' => config('app.version', '1.0.0'),\n            ],\n        ];\n    }\n}\n```\n\n`listChanged: false`\n\ntells the client your tool list is static for the session. Set it to `true`\n\nonly if you implement the corresponding push notification mechanism, otherwise you are advertising a capability you cannot fulfil.\n\nAll 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.\n\n``` php\n// routes/api.php\n\nuse App\\Http\\Controllers\\McpController;\n\nRoute::middleware(['auth:sanctum', 'ability:mcp:connect', 'throttle:mcp'])\n    ->group(function () {\n        Route::post('/mcp', [McpController::class, 'handle']);\n    });\n```\n\nA closure here is tempting and wrong. The dedicated controller gives you constructor injection, testability, and a clean extension point when your method list grows.\n\n``` php\n// app/Http/Controllers/McpController.php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\MCP\\Exceptions\\McpException;\nuse App\\MCP\\Handlers\\InitializeHandler;\nuse App\\MCP\\Handlers\\ToolsCallHandler;\nuse App\\MCP\\Handlers\\ToolsListHandler;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Response;\n\nclass McpController extends Controller\n{\n    public function __construct(\n        private InitializeHandler $initialize,\n        private ToolsListHandler  $toolsList,\n        private ToolsCallHandler  $toolsCall,\n    ) {}\n\n    public function handle(Request $request): JsonResponse|Response\n    {\n        $payload = $request->json()->all();\n        $id      = $payload['id'] ?? null;\n        $method  = $payload['method'] ?? null;\n        $params  = $payload['params'] ?? [];\n\n        // Notifications carry no id and expect no response body.\n        if ($id === null && str_starts_with((string) $method, 'notifications/')) {\n            return response()->noContent();\n        }\n\n        try {\n            $result = match ($method) {\n                'initialize' => $this->initialize->handle($params),\n                'tools/list' => $this->toolsList->handle($params),\n                'tools/call' => $this->toolsCall->handle($params),\n                default      => throw new McpException(-32601, 'Method not found'),\n            };\n        } catch (McpException $e) {\n            return response()->json([\n                'jsonrpc' => '2.0',\n                'id'      => $id,\n                'error'   => ['code' => $e->getCode(), 'message' => $e->getMessage()],\n            ]);\n        }\n\n        return response()->json([\n            'jsonrpc' => '2.0',\n            'id'      => $id,\n            'result'  => $result,\n        ]);\n    }\n}\nphp\n// app/MCP/Exceptions/McpException.php\n\nnamespace App\\MCP\\Exceptions;\n\nuse RuntimeException;\n\nclass McpException extends RuntimeException\n{\n    public function __construct(int $code, string $message)\n    {\n        parent::__construct($message, $code);\n    }\n}\n```\n\nOne 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`\n\n, `-32602`\n\n, and so on) is entirely separate from HTTP status codes.\n\nThe `tools/list`\n\nresponse 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.\n\n``` php\n// app/MCP/Handlers/ToolsListHandler.php\n\nnamespace App\\MCP\\Handlers;\n\nclass ToolsListHandler\n{\n    public function handle(array $params): array\n    {\n        return [\n            'tools' => [\n                [\n                    'name'        => 'v1__search_articles',\n                    'description' => 'Search the knowledge base for articles matching a query. Returns up to 10 results with title, excerpt, and URL. v1 — stable.',\n                    'inputSchema' => [\n                        '$schema'              => 'http://json-schema.org/draft-07/schema#',\n                        'type'                 => 'object',\n                        'required'             => ['query'],\n                        'additionalProperties' => false,\n                        'properties'           => [\n                            'query' => [\n                                'type'        => 'string',\n                                'description' => 'Full-text search query.',\n                                'minLength'   => 1,\n                                'maxLength'   => 500,\n                            ],\n                            'limit' => [\n                                'type'    => 'integer',\n                                'default' => 10,\n                                'minimum' => 1,\n                                'maximum' => 50,\n                            ],\n                        ],\n                    ],\n                ],\n                [\n                    'name'        => 'v1__get_article',\n                    'description' => 'Retrieve the full content of a single article by its slug. v1 — stable.',\n                    'inputSchema' => [\n                        '$schema'              => 'http://json-schema.org/draft-07/schema#',\n                        'type'                 => 'object',\n                        'required'             => ['slug'],\n                        'additionalProperties' => false,\n                        'properties'           => [\n                            'slug' => [\n                                'type'        => 'string',\n                                'description' => 'The article slug, e.g. laravel-scout-meilisearch.',\n                                'pattern'     => '^[a-z0-9-]+$',\n                            ],\n                        ],\n                    ],\n                ],\n            ],\n        ];\n    }\n}\n```\n\n`additionalProperties: false`\n\nrejects 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.\n\n[Architect’s Note] Do not embed tool definitions as inline PHP arrays in production code with a large tool surface. Build a`ToolRegistry`\n\nclass that loads definitions from config files or a dedicated directory. The inline approach here is for readability, not architecture.\n\nThe JSON Schema validator wraps `justinrainbow/json-schema`\n\n, which supports Draft 7 out of the box:\n\n```\ncomposer require justinrainbow/json-schema\nphp\n// app/MCP/JsonSchema/Validator.php\n\nnamespace App\\MCP\\JsonSchema;\n\nuse JsonSchema\\Validator as JsonSchemaValidator;\n\nclass Validator\n{\n    public function validate(array $data, array $schema): array\n    {\n        $validator = new JsonSchemaValidator();\n        $dataObj   = json_decode(json_encode($data));\n        $schemaObj = json_decode(json_encode($schema));\n\n        $validator->validate($dataObj, $schemaObj);\n\n        if ($validator->isValid()) {\n            return [];\n        }\n\n        return array_map(\n            fn($e) => \"[{$e['property']}] {$e['message']}\",\n            $validator->getErrors()\n        );\n    }\n}\nphp\n// app/MCP/Handlers/ToolsCallHandler.php\n\nnamespace App\\MCP\\Handlers;\n\nuse App\\MCP\\Exceptions\\McpException;\nuse App\\MCP\\JsonSchema\\Validator;\nuse App\\MCP\\Tools\\GetArticleTool;\nuse App\\MCP\\Tools\\SearchArticlesTool;\n\nclass ToolsCallHandler\n{\n    private array $tools;\n\n    public function __construct(\n        private Validator      $validator,\n        SearchArticlesTool     $searchArticles,\n        GetArticleTool         $getArticle,\n    ) {\n        $this->tools = [\n            'v1__search_articles' => $searchArticles,\n            'v1__get_article'     => $getArticle,\n        ];\n    }\n\n    public function handle(array $params): array\n    {\n        $toolName  = $params['name'] ?? null;\n        $arguments = $params['arguments'] ?? [];\n\n        if ($toolName === null || ! isset($this->tools[$toolName])) {\n            throw new McpException(-32602, \"Unknown tool: {$toolName}\");\n        }\n\n        $tool   = $this->tools[$toolName];\n        $errors = $this->validator->validate($arguments, $tool->schema());\n\n        if (! empty($errors)) {\n            throw new McpException(-32602, 'Invalid arguments: ' . implode(', ', $errors));\n        }\n\n        try {\n            $result = $tool->execute($arguments);\n        } catch (\\Throwable $e) {\n            // Tool execution errors use isError: true in the result envelope.\n            // This is NOT a JSON-RPC error object — the agent receives it and\n            // can reason about the failure without the protocol layer breaking.\n            return [\n                'content' => [['type' => 'text', 'text' => $e->getMessage()]],\n                'isError' => true,\n            ];\n        }\n\n        return [\n            'content' => [['type' => 'text', 'text' => json_encode($result)]],\n            'isError' => false,\n        ];\n    }\n}\n```\n\nThe `isError`\n\nfield 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`\n\nin 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.\n\nA concrete tool implementation for the knowledge base domain:\n\n``` php\n// app/MCP/Tools/SearchArticlesTool.php\n\nnamespace App\\MCP\\Tools;\n\nuse App\\Models\\Article;\n\nclass SearchArticlesTool\n{\n    public function schema(): array\n    {\n        return [\n            '$schema'              => 'http://json-schema.org/draft-07/schema#',\n            'type'                 => 'object',\n            'required'             => ['query'],\n            'additionalProperties' => false,\n            'properties'           => [\n                'query' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 500],\n                'limit' => ['type' => 'integer', 'default' => 10, 'minimum' => 1, 'maximum' => 50],\n            ],\n        ];\n    }\n\n    public function execute(array $arguments): array\n    {\n        $limit = min((int) ($arguments['limit'] ?? 10), 50);\n\n        return Article::where('status', 'published')\n            ->where(function ($q) use ($arguments) {\n                $q->where('title', 'like', \"%{$arguments['query']}%\")\n                  ->orWhere('content', 'like', \"%{$arguments['query']}%\");\n            })\n            ->orderBy('published_at', 'desc')\n            ->limit($limit)\n            ->get(['title', 'slug', 'excerpt', 'published_at'])\n            ->map(fn($a) => [\n                'title'   => $a->title,\n                'slug'    => $a->slug,\n                'excerpt' => $a->excerpt,\n                'url'     => url(\"/articles/{$a->slug}\"),\n            ])\n            ->toArray();\n    }\n}\n```\n\n[Production Pitfall]`LIKE`\n\nqueries against large article tables will not hold under load. At a few hundred thousand rows, a`LIKE`\n\non the`content`\n\ncolumn produces full table scans. Laravel Scout with Meilisearch is the production-appropriate replacement. The`LIKE`\n\nquery here keeps the example self-contained and focused on the MCP layer, not the search layer.\n\nMCP over HTTP requires authentication. Every connecting agent should present a Sanctum API token scoped to a client identifier.\n\nProvision tokens with an ability that identifies the MCP client type:\n\n``` php\n// A controller or Artisan command that provisions client tokens:\n\n$token = $user->createToken('claude-desktop', ['mcp:connect'])->plainTextToken;\n```\n\nDefine the named rate limiter in `AppServiceProvider::boot()`\n\n:\n\n``` php\n// app/Providers/AppServiceProvider.php\n\nuse Illuminate\\Cache\\RateLimiting\\Limit;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\RateLimiter;\n\npublic function boot(): void\n{\n    RateLimiter::for('mcp', function (Request $request) {\n        $tokenId = $request->user()?->currentAccessToken()?->id;\n\n        return $tokenId\n            ? Limit::perMinute(60)->by(\"mcp_token:{$tokenId}\")\n            : Limit::perMinute(10)->by($request->ip());\n    });\n}\n```\n\nFor Redis-backed enforcement across distributed workers, swap the throttle middleware alias in `bootstrap/app.php`\n\n:\n\n``` php\n// bootstrap/app.php\n\n->withMiddleware(function (Middleware $middleware) {\n    $middleware->alias([\n        'throttle' => \\Illuminate\\Routing\\Middleware\\ThrottleRequestsWithRedis::class,\n    ]);\n})\n```\n\nThe `throttle:mcp`\n\nreference in your route group now uses this named limiter, keyed at the token level. Keying on `currentAccessToken()->id`\n\nrather 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.\n\nThis 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.\n\n[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.\n\n[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.\n\nTwo 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.\n\nPHPUnit treats the MCP endpoint like any other HTTP endpoint in a Laravel application:\n\n``` php\n// tests/Feature/McpTest.php\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Testing\\TestResponse;\nuse Tests\\TestCase;\n\nclass McpTest extends TestCase\n{\n    use RefreshDatabase;\n\n    private function mcpPost(array $payload, ?User $user = null): TestResponse\n    {\n        $user  ??= User::factory()->create();\n        $token   = $user->createToken('test-client', ['mcp:connect'])->plainTextToken;\n\n        return $this->withToken($token)->postJson('/api/mcp', $payload);\n    }\n\n    public function test_initialize_returns_server_info(): void\n    {\n        $response = $this->mcpPost([\n            'jsonrpc' => '2.0',\n            'id'      => 1,\n            'method'  => 'initialize',\n            'params'  => [\n                'protocolVersion' => '2025-11-25',\n                'capabilities'    => [],\n                'clientInfo'      => ['name' => 'test', 'version' => '1.0'],\n            ],\n        ]);\n\n        $response->assertOk()\n            ->assertJsonPath('jsonrpc', '2.0')\n            ->assertJsonPath('result.protocolVersion', '2025-11-25')\n            ->assertJsonStructure(['result' => ['serverInfo', 'capabilities']]);\n    }\n\n    public function test_tools_list_returns_expected_tools(): void\n    {\n        $response = $this->mcpPost([\n            'jsonrpc' => '2.0',\n            'id'      => 2,\n            'method'  => 'tools/list',\n            'params'  => [],\n        ]);\n\n        $response->assertOk()\n            ->assertJsonPath('result.tools.0.name', 'v1__search_articles');\n    }\n\n    public function test_tools_call_rejects_unknown_tool(): void\n    {\n        $response = $this->mcpPost([\n            'jsonrpc' => '2.0',\n            'id'      => 3,\n            'method'  => 'tools/call',\n            'params'  => ['name' => 'nonexistent_tool', 'arguments' => []],\n        ]);\n\n        // MCP errors return HTTP 200 with the error inside the JSON-RPC envelope.\n        $response->assertOk()->assertJsonPath('error.code', -32602);\n    }\n\n    public function test_unknown_method_returns_method_not_found(): void\n    {\n        $response = $this->mcpPost([\n            'jsonrpc' => '2.0',\n            'id'      => 4,\n            'method'  => 'resources/list',\n            'params'  => [],\n        ]);\n\n        $response->assertOk()->assertJsonPath('error.code', -32601);\n    }\n\n    public function test_unauthenticated_request_is_rejected_at_http_layer(): void\n    {\n        $response = $this->postJson('/api/mcp', [\n            'jsonrpc' => '2.0',\n            'id'      => 5,\n            'method'  => 'initialize',\n            'params'  => [],\n        ]);\n\n        // Auth failures are the one legitimate case for non-200 at the HTTP layer.\n        $response->assertUnauthorized();\n    }\n}\n```\n\nThe Claude Desktop integration uses `claude_desktop_config.json`\n\n. For an HTTP+SSE server with Sanctum, point at your endpoint directly:\n\n```\n{\n  \"mcpServers\": {\n    \"knowledge-base\": {\n      \"url\": \"https://your-app.test/api/mcp\",\n      \"headers\": {\n        \"Authorization\": \"Bearer YOUR_SANCTUM_TOKEN\"\n      }\n    }\n  }\n}\n```\n\nFor local development under Laravel Herd or Sail, use your `.test`\n\ndomain. Claude Desktop supports the HTTP transport directly, no stdio proxy required.\n\nThe 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.\n\nGetting 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.\n\nThe named rate limiter defined in `AppServiceProvider`\n\nalready keys on the Sanctum token ID. For deployments with mixed client trust levels, extend it with role-based tiering:\n\n```\n// app/Providers/AppServiceProvider.php\n\nRateLimiter::for('mcp', function (Request $request) {\n    $user    = $request->user();\n    $tokenId = $user?->currentAccessToken()?->id;\n\n    if (! $tokenId) {\n        return Limit::perMinute(10)->by($request->ip());\n    }\n\n    $limit = $user->hasRole('trusted-agent') ? 300 : 60;\n\n    return Limit::perMinute($limit)->by(\"mcp_token:{$tokenId}\");\n});\n```\n\nTeams 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.\n\nTool 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.\n\nThe `v1__`\n\nnaming prefix is the versioning strategy. Keep it from day one:\n\n```\nv1__search_articles  →  stable, in production\nv2__search_articles  →  new parameter set, under parallel deployment\n```\n\nDuring a transition window, both versions appear in your `tools/list`\n\nresponse. Agents that cached `v1__search_articles`\n\ncontinue working. Agents that fetch the current tool list pick up `v2__search_articles`\n\n. You retire `v1__search_articles`\n\nonce your observability data confirms no active clients are still calling it.\n\nSignal deprecation inside the `description`\n\nfield, since that is what agents read:\n\n``` js\n'name'        => 'v1__search_articles',\n'description' => '[DEPRECATED — use v2__search_articles — removal: 2026-09-01] Search the knowledge base...',\n```\n\nClaude 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.\n\n[Word to the Wise] Build the versioning convention into your`ToolRegistry`\n\nbefore 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.\n\nEvery `tools/call`\n\ninvocation should produce a structured log entry. Add timing and logging directly into `ToolsCallHandler`\n\n:\n\n```\n// app/MCP/Handlers/ToolsCallHandler.php — updated handle() method\n\npublic function handle(array $params): array\n{\n    $toolName  = $params['name'] ?? null;\n    $arguments = $params['arguments'] ?? [];\n\n    if ($toolName === null || ! isset($this->tools[$toolName])) {\n        throw new McpException(-32602, \"Unknown tool: {$toolName}\");\n    }\n\n    $tool   = $this->tools[$toolName];\n    $errors = $this->validator->validate($arguments, $tool->schema());\n\n    if (! empty($errors)) {\n        throw new McpException(-32602, 'Invalid arguments: ' . implode(', ', $errors));\n    }\n\n    $clientId  = auth()->user()?->id;\n    $inputHash = hash('xxh3', json_encode($arguments));\n    $start     = hrtime(true);\n\n    try {\n        $result  = $tool->execute($arguments);\n        $elapsed = (hrtime(true) - $start) / 1e6;\n\n        Log::channel('mcp')->info('tool.call', [\n            'client_id'  => $clientId,\n            'tool'       => $toolName,\n            'input_hash' => $inputHash,\n            'elapsed_ms' => round($elapsed, 2),\n            'is_error'   => false,\n        ]);\n\n        return [\n            'content' => [['type' => 'text', 'text' => json_encode($result)]],\n            'isError' => false,\n        ];\n    } catch (\\Throwable $e) {\n        $elapsed = (hrtime(true) - $start) / 1e6;\n\n        Log::channel('mcp')->warning('tool.call.error', [\n            'client_id'  => $clientId,\n            'tool'       => $toolName,\n            'input_hash' => $inputHash,\n            'elapsed_ms' => round($elapsed, 2),\n            'error'      => $e->getMessage(),\n        ]);\n\n        return [\n            'content' => [['type' => 'text', 'text' => $e->getMessage()]],\n            'isError' => true,\n        ];\n    }\n}\n```\n\nAdd the dedicated log channel in `config/logging.php`\n\n:\n\n``` js\n// config/logging.php — add to 'channels':\n\n'mcp' => [\n    'driver' => 'daily',\n    'path'   => storage_path('logs/mcp.log'),\n    'level'  => 'debug',\n    'days'   => 30,\n],\n```\n\nLog 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`\n\nalgorithm is substantially faster than `sha256`\n\nfor short payloads, and the collision resistance is sufficient for log correlation, not cryptographic use.\n\nThese 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.\n\n[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.\n\nYour 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.\n\nPrism’s MCP client support ships as a separate companion package, Relay:\n\n```\ncomposer require prism-php/relay\nphp artisan vendor:publish --tag=\"relay-config\"\n```\n\nDefine your server connection in `config/relay.php`\n\n:\n\n``` php\n// config/relay.php\n\nuse Prism\\Relay\\Enums\\Transport;\n\nreturn [\n    'servers' => [\n        'knowledge-base' => [\n            'url'       => env('MCP_INTERNAL_SERVER_URL', 'http://localhost/api/mcp'),\n            'transport' => Transport::Http,\n            'timeout'   => 30,\n            'headers'   => [\n                'Authorization' => 'Bearer ' . env('MCP_INTERNAL_TOKEN'),\n            ],\n        ],\n    ],\n    'cache_duration' => env('RELAY_TOOLS_CACHE_DURATION', 60),\n];\n```\n\nThen use `Relay::tools()`\n\nin your Prism agent chain:\n\n``` php\nuse Prism\\Prism\\Enums\\Provider;\nuse Prism\\Prism\\Prism;\nuse Prism\\Relay\\Facades\\Relay;\n\n$response = Prism::text()\n    ->using(Provider::Anthropic, 'claude-sonnet-4-6')\n    ->withPrompt('Search the knowledge base for articles about Laravel queues and summarise the top three results.')\n    ->withTools(Relay::tools('knowledge-base'))\n    ->asText();\n```\n\n`Relay::tools()`\n\nfetches `tools/list`\n\nfrom 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`\n\n, Relay dispatches the `tools/call`\n\nrequest to your MCP server and returns the result back into the reasoning loop.\n\nThe 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.\n\nYour 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.\n\nIf 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.", "url": "https://wpnews.pro/news/building-a-production-mcp-server-in-laravel", "canonical_source": "https://dev.to/dewaldhugo/building-a-production-mcp-server-in-laravel-10ab", "published_at": "2026-05-27 06:16:05+00:00", "updated_at": "2026-05-27 06:22:42.425035+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "ai-infrastructure", "ai-tools", "large-language-models"], "entities": ["Laravel", "Model Context Protocol", "Claude Desktop", "JSON-RPC", "Origin Main"], "alternates": {"html": "https://wpnews.pro/news/building-a-production-mcp-server-in-laravel", "markdown": "https://wpnews.pro/news/building-a-production-mcp-server-in-laravel.md", "text": "https://wpnews.pro/news/building-a-production-mcp-server-in-laravel.txt", "jsonld": "https://wpnews.pro/news/building-a-production-mcp-server-in-laravel.jsonld"}}