laravel mcp server

Building a Production MCP Server in Laravel

Spec version notice: This article targets MCP specification version 2025-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 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.

What MCP actually is (and what it is not)

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

The two transport modes: stdio vs HTTP+SSE

MCP supports two transport modes. Choosing the wrong one for your deployment context is the most common early mistake.

ModeTransportUse caseMulti-clientProduction-appropriate
stdiostdin / stdoutLocal dev, IDE toolingNoNo
HTTP+SSEHTTP POST + Server-Sent EventsRemote, hosted, multi-clientYesYes

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.

Defining the server manifest and capability negotiation

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:

// config/mcp.php

return [
    'protocol_version' => env('MCP_PROTOCOL_VERSION', '2025-11-25'),
    'server_name'      => env('MCP_SERVER_NAME', 'knowledge-base-mcp'),
];
// 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.

Building the Laravel route layer

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.

MCP client
Claude Desktop · Cursor · custom agent
POST /api/mcp
Middleware stack
auth:sanctum · ability:mcp:connect · throttle:mcp
McpController::handle()
JSON-RPC method dispatch
initialize
HTTP 200 initialize
JSON-RPC result
tools/list
tools/call
ToolsCallHandler
Validate · execute · log

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

// 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,
        ]);
    }
}
// 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.

Structuring tool definitions with JSON Schema

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.

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

Handling tool execution: the tools/call flow

The JSON Schema validator wraps justinrainbow/json-schema, which supports Draft 7 out of the box:

composer require justinrainbow/json-schema
// 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()
        );
    }
}
// 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:

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

Authenticated MCP endpoints with Laravel Sanctum

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:

// 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():

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

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

Testing your MCP server end-to-end

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:

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

Production hardening

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.

Rate limiting per MCP client

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 schema versioning strategy

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:

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

Observability: logging MCP sessions

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:

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

Connecting a Prism PHP client to your MCP server

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:

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

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 beyond MCP client support, including multi-step reasoning loops and provider-agnostic pipelines, that article covers the complete agentic layer.

What to build next

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


Frequently Asked Questions

What is the difference between an MCP server and a REST API?

A REST API exposes resources and actions you define, using HTTP verbs and status codes as the communication layer. An MCP server exposes tools — structured, schema-typed functions — over JSON-RPC 2.0. The client is always an AI agent or AI-connected tool, and the protocol defines a standard handshake, a capability negotiation step, and a tool invocation flow that any compliant client can follow without custom integration work. REST is designed for developers writing code. MCP is designed for AI agents making decisions.

Can I use Laravel Sanctum to authenticate MCP clients?

Yes, and it is the recommended approach for HTTP+SSE transport. Issue API tokens scoped to an mcp:connect ability, one token per client identifier. Apply auth:sanctum and ability:mcp:connect middleware to your MCP route group. The throttle:mcp named rate limiter can then key on the token ID rather than the user ID, giving each client its own rate limit bucket independently of others on the same account.

Does MCP support streaming responses?

The HTTP+SSE transport uses Server-Sent Events for server-to-client streaming, typically for long-running operations or progress notifications. Tool execution results in the tools/call flow are returned as a complete response, not streamed incrementally. If your tool needs to stream output — for example, a tool that wraps a Claude streaming call — you would use the SSE channel for that, which requires additional implementation beyond what this article covers.

How do I test an MCP server locally with Claude Desktop?

Add an entry to claude_desktop_config.json pointing at your local server URL with the appropriate Authorization header. Under Laravel Herd, your application is accessible at https://your-app.test without any additional tunnelling. Under Sail, expose the port and use http://localhost:PORT. Claude Desktop supports the HTTP transport directly, you do not need to configure a stdio proxy or wrapper script for an HTTP+SSE server.

Dewald Hugo

A software architect with 15+ years of experience in the PHP and Laravel ecosystem. Dewald created Origin Main to provide the engineering rigour required to integrate AI into professional, high-concurrency production systems. He writes for developers who care less about "getting it to work" and more about "getting it to last".

Subscribe
Notify of
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Quick Navigation
Scroll to Top