cd /news/artificial-intelligence/building-a-production-mcp-server-in-… · home topics artificial-intelligence article
[ARTICLE · art-14992] src=dev.to pub= topic=artificial-intelligence verified=true sentiment=· neutral

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.

read17 min publishedMay 27, 2026

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 theprotocolVersion

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.

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.

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:

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

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

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

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:

// 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, aLIKE

on thecontent

column produces full table scans. Laravel Scout with Meilisearch is the production-appropriate replacement. TheLIKE

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:

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

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.

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:

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

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

:

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

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.

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.

── more in #artificial-intelligence 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-a-productio…] indexed:0 read:17min 2026-05-27 ·