Criar um servidor MCP em PHP sem qualquer dependência

Um servidor MCP conforme à especificação 2026-07-28, em PHP 8 puro, sem Composer nem framework. Escrito, executado e chamado a partir do Codex, com as respostas reais.

Criar um servidor MCP em PHP sem qualquer dependência
Resposta rápida

Um servidor MCP é um serviço que expõe ferramentas a um agente de IA em JSON-RPC 2.0, descritas por um esquema que o cliente lê em tempo de execução com tools/list. Em PHP, cabe num ficheiro de 380 linhas: uma rota POST /mcp, um token bearer, e uma função por ferramenta. Atenção, a revisão 2026-07-28 elimina o aperto de mão initialize, mas o Codex e o MCP Inspector 2.5.0 ainda o usam: o teu servidor tem de tratar dos dois.

Tens uma API interna, um catálogo de produtos ou uma base de logs, e gostavas que o Claude Code ou o Codex se servisse disso diretamente em vez de te pedir copiar e colar. É esse o trabalho de um servidor MCP, e cabe num único ficheiro PHP: o deste tutorial tem 380 linhas, sem Composer, sem framework e sem outra dependência além do PHP 8.

O que é um servidor MCP?

O Model Context Protocol é um protocolo aberto que estandardiza a forma como uma aplicação de IA se liga a dados e ferramentas externas. A especificação distingue três papéis: os anfitriões, aplicações que iniciam as ligações. Os clientes, conectores dentro do anfitrião. Os servidores, serviços que fornecem o contexto e as capacidades. As mensagens são JSON-RPC 2.0.

Um servidor pode oferecer três coisas: recursos (contexto e dados), prompts (modelos de mensagens) e ferramentas (funções que o modelo executa). Aqui só implementamos as ferramentas, porque são o que serve na maioria dos casos e porque são a parte que se testa com o curl.

MCP ou API REST: o que muda realmente?

O MCP não substitui o REST, acrescenta-lhe uma camada de descrição por cima. Eis o que muda na prática.

Ponto API REST clássica Servidor MCP
Descoberta Uma documentação para ler, um OpenAPI para carregar tools/list devolve os esquemas JSON de cada ferramenta, em tempo de execução
Chamador Código que escreves O modelo, que escolhe a ferramenta a partir da sua descrição
Formato O que quiseres JSON-RPC 2.0, imposto
Erros Códigos HTTP Duas famílias: erros de protocolo e erros de execução de ferramenta
Integração Um adaptador por cliente Um servidor, todos os agentes compatíveis

O último ponto é o único que conta mesmo. Um servidor MCP escrito uma vez liga-se ao Claude Code, ao Codex, ao Inspector, e aos outros clientes do ecossistema sem uma linha de adaptação. É a mesma promessa que a dos skills, cujo formato detalhamos no guia do ficheiro SKILL.md.

Porque é que o teu servidor tem de falar duas línguas

É a armadilha deste tutorial, e mais vale expô-la já. A revisão 2026-07-28 da especificação, publicada a 28 de julho de 2026 por David Soria Parra e Den Delimarsky, eliminou o aperto de mão. Acabou o initialize, acabou a notificação notifications/initialized, acabou o cabeçalho Mcp-Session-Id. Cada pedido transporta agora a sua versão de protocolo e a identidade do cliente num campo _meta, e o servidor pode ser replicado sem estado partilhado.

As outras alterações da mesma revisão tocam diretamente o transporte HTTP:

  • o ponto de entrada só aceita POST, o fluxo GET e o DELETE de fim de sessão desapareceram,
  • um método server/discover substitui a descoberta, e os servidores devem implementá-lo.
  • os cabeçalhos Mcp-Method e Mcp-Name copiam campos do corpo, para que um balanceador de carga encaminhe sem ler o JSON.
  • as respostas de lista transportam ttlMs e cacheScope para poderem ser postas em cache.

No papel, bastaria portanto escrever um servidor 2026-07-28. Só que registei o método de abertura e o cabeçalho User-Agent de cada cliente que se ligou ao meu servidor a 7 de setembro de 2026, e eis o que vi.

Cliente User-Agent Abertura Versão anunciada
Codex codex-mcp-client/0.153.4 initialize 2025-06-18
MCP Inspector 2.5.0 node initialize 2025-11-25

