Crear un servidor MCP en PHP sin ninguna dependencia

Un servidor MCP conforme a la especificación 2026-07-28, en PHP 8 puro, sin Composer ni framework. Escrito, ejecutado y llamado desde Codex, con las respuestas reales.

Crear un servidor MCP en PHP sin ninguna dependencia
Respuesta rápida

Un servidor MCP es un servicio que expone herramientas a un agente de IA en JSON-RPC 2.0, descritas mediante un esquema que el cliente lee en tiempo de ejecución con tools/list. En PHP, cabe en un archivo de 380 líneas: una ruta POST /mcp, un token bearer, y una función por herramienta. Ojo: la revisión 2026-07-28 elimina el handshake initialize, pero Codex y MCP Inspector 2.5.0 todavía lo usan: tu servidor debe gestionar los dos.

Tienes una API interna, un catálogo de productos o una base de logs, y te gustaría que Claude Code o Codex la usaran directamente en lugar de pedirte copiar y pegar. Ese es el trabajo de un servidor MCP, y cabe en un único archivo PHP: el de este tutorial tiene 380 líneas, sin Composer, sin framework y sin más dependencia que PHP 8.

¿Qué es un servidor MCP?

El Model Context Protocol es un protocolo abierto que estandariza la forma en que una aplicación de IA se conecta a datos y herramientas externas. La especificación distingue tres roles: los hosts, aplicaciones que inician las conexiones. Los clientes, conectores dentro del host. Los servidores, servicios que aportan el contexto y las capacidades. Los mensajes son JSON-RPC 2.0.

Un servidor puede ofrecer tres cosas: recursos (contexto y datos), prompts (plantillas de mensajes) y herramientas (funciones que ejecuta el modelo). Aquí solo implementamos las herramientas, porque es lo que se usa en la mayoría de los casos y porque es la parte que se prueba con curl.

¿MCP o API REST: qué cambia realmente?

MCP no sustituye a REST, añade una capa de descripción por encima. Esto es lo que cambia en concreto.

Punto API REST clásica Servidor MCP
Descubrimiento Una documentación que leer, un OpenAPI que cargar tools/list devuelve los esquemas JSON de cada herramienta, en tiempo de ejecución
Quién llama Código que tú escribes El modelo, que elige la herramienta según su descripción
Formato Lo que tú quieras JSON-RPC 2.0, impuesto
Errores Códigos HTTP Dos familias: errores de protocolo y errores de ejecución de herramienta
Integración Un adaptador por cliente Un servidor, todos los agentes compatibles

El último punto es el único que importa de verdad. Un servidor MCP escrito una vez se conecta a Claude Code, a Codex, al Inspector y a los demás clientes del ecosistema sin una sola línea de adaptación. Es la misma promesa que la de los skills, cuyo formato detallamos en la guía del archivo SKILL.md.

Por qué tu servidor debe hablar dos idiomas

Esta es la trampa de este tutorial, y más vale plantearla ya. La revisión 2026-07-28 de la especificación, publicada el 28 de julio de 2026 por David Soria Parra y Den Delimarsky, eliminó el handshake. Se acabó initialize, se acabó la notificación notifications/initialized, se acabó la cabecera Mcp-Session-Id. Cada petición transporta ahora su versión de protocolo y la identidad del cliente en un campo _meta, y el servidor puede replicarse sin estado compartido.

Los demás cambios de esa misma revisión afectan directamente al transporte HTTP:

  • el endpoint ya solo acepta POST, el flujo GET y el DELETE de cierre de sesión han desaparecido,
  • un método server/discover sustituye al descubrimiento, y los servidores deben implementarlo.
  • las cabeceras Mcp-Method y Mcp-Name copian campos del cuerpo, para que un balanceador de carga enrute sin leer el JSON.
  • las respuestas de listado transportan ttlMs y cacheScope para poder cachearse.

Sobre el papel, bastaría entonces con escribir un servidor 2026-07-28. Solo que registré el método de apertura y la cabecera User-Agent de cada cliente que se conectó a mi servidor el 7 de septiembre de 2026, y esto es lo que vi.

Cliente User-Agent Apertura Versión anunciada
Codex codex-mcp-client/0.153.4 initialize 2025-06-18
MCP Inspector 2.5.0 node initialize 2025-11-25

