mcp-expect
Asserções no estilo Jest para testar servidores MCP — verifica se as ferramentas existem, respondem dentro do tempo, rejeitam entradas inválidas e correspondem ao esquema declarado.
Documentação
mcp-expect
Asserções no estilo Jest para testar servidores MCP (Model Context Protocol).

flowchart LR
A["Your test file<br/>(*.mcptest.ts)"] --> B["mcp-expect<br/>expect().tool() assertions"]
B --> C["Official @modelcontextprotocol/sdk<br/>Client"]
C --> D["stdio or<br/>Streamable HTTP"]
D --> E["Your MCP Server"]
defineTest("search tool", { command: "node", args: ["server.js"] }, async ({ expect }) => {
await expect.tool("search").exists();
await expect.tool("search")
.withInput({ query: "hello" })
.respondsWithin(2000);
await expect.tool("search")
.withInput({ query: 123 }) // wrong type
.rejectsInvalidInput();
await expect.tool("search")
.withInput({ query: "hello" })
.returnsSchema({ results: "array" });
});
Execute e obtenha um relatório real de aprovação/reprovação — sem escrever chamadas client.callTool() cruas, sem adivinhar por que um assistente de codificação de IA acha que seu servidor funcional está quebrado.
Por quê
Servidores MCP falham de poucas maneiras muito específicas: uma ferramenta não está realmente registrada, um handler trava, um schema aceita silenciosamente entrada inválida, ou um resultado não se parece com o que o chamador espera. Essas são exatamente as quatro asserções abaixo. Isto deliberadamente não é um framework de testes geral — é uma camada fina e opinativa sobre o cliente oficial @modelcontextprotocol/sdk, voltada a capturar esses quatro modos de falha em CI, antes que um agente precise descobri-los em tempo de execução.
Funciona em CI sem configuração
npx mcp-expect "dist/**/*.mcptest.js" → fails the build on any red assertion → annotates the exact line on the PR diff
- run: npx mcp-expect "dist/**/*.mcptest.js"
Um código de saída diferente de zero em caso de falha significa que funciona em qualquer sistema de CI com zero configuração. Quando GITHUB_ACTIONS=true está definido (o que o GitHub faz automaticamente), falhas também são emitidas como anotações ::error file=...::, para que apareçam inline no diff do PR — não apenas enterradas em um log.
Instalação
npm install --save-dev mcp-expect zod
Início rápido
- Escreva um arquivo de teste terminando em
.mcptest.ts(compile-o, ou execute viatsx/ts-node):
// search.mcptest.ts
import { defineTest } from "mcp-expect";
const server = { command: "node", args: ["./dist/server.js"] };
defineTest("search tool is registered", server, async ({ expect }) => {
await expect.tool("search").exists();
});
- Execute-o:
npx mcp-expect "dist/**/*.mcptest.js"
Você obterá saída colorida de aprovação/reprovação e um código de saída diferente de zero em caso de falha, então ele se integra diretamente ao CI.
Configurações de servidor
Dois transportes são suportados nativamente:
// stdio — the server is a local process
{ command: "node", args: ["server.js"], env: { API_KEY: "..." } }
// Streamable HTTP — the server is already running somewhere
{ url: "http://localhost:3000/mcp", headers: { Authorization: "Bearer ..." } }
Uma nova conexão é feita por defineTest e fechada em seguida, para que os testes não vazem estado entre si. Se você tiver vários testes contra o mesmo servidor, describeServer() os agrupa sob uma conexão compartilhada — veja Características de desempenho.
import { describeServer } from "mcp-expect";
describeServer({ command: "node", args: ["server.js"] }, (defineTest) => {
defineTest("search exists", async ({ expect }) => {
await expect.tool("search").exists();
});
defineTest("search responds", async ({ expect }) => {
await expect.tool("search").withInput({ query: "hi" }).respondsWithin(1000);
});
});
O defineTest que você obtém, seja de nível superior ou escopado dentro de describeServer, suporta .only(...) (execute apenas este teste, pulando todos os outros testes em toda a invocação) e .skip(...) (nunca execute-o), para focar durante a depuração.
Referência da API
expect.tool(name)
Retorna um ToolAssertion para o nome da ferramenta fornecido, vinculado ao cliente do teste atual.
.withInput(args)
Define os argumentos usados pelas asserções abaixo dele. Retorna this, então ele encadeia.
.exists()
Afirma que a ferramenta está registrada e descobrível via tools/list.
.respondsWithin(ms)
Afirma que uma chamada com a entrada atual é concluída dentro de ms e não retorna isError: true. Esta é a asserção mais útil na prática — um handler travado é o motivo mais comum pelo qual um assistente de codificação de IA decide que seu servidor MCP funcional está quebrado e começa a "consertá-lo".
.rejectsInvalidInput()
Afirma que a entrada atual é rejeitada, seja por um erro de schema no nível de protocolo ou por um resultado isError: true. Se a chamada for concluída silenciosamente, a asserção falha — isso é um sinal de que seu schema de entrada é muito permissivo.
.returnsSchema(shape)
Afirma que o resultado corresponde a uma forma rasa, ex. { results: "array", count: "number" }. Intencionalmente não é validação JSON Schema completa — uma verificação rápida de forma, não um validador. Veja .matchesOutputSchema() abaixo para a versão completa.
.matchesOutputSchema()
Afirma que o resultado valida contra o próprio outputSchema declarado da ferramenta (de tools/list), usando ajv — validação JSON Schema completa, sem precisar escrever uma especificação de forma. Lança um erro claro se a ferramenta não declarar um outputSchema; use .returnsSchema() para esses casos.
.isSafeAgainst(field, categories)
Fuzza o campo de entrada fornecido com payloads maliciosos conhecidos — "path-traversal" e/ou "command-injection" — e afirma que todos são rejeitados (isError: true, ou um erro lançado). Qualquer payload que passe é um achado real: a mensagem de falha inclui exatamente qual payload teve sucesso e o que o servidor retornou. Este é um teste de fumaça estreito para a classe mais comum de bugs reais de ferramentas MCP (um argumento passado sem verificação para uma chamada de sistema de arquivos ou shell), não um scanner de segurança geral.
await expect.tool("read_file").withInput({ path: "safe.txt" }).isSafeAgainst("path", "path-traversal");
Exemplo funcional
Veja example/ para uma demonstração completa: um pequeno servidor MCP expondo uma ferramenta search, e um arquivo de teste exercitando todas as quatro asserções.
npm install
npm run test:example
Há também uma suíte de testes unitários rápida (test/, o runner node:test embutido do Node) para tudo que é difícil de acionar contra um servidor real sob demanda — a maior parte contra um Client falso (uma ferramenta sem outputSchema, um resultado JSON-RPC malformado, uma categoria de segurança desconhecida, a lógica de registro .only()/.skip(), ...), e alguns poucos que iniciam o binário CLI compilado real como caixa preta (erros de uso, um glob que não corresponde a nada, a anotação GITHUB_ACTIONS, stderr real capturado do servidor em caso de falha):
npm run test:unit
Quer ver como é uma asserção falhando? example/red-demo.mcptest.ts é o mesmo servidor com uma expectativa deliberadamente errada, mantido em seu próprio arquivo para não deixar a demonstração principal (ou o CI) vermelho:
npm run demo:fail
Não está convencido de que uma biblioteca de testes que só testa seu próprio servidor de demonstração prova algo? Ela também é executada contra dois dos servidores de referência oficiais do MCP, mantidos independentemente deste projeto — deliberadamente diferentes entre si e do servidor de demonstração, para expor peculiaridades de transporte e schema:
@modelcontextprotocol/server-everything(example/real-server.mcptest.ts) — um servidor stdio sem argumentos de inicialização, retornandostructuredContentformatado como um objeto plano.@modelcontextprotocol/server-filesystem(example/filesystem-server.mcptest.ts) — recebe um argumento de inicialização (o diretório permitido), rejeita entrada inválida de duas maneiras diferentes (tipo de argumento incorreto e um caminho fora do sandbox, ambos exibidos comoisError: trueem vez de um erro lançado), e aninha seu resultado sob uma stringcontentem vez de um objeto.- Um servidor Streamable HTTP mínimo mas correto segundo a especificação
(
example/http-server.ts+example/http-server.mcptest.ts) — todos os outros exemplos aqui rodam via stdio, então este é o único teste real do outro transporte que esta biblioteca suporta. O arquivo de teste inicia e para o servidor por conta própria, já que (conforme Configurações de servidor) esta biblioteca apenas conecta a um servidor HTTP, não o gerencia.
Todos os três usam describeServer() para compartilhar uma conexão entre todas as suas asserções.
npm run test:everything-server
npm run test:filesystem-server
npm run test:http-server
Quer prova de que .isSafeAgainst() realmente captura um bug real, não apenas passa contra servidores que já são seguros? example/vulnerable-demo-server.ts é uma ferramenta deliberadamente ingênua (interpola entrada sem verificação em um comando shell) e example/security-red-demo.mcptest.ts mostra a asserção capturando-o — incluindo a saída whoami vazada provando que o comando realmente foi executado:
npm run demo:security-fail
Características de desempenho
Um defineTest simples abre uma nova conexão antes de executar sua asserção, então o tempo de parede por teste é dominado pela inicialização do processo, não pela lógica da asserção em si:
- Servidor stdio local (binário já instalado): ~300-370ms por teste
- Servidor iniciado via
npx(como os servidores de referência acima): ~700-820ms por teste, principalmente a sobrecarga de resolução do próprionpx, não desta biblioteca
describeServer() evita pagar esse custo por teste compartilhando uma conexão entre um grupo. Medido nas suítes reais de servidores de referência em example/: o primeiro teste em um grupo ainda paga o custo de conexão de ~700-800ms, mas cada teste subsequente no mesmo grupo roda em 1-18ms — uma suíte de 7 testes que levaria ~5s sequencialmente agora leva cerca de 1s no total. O transporte Streamable HTTP é ainda mais rápido: ~50ms para a primeira chamada (que inicializa a sessão), depois 2-4ms por chamada — veja example/http-server.mcptest.ts.
A execução dos testes ainda é sequencial — conexões independentes (ou grupos) rodam uma após a outra, não concorrentemente. Paralelizar entre elas é uma melhoria futura razoável; ainda não foi construída, então este README não a reivindica.
As dependências de runtime são @modelcontextprotocol/sdk (o cliente no qual você já confia para falar com o servidor), ajv para .matchesOutputSchema(), chalk para saída colorida, e fast-glob para descoberta de arquivos de teste — não zero, mas pequenas e deliberadas.
A cobertura de código (via c8) está configurada com npm run coverage — ela executa tanto a suíte unitária quanto todas as suítes de servidores reais juntas, atualmente cerca de 98% das declarações em src/ (as lacunas restantes são coisas como um handler de crash de nível superior inalcançável — não vale a pena perseguir 100%). Ela não é rastreada no CI nem publicada como badge ainda — não há histórico para comparar, então um único número instantâneo seria mais decorativo do que útil.
Lançamento
A publicação no npm é automatizada via .github/workflows/publish.yml usando a publicação confiável OIDC do npm — nenhum token npm de longa duração é armazenado no repositório. Para fazer um lançamento:
npm version patch # or minor / major — updates package.json and creates a git tag
git push --follow-tags
O workflow verifica se a tag enviada corresponde à versão de package.json, executa a suíte de testes completa (demonstração local + ambas as suítes de servidores reais), e então publica. Se qualquer uma dessas etapas falhar, nada é publicado.
O que isto não é (escopo v1)
- Não é um harness de avaliação multi-modelo — não julga quão bem um LLM interpreta suas descrições de ferramentas.
- Não é um fuzzer geral —
.isSafeAgainst()verifica um conjunto pequeno e curado de payloads conhecidos de path traversal e injeção de comandos, não geração arbitrária de entrada. - Não é uma ferramenta de registro ou descoberta.
Estes podem aparecer em versões posteriores, uma vez que o conjunto central de asserções tenha se provado útil na prática. Contribuições e issues são bem-vindas — veja CONTRIBUTING.md, ou pegue uma good first issue. Encontrou uma vulnerabilidade real? Veja SECURITY.md em vez de abrir uma issue pública.
Se mcp-expect se provar útil, o plano é uma pequena família de ferramentas de desenvolvimento MCP focadas, em vez de um framework monolítico — publicadas sob a organização npm @mcp-expect conforme forem construídas. Nada além deste pacote existe ainda.
Licença
MIT