Nenhum dos dois enviou server/discover, e nenhum pôs _meta nos seus pedidos. Um servidor que só falasse a revisão de julho seria hoje inutilizável com estes clientes. A especificação previu o caso: chama dual-era a uma implementação que trata das duas, e autoriza explicitamente um servidor a servir as duas eras no mesmo ponto de entrada. É isso que vamos fazer, e custa uma dezena de linhas.

O esqueleto: um ficheiro, uma rota

O servidor de demonstração chama-se regexlab e expõe duas ferramentas: regex_test, que testa uma expressão regular sobre exemplos, e blog_search, que interroga a API REST pública de gekkode.com só de leitura. Lança-se com o servidor web integrado do PHP.

bash
cd docker/articles/2026-09-07/lab/creer-serveur-mcp-php
MCP_TOKEN=demo-token-local php -S 127.0.0.1:8765 mcp.php

Começamos por duas funções de invólucro. Todas as saídas passam por elas, o que garante que nenhuma resposta parte sem um código HTTP explícito.

php
<?php
declare(strict_types=1);

const SERVER_NAME = 'regexlab';
const SERVER_VERSION = '0.1.0';
const SUPPORTED_VERSIONS = ['2026-07-28', '2025-11-25', '2025-06-18'];
const ALLOWED_ORIGINS = ['http://127.0.0.1:8765', 'http://localhost:8765'];

function send(int $status, ?array $payload = null): never
{
    http_response_code($status);
    if ($payload === null) {
        exit;
    }
    header('Content-Type: application/json');
    echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";
    exit;
}

function fail(int $status, int $code, string $message, mixed $id = null, ?array $data = null): never
{
    $error = ['code' => $code, 'message' => $message];
    if ($data !== null) {
        $error['data'] = $data;
    }
    send($status, ['jsonrpc' => '2.0', 'id' => $id, 'error' => $error]);
}

Vem depois a porta de entrada: método HTTP, caminho, origem, token. Quatro controlos, e os códigos de retorno que a especificação impõe.

php
function header_value(string $name): ?string
{
    $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
    return isset($_SERVER[$key]) ? trim((string) $_SERVER[$key]) : null;
}

// O GET e o DELETE deixaram de fazer parte da revisão 2026-07-28.
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    header('Allow: POST');
    send(405, ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'Use POST on /mcp']]);
}

if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== '/mcp') {
    fail(404, -32601, 'Unknown endpoint');
}

// Origin: defesa contra o DNS rebinding, 403 exigido pela especificação.
$origin = header_value('Origin');
if ($origin !== null && !in_array($origin, ALLOWED_ORIGINS, true)) {
    fail(403, -32600, 'Origin not allowed');
}

// Token bearer.
$expected = getenv('MCP_TOKEN') ?: '';
if ($expected !== '') {
    $sent = (string) preg_replace('/^Bearer\s+/i', '', header_value('Authorization') ?? '');
    if (!hash_equals($expected, $sent)) {
        header('WWW-Authenticate: Bearer realm="regexlab"');
        fail(401, -32001, 'Unauthorized');
    }
}

A validação do cabeçalho Origin não é decorativa. A especificação classifica-a como MUST e exige um 403, precisamente porque sem ela um site aberto no teu navegador pode, por rebinding de DNS, dialogar com o servidor MCP que corre na tua máquina. Vamos verificar as três portas:

bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8765/mcp
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8765/mcp \
  -H 'Origin: https://evil.example' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl -s -X POST http://127.0.0.1:8765/mcp -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}'
json
405
403
{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}}

Ler o pedido e identificar a era

O corpo é JSON-RPC. Saem dele três informações: o método, os parâmetros, o identificador. A presença de io.modelcontextprotocol/protocolVersion em _meta basta para saber se o cliente é moderno.

php
$raw = file_get_contents('php://input') ?: '';
try {
    $req = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    fail(400, -32700, 'Parse error');
}
if (!is_array($req) || !isset($req['method'])) {
    fail(400, -32600, 'Invalid Request');
}

$method = (string) $req['method'];
$params = is_array($req['params'] ?? null) ? $req['params'] : [];
$id = $req['id'] ?? null;

$meta = is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
$bodyVersion = $meta['io.modelcontextprotocol/protocolVersion'] ?? null;
$modern = is_string($bodyVersion);

error_log(sprintf(
    '[mcp] %s | version=%s | agent=%s',
    $method,
    is_string($bodyVersion) ? $bodyVersion : ($params['protocolVersion'] ?? '-'),
    $_SERVER['HTTP_USER_AGENT'] ?? '-'
));

