Publicar una herramienta MCP de solo lectura

CloudflareBeginner
Practicar Ahora

Introducción

Un cliente de IA no debería necesitar una integración personalizada para cada aplicación que utiliza. El Model Context Protocol (MCP) proporciona una forma estándar para que los clientes descubran herramientas, inspeccionen sus contratos de entrada y las invoquen. En este laboratorio, la herramienta es deliberadamente pequeña: busca un caso de soporte sintético y no puede modificar nada.

Creará el servidor con el controlador MCP sin estado actual de Cloudflare:

  1. Un espacio de nombres dedicado de Cloudflare KV almacena el registro de negocio sintético. KV es el almacén explícito de datos de la aplicación; no es memoria de sesión MCP oculta.
  2. Un esquema estricto de Zod acepta únicamente un identificador de ticket sintético y rechaza los campos adicionales.
  3. McpServer.registerTool() publica una herramienta con anotaciones de solo lectura y no destructivas.
  4. createMcpHandler() crea un servidor nuevo para cada solicitud HTTP Streamable.
  5. El cliente oficial MCP de TypeScript descubre e invoca la herramienta desde conexiones independientes.
  6. Las comprobaciones locales y desplegadas demuestran una consulta válida, un comportamiento seguro cuando falta el registro, el rechazo de argumentos no válidos y la ausencia de estado de sesión compartido implícito.

Este endpoint no requiere autenticación intencionadamente porque solo expone un registro sintético, desechable y de solo lectura. No utilice este patrón para publicar datos privados de clientes. Los servidores de producción deben añadir autenticación y autorización antes de acceder a datos de tenants; los proveedores externos de OAuth quedan fuera de este laboratorio para principiantes.

El ecosistema MCP utilizaba anteriormente endpoints SSE y código estándar para servidores con estado. Este laboratorio no enseña ese diseño heredado. Utiliza Streamable HTTP y una fábrica de servidores por solicitud, que es la recomendación actual de Cloudflare para un servidor remoto nuevo.

Antes de acceder directamente a este curso, complete Conectar LabEx a su cuenta de Cloudflare. Cada VM nueva de LabEx necesita su propia autorización de Wrangler. Se recomiendan los laboratorios anteriores del curso, pero sus VM y recursos nunca se reutilizan aquí.

Autorizar la VM y crear un catálogo dedicado

En este paso, autorizará esta VM nueva, elegirá la cuenta de aprendizaje y creará un espacio de nombres KV desechable. Mantener el catálogo separado deja clara su propiedad y facilita su limpieza.

Entre en el proyecto preparado e inspeccione las herramientas fijadas:

cd /home/labex/project/read-only-mcp-tool
node --version
npx wrangler --version

Autorice esta VM:

npx wrangler login

Abra en el navegador el enlace al dispositivo que se muestra, revise los permisos solicitados y autorice su cuenta de aprendizaje dedicada. Vuelva al terminal, espere a que finalice el proceso e inspeccione la identidad estructurada:

Wrangler solicita los permisos necesarios para administrar el Worker y el espacio de nombres KV del laboratorio

La lista de permisos es más amplia que la de este laboratorio porque Wrangler es la CLI de desarrollo general de Cloudflare. Confirme que la página identifica a Wrangler, que está utilizando la cuenta de aprendizaje correcta y que no aparece ninguna contraseña ni token en el terminal antes de aprobar la solicitud.

npx wrangler whoami --json

Confirme loggedIn: true y el nombre de la cuenta prevista, incluso si la salida solo muestra una cuenta. Copie el id real de esa cuenta. Genere un prefijo único y guarde la configuración inicial del Worker, sustituyendo antes el marcador de posición:

ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s07-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/server.ts",
  "compatibility_date": "2026-09-19",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true }
}
JSON

Cree el espacio de nombres sin pedirle a Wrangler que edite el archivo automáticamente:

npx wrangler kv namespace create "$RUN-cases" --update-config=false

