Um servidor MCP no Laravel é um endpoint HTTP que fala JSON-RPC 2.0 e expõe tools que assistentes como Claude Code, Claude Desktop ou Cursor descobrem e chamam sozinhos. Dá para implementar o transporte HTTP do protocolo com uma rota, um controller e poucas classes, autenticando com Sanctum e limitando tudo a leitura. No fim, você pergunta ao Claude "qual o status da transação X?" e ele consulta o seu banco pelo seu código, com as suas regras de acesso.

O que você vai ter funcionando no final

  • Um endpoint POST /api/mcp que implementa o mínimo do MCP: initialize, ping, tools/list e tools/call.
  • Duas tools somente leitura: buscar_transacao e listar_transacoes.
  • Autenticação por token do Sanctum com a ability mcp:read, rate limit e log de auditoria.
  • O servidor testado com curl, com o MCP Inspector e conectado no Claude Code.

Pré-requisitos:

  1. PHP 8.2+ e Laravel 11 ou 12.
  2. Laravel Sanctum (instalamos no passo 1).
  3. Node.js 18+ para rodar o MCP Inspector via npx.
  4. Claude Code instalado, se quiser testar com um cliente real.

Você não precisa de chave da API da Anthropic ou da OpenAI. Em MCP quem chama o LLM é o cliente; o seu app só responde às chamadas de ferramenta.

Uso como exemplo uma consulta de transações de pagamento, que é o tipo de coisa que eu quero perguntar ao assistente no meio de um debug sem abrir o banco de produção. Troque pelo domínio do seu app: agenda, pedidos, cadastros.

O que é MCP e qual a diferença para tool calling?

O MCP (Model Context Protocol) é um protocolo aberto, criado pela Anthropic, que padroniza como uma aplicação expõe tools (funções), resources (dados) e prompts para clientes de IA. As mensagens seguem o JSON-RPC 2.0, um formato de chamada remota em JSON com id, method, params e result/error.

A diferença para tool calling é quem está no comando. No agente de agendamento com tool calling do Claude no Laravel, o seu app chama a API do modelo, recebe o pedido de ferramenta e executa. No MCP é o inverso:

Tool calling Servidor MCP
Quem chama o LLM Seu app O cliente (Claude Code, Cursor etc.)
Quem paga os tokens Você Quem usa o cliente
Onde ficam as tools Definidas na requisição à API Expostas pelo seu servidor
Caso típico Produto para o usuário final Ferramenta interna, suporte, operação

O transporte que vamos usar é o Streamable HTTP: o cliente faz POST de cada mensagem JSON-RPC num único endpoint, e o servidor pode responder com JSON simples ou abrir um stream SSE. Para tools síncronas e rápidas, JSON simples basta.

O fluxo de uma sessão é:

  1. Cliente envia initialize com a versão do protocolo; servidor responde com versão, capacidades e nome.
  2. Cliente envia a notificação notifications/initialized (sem id, não espera resposta).
  3. Cliente chama tools/list para descobrir as ferramentas.
  4. Durante a conversa, o modelo decide usar uma tool e o cliente envia tools/call.

Existe um pacote oficial, o laravel/mcp, e vale avaliar. Aqui implemento o protocolo direto porque são poucas classes, não depende de nada além do framework e você entende exatamente o que trafega, o que ajuda muito quando um cliente se recusa a listar as suas tools.

1. Instalar o Sanctum e gerar um token

No Laravel 11+, o comando abaixo instala o Sanctum e cria o routes/api.php:

php artisan install:api

Confirme que o model User usa a trait:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}

Crie um comando para emitir tokens com a ability mcp:read em routes/console.php:

use App\Models\User;
use Illuminate\Support\Facades\Artisan;

Artisan::command('mcp:token {email}', function (string $email) {
    $user = User::where('email', $email)->firstOrFail();
    $token = $user->createToken('mcp', ['mcp:read']);

    $this->info($token->plainTextToken);
});
php artisan mcp:token voce@empresa.com

