Publicar uma ferramenta MCP somente leitura

CloudflareBeginner
Pratique Agora

Introdução

Um cliente de IA não deveria precisar de uma integração personalizada para cada aplicação que utiliza. O Model Context Protocol (MCP) oferece aos clientes uma forma padronizada de descobrir ferramentas, consultar seus contratos de entrada e executá-las. Neste laboratório, a ferramenta é deliberadamente pequena: ela consulta um caso de suporte sintético e não pode alterar nada.

Você criará o servidor usando o manipulador MCP stateless atual do Cloudflare:

  1. Um namespace dedicado do Cloudflare KV armazena o registro empresarial sintético. O KV é o armazenamento explícito dos dados da aplicação; ele não é uma memória de sessão MCP oculta.
  2. Um esquema rigoroso do Zod aceita apenas um identificador de ticket sintético e rejeita campos adicionais.
  3. McpServer.registerTool() publica uma ferramenta com anotações somente leitura e não destrutivas.
  4. createMcpHandler() cria um servidor novo para cada requisição HTTP Streamable.
  5. O cliente oficial MCP TypeScript descobre e executa a ferramenta por meio de conexões independentes.
  6. As verificações locais e implantadas comprovam a consulta válida, o comportamento seguro para registros ausentes, a rejeição de argumentos inválidos e a ausência de estado de sessão compartilhado implícito.

Este endpoint não exige autenticação de propósito, pois expõe apenas um registro sintético, descartável e somente leitura. Não use este padrão para publicar dados privados de clientes. Servidores de produção devem adicionar autenticação e autorização antes de acessar dados de tenants; provedores OAuth externos estão fora do escopo deste laboratório para iniciantes.

Anteriormente, o ecossistema MCP usava endpoints SSE e código padrão de servidor com estado. Este laboratório não ensina esse design legado. Ele usa Streamable HTTP e uma fábrica de servidores por requisição, que é a orientação atual do Cloudflare para um novo servidor remoto.

Antes de entrar diretamente neste curso, conclua Conectar o LabEx à sua conta do Cloudflare. Cada VM nova do LabEx precisa de sua própria autorização do Wrangler. Os laboratórios anteriores do curso são recomendados, mas suas VMs e recursos nunca são reutilizados aqui.

Autorizar a VM e criar um catálogo dedicado

Nesta etapa, você autorizará esta VM nova, escolherá a conta de aprendizado e criará um namespace KV descartável. Manter o catálogo separado deixa clara a propriedade do recurso e facilita a limpeza.

Entre no projeto preparado e verifique as versões fixadas das ferramentas:

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

Autorize esta VM:

npx wrangler login

Abra no navegador o link do dispositivo exibido, revise as permissões solicitadas e autorize sua conta de aprendizado dedicada. Volte ao terminal, aguarde a conclusão e consulte a identidade estruturada:

O Wrangler solicita as permissões necessárias para gerenciar o Worker e o namespace KV do laboratório

A lista de permissões é mais ampla do que a necessária para este laboratório, pois o Wrangler é a CLI de desenvolvimento geral do Cloudflare. Antes de aprovar, confirme que a página identifica o Wrangler, que você está usando a conta de aprendizado correta e que nenhuma senha ou token aparece no terminal.

npx wrangler whoami --json

Confirme loggedIn: true e o nome da conta pretendida, mesmo quando a saída listar apenas uma conta. Copie o id real dessa conta. Gere um prefixo exclusivo e salve a configuração inicial do Worker, substituindo primeiro o espaço reservado:

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

Crie o namespace sem pedir ao Wrangler para editar o arquivo automaticamente:

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

Se o Wrangler perguntar se deve adicionar uma binding automaticamente, escolha No; a próxima edição fará essa conexão de forma explícita. Copie da saída o ID do namespace com 32 caracteres e adicione exatamente uma 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

O nome da binding, SUPPORT_CASES, é o identificador que seu código usará. O ID do namespace aponta para o recurso real na conta confirmada. Nada foi implantado ainda.

Inserir dados empresariais sintéticos explícitos

Nesta etapa, você colocará o mesmo registro fornecido no KV local e no remoto. O armazenamento de dados é explícito: uma requisição MCP pode ser stateless enquanto a aplicação continua lendo dados empresariais persistentes por chave.

Consulte o fixture antes de fazer o upload:

cat fixtures/case.json