// Uma notificação não tem identificador: acusa-se receção e para-se.
if (!array_key_exists('id', $req)) {
    send(202, null);
}

A chamada a error_log acima foi o que me permitiu traçar a tabela dos clientes mais acima. O servidor integrado do PHP escreve na saída de erro, por isso o registo aparece no terminal que o lançou. Mantém-nos durante todo o desenvolvimento.

O tratamento das notificações merece uma palavra. Uma notificação JSON-RPC é uma mensagem sem id: o cliente não espera resposta. A especificação é categórica, o servidor deve responder 202 Accepted sem corpo. É por aí que passa o notifications/initialized dos clientes herdados.

bash
curl -s -o /dev/null -w "status=%{http_code} octets=%{size_download}\n" \
  -X POST http://127.0.0.1:8765/mcp -H 'Authorization: Bearer demo-token-local' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
bash
status=202 octets=0

Porque é que este servidor só devolve JSON

Uma palavra sobre o formato de resposta, porque é uma escolha e não uma obrigação. Perante um pedido, o servidor pode responder ou com um objeto único em Content-Type: application/json, ou com um fluxo SSE em text/event-stream, próprio desse pedido, que transporta notificações antes da resposta final. O cliente, esse, tem de saber ler os dois: é a razão do cabeçalho Accept: application/json, text/event-stream que envia sistematicamente.

Este servidor limita-se ao JSON. É o mais simples, e chega enquanto nenhuma ferramenta precisar de dar conta do seu progresso. O fluxo torna-se necessário em dois casos: uma ferramenta longa que quer emitir notifications/progress durante o seu trabalho, e o pedido subscriptions/listen, cuja resposta fica aberta para transportar as alterações de lista. Chegado esse dia, a revisão 2026-07-28 faz do fecho do fluxo pelo cliente o sinal de cancelamento do pedido, e recomenda o cabeçalho X-Accel-Buffering: no para que o nginx não ponha os eventos em buffer.

Validar os cabeçalhos espelho

É a parte mais específica da revisão 2026-07-28, e a que se esquece. Quando um cliente moderno envia um pedido, deve copiar três valores do corpo para cabeçalhos: MCP-Protocol-Version, Mcp-Method, e Mcp-Name para um tools/call. O servidor deve verificar que o cabeçalho e o corpo coincidem, e rejeitar em 400 com o código de erro -32020 caso contrário.

A razão é uma falha clássica: se um balanceador de carga decide o encaminhamento pelo cabeçalho enquanto o servidor executa a partir do corpo, um cliente malicioso pode fazer passar uma chamada por outra. A especificação chama a este erro HeaderMismatch.

php
if ($modern) {
    $headerVersion = header_value('MCP-Protocol-Version');
    if ($headerVersion !== $bodyVersion) {
        fail(400, -32020, sprintf(
            'Header mismatch: MCP-Protocol-Version %s does not match body value %s',
            var_export($headerVersion, true),
            var_export($bodyVersion, true)
        ), $id);
    }
    if (header_value('Mcp-Method') !== $method) {
        fail(400, -32020, 'Header mismatch: Mcp-Method', $id);
    }
    if ($method === 'tools/call' && header_value('Mcp-Name') !== ($params['name'] ?? null)) {
        fail(400, -32020, 'Header mismatch: Mcp-Name', $id);
    }
    if (!in_array($bodyVersion, SUPPORTED_VERSIONS, true)) {
        fail(400, -32022, 'Unsupported protocol version', $id, [
            'supported' => SUPPORTED_VERSIONS,
            'requested' => $bodyVersion,
        ]);
    }
}

Um teste que mente sobre Mcp-Name: o cabeçalho anuncia blog_search, o corpo pede regex_test.

bash
curl -s -X POST http://127.0.0.1:8765/mcp \
  -H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: blog_search' \
  -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"regex_test","arguments":{"pattern":"a","subjects":["a"]},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
json
{"jsonrpc":"2.0","id":5,"error":{"code":-32020,"message":"Header mismatch: Mcp-Name"}}

E uma versão que o servidor não conhece. A especificação impõe responder -32022 listando as versões aceites, para que o cliente possa tentar de novo sem intervenção humana.

json
{
  "jsonrpc": "2.0",
  "id": 6,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": {
      "supported": ["2026-07-28", "2025-11-25", "2025-06-18"],
      "requested": "1900-01-01"
    }
  }
}

Responder à descoberta, dos dois lados