Ninguno de los dos envió server/discover, y ninguno puso _meta en sus peticiones. Un servidor que solo hablara la revisión de julio sería hoy inutilizable con estos clientes. La especificación ha previsto el caso: llama dual-era a una implementación que gestiona las dos, y autoriza explícitamente a un servidor a servir las dos épocas en el mismo endpoint. Es lo que vamos a hacer, y cuesta una decena de líneas.

El esqueleto: un archivo, una ruta

El servidor de demostración se llama regexlab y expone dos herramientas: regex_test, que prueba una expresión regular sobre ejemplos, y blog_search, que consulta la API REST pública de gekkode.com en modo solo lectura. Se lanza con el servidor web integrado de 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

Empezamos con dos funciones envoltorio. Todas las salidas pasan por ellas, lo que garantiza que ninguna respuesta sale sin un 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]);
}

Viene después la puerta de entrada: método HTTP, ruta, origen, token. Cuatro comprobaciones, y los códigos de retorno que impone la especificación.

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

// GET y DELETE ya no forman parte de la revisión 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: defensa contra el DNS rebinding, 403 exigido por la especificación.
$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');
    }
}

La validación de la cabecera Origin no es decorativa. La especificación la clasifica como MUST y exige un 403, precisamente porque sin ella una página web abierta en tu navegador puede, mediante DNS rebinding, hablar con el servidor MCP que corre en tu máquina. Comprobemos las tres puertas:

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"}}

Leer la petición y detectar la época

El cuerpo es JSON-RPC. De ahí salen tres datos: el método, los parámetros, el identificador. Que exista io.modelcontextprotocol/protocolVersion en _meta basta para saber si el cliente es 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'] ?? '-'
));

// Una notificación no tiene identificador: se acusa recibo y se termina.
if (!array_key_exists('id', $req)) {
    send(202, null);
}

La llamada a error_log de arriba es lo que me permitió elaborar la tabla de clientes de más arriba. El servidor integrado de PHP escribe en la salida de error, así que el registro aparece en la terminal que lo lanzó. Mantenlas durante todo el desarrollo.

El tratamiento de las notificaciones merece una palabra. Una notificación JSON-RPC es un mensaje sin id: el cliente no espera respuesta. La especificación es categórica, el servidor debe responder 202 Accepted sin cuerpo. Por ahí pasa el notifications/initialized de los clientes heredados.

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

Por qué este servidor solo devuelve JSON

Una palabra sobre el formato de respuesta, porque es una elección y no una obligación. Ante una petición, el servidor puede responder bien con un objeto único en Content-Type: application/json, bien con un flujo SSE en text/event-stream, propio de esa petición, que transporta notificaciones antes de la respuesta final. El cliente, por su parte, debe saber leer los dos: es la razón de la cabecera Accept: application/json, text/event-stream que envía siempre.

Este servidor se limita al JSON. Es lo más simple, y basta mientras ninguna herramienta necesite informar de su avance. El flujo se vuelve necesario en dos casos: una herramienta larga que quiere emitir notifications/progress durante su trabajo, y la petición subscriptions/listen, cuya respuesta se queda abierta para transportar los cambios de lista. Llegado el día, la revisión 2026-07-28 convierte el cierre del flujo por parte del cliente en la señal de cancelación de la petición, y recomienda la cabecera X-Accel-Buffering: no para que nginx no ponga los eventos en búfer.

Validar las cabeceras espejo

Es la parte más específica de la revisión 2026-07-28, y la que se olvida. Cuando un cliente moderno envía una petición, debe copiar tres valores del cuerpo en cabeceras: MCP-Protocol-Version, Mcp-Method, y Mcp-Name para un tools/call. El servidor debe comprobar que la cabecera y el cuerpo coinciden, y rechazar con 400 y el código de error -32020 si no es así.

La razón es un fallo clásico: si un balanceador de carga decide el enrutado según la cabecera mientras el servidor ejecuta según el cuerpo, un cliente malicioso puede hacer pasar una llamada por otra. La especificación llama a este error 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,
        ]);
    }
}

Una prueba que miente en Mcp-Name: la cabecera anuncia blog_search, el cuerpo pide 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"}}

Y una versión que el servidor no conoce. La especificación impone responder -32022 listando las versiones aceptadas, para que el cliente pueda reintentarlo sin intervención 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 al descubrimiento, por los dos lados