O prefixo T-SYNTH-101 e o marcador synthetic: true deixam visível o limite da demonstração. O registro não contém nome, e-mail, mensagem ou credencial de cliente real.

Insira os dados no armazenamento local usado por wrangler dev:

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

Insira os dados no namespace de nuvem dedicado:

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

Leia as duas cópias por meio da 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

O verificador confere o namespace pelo ID da conta, exige exatamente uma chave e compara o JSON remoto com o fixture sintético fornecido. O KV pode apresentar consistência eventual entre locais. Portanto, se a primeira leitura remota não encontrar por alguns instantes um valor recém-gravado, aguarde alguns segundos e tente novamente, em vez de gravar cópias repetidas.

Registrar uma ferramenta MCP rigorosa e somente leitura

Nesta etapa, você definirá uma fábrica de servidor MCP e uma ferramenta de consulta somente leitura.

McpServer descreve a superfície do protocolo. A fábrica cria uma instância nova para cada requisição HTTP, enquanto a binding SUPPORT_CASES continua sendo a fonte explícita dos dados empresariais. Crie 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

Três limites são importantes aqui:

  • .strict() rejeita campos não declarados em vez de aceitá-los silenciosamente.
  • As anotações informam aos clientes que a ferramenta lê um catálogo sintético fechado e não tem efeito destrutivo. Uma anotação é metadado útil, mas não substitui a revisão de código que confirma a ausência de put() ou delete().
  • requestInstance é gerado quando a fábrica cria um servidor. Requisições de protocolo diferentes devem retornar marcadores diferentes, tornando observável o ciclo de vida stateless sem armazenar dados de sessão.

A configuração de compatibilidade legacy: "stateless" ainda usa Streamable HTTP. Ela permite clientes atuais que negociam a família de protocolos de 2025 e garante que cada requisição receba uma instância nova do servidor; nenhuma rota SSE ou sessão MCP durável é criada.

Criar uma verificação independente com um cliente MCP

Nesta etapa, você usará a biblioteca oficial do cliente em vez de escrever JSON-RPC manualmente. Um cliente real executa a inicialização do protocolo, a descoberta de ferramentas e a chamada por meio de StreamableHTTPClientTransport.

Crie 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 chamada auxiliar cria e fecha seu próprio transporte de cliente. A descoberta comprova que o servidor anuncia o contrato da ferramenta. Duas chamadas válidas devem ler o mesmo registro do KV, mas retornar marcadores requestInstance diferentes. O caso ausente é um erro normal no nível da ferramenta, enquanto um identificador inválido é rejeitado pelo esquema de entrada antes que o manipulador leia o KV.

Exercitar localmente o contrato MCP

Nesta etapa, você iniciará o Worker usando o KV local e executará a verificação completa do cliente antes de acessar o endpoint implantado.

Inicie o servidor de desenvolvimento:

npx wrangler dev --ip 127.0.0.1 --port 8787

Deixe esse terminal em execução. Abra um segundo terminal, entre no mesmo projeto e verifique a pequena rota de saúde:

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

A saída deve conter transport: "streamable-http" e state: "stateless". Agora execute o cliente do protocolo:

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

As cinco linhas de verificação devem mostrar a descoberta, o resultado sintético válido, um erro seguro para o caso ausente, a rejeição da entrada inválida e STATELESS true. Volte ao primeiro terminal e pressione Ctrl+C depois da verificação.

Execute a verificação independente. Ela inicia outro Worker local limitado na porta 8791, exercita o mesmo código importado e o encerra automaticamente:

python3 .labex/verify.py local

Implantar e testar o endpoint MCP remoto

Nesta etapa, você implantará o Worker com sua binding KV explícita e executará o mesmo cliente no endpoint workers.dev real.

Faça a implantação usando a configuração do projeto:

npx wrangler deploy

Copie a URL de implantação exibida e salve-a sem a barra final:

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

Verifique a rota de saúde e conecte o 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

O verificador independente obtém o endpoint a partir da conta selecionada, em vez de confiar na variável do shell. Ele também verifica a binding implantada SUPPORT_CASES, o registro remoto exato e os cinco comportamentos MCP. Uma rota de saúde acessível, sozinha, não é suficiente: a descoberta e a chamada precisam passar pelo cliente do protocolo.

Abra Workers & Pages e selecione o Worker gerado. A visão geral deve associar o domínio workers.dev ao Worker e mostrar uma binding KV SUPPORT_CASES. Os valores abaixo são exemplos da execução testada; os nomes exclusivos dos seus recursos e as quantidades serão diferentes.

