# Como criar um servidor MCP no Laravel para expor dados do seu app ao Claude

> Passo a passo para implementar o Model Context Protocol via HTTP no Laravel, com tools somente leitura, autenticação Sanctum e testes no MCP Inspector e no Claude Code.

Por Lucas Roberto · IA · publicado em 2026-10-11 · 9 min de leitura
Versão web: https://lucasroberto.com/blog/como-criar-um-servidor-mcp-no-laravel-para-expor-dados-do-seu-app-ao-claude

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](/blog/como-criar-um-agente-de-ia-com-tool-calling-do-claude-no-laravel-para-agendamentos-no-whatsapp), 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`:

```bash
php artisan install:api
```

Confirme que o model `User` usa a trait:

```php
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`:

```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);
});
```

```bash
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
<?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
<?php

namespace App\Mcp;

use RuntimeException;

final class JsonRpcException extends RuntimeException {}
```

```php
<?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
<?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
<?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
<?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
<?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`:

```php
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
<?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`:

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

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```php
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](/blog/como-testar-codigo-que-usa-llm-no-laravel-httpfake-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](/blog/prompt-injection-e-lgpd-em-agentes-de-ia-no-laravel-como-proteger-dados-e-acoes).

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

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
