mcp-expect
Aserciones al estilo de Jest para probar servidores MCP: verifica que las herramientas existan, respondan a tiempo, rechacen entradas inválidas y coincidan con su esquema declarado.
Documentación
mcp-expect
Aserciones estilo Jest para probar 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" });
});
Ejecútalo y obtén un informe real de aprobado/fallido: sin escribir llamadas client.callTool() crudas, sin adivinar por qué un asistente de codificación con IA piensa que tu servidor que funciona está roto.
Por qué
Los servidores MCP fallan de un número reducido de formas muy específicas: una herramienta no está realmente registrada, un manejador se cuelga, un esquema acepta silenciosamente entrada incorrecta, o un resultado no se parece a lo que el llamador espera. Esas son exactamente las cuatro aserciones siguientes. Esto no es deliberadamente un marco de pruebas general: es una capa delgada y con opiniones propias sobre el cliente oficial @modelcontextprotocol/sdk orientada a detectar esos cuatro modos de fallo en CI, antes de que un agente tenga que descubrirlos en tiempo de ejecución.
Funciona en CI sin configuración
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"
Un código de salida distinto de cero en caso de fallo significa que funciona en cualquier sistema de CI con cero configuración. Cuando GITHUB_ACTIONS=true está establecido (lo que GitHub hace automáticamente), los fallos también se emiten como anotaciones ::error file=...::, de modo que aparecen en línea en el diff del PR, no solo enterrados en un registro.
Instalación
npm install --save-dev mcp-expect zod
Inicio rápido
- Escribe un archivo de prueba que termine en
.mcptest.ts(compílalo, o ejecútalo mediantetsx/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();
});
- Ejecútalo:
npx mcp-expect "dist/**/*.mcptest.js"
Obtendrás salida de aprobado/fallido en color y un código de salida distinto de cero en caso de fallo, por lo que encaja directamente en CI.
Configuraciones de servidor
Se admiten dos transportes de serie:
// 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 ..." } }
Se establece una conexión nueva por cada defineTest y se cierra después, de modo que las pruebas no filtran estado entre sí. Si tienes varias pruebas contra el mismo servidor, describeServer() las agrupa bajo una conexión compartida en su lugar; consulta Características de rendimiento.
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);
});
});
El defineTest que obtienes, ya sea de nivel superior o con ámbito dentro de describeServer, admite .only(...) (ejecutar solo esta prueba, omitiendo todas las demás de toda la invocación) y .skip(...) (no ejecutarla nunca), para concentrarse durante la depuración.
Referencia de la API
expect.tool(name)
Devuelve un ToolAssertion para el nombre de herramienta dado, vinculado al cliente de la prueba actual.
.withInput(args)
Establece los argumentos utilizados por las aserciones que le siguen. Devuelve this, por lo que se encadena.
.exists()
Afirma que la herramienta está registrada y es descubrible mediante tools/list.
.respondsWithin(ms)
Afirma que una llamada con la entrada actual se completa dentro de ms y no devuelve isError: true. Esta es, en la práctica, la aserción más útil de todas: un manejador que se cuelga es la razón más común por la que un asistente de codificación con IA decide que tu servidor MCP que funciona está roto y empieza a "arreglarlo".
.rejectsInvalidInput()
Afirma que la entrada actual es rechazada, ya sea por un error de esquema a nivel de protocolo o por un resultado isError: true. Si la llamada tiene éxito silenciosamente, la aserción falla: es señal de que tu esquema de entrada es demasiado permisivo.
.returnsSchema(shape)
Afirma que el resultado coincide con una forma superficial, p. ej. { results: "array", count: "number" }. Intencionadamente no es validación completa de JSON Schema: es una comprobación rápida de forma, no un validador. Consulta .matchesOutputSchema() más abajo para la versión completa.
.matchesOutputSchema()
Afirma que el resultado se valida contra el propio outputSchema declarado de la herramienta (de tools/list), usando ajv: validación completa de JSON Schema, sin especificación de forma que escribir tú mismo. Lanza un error claro si la herramienta no declara ningún outputSchema; usa .returnsSchema() para esos casos.
.isSafeAgainst(field, categories)
Fuzzifica el campo de entrada dado con cargas maliciosas conocidas — "path-traversal" y/o "command-injection" — y afirma que cada una es rechazada (isError: true, o un error lanzado). Cualquier carga que pase es un hallazgo real: el mensaje de fallo incluye exactamente qué carga tuvo éxito y qué devolvió el servidor. Esta es una prueba de humo estrecha para la clase más común de error real en herramientas MCP (un argumento pasado sin verificar a una llamada de sistema de archivos o shell), no un escáner de seguridad general.
await expect.tool("read_file").withInput({ path: "safe.txt" }).isSafeAgainst("path", "path-traversal");
Ejemplo funcional
Consulta example/ para una demo completa: un pequeño servidor MCP que expone una herramienta search, y un archivo de prueba que ejercita las cuatro aserciones.
npm install
npm run test:example
También hay una suite de pruebas unitarias rápida (test/, el ejecutor node:test integrado de Node) para todo lo que es incómodo de provocar contra un servidor real bajo demanda — la mayor parte contra un Client falso (una herramienta sin outputSchema, un resultado JSON-RPC malformado, una categoría de seguridad desconocida, la lógica de registro .only()/.skip(), ...), y unas pocas que lanzan el binario CLI compilado real como caja negra (errores de uso, un glob que no coincide con nada, la anotación GITHUB_ACTIONS, stderr real capturado del servidor en caso de fallo):
npm run test:unit
¿Quieres ver cómo se ve una aserción que falla? example/red-demo.mcptest.ts es el mismo servidor con una expectativa deliberadamente incorrecta, mantenido en su propio archivo para que no ponga en rojo la demo principal (ni el CI):
npm run demo:fail
¿No te convence que una biblioteca de pruebas que solo prueba su propio servidor de demostración demuestre algo? También se ejecuta contra dos de los servidores de referencia oficiales de MCP, mantenidos independientemente de este proyecto — deliberadamente diferentes entre sí y del servidor de demostración, para sacar a la luz peculiaridades de transporte y esquema:
@modelcontextprotocol/server-everything(example/real-server.mcptest.ts) — un servidor stdio sin argumentos de inicio, que devuelvestructuredContentcon forma de objeto plano.@modelcontextprotocol/server-filesystem(example/filesystem-server.mcptest.ts) — toma un argumento de inicio (el directorio permitido), rechaza entrada inválida de dos maneras diferentes (tipo de argumento incorrecto y una ruta fuera del sandbox, ambos presentados comoisError: trueen lugar de un error lanzado), y anida su resultado bajo una cadenacontenten lugar de un objeto.- Un servidor Streamable HTTP mínimo pero correcto según la especificación (
example/http-server.ts+example/http-server.mcptest.ts) — todos los demás ejemplos aquí se ejecutan sobre stdio, así que esta es la única cobertura real del otro transporte que admite esta biblioteca. El archivo de prueba inicia y detiene el servidor por sí mismo, ya que (según Configuraciones de servidor) esta biblioteca solo se conecta a un servidor HTTP, no lo gestiona.
Los tres usan describeServer() para compartir una conexión entre todas sus aserciones.
npm run test:everything-server
npm run test:filesystem-server
npm run test:http-server
¿Quieres prueba de que .isSafeAgainst() realmente detecta un error real, y no solo pasa contra servidores que ya son seguros? example/vulnerable-demo-server.ts es una herramienta deliberadamente ingenua (interpola entrada sin verificar en un comando de shell) y example/security-red-demo.mcptest.ts muestra la aserción detectándolo — incluida la salida whoami filtrada que demuestra que el comando realmente se ejecutó:
npm run demo:security-fail
Características de rendimiento
Un defineTest simple abre una conexión nueva antes de ejecutar su aserción, por lo que el tiempo de pared por prueba está dominado por el arranque del proceso, no por la lógica de la aserción en sí:
- Servidor stdio local (binario ya instalado): ~300-370ms por prueba
- Servidor lanzado mediante
npx(como los servidores de referencia anteriores): ~700-820ms por prueba, en su mayoría la sobrecarga de resolución propia denpx, no de esta biblioteca
describeServer() evita pagar ese costo por prueba compartiendo una conexión entre un grupo. Medido en las suites reales de servidores de referencia en example/: la primera prueba de un grupo aún paga el costo de conexión de ~700-800ms, pero cada prueba posterior del mismo grupo se ejecuta en 1-18ms — una suite de 7 pruebas que habría tomado ~5s secuencialmente ahora toma alrededor de 1s en total. El transporte Streamable HTTP es aún más rápido: ~50ms para la primera llamada (que inicializa la sesión), luego 2-4ms por llamada — consulta example/http-server.mcptest.ts.
La ejecución de pruebas sigue siendo secuencial — las conexiones (o grupos) independientes se ejecutan una tras otra, no de forma concurrente. Paralelizarlas es una mejora futura razonable; aún no está implementada, así que este README no lo afirma.
Las dependencias de tiempo de ejecución son @modelcontextprotocol/sdk (el cliente en el que ya confías para hablar con el servidor), ajv para .matchesOutputSchema(), chalk para salida en color, y fast-glob para el descubrimiento de archivos de prueba — no cero, pero pequeñas y deliberadas.
La cobertura de código (mediante c8) está configurada con npm run coverage — ejecuta tanto la suite unitaria como todas las suites de servidores reales juntas, actualmente alrededor del 98% de las sentencias en src/ (los vacíos restantes son cosas como un manejador de cierre inalcanzable de nivel superior — no vale la pena perseguir el 100%). Aún no se rastrea en CI ni se publica como insignia — no hay historial con el que comparar, así que un número único de instantánea sería más decorativo que útil.
Publicación
La publicación en npm está automatizada mediante .github/workflows/publish.yml usando la publicación confiable OIDC de npm — no se almacena ningún token npm de larga duración en el repositorio. Para hacer una versión:
npm version patch # or minor / major — updates package.json and creates a git tag
git push --follow-tags
El flujo de trabajo verifica que la etiqueta enviada coincida con la versión de package.json, ejecuta la suite de pruebas completa (demo local + ambas suites de servidores reales), y luego publica. Si algo de eso falla, no se publica nada.
Lo que esto no es (alcance v1)
- No es un arnés de evaluación multimodelo — no juzga qué tan bien un LLM interpreta tus descripciones de herramientas.
- No es un fuzzer general —
.isSafeAgainst()verifica un conjunto pequeño y curado de cargas conocidas de path traversal e inyección de comandos, no generación arbitraria de entrada. - No es una herramienta de registro o descubrimiento.
Estas pueden aparecer en versiones posteriores una vez que el conjunto central de aserciones haya demostrado ser útil en la práctica. Contribuciones y problemas bienvenidos — consulta CONTRIBUTING.md, o toma un good first issue. ¿Encontraste una vulnerabilidad real? Consulta SECURITY.md en lugar de abrir un problema público.
Si mcp-expect demuestra ser útil, el plan es una pequeña familia de herramientas de desarrollo MCP enfocadas en lugar de un marco monolítico — publicadas bajo la org npm @mcp-expect a medida que se construyan. Nada más allá de este paquete existe aún.
Licencia
MIT