Um servidor MCP pode aparecer conectado no host e ainda publicar o schema errado, falhar no transporte ou devolver um resultado que o cliente não consegue interpretar. O teste que importa não é apenas abrir o Inspector. É iniciar o processo como o host faria e verificar o contrato de ponta a ponta.

Este tutorial monta uma suíte pequena para um servidor MCP em TypeScript. Ela descobre as ferramentas, verifica o schema, executa uma chamada válida e cobre uma entrada inválida pelo mesmo transporte stdio usado em integrações locais. Ao final, você terá um comando que falha no CI quando a superfície do servidor muda sem atualização do teste.

Diagrama mostra um cliente descobrindo ferramentas MCP, enviando uma chamada e verificando o resultado antes do CI.

Resultado do tutorial

  • Um servidor MCP mínimo com uma ferramenta somente leitura.
  • Um cliente TypeScript que testa descoberta, chamada válida e entrada inválida.
  • Verificação manual no MCP Inspector para depurar o que o teste automatizado encontrou.
  • Uma rotina de CI que separa erro de ferramenta de erro de protocolo.

O que você precisa antes de começar?

Você precisa de Node.js 20 ou superior, TypeScript, npm e familiaridade com funções assíncronas. O primeiro servidor da documentação do SDK MCP usa esse ambiente, o pacote tsx e o SDK dividido entre servidor e cliente. O exemplo abaixo segue essa organização atual.

O projeto também usa Zod para o schema da ferramenta e Vitest para os testes. Você pode adaptar os comandos a pnpm ou Bun, mas mantenha o mesmo contrato: o teste deve iniciar o processo compilado e conversar com ele pelo transporte real. Um teste que importa apenas a função interna não encontra erro de JSON-RPC, de stdout ou de negociação de capacidades.

O que o teste deve provar?

Um contrato MCP tem três camadas que precisam aparecer na suíte. A primeira é a descoberta: o cliente encontra o nome da ferramenta e o schema que o servidor publicou. A segunda é a execução: uma chamada válida retorna uma resposta que o cliente consegue ler. A terceira é a falha: uma entrada inválida não alcança o efeito da ferramenta e aparece de forma verificável.

A documentação do cliente TypeScript do MCP separa listTools() de callTool() e também distingue erro da ferramenta de erro do protocolo. Essa separação é útil no teste. isError: true significa que a chamada chegou ao servidor e a ferramenta informou uma falha; uma exceção do cliente costuma apontar para transporte, protocolo ou processo encerrado.

O servidor deste exemplo expõe find-ci-failures. Ele consulta dados locais somente para manter o teste determinístico. Para começar pelo servidor que será testado, veja como criar um servidor MCP em TypeScript. O artigo sobre validação de tool calls em TypeScript cobre a fronteira do executor; aqui o foco é o processo MCP completo. Em um projeto real, substitua a lista fixa por um adaptador de banco ou API, mas conserve os casos de contrato e não transforme o teste em uma cópia frágil da implementação.

Passo 1: prepare o projeto e as dependências

Crie um projeto vazio, instale os pacotes e deixe o script de teste compilar antes de chamar o Vitest. O guia oficial de servidor MCP em TypeScript também reforça que o stdout pertence ao protocolo stdio; logs de diagnóstico devem ir para stderr.

mkdir mcp-contract-tests
cd mcp-contract-tests
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/client zod
npm install --save-dev @types/node typescript tsx vitest
mkdir -p src tests

Crie tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "build",
    "rootDir": ".",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src", "tests"]
}

No package.json, adicione os scripts:

{
  "scripts": {
    "build": "tsc",
    "test": "npm run build && vitest run"
  }
}

Se o seu projeto ainda usa o pacote legado @modelcontextprotocol/sdk, não misture imports das duas gerações. A estratégia continua a mesma, mas os nomes de pacote e alguns helpers mudam. Confira o guia de migração do SDK TypeScript antes de copiar o código.

Passo 2: separe a fábrica do processo stdio

Coloque a implementação em src/factory.ts. Separar a fábrica do ponto de entrada deixa claro o que é código do servidor e o que é processo. O teste de contrato não importa a fábrica para simular uma chamada; ele inicia build/server.js como um host local iniciaria.

import { McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";

const failures = [
  { id: "ci-1042", suite: "auth", summary: "token refresh returned 401" },
  { id: "ci-1043", suite: "billing", summary: "webhook retry exceeded the limit" },
];

const resultSchema = z.object({
  matches: z.array(
    z.object({
      id: z.string(),
      suite: z.string(),
      summary: z.string(),
    }),
  ),
});

export function createServer() {
  const server = new McpServer({
    name: "ci-evidence",
    version: "0.1.0",
  });

  server.registerTool(
    "find-ci-failures",
    {
      description: "Find a small set of CI failures by suite or message.",
      inputSchema: z.object({ query: z.string().min(1) }),
      outputSchema: resultSchema,
    },
    async ({ query }) => {
      const normalized = query.toLowerCase();
      const matches = failures.filter((failure) =>
        `${failure.suite} ${failure.summary}`
          .toLowerCase()
          .includes(normalized),
      );
      const structuredContent = resultSchema.parse({ matches });

      return {
        content: [
          { type: "text", text: JSON.stringify(structuredContent) },
        ],
        structuredContent,
      };
    },
  );

  return server;
}

Agora crie src/server.ts:

import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./factory.js";

void serveStdio(createServer);
console.error("servidor MCP de evidências iniciado");

O console.error() não é um detalhe cosmético. Em stdio, o stdout carrega as mensagens do protocolo. Um console.log() solto pode transformar uma conexão que parecia correta em JSON inválido. O guia de depuração do MCP recomenda verificar os logs e testar o processo isoladamente quando a conexão falha.

Passo 3: escreva o cliente de contrato

Crie tests/mcp.contract.test.ts e inicie o processo compilado com StdioClientTransport. O teste começa pela descoberta, porque uma ferramenta pode continuar respondendo em um teste antigo mesmo depois de desaparecer da lista pública.

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";

describe("contrato do servidor MCP", () => {
  let client: Client;

  beforeEach(async () => {
    client = new Client({ name: "contract-test", version: "0.1.0" });
    const transport = new StdioClientTransport({
      command: "node",
      args: ["build/server.js"],
    });
    await client.connect(transport);
  });

  afterEach(async () => {
    await client.close();
  });

  it("descobre a ferramenta e o schema público", async () => {
    const { tools } = await client.listTools();
    const tool = tools.find(({ name }) => name === "find-ci-failures");

    expect(tool).toBeDefined();
    expect(tool?.inputSchema).toMatchObject({ type: "object" });
    expect(tool?.inputSchema.properties).toHaveProperty("query");
  });

  it("executa uma chamada válida pelo transporte real", async () => {
    const result = await client.callTool({
      name: "find-ci-failures",
      arguments: { query: "auth" },
    });

    expect(result.isError).not.toBe(true);
    expect(JSON.stringify(result)).toContain("ci-1042");
  });

  it("rejeita input inválido antes do efeito da ferramenta", async () => {
    await expect(
      client.callTool({
        name: "find-ci-failures",
        arguments: { query: "" },
      }),
    ).rejects.toThrow();
  });
});

O último caso merece uma observação. Algumas versões do SDK expõem a rejeição do schema como exceção do cliente; outras superfícies podem devolvê-la como resultado de erro. O contrato do seu projeto deve escolher uma forma e fixá-la em um teste. Se a versão usada retorna isError: true, troque a asserção por expect(result.isError).toBe(true) e confirme que o handler não foi executado.

Passo 4: execute e interprete a suíte

Rode primeiro o build e depois o teste completo:

npm test

Uma execução saudável deve mostrar três testes aprovados. O primeiro prova que o servidor publica a superfície esperada. O segundo confirma a negociação, o transporte stdio, a chamada e o resultado. O terceiro protege a fronteira de entrada. Juntos, eles cobrem uma mudança de nome, um processo que não inicia e um schema permissivo demais.

Se o teste falhar com spawn node ENOENT, o runner não encontrou o executável no ambiente do CI. Use o mesmo caminho de Node configurado pelo job ou o script do gerenciador de versões adotado pelo projeto. Se falhar com JSON inválido, procure logs em stdout e mova diagnósticos para stderr. Se listTools() vier vazio, o servidor pode ter encerrado antes de concluir o initialize.

Passo 5: use o Inspector para depurar o caso que falhou

O cliente automatizado é a proteção do CI, mas o Inspector é mais rápido para entender uma falha local. A documentação oficial do MCP Inspector apresenta a ferramenta como uma interface para conexão, descoberta de schemas e execução de ferramentas com entradas de teste.

Depois do build, execute:

npx @modelcontextprotocol/inspector node build/server.js

No painel, conecte ao processo, abra Tools, confira find-ci-failures e envie auth. Depois repita com uma string vazia e observe se o cliente apresenta rejeição de argumentos. O Inspector mostra o que aconteceu nesta execução; ele não substitui o teste que deve rodar sem navegador no CI.

Esse fluxo também ajuda a separar camadas. Se o Inspector não conecta, investigue processo, stdout, versão do protocolo e transporte. Se conecta, mas a chamada retorna isError, investigue a regra da ferramenta. Se a resposta chega, mas o teste não encontra o dado esperado, o contrato de output ou a asserção está fraco.

Quando o servidor ganha mais ferramentas, mantenha uma tabela de casos por intenção. Inclua uma chamada válida para cada ferramenta crítica, um input obrigatório ausente, um enum desconhecido, um resultado vazio que seja válido e uma falha de dependência. Não transforme toda combinação de campos em teste de integração; cubra a lógica detalhada em testes unitários e reserve o processo MCP para a fronteira.

Como conectar isso ao CI sem criar ruído?

O job deve instalar dependências, compilar, executar a suíte e guardar logs somente quando houver falha. O teste não precisa chamar um modelo. A escolha da ferramenta e os argumentos já são parte do contrato, e a verificação determinística evita que uma variação de geração esconda regressões do servidor.

Em um pipeline de agentes, publique junto o nome da ferramenta, a versão do servidor, o resultado do contrato e a revisão testada. Isso combina com observabilidade de agentes de código no CI e com evals de regressão para agentes de código: o primeiro mostra o que ocorreu; o segundo verifica se o agente usou a evidência corretamente.

Se muitos handoffs repetem os mesmos erros e o histórico começa a ocupar a janela de contexto, uso RemoteCode para levar Claude Code e Codex mais longe em fluxos agentic com menos contexto repetido. É uma ferramenta minha, mencionada aqui porque testes de contrato produzem evidências curtas que podem atravessar várias etapas sem reenviar toda a execução.

Erros comuns e limites do teste

Um teste de contrato pode provar que o servidor publica uma ferramenta e responde a uma chamada. Ele não prova que a ferramenta tem autorização para acessar um banco, que o resultado atende à regra de negócio ou que o agente escolherá a ferramenta certa diante de uma pergunta ambígua.

Sintoma Causa provável Correção
O Inspector conecta, mas o teste não O teste inicia outro diretório ou outro build Use o mesmo comando do CI e confirme build/server.js
O teste passa mesmo com uma ferramenta ausente A suíte só chama uma função conhecida Verifique listTools() e o schema publicado
Erro de validação aparece como sucesso O teste ignora isError ou captura qualquer exceção Diferencie erro de ferramenta de erro de protocolo
O servidor trava sem mensagem clara Um log foi escrito em stdout Envie diagnóstico para stderr e conserve o código de saída
O resultado muda em cada execução A dependência externa está dentro do teste de contrato Use fixture determinística e teste o adaptador separadamente

O repositório de testes de conformidade do MCP cobre a aderência mais ampla à especificação. Use-o quando você mantém uma implementação de cliente ou servidor que precisa provar comportamento de protocolo. Para uma ferramenta de negócio comum, a suíte local continua necessária porque só ela conhece nomes, schemas, permissões e efeitos esperados.

Perguntas frequentes

O MCP Inspector substitui testes automatizados?

Não. O Inspector é excelente para explorar schemas, observar logs e reproduzir uma chamada manual. O teste automatizado roda no CI, repete o mesmo contrato e falha quando a superfície pública muda. Use o Inspector para diagnosticar; use o cliente TypeScript para proteger a integração.

Preciso chamar um modelo durante o teste?

Não para testar o contrato MCP. Descoberta, schemas, transporte e respostas podem ser verificados com um cliente determinístico. Adicione um eval com modelo somente quando a pergunta for se o agente escolhe a ferramenta correta, interpreta a resposta ou respeita a política do sistema.

Devo testar o servidor por stdio ou HTTP?

Teste o transporte usado em produção. Para um servidor local iniciado por Claude Code, Codex ou outro host, stdio reproduz o ciclo de vida do processo. Para um servidor remoto, use o cliente HTTP correspondente e inclua autenticação, sessão, timeout e encerramento no contrato.

O teste precisa verificar todas as combinações do schema?

Não. O contrato deve cobrir as fronteiras e os casos que mudariam a decisão do cliente. Validações detalhadas e combinações de domínio pertencem aos testes unitários do handler. O teste MCP deve permanecer pequeno o bastante para rodar em cada alteração sem virar uma segunda implementação do servidor.

O que muda quando o SDK do MCP for atualizado?

Reconfirme os imports, o transporte e o formato de erro. Mantenha os testes de comportamento e atualize apenas o adaptador necessário. A intenção permanece: descobrir, chamar, validar e distinguir falhas. Fixar a versão no lockfile ajuda a impedir que o CI mude de contrato sem uma revisão explícita.

O servidor está pronto para um agente quando o teste consegue dizer mais do que "conectou". Ele precisa provar quais ferramentas existem, quais argumentos entram, qual resposta sai e como a falha interrompe o caminho. Essa evidência é pequena, reproduzível e mais útil no CI do que uma captura do Inspector.