Esto es lo que hace al servidor bi-época: un case para initialize, otro para server/discover, y los dos devuelven las mismas capacidades en dos envoltorios distintos. Es la decena de líneas que cuesta seguir siendo compatible.

php
switch ($method) {
    // Handshake heredado (revisiones 2025-11-25 y 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.',
        ]);

    // Descubrimiento sin estado (revisión 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);
}

Dos detalles que no hay que perderse. El serverInfo se mueve: está en la raíz del resultado en modo heredado, y bajo _meta['io.modelcontextprotocol/serverInfo'] en 2026-07-28. Y el método desconocido no se trata igual: en modo moderno, la especificación pide un 404 Not Found acompañado de un -32601, porque el cuerpo JSON-RPC es lo que distingue este caso del 404 de un servidor antiguo que no aloja el endpoint.

Aquí está la respuesta 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"}}

Y la de initialize, tal como la recibe Codex:

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."
  }
}

Describir las herramientas: tools/list

Una herramienta es un nombre, una descripción, y un esquema JSON de entrada. La descripción es lo que lee el modelo para decidir si llama a la herramienta: escríbela para él, no para un desarrollador. El outputSchema es opcional pero recomendado, permite al cliente validar lo que devuelves.

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,
    ],
]

La respuesta a tools/list añade resultType, y los dos campos de caché introducidos por la revisión de julio.

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

Tres reglas de nomenclatura que respetar: el nombre de una herramienta debe tener entre 1 y 128 caracteres, limitarse a letras ASCII, cifras, _, - y ., y ser único en el servidor. Los espacios y las comas están prohibidos.

Ejecutar una herramienta: tools/call y sus dos familias de errores

La calidad de un servidor MCP se juega aquí, y muchos tutoriales lo pasan por alto. La especificación distingue dos mecanismos de propagación de errores, y confundirlos le cuesta idas y vueltas al modelo:

  • un error de protocolo (herramienta desconocida, petición malformada) es un error JSON-RPC clásico, el modelo tiene pocas posibilidades de arreglárselas solo,
  • un error de ejecución (argumento fuera de rango, API caída, fecha inválida) se devuelve en un resultado normal con isError: true, y el cliente debe dárselo al modelo para que se corrija.

El testeador de expresiones regulares es un caso de manual: un patrón mal escrito debe volver al modelo con el mensaje de PCRE, no con un «error 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.');
    }

    // Se escapan los delimitadores no protegidos en lugar de aceptar un patrón ya 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) {
        // El mensaje de PCRE dice dónde falla el patrón: el modelo puede corregirse solo.
        return tool_error('Motif invalide : ' . ($compileError ?? preg_last_error_msg()));
    }
    // …
}

Dos precauciones en estas pocas líneas. Nunca se acepta un patrón ya delimitado, se añaden los delimitadores por cuenta propia escapando las / no protegidas. Aceptar /patrón/flags tal cual equivaldría a dejar que quien llama elija los modificadores, algo que la validación previa prohíbe. Y el gestor de errores temporal recupera el mensaje exacto de PCRE, allí donde preg_last_error_msg() se conforma con un lacónico «Internal error».

La diferencia se ve en la llamada. Patrón con un paréntesis que 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
  }
}

Un modelo que recibe este texto cierra el paréntesis y vuelve a llamar a la herramienta. Si hubiera recibido un error JSON-RPC, habría tenido muchas menos posibilidades de salir adelante: la especificación señala que los errores de protocolo raramente llevan a una corrección.

Queda un riesgo propio de las expresiones regulares: la explosión combinatoria. Un patrón como (a+)+$ sobre una cadena de treinta y seis «a» seguidas de una «b» ocupa el procesador durante muchísimo tiempo. La solución cabe en una línea al principio del archivo, ini_set('pcre.backtrack_limit', '200000');, y en una comprobación del valor devuelto por preg_match.

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

Una llamada que tiene éxito, ahora, con su structuredContent conforme al 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
  }
}

Fíjate en que el texto legible está presente además del contenido estructurado. La especificación lo recomienda explícitamente: una herramienta que devuelve contenido estructurado también debería aportar la versión serializada en un bloque de texto, para los clientes que solo leen content.

La segunda herramienta: consultar una API de solo lectura