É isto que faz o servidor bi-era: um case para initialize, um para server/discover, e os dois devolvem as mesmas capacidades em duas embalagens diferentes. O primeiro é a dezena de linhas que custa para continuar compatível.

php
switch ($method) {
    // Aperto de mão herdado (revisões 2025-11-25 e anteriores).
    case 'initialize':
        $asked = (string) ($params['protocolVersion'] ?? '2025-11-25');
        ok($id, [
            'protocolVersion' => in_array($asked, SUPPORTED_VERSIONS, true) ? $asked : '2025-11-25',
            'capabilities' => ['tools' => ['listChanged' => false]],
            'serverInfo' => ['name' => SERVER_NAME, 'version' => SERVER_VERSION],
            'instructions' => 'regex_test teste une expression régulière ; blog_search interroge gekkode.com.',
        ]);

    // Descoberta sem estado (revisão 2026-07-28).
    case 'server/discover':
        ok($id, [
            'resultType' => 'complete',
            'supportedVersions' => SUPPORTED_VERSIONS,
            'capabilities' => ['tools' => ['listChanged' => false]],
            '_meta' => ['io.modelcontextprotocol/serverInfo' => [
                'name' => SERVER_NAME,
                'version' => SERVER_VERSION,
            ]],
            'instructions' => 'regex_test teste une expression régulière ; blog_search interroge gekkode.com.',
            'ttlMs' => 3600000,
            'cacheScope' => 'public',
        ]);

    default:
        fail($modern ? 404 : 200, -32601, 'Method not found: ' . $method, $id);
}

Dois detalhes a não perder. O serverInfo desloca-se: está na raiz do resultado em modo herdado, e sob _meta['io.modelcontextprotocol/serverInfo'] em 2026-07-28. E o método desconhecido não se trata da mesma forma: em modo moderno, a especificação pede um 404 Not Found acompanhado de um -32601, porque o corpo JSON-RPC é o que distingue este caso do 404 de um servidor antigo que não hospeda o ponto de entrada.

Eis a resposta real a server/discover:

bash
curl -s -X POST http://127.0.0.1:8765/mcp \
  -H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":"d1","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8.7.1"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
json
{"jsonrpc":"2.0","id":"d1","result":{"resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18"],"capabilities":{"tools":{"listChanged":false}},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"regexlab","version":"0.1.0"}},"instructions":"regex_test teste une expression régulière ; blog_search interroge gekkode.com.","ttlMs":3600000,"cacheScope":"public"}}

E a de initialize, tal como o Codex a recebe:

json
{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {"tools": {"listChanged": false}},
    "serverInfo": {"name": "regexlab", "version": "0.1.0"},
    "instructions": "regex_test teste une expression régulière ; blog_search interroge gekkode.com."
  }
}

Descrever as ferramentas: tools/list

Uma ferramenta é um nome, uma descrição, e um esquema JSON de entrada. A descrição é o que o modelo lê para decidir se chama a ferramenta: escreve-a para ele, não para um programador. O outputSchema é facultativo mas recomendado, permite ao cliente validar o que devolves.

php
[
    'name' => 'regex_test',
    'title' => 'Testeur d\'expression régulière',
    'description' => 'Teste une expression régulière PCRE sur une liste de chaînes et retourne, '
        . 'pour chacune, si elle correspond et les groupes capturés.',
    'inputSchema' => [
        'type' => 'object',
        'properties' => [
            'pattern' => ['type' => 'string', 'description' => 'Motif PCRE, sans délimiteurs.'],
            'flags' => ['type' => 'string', 'description' => 'Modificateurs parmi i, m, s, x, u.'],
            'subjects' => [
                'type' => 'array',
                'items' => ['type' => 'string'],
                'description' => 'Chaînes à tester (20 au maximum).',
            ],
        ],
        'required' => ['pattern', 'subjects'],
        'additionalProperties' => false,
    ],
]

A resposta a tools/list acrescenta resultType, e os dois campos de cache introduzidos pela revisão de julho.

php
case 'tools/list':
    ok($id, [
        'resultType' => 'complete',
        'tools' => tool_definitions(),
        'ttlMs' => 300000,
        'cacheScope' => 'public',
    ]);

Três regras de nomenclatura a respeitar: o nome de uma ferramenta deve ter entre 1 e 128 caracteres, limitar-se a letras ASCII, números, _, - e ., e ser único no servidor. Os espaços e as vírgulas são proibidos.

Executar uma ferramenta: tools/call e as suas duas famílias de erros