Guarde o token: ele só aparece uma vez. Cada pessoa do time tem o seu, o que permite revogar e auditar individualmente.

2. Definir o contrato das tools

Uma interface pequena, com o JSON Schema que o cliente vê e as regras de validação que o Laravel aplica. São duas coisas diferentes de propósito: o schema orienta o modelo, as rules() protegem o seu código.

<?php

namespace App\Mcp;

use App\Models\User;

interface McpTool
{
    public function name(): string;

    public function description(): string;

    public function inputSchema(): array;

    public function rules(): array;

    public function handle(array $args, User $user): array;
}

E duas exceções: uma para erro de protocolo, outra para erro de execução da tool.

<?php

namespace App\Mcp;

use RuntimeException;

final class JsonRpcException extends RuntimeException {}
<?php

namespace App\Mcp;

use RuntimeException;

final class ToolException extends RuntimeException {}

A distinção importa: tool inexistente é erro de protocolo (o cliente fez algo errado). Transação não encontrada é resultado da tool com isError: true, e o modelo lê essa mensagem e pode tentar de outro jeito.

3. Implementar as tools somente leitura

Primeiro um presenter que decide exatamente o que sai do sistema. Nada de devolver o model inteiro: CPF, e-mail e dados bancários não entram no contexto do LLM.

<?php

namespace App\Mcp;

use App\Models\Transaction;

final class TransactionPresenter
{
    public static function toArray(Transaction $t): array
    {
        return [
            'id' => $t->id,
            'status' => $t->status,
            'valor' => number_format($t->amount_cents / 100, 2, ',', '.'),
            'provedor' => $t->provider,
            'criada_em' => $t->created_at?->toIso8601String(),
            'paga_em' => $t->paid_at?->toIso8601String(),
        ];
    }
}

A tool de busca por ID:

<?php

namespace App\Mcp\Tools;

use App\Mcp\McpTool;
use App\Mcp\ToolException;
use App\Mcp\TransactionPresenter;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Gate;

final class BuscarTransacaoTool implements McpTool
{
    public function name(): string
    {
        return 'buscar_transacao';
    }

    public function description(): string
    {
        return 'Busca uma transação pelo ID (UUID) e retorna status, valor em reais, provedor e datas. Use quando o usuário citar um ID específico.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'id' => ['type' => 'string', 'description' => 'UUID da transação'],
            ],
            'required' => ['id'],
            'additionalProperties' => false,
        ];
    }

    public function rules(): array
    {
        return ['id' => ['required', 'uuid']];
    }

    public function handle(array $args, User $user): array
    {
        $transacao = Transaction::find($args['id']);

        if (! $transacao || Gate::forUser($user)->denies('view', $transacao)) {
            throw new ToolException('Transação não encontrada.');
        }

        return TransactionPresenter::toArray($transacao);
    }
}

Repare que "não existe" e "não pode ver" devolvem a mesma mensagem. Não dê ao modelo (nem a quem está do outro lado) a informação de que o ID existe.

A tool de listagem com filtros:

<?php

namespace App\Mcp\Tools;

use App\Mcp\McpTool;
use App\Mcp\TransactionPresenter;
use App\Models\Transaction;
use App\Models\User;

final class ListarTransacoesTool implements McpTool
{
    private const STATUS = ['pending', 'paid', 'failed', 'refunded'];

    public function name(): string
    {
        return 'listar_transacoes';
    }