blog_search muestra el caso más frecuente en las empresas: exponer un servicio ya existente. El host está codificado a fuego, el método es un GET, ningún parámetro de quien llama construye una URL arbitraria.

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.');
    }
    // …
}

El _fields de la API REST de WordPress hace mucho trabajo: reduce la respuesta a los cuatro campos útiles, y por tanto los tokens facturados al entrar en el contexto del modelo. Es el mismo reflejo descrito en nuestro artículo sobre la reducción de tokens, aplicado al servidor en vez de al cliente. Resultado de la llamada:

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/

Conectar el servidor a Codex

Codex lee sus servidores MCP en ~/.codex/config.toml, pero la opción -c permite declararlos para una sola ejecución, sin escribir nada en disco. Ideal para una prueba, y es por ahí que se comprobó el servidor.

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

El tercer -c es el que me costó dos intentos. Sin él, Codex se conecta al servidor, lista las herramientas, y rechaza la llamada con un mensaje sin ambigüedad:

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

En modo exec, la política de aprobación vale never y no hay ningún humano ahí para validar. La clave default_tools_approval_mode acepta cuatro valores, auto, prompt, writes y approve, solo la última deja pasar la llamada sin intervención. Resérvalo para los servidores que hayas escrito tú mismo, por las razones detalladas en nuestro artículo sobre sandbox y permisos.

Para una instalación duradera, el comando oficial escribe lo mismo en la configuración:

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

Conectar el servidor a Claude Code

En Claude Code, añadir un servidor HTTP cabe en un comando, y la cabecera de autorización se pasa con --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 compartir el servidor con el equipo, el ámbito project escribe un archivo .mcp.json en la raíz del repositorio, versionable. La documentación acepta la expansión de variables de entorno, lo que evita comitear el token:

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

La sintaxis ${VAR} y ${VAR:-valor por defecto} se reconoce en los campos url, headers, command, args y env. Desde la sesión, /mcp muestra el estado de los servidores y permite conectarse a los que piden OAuth.

Comprobar con MCP Inspector, sin instalar nada

La herramienta de prueba oficial se usa en modo gráfico, pero su modo --cli es mucho más práctico para una prueba rápida y se lanza con npx, así que sin instalación 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
}

El Inspector 2.5.0, publicado el 2 de septiembre de 2026, también abre con un initialize, anunciando la versión 2025-11-25. Sigue siendo la herramienta más rápida para ver qué devuelve realmente tu servidor, antes de conectarle un agente.

Lo que el servidor integrado de PHP no sabe hacer

php -S trata una petición a la vez. Mientras tus herramientas respondan en pocos milisegundos, no se nota. En cuanto una herramienta llama a la red, todo el servidor se bloquea. Lancé dos llamadas en paralelo, blog_search, que consulta la API del blog, y luego regex_test, que solo hace cálculo local:

Configuración blog_search regex_test lanzado 50 ms después
php -S por defecto 0,601 s 0,548 s
PHP_CLI_SERVER_WORKERS=4 0,578 s 0,002 s

Sin procesos adicionales, la llamada rápida espera obedientemente a que termine la lenta: 0,548 s en lugar de los 13 ms que tarda ella sola. Con cuatro workers, responde de inmediato. Es suficiente para desarrollar, no sustituye a PHP-FPM detrás de nginx en producción.

Pasar a producción: el SDK PHP oficial y Laravel MCP

Escribir tu propio servidor a mano es la mejor forma de entender el protocolo. Para código que vive en producción, dos paquetes merecen el desvío, y los dos se han movido este verano.

El SDK PHP oficial se instala con composer require mcp/sdk. Se presenta como el SDK oficial del protocolo para PHP, mantenido en colaboración con la Fundación PHP y siguiendo las prácticas del proyecto Symfony. Es agnóstico de framework, requiere PHP 8.1 como mínimo, y su repositorio anuncia el soporte de las dos épocas del protocolo, el handshake initialize y la revisión sin estado 2026-07-28. Última versión al 7 de septiembre de 2026: la v0.8.1, publicada el 29 de agosto.

Laravel MCP (composer require laravel/mcp) apunta al otro extremo del espectro: declaras tus servidores en routes/ai.php, con Mcp::web('/mcp/weather', WeatherServer::class) para un servidor HTTP o Mcp::local('weather', …) para un comando Artisan, y cada herramienta se convierte en una clase que extiende Tool con un método handle() y un schema(). El middleware de Laravel se aplica tal cual, incluido throttle. La primera versión estable está en preparación: la v1.0.0-beta.1 es del 14 de agosto de 2026 y requiere PHP 8.2 con Laravel 11.45.3, 12.41.1 o 13.

