Laravel + MCP: A Client-Server Architecture for Invoking External Tools Without Model Lock-In

📋 Table of Contents


The Real Problem With Custom AI Integrations

Every time you connect a large language model to your SaaS application, you write the same fragile glue code. You define an OpenAI tool array, map JSON schemas by hand, parse tool-call arguments, wrap internal Eloquent repositories in messy controller logic, and pray that the model doesn't hallucinate an unexpected database column.

Worse yet, when you want to plug the same AI capabilities into a different model provider or an entirely separate internal microservice, you rewrite the tool definitions all over again.

Anthropic introduced the Model Context Protocol (MCP) to solve this exact ecosystem fragmentation. Instead of hardcoding tools into individual AI wrappers, MCP standardizes how applications expose data sources and functional capabilities to language models via a clean, decoupled client-server architecture.

While much of the existing MCP ecosystem caters to TypeScript and Python, your core business logic might well live inside a robust Laravel monolith. Building an MCP client directly inside Laravel lets your PHP application act as an intelligent coordinator—querying specialized MCP servers, executing local or remote tools securely, and piping structured data straight into your AI workflows without scattering bespoke API integrations across your codebase.


1. Understanding the Model Context Protocol (MCP) in a PHP Context

Scenario: Your engineering team needs to let an AI assistant analyze production logs, query a staging database, and trigger deployment scripts. Writing custom API clients for each of these distinct domains inside your Laravel app creates a maintenance bottleneck.

Why it matters: Traditionally, extending an AI model's capabilities meant bundling tool schemas directly into your application code. If the underlying data provider changed its API, your AI integration broke. MCP flips this dynamic by treating data sources and execution engines as independent MCP Servers, while your Laravel application acts as the MCP Client.

Solution: In the MCP architecture, the protocol decouples the consumer of tools (your Laravel app) from the provider of tools (the external service or local process). Communication happens over standard JSON-RPC 2.0 messages, transported either via local subprocess Standard Input/Output (stdio) or HTTP-based Server-Sent Events (SSE).

+-------------------------------------------------------------+
|                       Laravel Application                   |
|                                                             |
|  +--------------------+       +--------------------------+  |
|  | LLM Orchestrator   | ----> |      MCP Client          |  |
|  +--------------------+       +--------------------------+  |
|                                            |                |
+--------------------------------------------|----------------+
                                             | JSON-RPC 2.0
                                             v (stdio / SSE)
+-------------------------------------------------------------+
|                        External MCP Server                  |
|                                                             |
|  +--------------------+       +--------------------------+  |
|  | Tool Registry      |       | Resource Providers       |  |
|  +--------------------+       +--------------------------+  |
+-------------------------------------------------------------+

Your Laravel application connects to these servers, negotiates capabilities during a handshake, discovers available tools, and forwards those tool definitions to whichever language model backend you choose.

🧠 The important part: MCP isn't an AI model wrapper; it's a protocol for exposing context and tools. Your Laravel app remains in total control of which tools are exposed, who executes them, and what permissions apply.


2. Bootstrapping the MCP Client Infrastructure in Laravel

Scenario: You want to integrate MCP capabilities cleanly into Laravel without polluting your app/Http/Controllers directory or mixing transport logic with business code.

Why it matters: Managing stateful connections, subprocess lifecycles, and asynchronous JSON-RPC payloads requires structured service classes and robust dependency injection containers. A slapdash implementation using raw proc_open calls scattered across your code will quickly lead to zombie processes and unhandled exceptions.

Solution: We will structure our implementation around a dedicated McpClientManager service registered as a singleton in the Laravel service container. This manager handles connection pooling, transport selection, and protocol handshakes.

First, let's configure our MCP servers in config/mcp.php:

