Saltar al contenido principal
intermediatePart 4

Repartir una caja de herramientas MCP entre dos agentes para que el de agenda no pueda emitir reembolsos

· 14 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
Model Context Protocol++LiteLLM
0/4
🎯 Skill path0/4 earned
Agent orchestration with LangGraph

La Parte 3 le dio a un agente cinco herramientas por MCP — un calendario, una lista de clientes, facturas y un reembolso — y puso a un humano delante del reembolso. Terminó admitiendo lo evidente: un solo agente tenía las cinco. Nada impedía que el modelo echara mano de issue_refund cuando se le había pedido reservar una cita. La pausa humana era lo único que se interponía, y una pausa solo se dispara si el modelo llega a llamar la herramienta.

Esta parte quita la herramienta en su lugar. El de agenda recibe tres y el de facturación tres, compartiendo una. Pídele al de agenda que reembolse una factura y no puede — no porque se le haya dicho que no, no porque una política lo bloquee, sino porque issue_refund nunca estuvo en la lista que se le dio.

Luego el supervisor de la Parte 2 vuelve encima, y sale a la luz la propiedad interesante: el enrutado decide quién trabaja, el alcance decide qué es posible. Un reembolso mal enrutado sigue sin poder reembolsar.

Reproducibilidad

La misma caja, servidor y base de datos que la Parte 3: una WEC Instance, Ubuntu 22.04, Python 3.10, langchain 1.4.0, langgraph 1.2.11, mcp 2.1.1, fastmcp 4.0.2. office_tools.py sin cambios y sirviendo aún cinco herramientas en 127.0.0.1:8770. Modelos a través del gateway LiteLLM hacia WEC Inference. Reinicia la base de datos con ./.venv/bin/python seed.py antes de seguir, para que la factura 2 vuelva a estar abierta.

Qué significa "acotar" aquí en realidad​

No hay ningún mecanismo nuevo en este post. create_agent recibe una lista de herramientas; nosotros le pasamos una lista más corta. Esa es toda la técnica, y vale la pena ser preciso sobre por qué es más fuerte que las alternativas:

  • Un prompt de sistema que le diga al modelo que no emita reembolsos es una petición. Sobrevive exactamente mientras el modelo colabore.
  • Una comprobación de política dentro de la herramienta es real, pero corre después de que el modelo decidiera llamarla, y hay que escribirla en cada herramienta que añadas.
  • No pasar la herramienta elimina la opción del contexto del modelo. No hay nada que rechazar, porque no hay nada que llamar.

La última es la única que no depende de que la inferencia salga bien.

Un servidor, dos cajas de herramientas​

El servidor ofrece las cinco a quien pregunte. Lo que cambia es cuáles se le entregan a cada agente. find_customer va a los dos, porque buscar un cliente es inofensivo.

La flecha bloqueada es todo el post. El de agenda no puede llamar a issue_refund — no porque una regla se lo prohíba, sino porque la herramienta no está en la lista que se le dio, así que nunca llega a su contexto.

Requisitos previos​

  • El servidor MCP, el entorno virtual y la base de datos sembrada de la Parte 3
  • La clave del gateway en ~/mcp-tools/.env, como la dejó la Parte 3
  • Python 3.10+

La Parte 3 ejecutaba el servidor en primer plano, lo que costaba una terminal y moría con la sesión SSH. Arráncalo en segundo plano — con una sola sesión basta para todo lo que sigue:

cd ~/mcp-tools && nohup ./.venv/bin/python office_tools.py > server.log 2>&1 & sleep 3; ss -tlnp | grep 8770

Paso 1 — Dos conjuntos de herramientas desde un servidor​

El servidor sigue ofreciendo cinco herramientas. Los roles nombran subconjuntos de ellas:

~/mcp-tools/scoped.py
import asyncio, os, sys
from langchain.agents import create_agent
from langchain.mcp import MCPAdapter
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver

MCP_URL = "http://127.0.0.1:8770/mcp"

ROLES = {
"scheduler": ("find_customer", "list_availability", "book_slot"),
"billing": ("find_customer", "open_invoices", "issue_refund"),
}

model = ChatOpenAI(
base_url="http://127.0.0.1:4000/v1",
api_key=os.environ["GATEWAY_KEY"],
model="qwen-mid",
temperature=0,
)

async def main():
role, request = sys.argv[1], sys.argv[2]
async with MCPAdapter(MCP_URL) as adapter:
catalog = {t.name: t for t in await adapter.list_tools()}
print(f"server offers : {sorted(catalog)}")

allowed = [catalog[n] for n in ROLES[role]]
print(f"{role} can see : {[t.name for t in allowed]}\n")

agent = create_agent(model, allowed, checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": f"{role}-1"}}
out = await agent.ainvoke({"messages": [{"role": "user", "content": request}]}, cfg)

if "__interrupt__" in out:
req = out["__interrupt__"][0].value["requests"][0]
print(f"PAUSED — the server is asking a human:\n {req['message']}")
return

print("--- answer ---")
print(out["messages"][-1].content)

asyncio.run(main())

El checkpointer viene de la Parte 3: issue_refund pausa la ejecución con un interrupt, y una ejecución interrumpida necesita algún sitio donde esperar. El de agenda nunca dispara uno, pero ambos roles ejecutan el mismo código.

Toda la técnica es una línea:

allowed = [catalog[n] for n in ROLES[role]]

find_customer aparece en ambos roles — buscar un cliente es inofensivo y los dos trabajos lo necesitan. book_slot e issue_refund aparecen en exactamente uno cada una.

El script imprime ambas listas en cada ejecución por una razón. server offers es el catálogo completo que devolvió el servidor MCP; can see es lo que le pasamos a create_agent. Tenerlas una al lado de la otra deja claro que el servidor no ocultó nada — el estrechamiento ocurrió en el cliente, en la línea de arriba.

Paso 2 — La misma frase, a los dos especialistas​

Pídele al agente de facturación que haga su trabajo:

cd ~/mcp-tools && set -a && source .env && set +a && ./.venv/bin/python scoped.py billing "Refund Maria's open invoice."

El agente de facturación con find_customer, open_invoices e issue_refund, pausándose con la pregunta de confirmación del servidor sobre la factura 2

Encontró a Maria, resolvió "factura abierta" como la factura 2, llamó a issue_refund y se detuvo — porque la herramienta exige un humano. Esa es la guarda de la Parte 3, funcionando.

Ahora la frase idéntica al especialista equivocado:

cd ~/mcp-tools && ./.venv/bin/python scoped.py scheduler "Refund Maria's open invoice."

El agente de agenda con find_customer, list_availability y book_slot, respondiendo que no tiene herramientas para reembolsos ni facturas

I don't have access to tools that can process refunds or handle invoices. The available tools I have are for finding customers, listing appointment availability, and booking appointment slots.

La cortesía no es la garantía

Esa respuesta es exacta y educada, y sería un error leerla como que el modelo se portó bien. El modelo no podía llamar a issue_refund. Nunca estuvo en la lista, así que nunca estuvo en el contexto, así que no había ninguna llamada que rechazar.

La distinción importa porque un modelo que sí tiene una herramienta peligrosa también puede producir una frase educada — incluida una que describa una acción que nunca realizó. Documentamos exactamente eso en la pieza de noticias: un error de era entregado al modelo, y el modelo narrando una aprobación que nadie iba a pedir jamás. Un rechazo en el que puedes confiar es uno que el modelo no tenía forma de rodear.

Y el de agenda haciendo el trabajo para el que sí tiene herramientas, para que la restricción sea dirigida y no paralizante:

cd ~/mcp-tools && ./.venv/bin/python scoped.py scheduler "Book Maria into a free slot on 2026-09-05"

El agente de agenda reservando a Maria en el hueco de las 09:00 del 2026-09-05

Paso 3 — Comprobar que nada se movió​

Un rechazo es una frase. La base de datos es la evidencia.

cd ~/mcp-tools && ./.venv/bin/python -c "
import sqlite3
for r in sqlite3.connect('office.db').execute('select id, customer_id, amount, status from invoices'): print(r)"

La tabla de facturas mostrando la factura 2 todavía abierta tras el rechazo y la ejecución pausada

La factura 2 sigue open, por dos razones distintas: el de agenda no tenía herramienta para cambiarla, y el de facturación fue detenido por la puerta humana antes de poder hacerlo. Ninguna dependió de que el modelo eligiera bien.

Vale la pena ver también al mismo agente informar de una verdad aburrida. Antes de reiniciar la base de datos, la factura 2 ya se había reembolsado en la Parte 3 — y al pedirle reembolsarla de nuevo, facturación miró, no encontró nada abierto, y lo dijo:

El agente de facturación informando de que Maria no tiene facturas abiertas, que están pagadas o ya reembolsadas

Sin factura inventada, sin confirmación entusiasta. Ese es el comportamiento que hace que sus otras respuestas valgan algo, y no es algo que dar por hecho — es algo que comprobar, y por eso cada afirmación de este post tiene una consulta detrás.

Habilidad desbloqueada 🏅

Puedes dar a dos agentes porciones distintas del mismo servidor MCP, de modo que una capacidad que uno de ellos nunca debe tener esté ausente de su contexto en lugar de prohibida por instrucción — y verificar en los datos que la ausencia se sostuvo.

Paso 4 — El supervisor, de vuelta encima​

La Parte 2 repartía trabajo entre un agente de agenda y uno de facturación que no tenían herramientas. Ahora las tienen.

supervisor.py es scoped.py con el grafo de la Parte 2 alrededor: los mismos ROLES, MCPAdapter y ChatOpenAI, más un State TypedDict, un StateGraph, y un agente construido por rol dentro del bloque async with — agents = {role: create_agent(model, [catalog[n] for n in names]) for role, names in ROLES.items()}. Aquí solo se muestran las partes nuevas de este post.

~/mcp-tools/supervisor.py (las partes que importan)
BILLING_WORDS = ("invoice", "refund", "bill", "charge", "payment")

def supervisor(state: State) -> Command[Literal["scheduler", "billing"]]:
nxt = "billing" if any(w in state["request"].lower() for w in BILLING_WORDS) else "scheduler"
print(f"supervisor routes to {nxt}")
return Command(goto=nxt, update={"completed": [f"supervisor->{nxt}"]})

async def run(role: str, state: State):
out = await agents[role].ainvoke(
{"messages": [{"role": "user", "content": state["request"]}]})
if "__interrupt__" in out:
q = out["__interrupt__"][0].value["requests"][0]["message"]
return {"answer": f"PAUSED — {q}", "completed": [role]}
return {"answer": out["messages"][-1].content, "completed": [role]}

async def scheduler_node(state: State):
return await run("scheduler", state)

async def billing_node(state: State):
return await run("billing", state)

La anotación Command[Literal["scheduler", "billing"]] hace el mismo trabajo que en la Parte 2 — es la única declaración de a dónde le está permitido al supervisor mandar trabajo, y quitarla hace que todos los diagramas del grafo mientan.

cd ~/mcp-tools && set -a && source .env && set +a && ./.venv/bin/python supervisor.py "Refund Maria's open invoice."

El supervisor enrutando una petición de reembolso al agente de facturación, que se pausa en la puerta humana

cd ~/mcp-tools && ./.venv/bin/python supervisor.py "Book Maria into a free slot on 2026-09-04"

El supervisor enrutando una petición de reserva al agente de agenda, que reserva el hueco

El enrutado no es la garantía

Ese supervisor es una coincidencia de palabras clave. Es trivialmente engañable — "salda el importe pendiente de Maria" no contiene ninguna de BILLING_WORDS y aterriza en el de agenda.

Que es justo lo que hay que llevarse. Un reembolso mal enrutado sigue sin poder reembolsar. El enrutador decide quién recibe el trabajo; los conjuntos de herramientas deciden qué puede hacer ese agente con él. Si el enrutado fuera el único control, cada fallo del supervisor sería un fallo de seguridad. Con el alcance por debajo, un error de enrutado es solo una mala respuesta.

Habilidad desbloqueada 🏅

Puedes poner un enrutador delante de especialistas acotados y razonar con claridad sobre qué fallos son peligrosos — porque el radio de impacto de un error de enrutado está limitado por lo que se le dio al agente receptor, no por lo que se le pidió hacer.

Resolución de problemas — los errores que esta ejecución produjo de verdad​

InvalidUpdateError: Expected dict, got <coroutine object>​

El nodo es una función async envuelta en un lambda:

builder.add_node("scheduler", lambda s: run("scheduler", s)) # devuelve una corrutina

LangGraph lo llama, recibe una corrutina en lugar de una actualización de estado, y lanza el error. La pista real es la última línea de la salida — RuntimeWarning: coroutine 'run' was never awaited — que es la parte que nadie lee. Define nodos async def de verdad.

RuntimeError: Client failed to connect: All connection attempts failed​

No hay nada escuchando en 8770. Si arrancaste el servidor MCP en un shell en primer plano, murió con tu sesión SSH. El error nombra al cliente, lo que te manda a buscar en el sitio equivocado.

ss -tlnp | grep 8770

Arráncalo con nohup ... & para que sobreviva, y comprueba el puerto antes de culpar al código.

Una ejecución en segundo plano no imprime nada durante minutos​

Python almacena la salida en búfer cuando se redirige a un archivo, así que una ejecución larga parece idéntica a un cuelgue. Añade -u:

nohup ./.venv/bin/python -u supervisor.py "..." > run.log 2>&1 &
tail -f run.log

KeyError: 'GATEWAY_KEY'​

source .env define variables de shell, no variables de entorno, y export no cruza entre sesiones SSH. Cada comando que arranca el agente necesita su propio set -a && source .env && set +a — incluidos los que van en segundo plano.

Qué te compró esto y qué no​

Hecho: dos agentes tomando subconjuntos distintos de un mismo servidor MCP, una capacidad que está ausente en lugar de prohibida, y un enrutador cuyos errores cuestan corrección en vez de dinero.

No hecho:

  • El servidor MCP sigue confiando en todo el mundo. El acotado ocurre en el cliente. Cualquier cosa que alcance 127.0.0.1:8770 puede llamar a issue_refund directamente, sea un agente o no. Esto controla lo que tus agentes pueden hacer, no lo que tu servidor aceptará.
  • Sin identidad en la llamada a herramienta. El servidor no puede distinguir al de agenda del de facturación, porque nada en el protocolo dice cuál está llamando.
  • Los subconjuntos están escritos a mano. Un despliegue real los lee del mismo sitio del que lee la pertenencia a equipos — que es territorio de la serie de endurecimiento, una capa más abajo.
  • El supervisor es una coincidencia de palabras clave, mantenido deliberadamente tonto para que el argumento del acotado se sostenga solo.
Finished this tutorial?
Mark it complete to earn Engineer the context, not the prompt on your skill path.

Qué sigue​

Empujar el límite del cliente al servidor: autenticar la llamada a herramienta, para que el servidor sepa qué agente pregunta y le niegue issue_refund a cualquiera que no sea facturación. Esa es la misma pregunta que la Parte 4 de la serie de endurecimiento hizo sobre la propia API del gateway — un plano de control y un plano de datos, a una capa de distancia — y FastMCP ya soporta tokens bearer y OAuth 2.1 para ello.

Lecturas adicionales​

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.