Probar un servidor MCP con clientes con guion
Un cliente con guion es un programa corto que envía una lista fija de mensajes a un servidor y comprueba cada respuesta. Nunca se cansa y nunca se salta un paso.
Importa porque un servidor se rompe fácilmente sin querer. Un cambio de un solo carácter puede alterar una respuesta, y nadie se entera hasta que falla un cliente real.
Al final de esta lección
Nota
Puedo probar un servidor con clientes con guion.
Palabras clave
| Palabra | Qué significa | Ejemplo |
|---|---|---|
| Guion | una lista de mensajes por enviar, cada uno con la respuesta esperada | |
| Esperada | la respuesta que quiere la prueba | |
| Real | la respuesta que el servidor envió de verdad | |
| Diferencia | el primer punto en que las dos no coinciden | |
| Comodín | una marca que encaja con cualquier valor | <any> |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.
Un guion empareja cada mensaje con su respuesta
Un guion es una lista de pasos. Cada paso tiene un mensaje que enviar y la respuesta esperada. Un paso de notificación no espera respuesta.
Algunos valores cambian, como un número de versión. La prueba los marca con un comodín, así que un cambio ahí no es un fallo. Todo lo demás debe coincidir exactamente.
Ejemplo resuelto: un guion para sumar
| Paso | Se envía | Respuesta esperada |
|---|---|---|
| 1 | initialize | La versión pedida, tools anunciadas, cualquier versión del servidor |
| 2 | La notificación de listo | Ninguna respuesta |
| 3 | tools/list | Una herramienta llamada add |
| 4 | add 2 y 3 | El texto 5, isError false |
| 5 | add "2" y 3 | Un error de herramienta, isError true |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.
Informa de la primera diferencia
Una prueba que solo dice «falló» hace perder tiempo. Una buena dice dónde. La ruta result.content[0].text señala un valor de la respuesta.
El comprobador recorre la respuesta esperada en un orden fijo y se detiene en la primera diferencia. Dice qué esperaba y qué obtuvo.
Un error frecuente
Advertencia
Mucha gente ejecuta una prueba solo contra un servidor que funciona. Una prueba que nunca ha fallado nunca ha demostrado que detecte algo. Rompe el servidor a propósito y comprueba que la prueba falla. Luego deshaz el cambio.
Escribe el ayudante del modelo
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' 'async function ask(messages, sample, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" }, body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 80 }), signal: AbortSignal.timeout(30000) });' ' if (res.ok === false) throw new Error(\"estado \" + res.status);' ' const data = await res.json();' ' return { text: data.choices[0].message.content, recorded: false };' ' } catch (error) {' ' console.log(\"Muestra grabada: ningún modelo respondió (\" + error.message + \").\");' ' return { text: sample, recorded: true };' ' }' '}' 'module.exports = { ask };' > model.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el servidor, parte 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function send(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'const SCHEMA = { type: \"object\", properties: { a: { type: \"integer\" }, b: { type: \"integer\" } }, required: [\"a\", \"b\"] };' 'function words(text, bad) { return { content: [{ type: \"text\", text: text }], isError: bad }; }' > server6.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el servidor, parte 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function handle(msg) {' ' if (msg.id === undefined) return;' ' const p = msg.params || {};' ' const args = p.arguments || {};' ' if (msg.method === \"initialize\") return send({ id: msg.id, result: { protocolVersion: p.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: \"sumador\", version: \"1.0\" } } });' >> server6.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el servidor, parte 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' if (msg.method === \"tools/list\") return send({ id: msg.id, result: { tools: [{ name: \"add\", description: \"Suma dos enteros\", inputSchema: SCHEMA }] } });' ' if (msg.method === \"tools/call\" && p.name === \"add\") {' ' if (Number.isInteger(args.a) && Number.isInteger(args.b)) return send({ id: msg.id, result: words(String(args.a + args.b), false) });' ' return send({ id: msg.id, result: words(\"a y b deben ser enteros\", true) });' ' }' ' if (msg.method === \"tools/call\") return send({ id: msg.id, error: { code: -32602, message: \"Unknown tool: \" + p.name } });' ' send({ id: msg.id, error: { code: -32601, message: \"Method not found: \" + msg.method } });' '}' 'require(\"readline\").createInterface({ input: process.stdin }).on(\"line\", function (line) { handle(JSON.parse(line)); });' >> server6.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el comprobador, parte 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const server = require(\"child_process\").spawn(\"node\", [process.argv[2] || \"server6.js\"]);' 'const any = \"<any>\";' 'const one = function (id, method, params) { return { jsonrpc: \"2.0\", id: id, method: method, params: params }; };' 'const add = function (id, args) { return one(id, \"tools/call\", { name: \"add\", arguments: args }); };' 'const ok = function (id, result) { return { jsonrpc: \"2.0\", id: id, result: result }; };' 'const text = function (t, bad) { return { content: [{ type: \"text\", text: t }], isError: bad }; };' > check6.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el comprobador, parte 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const steps = [' ' [one(1, \"initialize\", { protocolVersion: \"2025-03-26\" }), ok(1, { protocolVersion: \"2025-03-26\", capabilities: { tools: {} }, serverInfo: { name: \"sumador\", version: any } })],' ' [{ jsonrpc: \"2.0\", method: \"notifications/initialized\" }, null],' ' [one(2, \"tools/list\"), ok(2, { tools: [{ name: \"add\", description: any, inputSchema: any }] })],' ' [add(3, { a: 2, b: 3 }), ok(3, text(\"5\", false))],' ' [add(4, { a: \"2\", b: 3 }), ok(4, text(\"a y b deben ser enteros\", true))],' ' [one(5, \"tools/call\", { name: \"nope\" }), { jsonrpc: \"2.0\", id: 5, error: { code: -32602, message: \"Unknown tool: nope\" } }],' '];' >> check6.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el comprobador, parte 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function isObj(x) { return Boolean(x) && typeof x === \"object\"; }' 'function name(path, k, list) { return list ? path + \"[\" + k + \"]\" : (path ? path + \".\" + k : k); }' 'function diff(want, got, path) {' ' if (want === any) return null;' ' const both = isObj(want) && isObj(got) && Array.isArray(want) === Array.isArray(got);' ' const said = \" se esperaba \" + JSON.stringify(want) + \", se obtuvo \" + JSON.stringify(got);' ' if (both === false) return JSON.stringify(want) === JSON.stringify(got) ? null : (path || \"(raíz)\") + \":\" + said;' >> check6.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el comprobador, parte 4
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' const list = Array.isArray(want);' ' if (list && (want.length === got.length) === false) return (path || \"(raíz)\") + \": se esperaban \" + want.length + \" elementos, se obtuvieron \" + got.length;' ' const keys = Object.keys(want).sort();' ' for (const k of keys) {' ' if ((k in got) === false) return name(path, k, list) + \": falta\";' ' const d = diff(want[k], got[k], name(path, k, list));' ' if (d) return d;' ' }' ' const extra = Object.keys(got).sort().filter(function (k) { return keys.indexOf(k) < 0; });' ' return extra.length > 0 ? name(path, extra[0], list) + \": sobra\" : null;' '}' >> check6.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el comprobador, parte 5
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const lines = [];' 'const waiting = [];' 'require(\"readline\").createInterface({ input: server.stdout }).on(\"line\", function (l) {' ' if (waiting.length > 0) waiting.shift()(JSON.parse(l)); else lines.push(JSON.parse(l));' '});' 'function next() { return lines.length > 0 ? Promise.resolve(lines.shift()) : new Promise(function (r) { waiting.push(r); }); }' >> check6.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Escribe el comprobador, parte 6
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' let passed = 0, total = 0, first = null;' ' for (let i = 0; i < steps.length; i++) {' ' server.stdin.write(JSON.stringify(steps[i][0]) + \"\n\");' ' if (steps[i][1] === null) continue;' ' total += 1;' ' const d = diff(steps[i][1], await next(), \"\");' ' console.log((d ? \"FAIL paso \" + (i + 1) + \" en \" + d : \"PASS paso \" + (i + 1)));' ' if (d === null) passed += 1; else if (first === null) first = d;' ' }' ' server.stdin.end();' ' console.log(\"pasaron \" + passed + \" de \" + total);' ' if (first === null) return;' ' const r = await ask([{ role: \"user\", content: \"Una prueba de mi servidor falló con esta diferencia. En una frase, ¿cuál es la causa probable? \" + first }], \"El servidor dio un texto distinto del que esperaba la prueba.\", 60);' ' console.log(\"el modelo dijo: \" + r.text);' '}' 'main();' >> check6.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Ejecuta el comprobador con el servidor bueno
docker exec -w /home/node/project agent node check6.jsSe muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Haz una copia con un cambio malo
docker exec -w /home/node/project agent sh -c "sed 's/args.a + /args.a - /' server6.js > bad6.js"Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Ejecuta el comprobador con la copia mala
docker exec -w /home/node/project agent node check6.js bad6.jsSe muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Las dos ejecuciones, lado a lado
| Ejecución | Pasos comprobados | Resultado |
|---|---|---|
| Servidor bueno | 1, 3, 4, 5 y 6 | pasaron 5 de 5 |
| Copia mala | 1, 3, 4, 5 y 6 | pasaron 4 de 5 |
| Copia mala, paso 4 | add 2 y 3 | result.content[0].text: se esperaba "5", se obtuvo "-1" |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.