A qualidade de um servidor MCP joga-se aqui, e muitos tutoriais passam ao lado. A especificação distingue dois mecanismos de reporte de erro, e confundi-los custa idas e voltas ao modelo:

  • um erro de protocolo (ferramenta desconhecida, pedido malformado) é um erro JSON-RPC clássico, o modelo tem poucas hipóteses de se safar sozinho,
  • um erro de execução (argumento fora dos limites, API avariada, data inválida) devolve-se num resultado normal com isError: true, e o cliente deve entregá-la ao modelo para que se corrija.

O testador de expressões regulares é um caso de escola: um padrão mal escrito deve voltar ao modelo com a mensagem do PCRE, não com um «erro 500».

php
function run_regex_test(array $args): array
{
    $pattern = (string) ($args['pattern'] ?? '');
    $flags = (string) ($args['flags'] ?? '');
    $subjects = is_array($args['subjects'] ?? null) ? array_values($args['subjects']) : [];

    if ($pattern === '' || strlen($pattern) > 512) {
        return tool_error('Le motif doit faire entre 1 et 512 caractères.');
    }
    if ($flags !== '' && !preg_match('/^[imsxu]{1,5}$/', $flags)) {
        return tool_error('Modificateurs acceptés : i, m, s, x, u.');
    }
    if ($subjects === [] || count($subjects) > 20) {
        return tool_error('Fournissez entre 1 et 20 chaînes à tester.');
    }

    // Escapam-se os delimitadores não protegidos em vez de aceitar um padrão já delimitado.
    $delimited = '/' . preg_replace('~(?<!\\\\)/~', '\\/', $pattern) . '/' . $flags;
    $compileError = null;
    set_error_handler(static function (int $no, string $msg) use (&$compileError): bool {
        $compileError = $msg;
        return true;
    });
    $compiles = preg_match($delimited, '');
    restore_error_handler();
    if ($compiles === false) {
        // A mensagem do PCRE diz onde o padrão parte: o modelo pode corrigir-se sozinho.
        return tool_error('Motif invalide : ' . ($compileError ?? preg_last_error_msg()));
    }
    // …
}

Duas precauções nestas poucas linhas. Nunca se aceita um padrão já delimitado, acrescentam-se os próprios delimitadores escapando as / não protegidas. Aceitar /padrão/flags tal como está equivaleria a deixar o chamador escolher os modificadores, o que a validação a montante proíbe. E o gestor de erro temporário recupera a mensagem exata do PCRE, onde preg_last_error_msg() se contenta com um lacónico «Internal error».

A diferença vê-se na chamada. Padrão com um parêntese em falta:

json
{
  "jsonrpc": "2.0",
  "id": 8,
  "result": {
    "resultType": "complete",
    "content": [
      {
        "type": "text",
        "text": "Motif invalide : preg_match(): Compilation failed: missing closing parenthesis at offset 7"
      }
    ],
    "isError": true
  }
}

Um modelo que recebe este texto fecha o parêntese e volta a chamar a ferramenta. Se tivesse recebido um erro JSON-RPC, teria tido muito menos hipóteses de se safar: a especificação nota que os erros de protocolo raramente levam a uma correção.

Fica um risco próprio das expressões regulares: a explosão combinatória. Um padrão como (a+)+$ sobre uma cadeia de trinta e seis «a» seguidos de um «b» ocupa o processador durante muito tempo. A defesa cabe numa linha no topo do ficheiro, ini_set('pcre.backtrack_limit', '200000');, e num teste ao retorno de preg_match.

json
{
  "jsonrpc": "2.0",
  "id": 9,
  "result": {
    "resultType": "complete",
    "content": [{"type": "text", "text": "Échec du moteur PCRE : Backtrack limit exhausted"}],
    "isError": true
  }
}

Uma chamada bem-sucedida, agora, com o seu structuredContent conforme ao outputSchema declarado:

bash
curl -s -X POST http://127.0.0.1:8765/mcp \
  -H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: regex_test' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"regex_test","arguments":{"pattern":"^([A-Z]{2})-(\\d{4})$","subjects":["FR-2026","fr-2026","XX-12"]},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "resultType": "complete",
    "content": [
      {
        "type": "text",
        "text": "1 correspondance(s) sur 3 chaîne(s).\nOUI FR-2026 -> FR | 2026\nNON fr-2026\nNON XX-12"
      }
    ],
    "structuredContent": {
      "pattern": "^([A-Z]{2})-(\\d{4})$",
      "matches": 1,
      "results": [
        {"subject": "FR-2026", "matched": true, "groups": ["FR", "2026"]},
        {"subject": "fr-2026", "matched": false, "groups": []},
        {"subject": "XX-12", "matched": false, "groups": []}
      ]
    },
    "isError": false
  }
}