Si Wrangler pregunta si debe añadir un binding automáticamente, elija No; la siguiente edición establecerá esa conexión de forma explícita. Copie del resultado el ID del espacio de nombres de 32 caracteres y añada exactamente un binding:

NAMESPACE_ID="paste-the-created-namespace-id"
python3 - "$NAMESPACE_ID" <<'PY'
import json, sys
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data['kv_namespaces'] = [{'binding': 'SUPPORT_CASES', 'id': sys.argv[1]}]
path.write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler kv namespace list
python3 .labex/verify.py authorization

El nombre del binding SUPPORT_CASES es el identificador que utilizará el código. El ID del espacio de nombres apunta al recurso real de la cuenta confirmada. Todavía no se ha desplegado nada.

Cargar datos de negocio sintéticos explícitos

En este paso, colocará el mismo registro proporcionado en KV local y remoto. El almacén de datos es explícito: una solicitud MCP puede ser sin estado mientras la aplicación sigue leyendo datos de negocio persistentes mediante una clave.

Inspeccione el archivo de datos antes de cargarlo:

cat fixtures/case.json

El prefijo T-SYNTH-101 y la marca synthetic: true hacen visible el límite de la demostración. El registro no contiene ningún nombre, correo electrónico, mensaje ni credencial real de cliente.

Cargue los datos en el almacén local que utiliza wrangler dev:

npx wrangler kv key put case:T-SYNTH-101 --path fixtures/case.json --binding SUPPORT_CASES --local

Cargue los datos en el espacio de nombres dedicado de la nube:

npx wrangler kv key put case:T-SYNTH-101 --path fixtures/case.json --binding SUPPORT_CASES --remote

Lea las dos copias mediante el binding:

npx wrangler kv key get case:T-SYNTH-101 --binding SUPPORT_CASES --local --text
npx wrangler kv key get case:T-SYNTH-101 --binding SUPPORT_CASES --remote --text
python3 .labex/verify.py catalog

El verificador comprueba el espacio de nombres mediante el ID de cuenta, exige exactamente una clave y compara el JSON remoto con el archivo sintético proporcionado. KV puede presentar consistencia eventual entre ubicaciones; si la primera lectura remota no encuentra brevemente un valor recién escrito, espere unos segundos y vuelva a intentarlo en lugar de escribir copias repetidas.

Registrar una herramienta MCP estricta y de solo lectura

En este paso, definirá una fábrica de servidores MCP y una herramienta de consulta de solo lectura.

McpServer describe la superficie del protocolo. La fábrica crea una instancia nueva para cada solicitud HTTP, mientras que el binding SUPPORT_CASES sigue siendo la fuente explícita de los datos de negocio. Cree src/server.ts:

cat > src/server.ts <<'TS'
import { createMcpHandler } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";

interface Env {
  SUPPORT_CASES: KVNamespace;
}

const lookupInput = z.object({
  ticketId: z.string().regex(/^T-SYNTH-[0-9]{3}$/, "use a synthetic ticket ID")
}).strict();

const storedCase = z.object({
  ticketId: z.string(),
  subject: z.string(),
  status: z.string(),
  priority: z.string(),
  product: z.string(),
  synthetic: z.literal(true)
}).strict();

function buildServer(env: Env): McpServer {
  const requestInstance = crypto.randomUUID();
  const server = new McpServer({
    name: "synthetic-support-catalog",
    version: "1.0.0"
  });

  server.registerTool("lookup_support_case", {
    title: "Look up a synthetic support case",
    description: "Read one synthetic demonstration case by its T-SYNTH identifier.",
    inputSchema: lookupInput,
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false
    }
  }, async ({ ticketId }) => {
    const raw = await env.SUPPORT_CASES.get(`case:${ticketId}`, "json");
    if (raw === null) {
      return {
        isError: true,
        content: [{ type: "text", text: `Synthetic case ${ticketId} was not found.` }]
      };
    }

    const record = storedCase.parse(raw);
    const result = { ...record, requestInstance };
    return {
      structuredContent: result,
      content: [{ type: "text", text: JSON.stringify(result) }]
    };
  });

  return server;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/health") {
      return Response.json({
        service: "synthetic-support-mcp",
        transport: "streamable-http",
        state: "stateless"
      });
    }
    if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 });

    const handler = createMcpHandler(
      () => buildServer(env),
      { route: "/mcp", corsOptions: false, legacy: "stateless" }
    );
    return handler(request, env, ctx);
  }
};
TS
npm run check
python3 .labex/verify.py server