O Worker MCP implantado conectado a uma binding KV SUPPORT_CASES

Inspecionar e remover os recursos pertencentes à execução

Nesta etapa, você inspecionará o estado observável na nuvem e depois excluirá apenas o Worker e o namespace KV desta execução enquanto o Wrangler ainda estiver autorizado.

Abra o Cloudflare Dashboard e selecione a mesma conta de aprendizado. Em Workers & Pages, abra o Worker cujo nome começa com labex-c11-s07-. Confirme que a implantação mais recente está saudável, que a observabilidade está habilitada e que a binding SUPPORT_CASES aponta para o ID do namespace em wrangler.jsonc.

Abra Storage & databases > KV, selecione o namespace -cases correspondente e consulte case:T-SYNTH-101. O valor é o fixture sintético; não adicione informações pessoais. Essas telas do Dashboard são úteis para orientação, mas o cliente e o verificador continuam sendo as evidências funcionais autoritativas.

A visualização KV Pairs mostra primeiro a chave exata e uma prévia do valor JSON:

O namespace dedicado contém apenas a chave sintética do caso de suporte

Expanda a linha para associar essa chave aos campos retornados pela ferramenta MCP. O fixture testado usa status: investigating, priority: medium e synthetic: true.

O JSON expandido do caso de suporte sintético armazenado no KV

Volte ao Worker e abra Observability. Os eventos bem-sucedidos de POST /mcp e de transporte GET /mcp mostram que um cliente MCP remoto real alcançou o Worker implantado. Na execução testada, todos os 42 eventos capturados foram concluídos com sucesso e nenhum gerou erro do Worker; a quantidade de requisições pode ser diferente no seu caso.

A observabilidade do Cloudflare mostra requisições MCP remotas bem-sucedidas e zero erros

Execute mais uma verificação de observação independente antes da exclusão:

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

Confirme o nome exclusivo exato do Worker e o ID do namespace. Em seguida, exclua o Worker:

npx wrangler delete

Se for solicitado, confirme o nome do Worker exibido e responda y. Exclua apenas o namespace selecionado pela binding SUPPORT_CASES:

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

Atualize as listas de Workers e KV no Dashboard. Os dois recursos labex-c11-s07-... devem estar ausentes, enquanto os recursos não relacionados permanecem. Uma requisição malsucedida ao endpoint não comprova a exclusão; o verificador consulta diretamente os inventários da conta autorizada.

Pesquise o nome exato do Worker gerado. Um resultado vazio confirma que o Dashboard não o lista mais:

Workers and Pages não mostra nenhum projeto correspondente ao Worker removido do laboratório

Pesquise no Workers KV o namespace -cases exato. O estado vazio e o armazenamento atual de 0 B confirmam que o catálogo descartável também foi removido desta conta de teste limpa:

Workers KV não mostra nenhum namespace correspondente ao catálogo sintético removido

Revogar a autorização desta VM

Nesta etapa, você revogará a autorização temporária da VM depois de comprovar a ausência dos recursos.

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

O resultado estruturado deve informar loggedIn: false, ou o Wrangler pode retornar um resultado não autenticado com código diferente de zero. O logout é deixado propositalmente para o final: a verificação da exclusão precisa de acesso de leitura à conta selecionada, enquanto a VM descartável não precisa mais dele.

Resumo

Você publicou e removeu um serviço MCP limitado e somente leitura no Cloudflare. Você:

  • manteve os dados empresariais sintéticos em um namespace KV dedicado, em vez de usar um estado de sessão MCP implícito;
  • registrou uma ferramenta detectável com validação rigorosa de entrada e anotações somente leitura;
  • disponibilizou a ferramenta por meio do manipulador Streamable HTTP stateless atual;
  • usou um cliente MCP real para testar descoberta, consulta válida, registro ausente e entrada inválida;
  • comprovou que requisições independentes recebem instâncias novas do servidor enquanto leem os mesmos dados explícitos;
  • inspecionou o estado do Worker e do KV, excluiu os dois recursos pertencentes à execução e revogou a autorização da VM.

A principal lição de design é que transporte stateless não significa uma aplicação sem dados. Isso significa que as requisições do protocolo não dependem de memória de sessão oculta. Os dados empresariais persistentes continuam explícitos, delimitados e administrados de forma independente.