Cómo limita permisos un servidor MCP
Una herramienta que hace un pedido y otra que cancela todos los pedidos no deben estar abiertas a la misma persona. Un servidor lo limita con alcances: una etiqueta corta para cada cosa que puede hacer quien llama.
Un servidor que se ejecuta por entrada y salida estándar no tiene página de inicio de sesión. Quien lo arranca dice qué alcances tiene quien llama, y el servidor los comprueba en cada llamada.
Al final de esta lección
Nota
Puedo limitar las herramientas de un servidor según quién las pide.
Palabras clave
| Palabra | Qué significa | Ejemplo |
|---|---|---|
| Alcance | una etiqueta para una cosa que puede hacer quien llama | orders:write |
| Permiso | un alcance que tiene quien llama | |
| Denegar | rechazar una llamada porque falta un alcance | |
| Comodín | un permiso que cubre todos los alcances con el mismo principio | orders:* |
| Registro de auditoría | un apunte de cada llamada y de lo que se decidió |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.
Cada herramienta nombra el alcance que necesita
El servidor guarda una tabla: cada herramienta y el único alcance que necesita. Quien llama tiene una lista de permisos. Una llamada se permite cuando un permiso cubre el alcance.
Un permiso comodín como orders: cubre todos los alcances que empiezan por orders: y nada más. El permiso lo cubre todo.
Ejemplo resuelto: quien llama tiene dos permisos
| Herramienta | Necesita | Quien llama tiene menu:read, orders:write | Decisión |
|---|---|---|---|
| read_menu | menu:read | Cubierto | Permitir |
| place_order | orders:write | Cubierto | Permitir |
| cancel_all | orders:admin | No cubierto | Denegar |
| burn_kitchen | No existe | No se aplica | Desconocida |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.
Ocultar una herramienta no es rechazarla
El servidor puede dejar fuera de tools/list las herramientas que quien llama no puede usar. Así el modelo no las intenta. Pero quien llama aún puede enviar una llamada a una herramienta que nunca se le mostró.
Por eso la comprobación se hace en cada tools/call, siempre. Un alcance que falta da un error de herramienta que nombra el alcance. Una herramienta desconocida sigue siendo un error de protocolo.
Un error frecuente
Advertencia
Mucha gente oculta de la lista la herramienta peligrosa y lo llama seguridad. Una herramienta oculta sigue ahí. Quien sepa su nombre puede llamarla. Comprueba el alcance en cada llamada, y registra tanto las llamadas rechazadas como las que se ejecutan.
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' 'const NEEDS = { read_menu: \"menu:read\", place_order: \"orders:write\", cancel_all: \"orders:admin\" };' 'const HAVE = (process.env.SCOPES || \"\").split(\",\");' 'let count = 0;' 'function send(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'function words(text, bad) { return { content: [{ type: \"text\", text: text }], isError: bad }; }' 'function note(tool, decision) {' ' count += 1;' ' process.stderr.write(JSON.stringify({ n: count, tool: tool, decision: decision, needs: NEEDS[tool] || null }) + \"\n\");' '}' > server5.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 call(msg, p) {' ' if (Object.keys(NEEDS).indexOf(p.name) < 0) {' ' note(p.name, \"unknown\");' ' return send({ id: msg.id, error: { code: -32602, message: \"Unknown tool: \" + p.name } });' ' }' ' if (HAVE.indexOf(NEEDS[p.name]) < 0) {' ' note(p.name, \"deny\");' ' return send({ id: msg.id, result: words(\"No permitido: \" + p.name + \" necesita \" + NEEDS[p.name], true) });' ' }' ' note(p.name, \"allow\");' ' send({ id: msg.id, result: words(\"hecho: \" + p.name, false) });' '}' >> server5.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' 'function listed() {' ' return Object.keys(NEEDS).filter(function (n) { return HAVE.indexOf(NEEDS[n]) >= 0; }).map(function (n) {' ' return { name: n, description: \"Herramienta del comedor \" + n, inputSchema: { type: \"object\" } };' ' });' '}' >> server5.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 4
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function handle(msg) {' ' if (msg.id === undefined) return;' ' const p = msg.params || {};' ' if (msg.method === \"initialize\") return send({ id: msg.id, result: { protocolVersion: p.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: \"comedor\", version: \"0.5\" } } });' ' if (msg.method === \"tools/list\") return send({ id: msg.id, result: { tools: listed() } });' ' if (msg.method === \"tools/call\") return call(msg, p);' ' 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)); });' >> server5.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 cliente con guion, parte 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const env = Object.assign({}, process.env, { SCOPES: \"menu:read,orders:write\" });' 'const server = require(\"child_process\").spawn(\"node\", [\"server5.js\"], { env: env });' 'const call = function (id, name) { return { id: id, method: \"tools/call\", params: { name: name, arguments: {} } }; };' 'const script = [' ' { id: 1, method: \"initialize\", params: { protocolVersion: \"2025-03-26\" } },' ' { id: 2, method: \"tools/list\" },' ' call(3, \"read_menu\"), call(4, \"place_order\"), call(5, \"cancel_all\"), call(6, \"burn_kitchen\"),' '];' > client5.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 cliente con guion, parte 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const seen = [];' 'const log = [];' 'require(\"readline\").createInterface({ input: server.stderr }).on(\"line\", function (line) { log.push(line); });' 'require(\"readline\").createInterface({ input: server.stdout }).on(\"line\", function (line) {' ' const m = JSON.parse(line);' ' const names = m.result && m.result.tools ? \"herramientas: \" + m.result.tools.map(function (t) { return t.name; }).join(\", \") : null;' ' console.log(\"<- \" + (names || (m.result && m.result.content ? m.result.content[0].text : JSON.stringify(m.error || m.result))));' ' seen.push(m);' ' if (seen.length === 6) server.stdin.end();' '});' 'script.forEach(function (m) { server.stdin.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); });' >> client5.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 cliente con guion, parte 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'server.on(\"close\", async function () {' ' log.forEach(function (l) { console.log(\"log \" + l); });' ' const refusal = seen[4].result.content[0].text;' ' const r = await ask([{ role: \"user\", content: \"En una frase corta, explica este rechazo a un estudiante: \" + refusal }], \"Tu cuenta no tiene permiso para cancelar todos los pedidos.\", 60);' ' console.log(\"el modelo dijo: \" + r.text);' '});' >> client5.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 cliente contra el servidor
docker exec -w /home/node/project agent node client5.jsSe muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Qué hace el guion
El cliente arranca el servidor con los alcances menu:read y orders:write en su entorno. Lista las herramientas y luego llama a tres que existen y a una que no.
Imprime el registro del servidor al final. Una llamada corta al modelo explica un rechazo. Sin modelo, imprime una Muestra grabada.
Qué devuelve y qué registra cada llamada
| Llamada | Respuesta | Decisión en el registro |
|---|---|---|
| tools/list | Solo read_menu y place_order | No es una llamada a herramienta, no se registra |
| read_menu | hecho: read_menu | allow |
| place_order | hecho: place_order | allow |
| cancel_all | No permitido: cancel_all necesita orders:admin | deny |
| burn_kitchen | Error de protocolo -32602 | unknown |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.