Aquí importan tres límites:

  • .strict() rechaza los campos no declarados en lugar de aceptarlos silenciosamente.
  • Las anotaciones indican a los clientes que la herramienta lee un catálogo sintético cerrado y no tiene efectos destructivos. Una anotación es metadatos útiles, no sustituye la revisión del código que confirma que no existe ningún put() ni delete().
  • requestInstance se genera cuando la fábrica crea un servidor. Las distintas solicitudes de protocolo deben devolver marcadores diferentes, lo que hace observable el ciclo de vida sin estado sin almacenar datos de sesión.

La configuración de compatibilidad legacy: "stateless" sigue utilizando Streamable HTTP. Permite clientes actuales que negocian la familia de protocolos de 2025 y, al mismo tiempo, garantiza que cada solicitud reciba una instancia de servidor nueva; no se crea ninguna ruta SSE ni ninguna sesión MCP persistente.

Crear una comprobación independiente con un cliente MCP

En este paso, utilizará la biblioteca oficial del cliente en lugar de escribir JSON-RPC manualmente. Un cliente real realiza la inicialización del protocolo, el descubrimiento de herramientas y la invocación mediante StreamableHTTPClientTransport.

Cree scripts/test-client.mjs:

cat > scripts/test-client.mjs <<'JS'
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";

const endpoint = process.argv[2];
if (!endpoint) throw new Error("usage: node scripts/test-client.mjs <mcp-url>");

async function withClient(label, action) {
  const transport = new StreamableHTTPClientTransport(new URL(endpoint));
  const client = new Client({ name: `labex-${label}`, version: "1.0.0" });
  try {
    await client.connect(transport);
    return await action(client);
  } finally {
    await client.close();
  }
}

const tools = await withClient("discovery", (client) => client.listTools());
const tool = tools.tools.find((item) => item.name === "lookup_support_case");
if (!tool || tool.annotations?.readOnlyHint !== true) {
  throw new Error("the read-only lookup tool was not discoverable");
}
console.log("DISCOVERED lookup_support_case");

async function lookup(ticketId) {
  return withClient(`lookup-${ticketId.toLowerCase()}`, (client) => client.callTool({
    name: "lookup_support_case",
    arguments: { ticketId }
  }));
}

const first = await lookup("T-SYNTH-101");
const second = await lookup("T-SYNTH-101");
const a = first.structuredContent;
const b = second.structuredContent;
if (!a || !b || a.synthetic !== true || a.status !== "investigating") {
  throw new Error("the valid synthetic record was not returned");
}
console.log(`VALID synthetic=${a.synthetic} status=${a.status}`);

const missing = await lookup("T-SYNTH-404");
console.log(`MISSING isError=${missing.isError === true}`);

let invalidRejected = false;
try {
  const invalid = await withClient("invalid", (client) => client.callTool({
    name: "lookup_support_case",
    arguments: { ticketId: "REAL-101", unexpected: "must-not-pass" }
  }));
  invalidRejected = invalid.isError === true;
} catch {
  invalidRejected = true;
}
console.log(`INVALID_REJECTED ${invalidRejected}`);

const stateless = typeof a.requestInstance === "string"
  && typeof b.requestInstance === "string"
  && a.requestInstance !== b.requestInstance;