// config/mcp.php
return [
    'default' => env('MCP_DEFAULT_CLIENT', 'filesystem'),

    'clients' => [
        'filesystem' => [
            'transport' => 'stdio',
            'command' => 'npx',
            'args' => ['-y', '@modelcontextprotocol/server-filesystem', storage_path('app/mcp')],
            'timeout' => 30,
        ],
        'github' => [
            'transport' => 'sse',
            'url' => env('MCP_GITHUB_URL', 'https://mcp.internal.example.com/sse'),
            'token' => env('MCP_GITHUB_TOKEN'),
            'timeout' => 15,
        ],
    ],
];

Next, let's create a service provider to bind our core client into the container:

// app/Providers/McpServiceProvider.php
namespace App\Providers;

use App\Services\Mcp\McpManager;
use Illuminate\Support\ServiceProvider;

class McpServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(McpManager::class, function ($app) {
            return new McpManager(config('mcp'));
        });
    }

    public function boot(): void
    {
        $this->publishes([
            __DIR__.'/../../config/mcp.php' => config_path('mcp.php'),
        ], 'mcp-config');
    }
}

Why this works: By isolating client configuration and instantiation behind a manager class, your controllers and jobs can simply request an MCP client instance by name (McpManager::client('filesystem')) without worrying about whether the underlying server communicates over a local pipe or a network socket.


3. Establishing the Transport Layer: Standard I/O vs. Server-Sent Events (SSE)

Scenario: Some MCP servers run locally as CLI utilities (like a local database inspector or filesystem watcher), while others run as remote microservices over HTTP. Your client must support both transparently.

Why it matters: stdio transport requires spawning a local OS process and communicating via standard input and output streams. SSE transport relies on HTTP POST requests for client-to-server messages and an open EventSource connection for server-to-client messages. Treating these two paradigms uniformly in PHP requires a clean transport interface.

Solution: Define a contract interface for your transports and implement concrete classes for both stdio and SSE.

// app/Contracts/McpTransportInterface.php
namespace App\Contracts;

interface McpTransportInterface
{
    public function connect(): void;
    public function send(array $payload): array;
    public function disconnect(): void;
    public function isConnected(): bool;
}

Now, implement the StdioTransport using PHP's native process control functions:

// app/Services/Mcp/Transports/StdioTransport.php
namespace App\Services\Mcp\Transports;

use App\Contracts\McpTransportInterface;
use Symfony\Component\Process\Process;
use RuntimeException;

class StdioTransport implements McpTransportInterface
{
    protected ?Process $process = null;
    protected bool $connected = false;

    public function __construct(
        protected array $command,
        protected int $timeout = 30
    ) {}

    public function connect(): void
    {
        $this->process = new Process($this->command);
        $this->process->setTimeout($this->timeout);
        $this->process->start();

        if (!$this->process->isRunning()) {
            throw new RuntimeException("Failed to start MCP process: " . $this->process->getErrorOutput());
        }

        $this->connected = true;
    }

    public function send(array $payload): array
    {
        if (!$this->isConnected()) {
            throw new RuntimeException("MCP transport is not connected.");
        }

        $jsonPayload = json_encode($payload, JSON_THROW_ON_ERROR) . "\n";
        
        $this->process->setInput($jsonPayload);
        $this->process->wait(function ($type, $buffer) {
            // Optional: capture stderr logs
        });

        $output = trim($this->process->getOutput());
        
        if (empty($output)) {
            throw new RuntimeException("Empty response received from MCP stdio process.");
        }

        // Reset process output buffer for next interaction
        $this->process->clearOutput();

        return json_decode($output, true, 512, JSON_THROW_ON_ERROR);
    }

    public function disconnect(): void
    {
        if ($this->process && $this->process->isRunning()) {
            $this->process->stop();
        }
        $this->connected = false;
    }

    public function isConnected(): bool
    {
        return $this->connected && $this->process && $this->process->isRunning();
    }
}

⚠️ Gotcha: Managing stateful stdio subprocesses inside a web request cycle can cause serious performance bottlenecks if processes aren't pooled or cleaned up properly. For high-throughput web apps, prefer long-lived queue workers or SSE-based remote servers when interacting with heavy tools.


4. Managing the JSON-RPC 2.0 Lifecycle and Handshake Rituals