Repara que o texto legível está presente além do conteúdo estruturado. A especificação recomenda-o explicitamente: uma ferramenta que devolve dados estruturados deve também fornecer a versão serializada num bloco de texto, para os clientes que só leem content.

A segunda ferramenta: interrogar uma API só de leitura

blog_search mostra o caso mais comum em empresa, expor um serviço existente. O anfitrião está codificado de forma fixa, o método é um GET, nenhum parâmetro do chamador constrói um URL arbitrário.

php
function run_blog_search(array $args): array
{
    $query = trim((string) ($args['query'] ?? ''));
    $limit = max(1, min(5, (int) ($args['limit'] ?? 3)));
    if ($query === '' || mb_strlen($query) > 120) {
        return tool_error('La requête doit faire entre 1 et 120 caractères.');
    }

    $url = BLOG_API . '?' . http_build_query([
        'search' => $query,
        'per_page' => $limit,
        'orderby' => 'relevance',
        '_fields' => 'id,date,link,title',
    ]);
    $context = stream_context_create(['http' => [
        'method' => 'GET',
        'timeout' => 8,
        'header' => "Accept: application/json\r\nUser-Agent: regexlab-mcp/0.1\r\n",
        'ignore_errors' => true,
    ]]);
    $body = @file_get_contents($url, false, $context);
    if ($body === false) {
        return tool_error('API du blog injoignable.');
    }
    // …
}

O _fields da API REST do WordPress faz muito trabalho: reduz a resposta aos quatro campos úteis, logo os tokens faturados ao entrarem no contexto do modelo. É o mesmo reflexo descrito no nosso artigo sobre a redução de tokens, aplicado ao servidor em vez de ao cliente. Resultado da chamada:

bash
2024-08-11 — Les 10 meilleurs packages Laravel pour créer votre site web
Les 10 meilleurs packages Laravel pour créer votre site web
2023-02-03 — Comment installer Redis sur Debian et Laravel https://www.gekkode.com/developpement/comment-installer-redis-sur-debian-et-laravel/

Ligar o servidor ao Codex

O Codex lê os seus servidores MCP em ~/.codex/config.toml, mas a opção -c permite declará-los para uma única execução, sem escrever nada em disco. Ideal para um teste, e foi por aí que o servidor foi verificado.

bash
export REGEXLAB_TOKEN=demo-token-local

codex exec \
  -c 'mcp_servers.regexlab.url="http://127.0.0.1:8765/mcp"' \
  -c 'mcp_servers.regexlab.bearer_token_env_var="REGEXLAB_TOKEN"' \
  -c 'mcp_servers.regexlab.default_tools_approval_mode="approve"' \
  -m gpt-6-astra --skip-git-repo-check \
  "Utilise UNIQUEMENT l'outil MCP regexlab.regex_test. Teste le motif ^([A-Z]{2})-(\d{4})\$ sur les chaînes FR-2026, fr-2026 et XX-12."
bash
codex
Je vais tester ce motif sur les trois chaînes avec l'outil demandé.

mcp: regexlab/regex_test started
mcp: regexlab/regex_test (completed)
codex
Il y a 1 correspondance sur 3 : « FR-2026 », avec les groupes capturés « FR » et « 2026 ».
tokens used
9 342

O terceiro -c é o que me custou duas tentativas. Sem ele, o Codex liga-se ao servidor, lista as ferramentas, e recusa a chamada com uma mensagem sem ambiguidade:

bash
mcp: regexlab/regex_test started
mcp: regexlab/regex_test (failed)
MCP tool call requires approval, but approval policy is never

Em modo exec, a política de aprovação vale never e não há nenhum humano para validar. A chave default_tools_approval_mode aceita quatro valores, auto, prompt, writes e approve, só o último deixa passar a chamada sem intervenção. A reservar para os servidores que escreveste tu mesmo, pelas razões detalhadas no nosso artigo sobre as sandbox e as permissões.

Para uma instalação duradoura, o comando oficial escreve o mesmo na configuração:

bash
codex mcp add regexlab --url http://127.0.0.1:8765/mcp \
  --bearer-token-env-var REGEXLAB_TOKEN