    public function description(): string
    {
        return 'Lista as transações mais recentes, com filtro opcional por status e data inicial. Retorna no máximo 50 itens.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'status' => ['type' => 'string', 'enum' => self::STATUS],
                'desde' => ['type' => 'string', 'description' => 'Data inicial no formato AAAA-MM-DD'],
                'limite' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50],
            ],
            'additionalProperties' => false,
        ];
    }

    public function rules(): array
    {
        return [
            'status' => ['nullable', 'in:'.implode(',', self::STATUS)],
            'desde' => ['nullable', 'date_format:Y-m-d'],
            'limite' => ['nullable', 'integer', 'between:1,50'],
        ];
    }

    public function handle(array $args, User $user): array
    {
        $query = Transaction::query()
            ->when($args['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
            ->when($args['desde'] ?? null, fn ($q, $d) => $q->where('created_at', '>=', $d))
            ->latest()
            ->limit($args['limite'] ?? 20);

        // Adapte à sua regra de acesso
        if (! $user->is_staff) {
            $query->where('merchant_id', $user->merchant_id);
        }

        return [
            'transacoes' => $query->get()
                ->map(fn ($t) => TransactionPresenter::toArray($t))
                ->all(),
        ];
    }
}

O limite de 50 não é só performance: cada item vira token no contexto do cliente. Tool que devolve 2 mil linhas estoura a janela e piora a resposta.

4. Escrever o dispatcher JSON-RPC

O coração do servidor recebe uma mensagem decodificada e devolve a resposta, ou null quando é notificação.

<?php

namespace App\Mcp;

use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use stdClass;
use Throwable;

final class McpServer
{
    private const VERSIONS = ['2025-06-18', '2025-03-26'];

    /** @var array<string, McpTool> */
    private array $tools = [];

    public function __construct(iterable $tools)
    {
        foreach ($tools as $tool) {
            $this->tools[$tool->name()] = $tool;
        }
    }

    public function handle(array $message, User $user): ?array
    {
        $id = $message['id'] ?? null;
        $method = $message['method'] ?? null;

        if (($message['jsonrpc'] ?? null) !== '2.0' || ! is_string($method)) {
            return $this->error($id, -32600, 'Invalid Request');
        }

        // Notificações não têm id e não recebem resposta
        if (! array_key_exists('id', $message)) {
            return null;
        }

        try {
            $result = match ($method) {
                'initialize' => $this->initialize($message['params'] ?? []),
                'ping' => new stdClass(),
                'tools/list' => ['tools' => $this->listTools()],
                'tools/call' => $this->callTool($message['params'] ?? [], $user),
                default => throw new JsonRpcException("Method not found: {$method}", -32601),
            };
        } catch (JsonRpcException $e) {
            return $this->error($id, $e->getCode(), $e->getMessage());
        }

        return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
    }

    private function initialize(array $params): array
    {
        $requested = $params['protocolVersion'] ?? null;

        return [
            'protocolVersion' => in_array($requested, self::VERSIONS, true) ? $requested : self::VERSIONS[0],
            'capabilities' => ['tools' => ['listChanged' => false]],
            'serverInfo' => ['name' => 'transacoes-mcp', 'version' => '1.0.0'],
            'instructions' => 'Consulta somente leitura de transações. Valores em reais. Não existem ferramentas de escrita.',
        ];
    }

    private function listTools(): array
    {
        return array_values(array_map(fn (McpTool $t) => [
            'name' => $t->name(),
            'description' => $t->description(),
            'inputSchema' => $t->inputSchema(),
        ], $this->tools));
    }

    private function callTool(array $params, User $user): array
    {
        $name = $params['name'] ?? null;

        if (! is_string($name) || ! isset($this->tools[$name])) {
            throw new JsonRpcException('Unknown tool', -32602);
        }

        $tool = $this->tools[$name];
        $args = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];

        Log::info('mcp.tools.call', ['user_id' => $user->id, 'tool' => $name, 'args' => $args]);

        $validator = Validator::make($args, $tool->rules());

        if ($validator->fails()) {
            return $this->toolResult('Argumentos inválidos: '.implode(' ', $validator->errors()->all()), true);
        }

        try {
            $data = $tool->handle($validator->validated(), $user);
        } catch (ToolException $e) {
            return $this->toolResult($e->getMessage(), true);
        } catch (Throwable $e) {
            report($e);

            return $this->toolResult('Erro interno ao executar a ferramenta.', true);
        }

        return $this->toolResult(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
    }

    private function toolResult(string $text, bool $isError = false): array
    {
        return ['content' => [['type' => 'text', 'text' => $text]], 'isError' => $isError];
    }

    private function error(mixed $id, int $code, string $message): array
    {
        return ['jsonrpc' => '2.0', 'id' => $id, 'error' => ['code' => $code, 'message' => $message]];
    }
}

Três decisões que valem explicar:

  1. Negociação de versão: se o cliente pede uma versão que você suporta, responda a mesma; senão, responda a sua mais recente e deixe o cliente decidir se continua.
  2. ping devolve objeto vazio: new stdClass() serializa como {}. Um array vazio viraria [], e alguns clientes rejeitam.
  3. Throwable vira mensagem genérica: stack trace e SQL não vão para o contexto do modelo. Vão para o report(), que é onde você olha.

Registre o servidor com as tools no AppServiceProvider:

use App\Mcp\McpServer;
use App\Mcp\Tools\BuscarTransacaoTool;
use App\Mcp\Tools\ListarTransacoesTool;

public function register(): void
{
    $this->app->singleton(McpServer::class, fn () => new McpServer([
        new BuscarTransacaoTool(),
        new ListarTransacoesTool(),
    ]));
}

5. Criar o controller e a rota

<?php

namespace App\Http\Controllers;

use App\Mcp\McpServer;
use Illuminate\Http\Request;

class McpController
{
    public function __invoke(Request $request, McpServer $server)
    {
        abort_unless($request->user()->tokenCan('mcp:read'), 403);

        $message = json_decode($request->getContent(), true);

        if (! is_array($message) || array_is_list($message)) {
            return response()->json([
                'jsonrpc' => '2.0',
                'id' => null,
                'error' => ['code' => -32700, 'message' => 'Parse error or batch not supported'],
            ], 400);
        }

        $response = $server->handle($message, $request->user());

        return $response === null
            ? response()->noContent(202)
            : response()->json($response);
    }
}

Em routes/api.php:

use App\Http\Controllers\McpController;
use Illuminate\Support\Facades\Route;

Route::post('/mcp', McpController::class)
    ->middleware(['auth:sanctum', 'throttle:60,1']);

// Não abrimos stream SSE iniciado pelo servidor
Route::get('/mcp', fn () => response('', 405)->header('Allow', 'POST'));

O GET com 405 é o comportamento previsto no protocolo quando o servidor não oferece stream de eventos. Sem ele, alguns clientes ficam tentando reconectar.

6. Testar com curl

TOKEN="cole-o-token-aqui"

curl -s http://localhost:8000/api/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'

Depois liste e chame uma tool:

curl -s http://localhost:8000/api/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"listar_transacoes","arguments":{"status":"failed","limite":5}}}'

A resposta esperada tem result.content[0].text com o JSON das transações e isError: false.

7. Conectar no MCP Inspector e no Claude Code

O MCP Inspector é a ferramenta oficial de debug do protocolo. Rode:

npx @modelcontextprotocol/inspector

Na interface, escolha o transporte Streamable HTTP, informe http://localhost:8000/api/mcp e adicione o header Authorization: Bearer SEU_TOKEN. Se ele conectar e listar as duas tools, qualquer cliente compatível vai funcionar.

No Claude Code:

claude mcp add --transport http transacoes http://localhost:8000/api/mcp \
  --header "Authorization: Bearer SEU_TOKEN"

Dentro do Claude Code, o comando /mcp mostra o status do servidor. Aí é só perguntar: "liste as transações que falharam desde ontem e me diga qual provedor concentra as falhas". O modelo chama listar_transacoes, recebe o JSON e raciocina em cima.

8. Cobrir com testes automatizados

O Sanctum permite autenticar com abilities específicas no teste, sem gerar token real:

use App\Models\User;
use Laravel\Sanctum\Sanctum;

it('lista as tools', function () {
    Sanctum::actingAs(User::factory()->create(), ['mcp:read']);

    $this->postJson('/api/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'])
        ->assertOk()
        ->assertJsonPath('result.tools.0.name', 'buscar_transacao');
});