Si tu necesidad es WordPress o PrestaShop más que un servidor genérico, dos artículos de esta serie tratan el caso: MCP para WordPress y MCP para PrestaShop.

Cinco líneas de seguridad que no cuestan nada

Un servidor MCP es una superficie de ejecución de código accesible por un modelo. La especificación dedica una página entera al tema, recuerda como mínimo estas reglas, todas ellas respetadas por el archivo de este tutorial.

  • Escucha en 127.0.0.1, nunca en 0.0.0.0, para un servidor local. La especificación lo clasifica como SHOULD.
  • Valida la cabecera Origin y responde 403 cuando no figure en tu lista. Sin eso, una página web abierta en tu navegador puede hablar con tu servidor.
  • Exige un token, comparado con hash_equals() para no filtrar información por el tiempo de comparación.
  • Quédate en solo lectura mientras no necesites escribir, y limita todo: longitud de las cadenas, número de elementos, tiempo de espera de red, límite de backtracking.
  • Codifica el host a fuego en las herramientas que llaman a la red. Un parámetro de URL libre convierte tu servidor en un relé para la exfiltración.

Este último punto no es teórico. Los incidentes públicos del ecosistema MCP recogidos hasta ahora tratan casi todos sobre servidores que hacían más de lo anunciado: un paquete publicado en npm que copiaba de paso los correos que se suponía que debía enviar, un relé cuyo fallo abría la ejecución de comandos. Una herramienta incapaz de hacer otra cosa que lo que dice su descripción es una herramienta que se puede auditar. La cuestión del alcance de un agente y de sus herramientas se trata en detalle en el artículo sobre sandbox y permisos.

Lo que hay que recordar

  • Un servidor MCP útil cabe en un archivo PHP de 380 líneas: una ruta POST /mcp, JSON-RPC 2.0, y dos funciones por herramienta.
  • La revisión 2026-07-28 elimina initialize, las sesiones y el flujo GET, pero Codex y el Inspector todavía abren con el handshake heredado: escribe un servidor que gestione las dos épocas.
  • En modo moderno, copia y comprueba MCP-Protocol-Version, Mcp-Method y Mcp-Name, con -32020 si hay discrepancia y -32022 para una versión desconocida.
  • Devuelve los errores de negocio en el resultado con isError: true, no como error JSON-RPC: es lo que permite al modelo corregirse solo.
  • Prueba con curl, y luego con npx @modelcontextprotocol/inspector --cli, antes de conectar un agente.
  • php -S solo sirve para el desarrollo: una llamada de red lenta bloquea todo el servidor mientras no se defina PHP_CLI_SERVER_WORKERS.
  • Para producción, parte del SDK oficial mcp/sdk o de laravel/mcp en lugar de mantener tu propia capa de transporte.

Errores frecuentes

Escribir un servidor que solo hable la revisión 2026-07-28 Los clientes probados el 7 de septiembre de 2026 todavía abren con initialize. Mantén el case initialize junto a server/discover: la especificación autoriza a un servidor a servir las dos épocas en el mismo endpoint.
Olvidar validar las cabeceras espejo En 2026-07-28, MCP-Protocol-Version, Mcp-Method y Mcp-Name deben corresponder con el cuerpo. Una discrepancia se rechaza con 400 y el código -32020, si no, un intermediario y el servidor pueden leer dos cosas distintas.
Responder a una notificación Un mensaje sin id no espera respuesta. Devuelve 202 Accepted sin cuerpo, si no, los clientes heredados se atascan en notifications/initialized.
Propagar un error de negocio como error JSON-RPC Un argumento inválido se devuelve en un resultado con isError: true y un mensaje utilizable. Un error de protocolo hace que el modelo abandone, un error de ejecución hace que se corrija.
Llamar a la herramienta desde codex exec sin ajustar la aprobación La política vale never y Codex rechaza con «MCP tool call requires approval». Añade -c 'mcp_servers.NOM.default_tools_approval_mode="approve"', y solo para un servidor que hayas escrito tú.

Claude CodeCodexLaravelMCPPHP

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.