Scenario: You've established a transport channel to an MCP server, but immediately sending a tool execution request fails because the server hasn't completed the initialization handshake required by the MCP specification.

Why it matters: The Model Context Protocol strictly mandates an initialization handshake before any operational methods (tools/list, tools/call) can be invoked. The client must send an initialize request specifying protocol versions and capabilities, await server confirmation, and then dispatch an notifications/initialized confirmation notification.

Solution: Build a robust JSON-RPC message dispatcher inside your client class that handles request IDs, protocol versions, and handshake sequencing.

// app/Services/Mcp/McpClient.php
namespace App\Services\Mcp;

use App\Contracts\McpTransportInterface;
use RuntimeException;

class McpClient
{
    protected int $requestId = 1;
    protected bool $initialized = false;

    public function __construct(
        protected McpTransportInterface $transport
    ) {}

    public function connect(): void
    {
        $this->transport->connect();
        $this->initializeHandshake();
    }

    protected function initializeHandshake(): void
    {
        $response = $this->sendRequest('initialize', [
            'protocolVersion' => '2024-11-05',
            'capabilities' => [
                'sampling' => [],
            ],
            'clientInfo' => [
                'name' => 'LaravelMcpClient',
                'version' => '1.0.0',
            ],
        ]);

        if (!isset($response['result']['protocolVersion'])) {
            throw new RuntimeException("MCP Handshake failed: Invalid initialization response.");
        }

        // Send initialized notification
        $this->sendNotification('notifications/initialized', []);
        $this->initialized = true;
    }

    public function sendRequest(string $method, array $params = []): array
    {
        $payload = [
            'jsonrpc' => '2.0',
            'id' => $this->requestId++,
            'method' => $method,
            'params' => $params,
        ];

        $response = $this->transport->send($payload);

        if (isset($response['error'])) {
            throw new RuntimeException(
                "MCP JSON-RPC Error [{$response['error']['code']}]: {$response['error']['message']}"
            );
        }

        return $response;
    }

    public function sendNotification(string $method, array $params = []): void
    {
        $payload = [
            'jsonrpc' => '2.0',
            'method' => $method,
            'params' => $params,
        ];

        // Notifications do not expect a response ID
        $this->transport->send($payload);
    }
}

Why this works: By encapsulating the initialize and notification sequence inside the connection flow, you ensure that any consumer of McpClient receives a fully initialized and protocol-compliant connection ready for tool discovery.


5. Discovering and Parsing Remote Server Capabilities

Scenario: You want your AI assistant to automatically know what tools are available on the connected filesystem or database MCP server without hardcoding tool schemas in your Laravel application.

Why it matters: MCP servers are dynamic. A server might expose three tools today and ten tools tomorrow after a plugin update. Hardcoding tool definitions defeats the primary architectural benefit of MCP.

Solution: Implement a discovery method on your client that queries the tools/list endpoint and normalizes the returned JSON schemas into clean PHP DTOs (Data Transfer Objects).

// app/Services/Mcp/DataTransferObjects/ToolDefinition.php
namespace App\Services\Mcp\DataTransferObjects;

class ToolDefinition
{
    public function __construct(
        public string $name,
        public string $description,
        public array $inputSchema
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            name: $data['name'],
            description: $data['description'] ?? '',
            inputSchema: $data['inputSchema'] ?? []
        );
    }

    public function toOpenAiFormat(): array
    {
        return [
            'type' => 'function',
            'function' => [
                'name' => $this->name,
                'description' => $this->description,
                'parameters' => $this->inputSchema,
            ],
        ];
    }
}

Now, add the list tools method to your McpClient:

// Add to App\Services\Mcp\McpClient
/**
 * @return array<ToolDefinition>
 */
public function listTools(): array
{
    if (!$this->initialized) {
        throw new RuntimeException("Client must be initialized before listing tools.");
    }

    $response = $this->sendRequest('tools/list');
    $rawTools = $response['result']['tools'] ?? [];

    return array_map(
        fn($tool) => ToolDefinition::fromArray($tool),
        $rawTools
    );
}

