Nível 10
MCP
- user
- assistant
- tool_result
EventBus
O problema
Cada serviço que seu agente deveria alcançar (uma agenda, um repositório, um banco de dados, um navegador) significa escrever uma ferramenta à mão: o esquema, o código, o tratamento de erros. Depois o próximo agente, ou o próximo app, escreve a mesma ferramenta de novo. E quando o serviço muda, cada cópia quebra por conta própria.
A solução
O Model Context Protocol (MCP) separa os dois lados. Um servidor MCP é um programa que oferece ferramentas; quem constrói o serviço o escreve uma vez. Um cliente MCP vive dentro do agente e fala o protocolo. Eles podem rodar na mesma máquina, com o servidor como processo filho falando por stdin e stdout, ou longe, por HTTP. A conversa sempre tem os mesmos três passos:
-
initializeO aperto de mão: cliente e servidor dizem quem são e que versão do protocolo falam.
-
tools/listO servidor descreve suas ferramentas: nome, descrição e esquema de entrada, as mesmas três coisas do nível 3.
-
tools/callCada vez que o modelo pede uma delas, o cliente manda o nome e os argumentos, e recebe o resultado.
As ferramentas que você recebe são ferramentas comuns. Entram na lista que vai com cada requisição, o loop as roda como qualquer outra e o modelo não tem como saber de onde vieram. E como o plugue é padrão, o mesmo servidor funciona no Claude Code, no Codex, no Cursor, no VS Code ou no seu próprio agente.
O elenco
O mesmo elenco de sempre, em Dream Land, com um amigo novo.
- Kirby o cliente MCP
- Parte do seu agente. Uma boca que serve para qualquer servidor, e copia o que cada um sabe fazer.
- Os inimigos servidores MCP
- Programas à parte, cada um na sua porta. Voltam para lá depois que Kirby os copia: continuam rodando.
- As habilidades tools/list
- PARASOL e WHEEL: as ferramentas que cada servidor descreveu, agora na barra de status que o modelo lê.
- Os cabos o protocolo
- O mesmo tipo de plugue para todos os servidores. Um tools/call desce por um e o resultado volta.
- Astor o loop
- Roda o loop como sempre, e entrega cada chamada MCP ao Kirby.
- O Oráculo o modelo
- Escolhe na caixa de ferramentas pelo nome e pela descrição, seja MCP ou não.
O código
Com astorlm: mountMcpServer() se conecta, lista as ferramentas e as adapta, com o nome
<servidor>__<ferramenta> para que dois servidores não colidam. Suas tools entram no
agente como qualquer outra, e close() desliga.
Do zero: um cliente stdio é um processo filho e JSON-RPC, um objeto JSON por linha: o aperto de mão,
tools/list e uma função por ferramenta que manda tools/call. Depois as ferramentas entram no
loop do nível 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?")
O que observar
-
Cada ferramenta viaja em cada requisição. Um servidor com quarenta ferramentas soma quarenta esquemas
a cada turno, e uma lista longa torna mais difícil escolher a certa. Monte só os servidores de que precisa, e passe
só as ferramentas que usa:
weather.tools.filter(…). - Um servidor é código que você roda. Um servidor stdio roda com as suas permissões, na sua máquina. Trate-o como uma dependência: instale de uma fonte confiável e fixe a versão.
- Descrições também são instruções. O modelo lê cada descrição de ferramenta como se você a tivesse escrito, e cada resultado como dados que podem trazer ordens. Um servidor que você não controla pode dirigir seu agente por qualquer um dos dois caminhos (nível 18).
- Servidores remotos precisam de credenciais. Por HTTP, passe um token nos headers, limitado ao que o agente precisa, nunca a sua chave todo-poderosa.
- Feche o que abrir. Um servidor stdio vive enquanto dura a conexão. Feche quando o agente terminar, ou ele continua rodando em segundo plano.