it('responde 202 para notificações', function () {
    Sanctum::actingAs(User::factory()->create(), ['mcp:read']);

    $this->postJson('/api/mcp', ['jsonrpc' => '2.0', 'method' => 'notifications/initialized'])
        ->assertStatus(202);
});

it('não vaza transação de outro lojista', function () {
    $user = User::factory()->create(['is_staff' => false]);
    $alheia = Transaction::factory()->create();
    Sanctum::actingAs($user, ['mcp:read']);

    $this->postJson('/api/mcp', [
        'jsonrpc' => '2.0', 'id' => 3, 'method' => 'tools/call',
        'params' => ['name' => 'buscar_transacao', 'arguments' => ['id' => $alheia->id]],
    ])->assertJsonPath('result.isError', true);
});

O terceiro teste é o que mais importa. Aqui não tem LLM envolvido, então não precisa de fake de API; se você quiser avaliar se o modelo escolhe a tool certa, aí entram as técnicas de testes de código que usa LLM com respostas gravadas e evals.

Erros comuns

  1. Route [login] not defined ao usar token inválido. O Laravel tenta redirecionar para login quando acha que a requisição não quer JSON, e o header Accept de alguns clientes engana essa detecção. Em bootstrap/app.php, force JSON para a API: $exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));.
  2. Cliente conecta mas não mostra nenhuma tool. Quase sempre é inputSchema com properties vazio: um array PHP vazio vira [] no JSON, e o schema exige objeto. Para tools sem argumentos, use 'properties' => new stdClass().
  3. Cliente trava depois do initialize. Você está respondendo a notificação com 200 e corpo, ou com JSON-RPC de erro. Notificação recebe 202 sem corpo.
  4. Erro 419 (CSRF). A rota foi parar no routes/web.php. Endpoint MCP fica no routes/api.php, sem sessão nem cookie.
  5. Respostas lentas ou cortadas. Tool fazendo query sem índice ou devolvendo milhares de linhas. Limite resultados, pagine por filtro de data e mantenha cada chamada abaixo de poucos segundos; o cliente tem timeout próprio.
  6. Stack trace aparecendo na conversa. Exceção não tratada dentro da tool com APP_DEBUG=true. O catch (Throwable) no dispatcher resolve, e o erro real fica no seu log.