💡 Practical note: Cache the output of listTools() during a user session or queue job execution if the remote server is slow to respond, as querying tool definitions on every single chat turn introduces unnecessary latency.


6. Invoking External Tools From Laravel Services and Queues

Scenario: The language model decides it needs to invoke the read_file tool provided by the filesystem MCP server. Your Laravel application receives this tool call and needs to execute it securely and return the result.

Why it matters: Executing remote tools requires passing structured arguments, handling potential runtime execution errors on the server side, and formatting the output back into a structure the LLM can interpret.

Solution: Implement the tools/call method on your client and wrap it in a dedicated service class that coordinates tool execution.

// Add to App\Services\Mcp\McpClient
public function callTool(string $toolName, array $arguments = []): mixed
{
    if (!$this->initialized) {
        throw new RuntimeException("Client must be initialized before calling tools.");
    }

    $response = $this->sendRequest('tools/call', [
        'name' => $toolName,
        'arguments' => $arguments,
    ]);

    return $response['result'] ?? null;
}

Let's create an orchestrator service that executes tools requested by an AI model:

// app/Services/Mcp/McpToolExecutor.php
namespace App\Services\Mcp;

class McpToolExecutor
{
    public function __construct(
        protected McpManager $manager
    ) {}

    public function execute(string $clientName, string $toolName, array $arguments): mixed
    {
        $client = $this->manager->client($clientName);
        $client->connect();

        try {
            $result = $client->callTool($toolName, $arguments);
            return $result;
        } finally {
            $client->disconnect();
        }
    }
}

Why this works: This encapsulates the connect-execute-disconnect lifecycle in a finally block, ensuring that even if the tool execution throws an exception, underlying OS processes or network handles are properly cleaned up.


7. Bridging MCP Tools with Laravel AI Packages and Language Models

Scenario: You are building an AI chat interface in Laravel using popular SDKs or native API calls. You want the model to dynamically choose and execute MCP tools without writing custom glue code for every tool schema.

Why it matters: AI providers like OpenAI, Anthropic, and open-source models accessed via Ollama all accept tool definitions in slightly different array structures. Bridging MCP tool definitions into your preferred AI package keeps your architecture clean.

Solution: Let's build a controller or job that fetches tools from our MCP client, formats them for an LLM request, handles the model's tool call response, and dispatches the execution back to the MCP server.

// app/Http/Controllers/AiChatController.php
namespace App\Http\Controllers;

use App\Services\Mcp\McpManager;
use App\Services\Mcp\McpToolExecutor;
use Illuminate\Http\Request;
use OpenAI\Client as OpenAiClient;

class AiChatController extends Controller
{
    public function __construct(
        protected McpManager $mcpManager,
        protected McpToolExecutor $toolExecutor,
        protected OpenAiClient $openAi
    ) {}

    public function handle(Request $request)
    {
        $clientName = $request->input('mcp_client', 'filesystem');
        $mcpClient = $this->mcpManager->client($clientName);
        $mcpClient->connect();

        try {
            // 1. Discover tools from MCP server
            $toolDefinitions = $mcpClient->listTools();
            $openAiTools = array_map(fn($t) => $t->toOpenAiFormat(), $toolDefinitions);

            // 2. Send prompt and tools to OpenAI
            $response = $this->openAi->chat()->create([
                'model' => 'gpt-4o',
                'messages' => [
                    ['role' => 'user', 'content' => $request->input('prompt')],
                ],
                'tools' => $openAiTools,
            ]);

            $choice = $response->choices[0];

            // 3. Check if the model wants to call an MCP tool
            if ($choice->finishReason === 'tool_calls') {
                $toolCall = $choice->message->toolCalls[0];
                $toolName = $toolCall->function->name;
                $arguments = json_decode($toolCall->function->arguments, true);

                // 4. Execute tool via MCP
                $toolResult = $this->toolExecutor->execute($clientName, $toolName, $arguments);

                return response()->json([
                    'status' => 'tool_executed',
                    'tool' => $toolName,
                    'arguments' => $arguments,
                    'result' => $toolResult,
                ]);
            }

            return response()->json([
                'status' => 'success',
                'reply' => $choice->message->content,
            ]);

        } finally {
            $mcpClient->disconnect();
        }
    }
}