codex mcp list
codex mcp get regexlab
codex mcp remove regexlab

Ligar o servidor ao Claude Code

Do lado do Claude Code, adicionar um servidor HTTP cabe num comando, e o cabeçalho de autorização passa-se com --header.

bash
claude mcp add --transport http regexlab http://127.0.0.1:8765/mcp \
  --header "Authorization: Bearer demo-token-local"
claude mcp list
claude mcp get regexlab

Para partilhar o servidor com a equipa, o âmbito project escreve um ficheiro .mcp.json na raiz do repositório, versionável. A documentação aceita a expansão de variáveis de ambiente, o que evita comitar o token:

json
{
  "mcpServers": {
    "regexlab": {
      "type": "http",
      "url": "http://127.0.0.1:8765/mcp",
      "headers": {
        "Authorization": "Bearer ${REGEXLAB_TOKEN}"
      }
    }
  }
}

A sintaxe ${VAR} e ${VAR:-valor por defeito} é reconhecida nos campos url, headers, command, args e env. A partir da sessão, /mcp mostra o estado dos servidores e permite ligar-se aos que pedem OAuth.

Verificar com o MCP Inspector, sem instalar nada

A ferramenta de teste oficial usa-se em modo gráfico, mas o seu modo --cli é bem mais prático para um teste rápido e lança-se com npx, portanto sem instalação global.

bash
npx -y @modelcontextprotocol/inspector@2.5.0 --cli http://127.0.0.1:8765/mcp \
  --transport http --header "Authorization: Bearer demo-token-local" \
  --method tools/call --tool-name regex_test \
  --tool-arg 'pattern=^\d{5}$' --tool-arg 'subjects=["62500","6250"]'
json
{
  "content": [
    {"type": "text", "text": "1 correspondance(s) sur 2 chaîne(s).\nOUI 62500\nNON 6250"}
  ],
  "structuredContent": {
    "pattern": "^\\d{5}$",
    "matches": 1,
    "results": [
      {"subject": "62500", "matched": true, "groups": []},
      {"subject": "6250", "matched": false, "groups": []}
    ]
  },
  "isError": false
}

O Inspector 2.5.0, publicado a 2 de setembro de 2026, abre também por um initialize, anunciando a versão 2025-11-25. Continua a ser a ferramenta mais rápida para ver o que o teu servidor devolve mesmo, antes de lhe ligares um agente.

O que o servidor integrado do PHP não sabe fazer

php -S trata um pedido de cada vez. Enquanto as tuas ferramentas responderem em poucos milissegundos, isso não se nota. Assim que uma ferramenta chama a rede, todo o servidor bloqueia. Lancei duas chamadas em paralelo, blog_search, que interroga a API do blog, depois regex_test, que só faz cálculo local:

Configuração blog_search regex_test lançado 50 ms depois
php -S por defeito 0,601 s 0,548 s
PHP_CLI_SERVER_WORKERS=4 0,578 s 0,002 s

Sem processos suplementares, a chamada rápida espera pacientemente que a lenta acabe: 0,548 s em vez dos 13 ms que leva sozinha. Com quatro trabalhadores, responde de imediato. É suficiente para desenvolver, não substitui o PHP-FPM atrás do nginx em produção.

Passar para produção: o SDK PHP oficial e o Laravel MCP

Escrever o próprio servidor à mão é a boa forma de perceber o protocolo. Para código que vive, dois pacotes merecem o desvio, e ambos se mexeram este verão.

O SDK PHP oficial instala-se com composer require mcp/sdk. Apresenta-se como o SDK oficial do protocolo para PHP, mantido em colaboração com a Fundação PHP e retomando as práticas do projeto Symfony. É framework-agnóstico, pede PHP 8.1 no mínimo, e o seu repositório anuncia o suporte das duas eras de protocolo, o aperto de mão initialize e a revisão sem estado 2026-07-28. Última versão a 7 de setembro de 2026: a v0.8.1, publicada a 29 de agosto.

O Laravel MCP (composer require laravel/mcp) visa a outra ponta do espetro: declaras os teus servidores em routes/ai.php, com Mcp::web('/mcp/weather', WeatherServer::class) para um servidor HTTP ou Mcp::local('weather', …) para um comando Artisan, e cada ferramenta torna-se uma classe que estende Tool com um método handle() e um schema(). O middleware do Laravel aplica-se tal como está, incluindo o throttle. A primeira versão estável está em preparação: a v1.0.0-beta.1 data de 14 de agosto de 2026 e pede PHP 8.2 com Laravel 11.45.3, 12.41.1 ou 13.