Perguntas frequentes

Preciso de chave da API do Claude ou da OpenAI para criar um servidor MCP?

Não. O servidor só expõe ferramentas. Quem chama o modelo e paga os tokens é o cliente MCP, como o Claude Code ou o Cursor.

Posso expor tools que alteram dados, como estornar um pagamento?

Pode, mas comece só com leitura. Ações de escrita exigem ability separada, confirmação humana e defesa contra instruções maliciosas vindas dos próprios dados; detalhei isso em prompt injection e LGPD em agentes de IA no Laravel.

Preciso implementar SSE no endpoint MCP?

Não para tools síncronas. O Streamable HTTP permite responder com application/json direto. SSE só faz sentido se a tool precisar enviar progresso ou fazer requisições de volta ao cliente.

Funciona em outros clientes além do Claude?

Sim, qualquer cliente que suporte MCP via HTTP. Teste primeiro no MCP Inspector: se funcionar lá, os problemas restantes costumam ser de configuração do cliente.

Os dados do meu banco vão para a Anthropic?

O que a tool devolve entra no contexto do modelo usado pelo cliente. Por isso o presenter existe: trate cada campo retornado como dado compartilhado com o provedor do LLM e deixe documento, e-mail e dados bancários de fora.

Conclusão prática

Comece com uma ou duas tools de leitura que resolvem uma dor concreta do time, como investigar uma transação ou um agendamento, e coloque em produção atrás de token individual, rate limit e log. O dispatcher acima quase não muda quando você adiciona tools; o trabalho real está em três lugares: no presenter, que decide o que sai do sistema; na policy, que decide quem vê o quê; e na descrição de cada tool, que é o que faz o modelo escolher a chamada certa. Quando essas três estiverem sólidas, aí sim pense em escrita.