Why this works: Your application becomes a dynamic bridge. The AI model inspects the MCP server's capabilities in real time, requests a tool invocation, and your Laravel app executes it safely through the standardized MCP protocol.


8. Securing Subprocess Execution and Sandboxing External Servers

Scenario: Your MCP client spawns local Node.js or Python subprocesses to execute file operations or run database queries. If a malicious prompt tricks the model into executing unsafe shell arguments, your server could be compromised.

Why it matters: Running arbitrary command-line processes via proc_open or Symfony Process within a web application exposes a massive attack surface if user input or unvalidated model parameters slip into command arguments.

Solution: Apply strict validation rules to all tool arguments before passing them to the MCP client, and run local MCP server subprocesses under restricted system users with minimal filesystem permissions.

// app/Http/Requests/AiToolRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class AiToolRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('run-ai-tools');
    }

    public function rules(): array
    {
        return [
            'mcp_client' => ['required', 'string', 'in:filesystem,github'],
            'prompt' => ['required', 'string', 'max:2000'],
        ];
    }
}

Additionally, ensure your local MCP subprocesses are confined to designated storage directories:

// In config/mcp.php filesystem client args
'args' => [
    '-y', 
    '@modelcontextprotocol/server-filesystem', 
    storage_path('app/mcp/sandboxed_root')
],

🚨 Production warning: Never pass unvalidated user input directly into MCP tool arguments or command-line option arrays. Always validate parameters against strict JSON schemas before dispatching tools/call.


9. Handling Failures, Timeouts, and Reconnections in Production

Scenario: A remote SSE-based MCP server goes down for maintenance mid-request. Your Laravel application throws an unhandled RuntimeException, causing the user's HTTP request to fail with a 500 error.

Why it matters: Distributed systems fail unpredictably. Network partitions, rate limits, and crashed subprocesses will happen in production. A resilient MCP client must handle timeouts and transient failures gracefully.

Solution: Wrap your MCP transport calls in Laravel's retry mechanisms and implement connection health checks.

// app/Services/Mcp/ResilientMcpClient.php
namespace App\Services\Mcp;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Client\ConnectionException;
use RuntimeException;
use Throwable;

class ResilientMcpClient extends McpClient
{
    public function sendRequest(string $method, array $params = []): array
    {
        return rescue(function () use ($method, $params) {
            return retry(3, function () use ($method, $params) {
                return parent::sendRequest($method, $params);
            }, 100, function (Throwable $e) {
                Log::warning("MCP request failed, retrying...", [
                    'method' => $method,
                    'error' => $e->getMessage()
                ]);
                return $e instanceof ConnectionException || $e instanceof RuntimeException;
            });
        }, function () use ($method) {
            throw new RuntimeException("MCP server failed to respond after multiple retries for method: {$method}");
        });
    }
}

Why this works: Using Laravel's built-in retry() helper combined with rescue() prevents transient network blips or momentary subprocess hiccups from crashing user-facing chat threads.


Architectural Decision Guide: When to Build vs. When to Delegate

Approach Complexity Flexibility Maintenance Overhead Best Use Case
Custom Hardcoded Tools Low Low High Simple apps with 1-2 static API integrations.
Custom Laravel MCP Client Medium High Low-Medium Monolithic apps needing multi-model and dynamic tool flexibility.
Dedicated MCP Gateway Microservice High Maximum High Enterprise architectures with dozens of AI clients and shared servers.

Building your own MCP client in Laravel gives you the best of both worlds: the robust architecture and ecosystem of Laravel combined with the standardized, decoupled extensibility of the Model Context Protocol. By structuring your client with proper transport interfaces, JSON-RPC lifecycle handlers, and strict security validation, you transform your Laravel application into a powerful, modular AI orchestration hub.

Post a Comment

Previous Post Next Post