Se a tua necessidade é WordPress ou PrestaShop em vez de um servidor genérico, dois artigos desta série tratam o caso: MCP para WordPress e MCP para PrestaShop.

Cinco linhas de segurança que não custam nada

Um servidor MCP é uma superfície de execução de código acessível por um modelo. A especificação dedica uma página inteira ao assunto, retém no mínimo estas regras, todas cumpridas pelo ficheiro deste tutorial.

  • Escuta em 127.0.0.1, nunca em 0.0.0.0, para um servidor local. A especificação classifica-o como SHOULD.
  • Valida o cabeçalho Origin e responde 403 quando não consta da tua lista. Sem isso, uma página web aberta no teu navegador pode falar com o teu servidor.
  • Exige um token, comparado com hash_equals() para não vazar informação pelo tempo de comparação.
  • Fica em leitura só enquanto não precisares de escrever, e limita tudo: comprimento das cadeias, número de elementos, tempo limite de rede, limite de backtracking.
  • Codifica o anfitrião de forma fixa nas ferramentas que chamam a rede. Um parâmetro de URL livre transforma o teu servidor num retransmissor para exfiltração.

Este último ponto não é teórico. Os incidentes públicos do ecossistema MCP recenseados até agora incidem quase todos sobre servidores que faziam mais do que o anunciado: um pacote publicado no npm que copiava de passagem os e-mails que devia enviar, um retransmissor cuja falha abria a execução de comandos. Uma ferramenta incapaz de fazer outra coisa além do que diz a sua descrição é uma ferramenta que se pode auditar. A questão do perímetro de um agente e das suas ferramentas é tratada em detalhe no artigo sobre as sandbox e as permissões.

A reter

  • Um servidor MCP útil cabe num ficheiro PHP de 380 linhas: uma rota POST /mcp, JSON-RPC 2.0, e duas funções por ferramenta.
  • A revisão 2026-07-28 elimina o initialize, as sessões e o fluxo GET, mas o Codex e o Inspector ainda abrem pelo aperto de mão herdado: escreve um servidor que trata das duas eras.
  • Em modo moderno, copia e verifica MCP-Protocol-Version, Mcp-Method e Mcp-Name, com -32020 em caso de desvio e -32022 para uma versão desconhecida.
  • Devolve os erros de negócio no resultado com isError: true, não como erro JSON-RPC: é isso que permite ao modelo corrigir-se sozinho.
  • Testa com o curl, depois com npx @modelcontextprotocol/inspector --cli, antes de ligar um agente.
  • O php -S só serve para o desenvolvimento: uma chamada de rede lenta bloqueia todo o servidor enquanto o PHP_CLI_SERVER_WORKERS não estiver definido.
  • Para produção, parte do SDK oficial mcp/sdk ou do laravel/mcp em vez de manteres a tua própria camada de transporte.

Erros frequentes

Escrever um servidor que só fala a revisão 2026-07-28 Os clientes testados a 7 de setembro de 2026 ainda abrem por initialize. Mantém o caso initialize ao lado de server/discover: a especificação autoriza um servidor a servir as duas eras no mesmo ponto de entrada.
Esquecer de validar os cabeçalhos espelho Em 2026-07-28, MCP-Protocol-Version, Mcp-Method e Mcp-Name devem corresponder ao corpo. Um desvio rejeita-se em 400 com o código -32020, senão um intermediário e o servidor podem ler duas coisas diferentes.
Responder a uma notificação Uma mensagem sem id não espera resposta. Devolve 202 Accepted sem corpo, senão os clientes herdados encravam em notifications/initialized.
Reportar um erro de negócio como erro JSON-RPC Um argumento inválido devolve-se num resultado com isError: true e uma mensagem utilizável. Um erro de protocolo faz o modelo desistir, um erro de execução faz-o corrigir-se.
Chamar a ferramenta a partir de codex exec sem ajustar a aprovação A política vale never e o Codex recusa com «MCP tool call requires approval». Acrescenta -c 'mcp_servers.NOME.default_tools_approval_mode="approve"', e só para um servidor que tenhas escrito.

Claude CodeCodexLaravelMCPPHP

Damien Flandrin Programador web desde 2010, criador da Gekkode e do Email Impact. Cada artigo é testado num projeto real antes de ser publicado. Contacto
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.