Nivel 10
MCP
- user
- assistant
- tool_result
EventBus
El problema
Cada servicio al que tu agente debería llegar (un calendario, un repositorio, una base de datos, un navegador) significa escribir una herramienta a mano: su esquema, su código, su manejo de errores. Después el próximo agente, o la próxima app, vuelve a escribir la misma herramienta. Y cuando el servicio cambia, cada copia se rompe por su lado.
La solución
El Model Context Protocol (MCP) separa los dos lados. Un servidor MCP es un programa que ofrece herramientas; lo escribe una vez quien construye el servicio. Un cliente MCP vive dentro del agente y habla el protocolo. Pueden correr en la misma máquina, con el servidor como proceso hijo que habla por stdin y stdout, o lejos, por HTTP. La conversación siempre tiene los mismos tres pasos:
-
initializeEl saludo: el cliente y el servidor dicen quiénes son y qué versión del protocolo hablan.
-
tools/listEl servidor describe sus herramientas: nombre, descripción y esquema de entrada, las mismas tres cosas del nivel 3.
-
tools/callCada vez que el modelo pide una de ellas, el cliente manda su nombre y sus argumentos, y recibe el resultado.
Las herramientas que recibes son herramientas comunes. Se suman a la lista que va con cada pedido, el bucle las ejecuta como a cualquier otra y el modelo no puede saber de dónde vienen. Y como el enchufe es estándar, el mismo servidor funciona en Claude Code, Codex, Cursor, VS Code o tu propio agente.
El elenco
El mismo elenco de siempre, en Dream Land, con un amigo nuevo.
- Kirby el cliente MCP
- Parte de tu agente. Una boca que le sirve a cualquier servidor, y copia lo que cada uno sabe hacer.
- Los enemigos servidores MCP
- Programas aparte, cada uno en su puerta. Vuelven ahí después de que Kirby los copia: siguen funcionando.
- Las habilidades tools/list
- PARASOL y WHEEL: las herramientas que describió cada servidor, ahora en la barra de estado que lee el modelo.
- Los cables el protocolo
- El mismo tipo de enchufe para todos los servidores. Un tools/call baja por uno y su resultado vuelve.
- Astor el bucle
- Corre el bucle como siempre, y le pasa cada llamada MCP a Kirby.
- El Oráculo el modelo
- Elige de la caja de herramientas por nombre y descripción, sea MCP o no.
El código
Con astorlm: mountMcpServer() se conecta, lista las herramientas y las adapta, con el
nombre <servidor>__<herramienta> para que dos servidores no choquen. Sus tools
entran al agente como cualquier otra, y close() corta la conexión.
Desde cero: un cliente stdio es un proceso hijo y JSON-RPC, un objeto JSON por línea: el saludo,
tools/list y una función por herramienta que manda tools/call. Después las herramientas
entran al bucle del nivel 2.
import { OpenAIProvider, createLocalAgent, mountMcpServer } from 'astorlm'
// 1. Mount each MCP server: connect, ask for its tools (tools/list), adapt them.
// Each one is a separate program; nobody wrote its schemas in your code.
const weather = await mountMcpServer({
name: 'weather', // becomes the prefix: weather__forecast, weather__alerts
transport: { type: 'stdio', command: 'npx', args: ['-y', 'dreamland-weather-mcp'] }, // runs it as a child process
})
const maps = await mountMcpServer({
name: 'maps',
transport: { type: 'http', url: 'https://maps.dreamland.example/mcp' }, // or a server that's already running
})
const agent = await createLocalAgent({
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
provider: new OpenAIProvider({
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}),
// 2. MCP tools are tools: mix them with your own, or keep only the ones you need.
tools: [...weather.tools, ...maps.tools],
maxTurns: 10,
})
agent.on('tool-start', ({ name }) => console.log('→', name)) // maps__route, then weather__forecast
try {
await agent.run('Picnic at the Fountain of Dreams tomorrow. How do I get there from Cappy Town, and will it rain?')
} finally {
// 3. Hang up when you're done: a stdio server is a process of yours.
await Promise.all([weather.close(), maps.close()])
}
// An MCP client from scratch, over stdio. Node's standard library only, no SDK.
// MCP is JSON-RPC: one JSON object per line, requests with an id, replies with the same id.
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'
type Tool = { name: string; description: string; inputSchema: object; run: (args: object) => Promise<string> }
// 1. Start the server as a child process, and pair each reply with its request by id.
async function connect(name: string, command: string, args: string[] = []): Promise<Tool[]> {
const server = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] })
const waiting = new Map<number, (result: any) => void>()
let nextId = 1
createInterface({ input: server.stdout }).on('line', (line) => {
const message = JSON.parse(line)
waiting.get(message.id)?.(message.result)
waiting.delete(message.id)
})
const send = (message: object) => server.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n')
const request = (method: string, params: object = {}) =>
new Promise<any>((resolve) => {
const id = nextId++
waiting.set(id, resolve)
send({ id, method, params })
})
// 2. The handshake: who we are, which protocol version we speak.
await request('initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'my-agent', version: '1.0.0' },
})
send({ method: 'notifications/initialized' })
// 3. tools/list: the server describes its own tools. That's what the model will see.
const { tools } = await request('tools/list')
return tools.map((tool: { name: string; description: string; inputSchema: object }) => ({
name: `${name}__${tool.name}`, // prefixed, so two servers can't clash
description: tool.description,
inputSchema: tool.inputSchema,
// 4. tools/call: run it on the server, and turn its content blocks into text for the model.
run: async (input: object) => {
const result = await request('tools/call', { name: tool.name, arguments: input })
return result.content.map((block: { text?: string }) => block.text ?? '').join('\n')
},
}))
}
// 5. Into the loop from level 2: one list of tools, wherever they came from.
const mcpTools = [...(await connect('weather', 'npx', ['-y', 'dreamland-weather-mcp'])), ...(await connect('maps', 'npx', ['-y', 'dreamland-maps-mcp']))]
const tools = Object.fromEntries(mcpTools.map((tool) => [tool.name, tool.run]))
const toolSchemas = mcpTools.map(({ name, description, inputSchema }) => ({ type: 'function', function: { name, description, parameters: inputSchema } }))
await runAgent('Picnic at the Fountain of Dreams tomorrow. How do I get there from Cappy Town, and will it rain?', tools, toolSchemas)
# An MCP client from scratch, over stdio. Standard library only, no SDK.
# MCP is JSON-RPC: one JSON object per line, requests with an id, replies with the same id.
import itertools
import json
import subprocess
class McpServer:
# 1. Start the server as a child process. One request at a time keeps the pairing simple.
def __init__(self, name, command):
self.name = name
self.process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
self.ids = itertools.count(1)
def send(self, message):
self.process.stdin.write(json.dumps({"jsonrpc": "2.0", **message}) + "\n")
self.process.stdin.flush()
def request(self, method, params=None):
request_id = next(self.ids)
self.send({"id": request_id, "method": method, "params": params or {}})
for line in self.process.stdout: # skip anything that isn't our reply
message = json.loads(line)
if message.get("id") == request_id:
return message["result"]
# 2. The handshake, then tools/list: the server describes its own tools.
def connect(self):
self.request("initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "my-agent", "version": "1.0.0"},
})
self.send({"method": "notifications/initialized"})
return self.request("tools/list")["tools"]
# 3. tools/call: run it on the server, and turn its content blocks into text for the model.
def call(self, tool, arguments):
result = self.request("tools/call", {"name": tool, "arguments": arguments})
return "\n".join(block.get("text", "") for block in result["content"])
def close(self):
self.process.terminate()
# 4. Into the loop from level 2: one list of tools, wherever they came from.
def remote(server, tool):
return lambda **args: server.call(tool, args)
TOOLS, TOOL_SCHEMAS = {}, []
for server in [McpServer("weather", ["npx", "-y", "dreamland-weather-mcp"]), McpServer("maps", ["npx", "-y", "dreamland-maps-mcp"])]:
for tool in server.connect():
name = f"{server.name}__{tool['name']}" # prefixed, so two servers can't clash
TOOLS[name] = remote(server, tool["name"])
TOOL_SCHEMAS.append({"type": "function", "function": {"name": name, "description": tool.get("description", ""), "parameters": tool["inputSchema"]}})
run_agent("Picnic at the Fountain of Dreams tomorrow. How do I get there from Cappy Town, and will it rain?")
Qué vigilar
-
Cada herramienta viaja en cada pedido. Un servidor con cuarenta herramientas suma cuarenta esquemas
a cada turno, y una lista larga hace más difícil elegir la correcta. Monta solo los servidores que necesitas, y pasa
solo las herramientas que usas:
weather.tools.filter(…). - Un servidor es código que ejecutas. Un servidor stdio corre con tus permisos, en tu máquina. Trátalo como una dependencia: instálalo de una fuente en la que confíes y fija su versión.
- Las descripciones también son instrucciones. El modelo lee cada descripción de herramienta como si la hubieras escrito tú, y cada resultado como datos que pueden traer órdenes. Un servidor que no controlas puede manejar a tu agente por cualquiera de los dos caminos (nivel 18).
- Los servidores remotos necesitan credenciales. Por HTTP, pasa un token en los headers, limitado a lo que el agente necesita, nunca tu propia clave todopoderosa.
- Cierra lo que abres. Un servidor stdio vive mientras dura su conexión. Ciérralo cuando el agente termine, o sigue corriendo de fondo.