console.log(`STATELESS ${stateless}`);

if (missing.isError !== true || !invalidRejected || !stateless) process.exitCode = 1;
JS
python3 .labex/verify.py client

Cada llamada auxiliar crea y cierra su propio transporte de cliente. El descubrimiento demuestra que el servidor anuncia el contrato de la herramienta. Dos llamadas válidas deben leer el mismo registro de KV, pero devolver marcadores requestInstance diferentes. El caso inexistente es un error normal de nivel de herramienta, mientras que un identificador no válido es rechazado por el esquema de entrada antes de que el controlador lea KV.

Probar localmente el contrato MCP

En este paso, iniciará el Worker contra KV local y ejecutará la comprobación completa del cliente antes de acceder al endpoint desplegado.

Inicie el servidor de desarrollo:

npx wrangler dev --ip 127.0.0.1 --port 8787

Deje ese terminal en ejecución. Abra un segundo terminal, entre en el mismo proyecto y compruebe la pequeña ruta de estado:

cd /home/labex/project/read-only-mcp-tool
curl --fail --silent http://127.0.0.1:8787/health | python3 -m json.tool

Espere encontrar transport: "streamable-http" y state: "stateless". Ahora ejecute el cliente de protocolo:

node scripts/test-client.mjs http://127.0.0.1:8787/mcp

Las cinco líneas de comprobación deben mostrar el descubrimiento, el resultado sintético válido, un error seguro para el caso inexistente, el rechazo de la entrada no válida y STATELESS true. Vuelva al primer terminal y pulse Ctrl+C después de la comprobación.

Ejecute la comprobación independiente. Inicia otro Worker local limitado en el puerto 8791, utiliza el mismo código importado y lo detiene automáticamente:

python3 .labex/verify.py local

Desplegar y probar el endpoint MCP remoto

En este paso, desplegará el Worker con su binding KV explícito y ejecutará el mismo cliente contra el endpoint real de workers.dev.

Despliegue usando la configuración del proyecto:

npx wrangler deploy

Copie la URL de despliegue que se muestra y guárdela sin la barra final:

WORKER_URL="https://your-generated-worker.your-subdomain.workers.dev"

Compruebe la ruta de estado y, después, conecte el cliente MCP a /mcp:

curl --fail --silent "$WORKER_URL/health" | python3 -m json.tool
node scripts/test-client.mjs "$WORKER_URL/mcp"
python3 .labex/verify.py deployed

El verificador independiente obtiene el endpoint a partir de la cuenta seleccionada en lugar de confiar en la variable del shell. También comprueba el binding SUPPORT_CASES desplegado, el registro remoto exacto y los cinco comportamientos MCP. Que la ruta de estado sea accesible no es suficiente: el descubrimiento y la invocación deben pasar por el cliente de protocolo.

Abra Workers & Pages y seleccione el Worker generado. Su vista general debe vincular el dominio workers.dev con el Worker y mostrar un binding KV SUPPORT_CASES. Los valores siguientes son ejemplos de la ejecución probada; los nombres únicos de sus recursos y los recuentos serán diferentes.

El Worker MCP desplegado conectado a un binding KV SUPPORT_CASES

Inspeccionar y eliminar los recursos propios

En este paso, inspeccionará el estado observable en la nube y, después, eliminará únicamente el Worker y el espacio de nombres KV de esta ejecución mientras Wrangler siga autorizado.

Abra el Dashboard de Cloudflare y seleccione la misma cuenta de aprendizaje. En Workers & Pages, abra el Worker cuyo nombre comienza por labex-c11-s07-. Confirme que su último despliegue está operativo, que la observabilidad está habilitada y que el binding SUPPORT_CASES apunta al ID del espacio de nombres indicado en wrangler.jsonc.

Abra Storage & databases > KV, seleccione el espacio de nombres -cases correspondiente e inspeccione case:T-SYNTH-101. El valor es el archivo sintético; no añada información personal. Estas vistas del Dashboard sirven como orientación, mientras que el cliente y el verificador siguen siendo la evidencia funcional autorizada.

