第 10 关
MCP
- user
- assistant
- tool_result
EventBus
问题
智能体要接触的每一个服务(日历、代码仓库、数据库、浏览器),都意味着手写一个工具:它的 schema、代码、错误处理。然后下一个智能体,或下一个应用,又把同样的工具重写一遍。而服务一变,每份拷贝都各自坏掉。
解决方案
Model Context Protocol(MCP)把两边分开。MCP 服务器是提供工具的程序,由构建服务的人写一次。MCP 客户端住在智能体里,负责说这个协议。它们可以跑在同一台机器上,服务器作为子进程通过 stdin 和 stdout 通信;也可以相隔很远,通过 HTTP 通信。对话总是同样的三步:
-
initialize握手:客户端和服务器互报身份,以及各自说的是哪个版本的协议。
-
tools/list服务器描述它的工具:名称、描述和输入 schema,正是第 3 关讲的那三样东西。
-
tools/call每当模型要用其中一个,客户端就发出工具名和参数,再拿回结果。
你拿到的工具就是普通工具。它们加入随每个请求发送的工具列表,循环像运行其他工具一样运行它们,模型也分辨不出它们从哪儿来。而且因为插头是标准的,同一个服务器在 Claude Code、Codex、Cursor、VS Code 或你自己的智能体里都能用。
角色
还是那群熟悉的角色,这次在 Dream Land,还多了一位新朋友。
- 卡比 MCP 客户端
- 你智能体的一部分。一张能对上任何服务器的嘴,复制每个服务器会做的事。
- 敌人 MCP 服务器
- 各自独立的程序,各守一扇门。卡比复制完后它们会回到门前:它们还在继续运行。
- 能力 tools/list
- PARASOL 和 WHEEL:每个服务器描述的工具,现在出现在模型读取的状态栏里。
- 线缆 协议
- 每个服务器用的都是同一种插头。一个 tools/call 顺着线缆过去,结果再顺着回来。
- Astor 循环
- 照常跑循环,把每个 MCP 调用交给卡比。
- 神谕者 模型
- 按名称和描述从工具箱里挑选,不管是不是 MCP。
代码
使用 astorlm:mountMcpServer() 负责连接、列出工具并做适配,工具名为 <服务器>__<工具>,这样两个服务器就不会撞名。它的 tools 像其他工具一样放进智能体,close() 负责挂断。
从零手写:一个 stdio 客户端就是一个子进程加 JSON-RPC,每行一个 JSON 对象:握手、tools/list,再为每个工具写一个发送 tools/call 的函数。然后把这些工具放进第 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?")
注意事项
-
每个工具都会随每个请求发送。一个有四十个工具的服务器,每一轮都会多出四十份 schema,列表太长也会让模型更难选对。只挂载需要的服务器,只传入用得上的工具:
weather.tools.filter(…)。 - 服务器是你运行的代码。stdio 服务器以你的权限、在你的机器上运行。把它当成依赖对待:从可信的来源安装,并锁定版本。
- 描述也是指令。模型会把每条工具描述当成你写的来读,把每个结果当成可能夹带命令的数据。一个你控制不了的服务器,可以通过这两条路中的任何一条操纵你的智能体(第 18 关)。
- 远程服务器需要凭证。走 HTTP 时,在 headers 里传一个 token,权限只限于智能体需要的范围,绝不要用你自己那把万能钥匙。
- 打开的要记得关。stdio 服务器的寿命和它的连接一样长。智能体用完就关掉,否则它会在后台一直跑下去。