
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 fluxoGETe oDELETEde fim de sessão desapareceram, - um método
server/discoversubstitui a descoberta, e os servidores devem implementá-lo. - os cabeçalhos
Mcp-MethodeMcp-Namecopiam campos do corpo, para que um balanceador de carga encaminhe sem ler o JSON. - as respostas de lista transportam
ttlMsecacheScopepara 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.
cd docker/articles/2026-09-07/lab/creer-serveur-mcp-php
MCP_TOKEN=demo-token-local php -S 127.0.0.1:8765 mcp.phpComeç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
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.
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:
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":{}}'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.
$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.
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"}'status=202 octets=0Porque é 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.
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.
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"}}}'{"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.
{
"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.
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:
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":{}}}}'{"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:
{
"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.
[
'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.
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».
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:
{
"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.
{
"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:
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"}}}'{
"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.
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:
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.
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."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 342O 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:
mcp: regexlab/regex_test started
mcp: regexlab/regex_test (failed)
MCP tool call requires approval, but approval policy is neverEm 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:
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 regexlabLigar 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.
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 regexlabPara 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:
{
"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.
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"]'{
"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
Origine 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 fluxoGET, 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-MethodeMcp-Name, com-32020em caso de desvio e-32022para 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 -Ssó serve para o desenvolvimento: uma chamada de rede lenta bloqueia todo o servidor enquanto oPHP_CLI_SERVER_WORKERSnão estiver definido. - Para produção, parte do SDK oficial
mcp/sdkou dolaravel/mcpem vez de manteres a tua própria camada de transporte.
Erros frequentes
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.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.id não espera resposta. Devolve 202 Accepted sem corpo, senão os clientes herdados encravam em notifications/initialized.isError: true e uma mensagem utilizável. Um erro de protocolo faz o modelo desistir, um erro de execução faz-o corrigir-se.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.