La vista KV Pairs muestra primero la clave exacta y una vista previa de su valor JSON:

El espacio de nombres dedicado contiene únicamente la clave del caso de soporte sintético

Expanda la fila para relacionar esa clave con los campos que devuelve la herramienta MCP. El archivo sintético probado utiliza status: investigating, priority: medium y synthetic: true.

El JSON ampliado del caso de soporte sintético almacenado en KV

Vuelva al Worker y abra Observability. Los eventos correctos POST /mcp y de transporte GET /mcp muestran que un cliente MCP remoto real llegó al Worker desplegado. En la ejecución probada, los 42 eventos capturados finalizaron correctamente y ninguno produjo un error del Worker; el número de solicitudes puede ser diferente en su caso.

La observabilidad de Cloudflare muestra solicitudes MCP remotas correctas y ningún error

Ejecute una comprobación de observación independiente más antes de eliminar los recursos:

python3 .labex/verify.py observed
cat wrangler.jsonc

Confirme el nombre único exacto del Worker y el ID del espacio de nombres; después, elimine el Worker:

npx wrangler delete

Si se le solicita confirmación, compruebe el nombre del Worker mostrado y responda y. Elimine únicamente el espacio de nombres seleccionado por el binding SUPPORT_CASES:

npx wrangler kv namespace delete --binding SUPPORT_CASES
npx wrangler kv namespace list
python3 .labex/verify.py deleted

Actualice las listas de Workers y KV del Dashboard. Ambos recursos labex-c11-s07-... deben haber desaparecido, mientras que los recursos no relacionados deben permanecer. Una solicitud fallida al endpoint no demuestra que se haya eliminado; el verificador comprueba directamente los inventarios de la cuenta autorizada.

Busque el nombre exacto del Worker generado. Un resultado vacío confirma que el Dashboard ya no lo muestra:

Workers and Pages no muestra ningún proyecto que coincida con el Worker eliminado del laboratorio

Busque en Workers KV el espacio de nombres -cases exacto. El estado vacío y el almacenamiento actual de 0 B confirman que el catálogo desechable también se eliminó de esta cuenta de prueba limpia:

Workers KV no muestra ningún espacio de nombres que coincida con el catálogo sintético eliminado

Revocar la autorización de esta VM

En este paso, revocará la autorización temporal de la VM después de demostrar que los recursos ya no existen.

npx wrangler logout
npx wrangler whoami --json || true

El resultado estructurado debe indicar loggedIn: false, aunque Wrangler también puede devolver un resultado no autenticado con código distinto de cero. El cierre de sesión se realiza intencionadamente al final: la verificación de la eliminación necesita acceso de lectura a la cuenta seleccionada, mientras que la VM desechable ya no lo necesita.

Resumen

Publicó y eliminó un servicio MCP acotado, de solo lectura, en Cloudflare. Usted:

  • mantuvo los datos de negocio sintéticos en un espacio de nombres KV dedicado en lugar de utilizar un estado de sesión MCP implícito;
  • registró una herramienta detectable con validación estricta de entradas y anotaciones de solo lectura;
  • la sirvió mediante el controlador Streamable HTTP sin estado actual;
  • utilizó un cliente MCP real para probar el descubrimiento, la consulta válida, el registro inexistente y la entrada no válida;
  • demostró que las solicitudes independientes reciben instancias de servidor nuevas mientras leen los mismos datos explícitos;
  • inspeccionó el estado del Worker y de KV, eliminó ambos recursos propios y revocó la autorización de la VM.

La lección de diseño principal es que un transporte sin estado no significa que la aplicación no tenga datos. Significa que las solicitudes del protocolo no dependen de memoria de sesión oculta. Los datos de negocio persistentes siguen siendo explícitos, acotados y administrados de forma independiente.