MCP and Skills: the Protocol, Server/Client, How It Differs From Function Calling; a Tour of the Claude Agent SDK
Get acquainted with the MCP protocol's server/client structure and how it differs from plain function calling, write a minimal MCP server and wire it into an agent, then take a quick tour of the Claude Agent SDK.
今日目标
- 能说清 MCP 的 server/client 结构和它解决的问题
- 能对比 MCP 和普通 function calling 的区别与各自适用场景
- 能写一个最小的 MCP server 并把它接入到自己的 Agent 里
昨天你把工具的闸门收紧了:白名单、参数上限、不可信输入一律不当指令。但那些工具本身还是硬编码在 Agent 里的——每接一个新能力,都要改一次宿主代码、重新部署一次。今天把这一层也拆开。读完回来把上面三条勾掉。
小白版讲解
每台打印机配一张驱动盘
二十年前买打印机,盒子里会附一张驱动盘。装机要插盘,换台电脑要再插一遍,系统一升级驱动就可能失效。更麻烦的是站在厂商那一侧看:同一台打印机,要为每个操作系统各写一份驱动,操作系统出新版本就得再出一版。两边的版本号乘在一起,才是真正要维护的东西。
后来有了统一的打印协议:打印机按协议说话,操作系统按协议听,插上就能用。厂商不用管你装的是什么系统,系统也不用认识每一台具体的打印机,两边各自演进、互不打扰。
给 Agent 接能力,现在正处在驱动盘那个阶段。你的 Agent 要查订单,就在代码里 import 一个函数、写一段工具描述、把它注册进工具表;风控团队给了你一个黑名单查询,你再 import 一次、再写一段描述。看一眼你现在的代码长什么样:
// 没有协议的时候:每接一个能力,宿主代码都要改一次、重新部署一次
import { queryOrder } from './tools/order.js'
import { trackShipment } from './tools/shipment.js'
import { queryRiskList } from './vendor/risk-sdk.js' // 风控团队给的包,字段含义只有他们清楚
export const TOOLS = [
{ name: 'query_order', description: '按订单号查询订单状态、金额和下单时间', run: queryOrder },
{ name: 'track_shipment', description: '按订单号查询物流轨迹与承运商', run: trackShipment },
// 风控团队上周把返回字段改了名,于是这一行、这个文件、这个服务,都得跟着发一次版
{ name: 'query_risk_list', description: '查询用户是否在风控黑名单里', run: queryRiskList },
]# 没有协议的时候:每接一个能力,宿主代码都要改一次、重新部署一次
from dataclasses import dataclass
from typing import Callable
from tools.order import query_order
from tools.shipment import track_shipment
from vendor.risk_sdk import query_risk_list # 风控团队给的包,字段含义只有他们清楚
@dataclass
class ToolSpec:
name: str
description: str
run: Callable[[dict], str]
TOOLS = [
ToolSpec("query_order", "按订单号查询订单状态、金额和下单时间", query_order),
ToolSpec("track_shipment", "按订单号查询物流轨迹与承运商", track_shipment),
# 风控团队上周把返回字段改了名,于是这一行、这个文件、这个服务,都得跟着发一次版
ToolSpec("query_risk_list", "查询用户是否在风控黑名单里", query_risk_list),
]// 依赖:JDK 17+。record 描述这种不可变的注册表最干净
// 没有协议的时候:每接一个能力,宿主代码都要改一次、重新部署一次
record ToolSpec(String name, String description, Function<Map<String, Object>, String> run) {}
static final List<ToolSpec> TOOLS = List.of(
new ToolSpec("query_order", "按订单号查询订单状态、金额和下单时间", OrderTools::queryOrder),
new ToolSpec("track_shipment", "按订单号查询物流轨迹与承运商", ShipmentTools::trackShipment),
// 风控团队上周把返回字段改了名,于是这一行、这个文件、这个服务,都得跟着发一次版
new ToolSpec("query_risk_list", "查询用户是否在风控黑名单里", RiskSdk::queryRiskList)
);// 没有协议的时候:每接一个能力,宿主代码都要改一次、重新部署一次
struct ToolSpec {
let name: String
let description: String
let run: ([String: Any]) -> String
}
let tools: [ToolSpec] = [
ToolSpec(name: "query_order", description: "按订单号查询订单状态、金额和下单时间", run: queryOrder),
ToolSpec(name: "track_shipment", description: "按订单号查询物流轨迹与承运商", run: trackShipment),
// 风控团队上周把返回字段改了名,于是这一行、这个文件、这个服务,都得跟着发一次版
ToolSpec(name: "query_risk_list", description: "查询用户是否在风控黑名单里", run: queryRiskList),
]这段代码没有一行是错的。问题在于它把「谁提供这个能力」和「谁使用这个能力」焊死在了同一次编译里。三个后果,一个比一个贵:
第一,接入成本是乘法。 你手上不止一个宿主:自己写的客服 Agent、IDE 里的编码助手、运维群里的值班机器人。能力也不止一个:订单、物流、退款、风控、知识库。三个宿主乘五个能力,就是十五份接入代码,每一份都要各自测试、各自升级。协议的全部价值,就是把这个乘法变成加法。
第二,责任边界被抹掉了。 风控的黑名单查询是风控团队维护的,可那段接入代码在你的仓库里、由你的 CI 构建、出问题先找你。他们改一个字段名,你得先在自己的服务上复现一遍才敢说这不是你的锅。
第三,加一个能力要走一次完整的发版流程。 改代码、过 code review、构建、灰度、观察。给一个已经上线的 Agent 临时接一个内部工具,本该是配置层面的事,现在成了一次发布。
这就是驱动盘时代。而 MCP(Model Context Protocol,模型上下文协议)想做的,正是打印协议那件事:在你的程序和能力提供方之间,插一层双方都认的接口。
那问题来了——你前面二十多天一直在写的 function calling,不也是「让模型用上外部能力」吗?MCP 和它到底是什么关系?很多人的第一反应是「MCP 是 function calling 的升级版,以后不用写 function calling 了」。这个答案错得相当彻底,而且是面试里最容易被一句话戳穿的那种错。下面把这件事讲清楚。
server 和 client:谁提供能力,谁消费能力
先把三个角色分开,这三个词在面试里经常被人混着用:
- MCP server(能力提供方):一个独立的程序,把自己会做的事按协议暴露出来。它可以是你自己写的十几行脚本,也可以是某个团队维护的服务,或者第三方发布的一个包。
- MCP client(连接方):宿主里负责跟一个 server 说话的那一小块代码。一个 client 只连一个 server,这是很多人搞错的地方。
- 宿主(host):你的 Agent 应用本身。它同时持有多个 client,每个 client 挂着一个 server。
用打印机继续对:server 是打印机,client 是操作系统里那个打印队列,宿主是你的电脑。你的电脑可以同时连三台打印机,每台各有一个队列。
server 侧能暴露三类东西,别只记得工具那一类:
| 原语 | 是什么 | 谁来选 |
|---|---|---|
| tools | 可执行的动作,比如查订单、发工单 | 模型选。它们会进入模型的工具列表 |
| resources | 只读数据,按 URI 读取,比如一份文档、一张表的当前快照 | 通常由用户或宿主决定给不给模型看 |
| prompts | 可复用的提示词模板,比如「按这个格式写一份工单摘要」 | 通常由用户主动触发 |
一句话记住区别:tools 是模型来挑,resources 和 prompts 通常是人来挑。 这个分工不是随意的——把一份大文档做成 resource 而不是 tool,等于把「要不要花这些 token」的决定权从模型手里收回到人手里,这是上下文预算的一部分(D6 讲过为什么这笔预算要抠)。
反过来,client 一侧也能声明自己的能力,让 server 反过来请求宿主做事:sampling(server 请宿主跑一次模型补全)、roots(宿主告诉 server 哪些目录对它可见)、elicitation(server 请宿主向用户要一条输入)。这几样今天只提一句,知道有这回事就够了,实际用到的机会远少于 tools。
传输方式有两种,别记错:
- stdio:server 是一个本地可执行程序,宿主把它当子进程拉起来,两边用它的标准输入输出通信。本地工具绝大多数走这条路。
- Streamable HTTP:远程 server 走这条,一个 HTTP 端点收发消息,需要长连接推送时在同一个端点上升级成事件流。
还有一种旧的 HTTP 加 SSE 的双端点传输,在官方 SDK 里已经标成 legacy,只为兼容老客户端保留。面试时把它说成现行方案,等于告诉对方你看的是去年的文章。
报文本身没有任何新鲜东西,就是 JSON-RPC 2.0。stdio 传输下,一条消息占一行,用换行分隔:
--> {"jsonrpc":"2.0","id":2,"method":"tools/list"}
<-- {"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"query_order",
"description":"按订单号查询一笔订单的状态、金额和下单时间。只查订单本身;物流轨迹请用 track_shipment。",
"inputSchema":{"type":"object",
"properties":{"order_id":{"type":"string","pattern":"^SO\\d{8}$"}},
"required":["order_id"]}}]}}看清楚 inputSchema 那一段——它就是一份普通的 JSON Schema,和你 D5 那天手写的工具参数定义长得一模一样。记住这个细节,下一节的立论全靠它。
握手的顺序是固定的:client 发 initialize 报上自己的协议版本和能力,server 回自己的版本和能力,client 再发一条 notifications/initialized 表示可以开工,之后才轮到 tools/list 和 tools/call。用官方 SDK 时这些它替你做了,但你至少要知道链路上有这几步,否则卡在哪一步都排查不出来。手写一遍是最快的理解方式:
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'
// stdio 传输:server 就是一个子进程,它的 stdout 是 JSON-RPC 的专用管道
const child = spawn('node', ['mcp-server.js'], { stdio: ['pipe', 'pipe', 'inherit'] })
const reader = createInterface({ input: child.stdout })[Symbol.asyncIterator]()
function send(payload) {
child.stdin.write(JSON.stringify(payload) + '\n') // 换行就是消息边界
}
async function rpc(id, method, params) {
send({ jsonrpc: '2.0', id, method, params })
for (;;) {
const { value, done } = await reader.next()
if (done) throw new Error('server 已退出')
const msg = JSON.parse(value)
if (msg.id === id) return msg.result // 通知没有 id,读到就跳过继续等
}
}
await rpc(1, 'initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'shop-agent', version: '1.0.0' },
})
send({ jsonrpc: '2.0', method: 'notifications/initialized' }) // 握手的第三步,不能省
const listed = await rpc(2, 'tools/list', {})
const called = await rpc(3, 'tools/call', {
name: 'query_order',
arguments: { order_id: 'SO20260901' },
})import json
import subprocess
# stdio 传输:server 就是一个子进程,它的 stdout 是 JSON-RPC 的专用管道
child = subprocess.Popen(
["python", "mcp_server.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
encoding="utf-8",
)
def send(payload: dict) -> None:
child.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n") # 换行就是消息边界
child.stdin.flush()
def rpc(rid: int, method: str, params: dict) -> dict:
send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
for line in child.stdout:
msg = json.loads(line)
if msg.get("id") == rid: # 通知没有 id,读到就跳过继续等
return msg["result"]
raise RuntimeError("server 已退出")
rpc(1, "initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "shop-agent", "version": "1.0.0"},
})
send({"jsonrpc": "2.0", "method": "notifications/initialized"}) # 握手的第三步,不能省
listed = rpc(2, "tools/list", {})
called = rpc(3, "tools/call", {"name": "query_order", "arguments": {"order_id": "SO20260901"}})// 依赖:JDK 17+ 的 ProcessBuilder + Jackson。BufferedReader 自带按行切分,不用手写行缓冲
var child = new ProcessBuilder("java", "-jar", "mcp-server.jar").start();
var out = new BufferedWriter(new OutputStreamWriter(child.getOutputStream(), UTF_8));
var in = new BufferedReader(new InputStreamReader(child.getInputStream(), UTF_8));
var mapper = new ObjectMapper();
Consumer<ObjectNode> send = payload -> {
try {
out.write(mapper.writeValueAsString(payload));
out.write('\n'); // 换行就是消息边界
out.flush(); // 不 flush 就永远等不到回复:请求还躺在缓冲区里
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
JsonNode rpc(int id, String method, ObjectNode params) throws IOException {
var payload = mapper.createObjectNode();
payload.put("jsonrpc", "2.0").put("id", id).put("method", method).set("params", params);
send.accept(payload);
for (String line = in.readLine(); line != null; line = in.readLine()) {
var msg = mapper.readTree(line);
if (msg.path("id").asInt(-1) == id) return msg.get("result"); // 通知没有 id,跳过
}
throw new IOException("server 已退出");
}import Foundation
// Swift 的地道写法是给报文定类型,而不是到处 as? 强转字典
struct RpcRequest<P: Encodable>: Encodable {
let jsonrpc = "2.0"
let id: Int?
let method: String
let params: P?
}
struct RpcResponse<R: Decodable>: Decodable {
let id: Int?
let result: R?
}
struct ToolDescriptor: Decodable { let name: String; let description: String? }
struct ToolsList: Decodable { let tools: [ToolDescriptor] }
let child = Process()
child.executableURL = URL(fileURLWithPath: "/usr/bin/env")
child.arguments = ["node", "mcp-server.js"]
let toServer = Pipe()
let fromServer = Pipe()
child.standardInput = toServer
child.standardOutput = fromServer
try child.run()
// bytes.lines 已经替你做了按行切分与缓冲,不用像 JS 那样手动挂一个 readline
var lines = fromServer.fileHandleForReading.bytes.lines.makeAsyncIterator()
func send<P: Encodable>(_ request: RpcRequest<P>) throws {
var data = try JSONEncoder().encode(request)
data.append(0x0A) // 换行就是消息边界
try toServer.fileHandleForWriting.write(contentsOf: data)
}
func rpc<P: Encodable, R: Decodable>(_ id: Int, _ method: String, _ params: P) async throws -> R {
try send(RpcRequest(id: id, method: method, params: params))
while let line = try await lines.next() {
guard let data = line.data(using: .utf8),
let envelope = try? JSONDecoder().decode(RpcResponse<R>.self, from: data),
envelope.id == id // 通知没有 id,读到就跳过继续等
else { continue }
if let result = envelope.result { return result }
}
throw URLError(.badServerResponse)
}
let listed: ToolsList = try await rpc(2, "tools/list", [String: String]())工程代价有三条,都很具体。一,stdio server 里绝对不能往 stdout 打日志——那是协议管道,多打一行普通文本,client 就会收到一条解析不了的消息。这个坑最阴的地方在于,你单独跑 server 一切正常,只有被当子进程接进去才炸。二,子进程的生命周期归你管:宿主退出没杀掉 server,机器上就留一堆孤儿进程。三,排障链路变长了:以前工具没被调用只有一个原因(模型没选它),现在有三个——模型没选、翻译时把 schema 弄丢了、server 压根没起来。
全章最值钱的一句:MCP 不是 function calling 的升级版
现在可以下这个判断了:
function calling 是「模型 ↔ 你的程序」之间的约定,MCP 是「你的程序 ↔ 能力提供方」之间的约定。
两者不在同一段链路上,因此不是替代关系,是上下游。模型根本不知道 MCP 存在——它收到的永远是一个工具列表和一份 JSON Schema。你从 MCP server 上 tools/list 拿回来的每一个工具,最终都要被翻译成 function calling 的形状发出去。接了 MCP 之后,你的 function calling 代码一行都不会少。
翻译这一步有多便宜?看代码。MCP 的 inputSchema 本身就是 JSON Schema,所以这根本不是格式转换,是原样搬运:
// MCP 的 inputSchema 本身就是 JSON Schema,这一步是原样搬运,不是格式转换
export function toFunctionTool(tool) {
return {
type: 'function',
function: {
name: tool.name,
description: tool.description ?? '', // 漏了它,模型只能靠工具名猜用途(D5 的口径)
parameters: {
type: 'object',
properties: tool.inputSchema.properties ?? {},
required: tool.inputSchema.required ?? [],
},
},
}
}
// 本地工具和 MCP 工具合并成同一个数组发给模型——模型分不出,也不需要分
const tools = [...localTools, ...mcpTools.map(toFunctionTool)]# MCP 的 input_schema 本身就是 JSON Schema,这一步是原样搬运,不是格式转换
def to_function_tool(tool: dict) -> dict:
schema = tool.get("inputSchema", {})
return {
"type": "function",
"function": {
"name": tool["name"],
# 漏了 description,模型只能靠工具名猜用途(D5 的口径)
"description": tool.get("description", ""),
"parameters": {
"type": "object",
"properties": schema.get("properties", {}),
"required": schema.get("required", []),
},
},
}
# 本地工具和 MCP 工具合并成同一个列表发给模型——模型分不出,也不需要分
tools = local_tools + [to_function_tool(t) for t in mcp_tools]// 依赖:Jackson。MCP 的 inputSchema 本身就是 JSON Schema,这一步是原样搬运
static ObjectNode toFunctionTool(JsonNode tool, ObjectMapper mapper) {
var schema = tool.path("inputSchema");
var parameters = mapper.createObjectNode();
parameters.put("type", "object");
parameters.set("properties", schema.has("properties")
? schema.get("properties")
: mapper.createObjectNode());
parameters.set("required", schema.has("required")
? schema.get("required")
: mapper.createArrayNode());
var function = mapper.createObjectNode();
function.put("name", tool.get("name").asText());
// 漏了 description,模型只能靠工具名猜用途(D5 的口径)
function.put("description", tool.path("description").asText(""));
function.set("parameters", parameters);
var wrapper = mapper.createObjectNode();
wrapper.put("type", "function");
wrapper.set("function", function);
return wrapper;
}// MCP 的 inputSchema 本身就是 JSON Schema,这一步是原样搬运。
// Swift 里给它一个 Codable 类型,比端着一个 [String: Any] 字典安全得多
struct JsonSchema: Codable {
var type: String = "object"
var properties: [String: SchemaField]?
var required: [String]?
}
struct SchemaField: Codable {
let type: String
let description: String?
let pattern: String?
}
struct McpTool: Decodable {
let name: String
let description: String?
let inputSchema: JsonSchema
}
struct FunctionTool: Encodable {
struct Function: Encodable {
let name: String
let description: String
let parameters: JsonSchema
}
let type = "function"
let function: Function
}
func toFunctionTool(_ tool: McpTool) -> FunctionTool {
let parameters = JsonSchema(
properties: tool.inputSchema.properties ?? [:],
required: tool.inputSchema.required ?? []
)
return FunctionTool(function: .init(
name: tool.name,
// 漏了 description,模型只能靠工具名猜用途(D5 的口径)
description: tool.description ?? "",
parameters: parameters
))
}正因为这一步这么便宜,才更说明它们是上下游而不是替代品。 如果 MCP 真是 function calling 的升级版,这个函数应该很难写,因为两边的模型不同;实际上它只是把同一份 schema 换了个外壳。
顺便交代 MCP 的工具结果长什么样,这一点和 D5 的错误回传直接对上:结果是一个内容块数组,外加一个 isError 布尔字段。isError 为真不是异常——这次远程调用是成功的,只是工具在业务上失败了(订单不存在)。真正会抛异常的是另一类:参数没过 schema 校验、工具名不存在,那走的是 JSON-RPC 的 error。两类都要接住,都要变成一段模型读得懂的纠错说明回填回去,D5 那套错误回传在这里一字不用改。只处理其中一类,另一类就会以「模型收到一句空话然后原地打转」的形式静默跑偏。
那什么时候才该上 MCP?三条判据,命中任意一条才考虑,一条都不命中就直接写 function calling:
- 这个能力要被多个宿主复用。 一个 server 接一次,三个宿主都能用,乘法变加法。
- 这个能力由另一个团队或第三方维护。 进程边界就是责任边界,他们改他们的,你的服务不用重新发版。
- 需要在不改宿主代码的前提下增删能力。 配置里加一行、重启宿主,新工具就出现在工具列表里了。
一条都不命中还硬上,你付出的是:多一个进程、多一次握手、多一层排障,以及一份需要自己保活的子进程生命周期。给自家 Agent 写的、自己维护的、也不需要热插拔的能力,直接写成本地函数是更好的工程决策。
写到这里你应该觉得眼熟了:D4 把模型做成可插拔的 provider,网站的支付做成 PaymentProvider,D20 把通知渠道做成 provider,今天是能力提供方也变成可插拔的。这是同一招的第四次复现——把「谁来干这件事」从代码里抽出来,变成一个接口加一份配置。认出这个模式,比记住 MCP 的方法名有用得多。
Skills:MCP 扩展「能做什么」,Skills 扩展「怎么做得好」
回到打印机:驱动让打印机能用,而厂商附带的预设纸型、排版模板和色彩配置,让它用得好。这两样解决的不是同一个问题。
Skills 就是后者。它不给 Agent 新增任何调用能力,而是把「做好某一类事所需要的知识」打成一个包:一段说明该怎么做的提示词,几个可以直接跑的脚本,外加一些参考资料。形状大概是这样:
skills/
└── refund-review/
├── SKILL.md 一句话说明它解决什么问题 + 什么时候该用它 + 详细步骤
├── scripts/
│ └── check-policy.py 校验退款金额是否超出该客户等级的上限
└── references/
└── refund-rules.md 退款规则全文,只在真的要判定时才读进来一句话钉死区别:MCP 扩展的是「能做什么」,Skills 扩展的是「怎么做得好」。 一个新增动作,一个新增方法论。
关键机制是按需加载,而这一点直接接上 D6 讲过的上下文预算。假如你有二十份这样的作业指导书,全塞进系统提示词,光它们就能吃掉几万 token,每一轮对话都要为此付一次钱,而其中十九份跟当前这个问题毫无关系。按需加载的做法是:平时只把每个 skill 的一句话简介放进上下文,模型判断这次用得上,才把正文和参考资料读进来。从「每轮都付全款」变成「用到才付」。
工程代价有两条,都要说在前面。一,命中判断是概率性的。 「什么时候该加载这个 skill」由模型读那句简介来判断,简介写得含糊就永远不会被命中——这跟 D5 讲的工具描述是同一件事,也同样值得反复打磨。二,skill 里的脚本是要被执行的。 一个能跑脚本的包,就是一个新增的攻击面,昨天讲的最小权限和沙箱在这里全部适用:脚本能读哪些文件、能不能联网、跑多久超时,这些必须由你的运行时说了算,而不是由包里的说明文字说了算。
各家产品对 Skills 的具体实现和目录约定并不统一,也还在变。这里只讲思路不给 API,落地时以官方文档为准——记住「一组提示词加脚本加参考资料、按需加载」这个形状就够了。
把 Claude Agent SDK 放进 D21 那张表
D21 已经把 Pi SDK 和 LangGraph 的选型做过一次了,今天不重开讨论,只是把第三个选项放进同一张表:
| 框架 | 它抽象的是什么 | 什么时候选它 | 代价 |
|---|---|---|---|
| Pi SDK | 单个 Agent 的循环:模型层、内核层、应用层三层(D3) | 一个 Agent 就够用,要看得见循环的每一步,要把它嵌进自己的程序 | 多个角色之间的编排要自己写 |
| LangGraph | 多个角色之间的状态流转:节点、边、reducer、checkpointer(D15 到 D18) | 有分支、有回路、要人工介入、要断点续跑 | 单 Agent 场景是杀鸡用牛刀,调试强依赖链路追踪 |
| Claude Agent SDK | 一个通用 Agent 运行时:自带文件读写与命令执行,能挂 MCP server 和 Skills | Agent 的工作对象就是一个代码仓库或一台机器上的文件 | 与一家模型厂商绑定,运行时替你做的事更多、可见性更低 |
选的时候先问自己一句:我要抽象掉的是循环、是编排,还是整个运行时? 三个答案对应三行,基本不会选错。
最后一条提醒,正好也是 D4 那天的老话:Claude Agent SDK 这类和厂商绑定的运行时,用之前先确认自己能不能接受这个绑定。能力可以插拔,模型也应该能插拔——两件事的道理是同一件。
源码导读
动手实验
starter/ 里挖了五个练习点,MOCK=1 下完全离线跑通。注意这里的 MOCK=1 只挡住模型调用那一个网络出口,MCP server 照样真的被拉起来、真的走一遍协议——自检里看到的工具列表、参数校验报错、调用返回,全是真实往返出来的。toFunctionTool 的挖空处返回的正是本章批判的那个空壳 schema,先原样跑一次,你会看到模型交上来一个空参数对象然后被 server 挡回来。
- 在
mcp-server.ts里用registerTool把track_shipment也注册进去,重跑自检,看第 2 项从一个工具变成两个——宿主代码一行没动。 - 补完
toFunctionTool,把 MCP 的inputSchema原样搬进 function calling 的parameters,确认第 3 项的两份 schema 逐字段相等。 - 在
mcp-client.ts里真的发一次tools/call,把结果压成文本,看第 4 项跑通完整链路。 - 在
agent.ts里把失败原文回填给模型(业务失败看isError,协议失败接住异常),看第 5 项里模型自己把订单号改对。 - 写出
shouldUseMcp的三条判据,让「一条都不命中就直接写 function calling」成为默认答案。
面试题
今天 4 道题在下方题库区,侧重 MCP 是什么、和 function calling 是什么关系、什么时候该用。展开后先看"分析过程"再看要点——第 1 题里那句「上下游而不是替代」是本章的题眼,第 4 题的第三方风险是最容易问到的追问点,别跳过。
检查清单与明日预告
- 能说清 MCP 的 server/client 结构和它解决的问题
- 能对比 MCP 和普通 function calling 的区别与各自适用场景
- 能写一个最小的 MCP server 并把它接入到自己的 Agent 里
- 能一句话说出 MCP 与 Skills 的分工:一个扩展能做什么,一个扩展怎么做得好
- 实验的 5 条验收标准全部通过
- 4 道面试题不看要点也能答出至少 3 道
明天(D24)我们回头修检索。今天把「接一个能力」变便宜了,于是你会很自然地接进来一堆知识库和检索服务——但 D12 那一路检索本身还很粗:只有一路向量相似度,型号、错误码、订单号这种需要精确匹配的查询经常整条漏掉,而且没有任何指标能告诉你漏了多少。接得再多,也救不回一个召回率本身就低的检索。 所以先修上游:混合检索、重排、引用,以及一套能量出「找得到还是找不到」的评估方法。
Interview questions
What problem does MCP solve, and how is it different from function calling?MCP 协议解决了什么问题?它和 function calling 有什么区别?
Common in ChinaCommon overseasBasic#mcp#tool-calling#protocolHow to reason about it · think before answering
- This question has a canonical wrong answer that interviewers screen on: calling MCP 'function calling v2' or saying you no longer need function calling. Say that and the rest of your answer cannot recover the points.
- Put each one back on its own hop and the confusion disappears: function calling is the contract between the model and your program; MCP is the contract between your program and a capability provider. Different hops, so they stack — they do not replace each other.
- Offer a one-line proof: every tool returned by an MCP server's tools/list carries an inputSchema that is already plain JSON Schema, and all you do is copy it into the parameters field of a function-calling tool definition. The model never learns MCP exists, and adopting MCP removes not a single line of your function-calling code.
- Then answer what it actually solves: integration cost goes from multiplication to addition. N hosts times M capabilities means N times M integrations; a shared protocol makes it N plus M. It also draws a responsibility boundary — a third-party capability failing is no longer something you must first reproduce inside your own service.
- Volunteer the Skills distinction, since it is the natural follow-up: MCP extends what the agent can do (new callable actions), Skills extend how well it does it (a bundle of prompt, scripts and reference material, loaded on demand). One adds capability, the other adds method.
- Expect the follow-up: then where is MCP's value? In standardizing discovery and invocation, so capabilities can be owned by another team, reused by several hosts, and added or removed without a code change — while the hop to the model stays function calling.
分析过程 · 先想清楚再作答
- 这题有一个标准的错误答案,面试官就是靠它筛人:把 MCP 说成「function calling 的升级版」「以后不用写 function calling 了」。说出这句,后面讲得再多也已经扣完分了。
- 把两者放回各自的链路上就不会混:function calling 是「模型 ↔ 你的程序」之间的约定,MCP 是「你的程序 ↔ 能力提供方」之间的约定。它们不在同一段线上,所以是上下游,不是替代。
- 给一个能一句话验证的证据:MCP server 通过 tools/list 返回的每个工具,它的 inputSchema 本身就是 JSON Schema,你要做的只是把它搬进 function calling 的 parameters 字段发给模型。模型自始至终不知道 MCP 存在。接了 MCP 之后 function calling 那段代码一行都不会少。
- 再答「解决了什么问题」:接入成本从乘法变加法。N 个宿主乘 M 个能力等于 N 乘 M 份接入代码,有了协议就变成 N 加 M;顺带把责任边界划清楚了,第三方能力出问题不用先在你的服务里复现。
- 顺手把 Skills 也区分掉,这是很自然的追问:MCP 扩展的是「能做什么」(新增可调用的动作),Skills 扩展的是「怎么做得好」(一组提示词、脚本和参考资料打成的按需加载包)。一个给能力,一个给方法论。
- 可以预期的追问:那 MCP 的价值到底在哪?答案是它把「能力的发现与调用」标准化了,所以能力可以由别人维护、被多个宿主复用、不改代码就增删——但发给模型的那一段,永远还是 function calling。
Key points
- Function calling is the model-to-your-program contract; MCP is the your-program-to-provider contract — they stack rather than replace
- Every MCP tool still gets translated into a function-calling JSON Schema before it reaches the model, which never learns MCP exists
- It solves integration cost: N hosts times M capabilities becomes N plus M, and the process boundary becomes the ownership boundary
- Calling MCP an upgraded function calling is the classic wrong answer — naming that yourself scores points
- Distinguish Skills too: MCP extends what the agent can do, Skills extend how well it does it
答题要点
- function calling 是「模型和你的程序」之间的约定,MCP 是「你的程序和能力提供方」之间的约定,两者是上下游不是替代
- MCP server 列出的每个工具最终仍要翻译成 function calling 的 JSON Schema 发给模型,模型不知道 MCP 存在
- 它解决的是接入成本:N 个宿主乘 M 个能力的乘法,变成 N 加 M 的加法,同时把责任边界划到进程边界上
- 把 MCP 说成 function calling 的升级版是最常见的错误答案,主动点破这一点会加分
- 顺带区分 Skills:MCP 扩展「能做什么」,Skills 扩展「怎么做得好」
What roles do the MCP server and client play, what can a server expose, and which transports exist?MCP 里 server 和 client 分别承担什么角色?server 能暴露哪几类东西,传输方式有哪些?
Common in ChinaCommon overseasIntermediate#mcp#protocol#transportHow to reason about it · think before answering
- This looks like recall, but it discriminates on two small things: whether you separate host from client, and whether you know there are primitives beyond tools. 'Server provides tools, client calls them' is below the bar.
- Lay out three roles: the server is the capability provider and its own process; the client is the piece inside the host that talks to exactly one server; the host is your agent application, holding several clients at once. People who conflate host and client fall apart the moment you ask how they would connect to three servers.
- Cover all three server-side primitives and say who chooses each: tools are executable actions chosen by the model; resources are read-only data addressed by URI; prompts are reusable templates — the latter two are normally chosen by the user or host. That 'who chooses' framing shows you actually read the spec: modelling a large document as a resource rather than a tool moves the decision to spend those tokens from the model back to a human.
- The client side declares capabilities too, letting the server call back into the host: sampling asks the host to run a model completion, roots tells the server which directories are visible, elicitation asks the host to collect user input. Naming them without elaborating is the right level of detail.
- Two transports: stdio for a local subprocess, Streamable HTTP for remote. The dated detail worth knowing is that the older two-endpoint HTTP+SSE transport is now legacy, kept only for backwards compatibility — presenting it as current signals you read last year's blog posts.
- Expect the follow-up: anything special about stdio servers? Stdout is reserved for JSON-RPC, so every log line must go to stderr or the client receives unparseable messages; and the host owns the subprocess lifecycle, so it must reap the child on exit or leave orphans behind.
分析过程 · 先想清楚再作答
- 这题看着是背概念,实际区分度在两个小地方:一是能不能把宿主和 client 分开说,二是知不知道 tools 之外还有别的原语。只答「server 提供工具、client 调用工具」是及格线以下。
- 先把三个角色摆清楚:server 是能力提供方,一个独立进程;client 是宿主里负责跟某一个 server 说话的那一小块,一个 client 只连一个 server;宿主是你的 Agent 应用,它同时持有多个 client。很多人把宿主和 client 当成一个东西,一问「连三个 server 怎么办」就露馅。
- server 侧三种原语要一起说,并且要说清谁来选:tools 是可执行的动作,由模型来挑;resources 是按 URI 读的只读数据;prompts 是可复用的提示词模板,后两者通常由用户或宿主来挑。这句「谁来选」比原语名字本身更能体现你真读过协议——把一份大文档做成 resource 而不是 tool,等于把花不花这笔 token 的决定权从模型手里收回给人。
- client 侧也能声明能力让 server 反过来请求宿主:sampling 是让宿主跑一次模型补全,roots 是告诉 server 哪些目录可见,elicitation 是请宿主向用户要一条输入。知道有这三样、不展开,分寸刚好。
- 传输两种:stdio 用于本地子进程,Streamable HTTP 用于远程。这里有个时间戳式的加分点——旧的 HTTP 加 SSE 双端点传输已经被标为 legacy,只为兼容老客户端保留;把它当现行方案讲,等于告诉对方你看的是去年的文章。
- 可以预期的追问:stdio server 有什么特别要注意的?答 stdout 被 JSON-RPC 独占,所有日志必须走 stderr,否则 client 会收到解析不了的消息;另外子进程的生命周期归宿主管,退出时要杀掉,不然留一堆孤儿进程。
Key points
- The server is the capability provider in its own process; a client connects to exactly one server; the host holds many clients
- Three server-side primitives: tools chosen by the model, resources as URI-addressed read-only data, prompts as reusable templates — the latter two usually chosen by a human
- Clients can declare sampling, roots and elicitation so the server can call back into the host
- Two transports: stdio for local subprocesses and Streamable HTTP for remote; the old HTTP+SSE transport is legacy
- On stdio, stdout belongs to JSON-RPC so logs must go to stderr, and the host must reap the child process
答题要点
- server 是能力提供方(独立进程),client 是宿主里连接单个 server 的那一块,宿主可以同时持有多个 client
- server 侧三种原语:tools 由模型挑,resources 是按 URI 读的只读数据,prompts 是可复用模板,后两者通常由人来挑
- client 侧还能声明 sampling、roots、elicitation,让 server 反过来请求宿主做事
- 传输两种:stdio(本地子进程)与 Streamable HTTP(远程);旧的 HTTP 加 SSE 已是 legacy,不要当现行方案讲
- stdio server 的 stdout 被 JSON-RPC 独占,日志必须走 stderr;子进程生命周期由宿主负责回收
When should you reach for MCP instead of plain function calling, and what does it cost when you shouldn't?什么场景下应该考虑用 MCP,而不是直接写 function calling?不该用的时候硬上会付出什么代价?
Common in ChinaCommon overseasIntermediate#mcp#architecture#trade-offsHow to reason about it · think before answering
- The hinge is the second half. Answering only 'MCP is more standard and decoupled' is like saying 'microservices are more decoupled' — true-sounding but with no criterion, and the interviewer will immediately ask whether you turned every tool into an MCP server.
- Give three actionable criteria: the capability must be reused by more than one host, owned by another team or a third party, or added and removed without changing host code. Any one of them justifies MCP; none of them means write a local function. Making 'no' the default answer shows more engineering judgment than the criteria themselves.
- Attach a reason to each: multi-host reuse turns N times M into N plus M; external ownership makes the process boundary the responsibility boundary, so their change is not your release; hot-swapping demotes adding an internal tool from a deployment to a config change.
- Then state the costs honestly, which is where shipped experience shows: another process to keep alive, another handshake with its own timeouts and reconnects, and a debugging path that went from one hop to three — a tool that never got called might mean the model did not pick it, the schema lost fields in translation, or the server never started. On stdio you also own reaping the child process.
- One more point that is easy to miss and scores well: MCP does not change your cost structure. Tool descriptions still enter the context every turn, and more tools still degrade tool selection. The rule that you should consolidate tools past a certain count survives MCP unchanged — arguably it matters more, because now other people can add entries to your tool list.
- Expect the follow-up: so internal tools never go through MCP? Not quite. If you want the same capability available to an IDE assistant and an ops bot as well, the first criterion is met even though you own the code.
分析过程 · 先想清楚再作答
- 题眼在后半句。只会说「MCP 更标准更解耦」的人,等于说「微服务更解耦」——听起来对,但没有判据,面试官会立刻追问「那你们所有工具都做成 MCP server 了吗」。
- 先给判据,而且要是可执行的三条:能力要被多个宿主复用、能力由另一个团队或第三方维护、需要不改宿主代码就能增删能力。命中任意一条才考虑,**一条都不命中就直接写本地函数**——把默认答案摆成「不上」,这条比三条判据本身更能体现工程判断。
- 每条判据配一句为什么:多宿主复用把 N 乘 M 变成 N 加 M;别人维护时进程边界就是责任边界,他们改他们的、你不用发版;热插拔让加一个内部工具从一次发布降级成一次配置变更。
- 然后老实说代价,这是区分「用过」和「读过」的地方:多一个进程要保活、多一次握手要处理超时与重连、排障链路从一段变三段——工具没被调用,现在可能是模型没选、可能是 schema 翻译时丢了字段、也可能是 server 压根没起来。stdio 的子进程还要你自己回收,否则留孤儿进程。
- 还有一条容易被忽略但很加分:MCP 不改变你的成本结构。工具描述照样每轮都进上下文,工具多了照样会让模型选错——D5 那条「工具超过一定数量就该合并描述」在接了 MCP 之后一字不变,甚至更需要,因为现在别人可以往你的工具列表里塞东西。
- 可以预期的追问:那内部工具一律不上 MCP 吗?不是。有一类值得例外——你希望它能被 IDE 里的助手和运维机器人一起用,那第一条判据就命中了,即使它是你自己维护的。
Key points
- Three criteria, any one justifies MCP: reuse across hosts, ownership by another team, or add/remove without touching host code
- The default is no — if none of the three apply, a local function is the better engineering decision
- Costs: another process to supervise, another handshake with timeouts, and a debug path that grows from one hop to three
- MCP does not change your cost structure: descriptions still enter context every turn and too many tools still hurt selection
- Once third-party capabilities are attached, your tool list is no longer fully under your control, which is itself a design problem
答题要点
- 三条判据,命中任意一条才考虑 MCP:多宿主复用、由他人维护、需要不改代码增删能力
- 默认答案是不上:三条都不命中就直接写本地函数,这是更好的工程决策
- 代价是多一个进程要保活、多一次握手要处理超时、排障从一段链路变成三段
- MCP 不改变成本结构:工具描述照样每轮进上下文,工具过多照样会让模型选错,该合并还是要合并
- 第三方能力接进来之后,工具列表不再完全由你掌控,这本身就是需要设计的一件事
You are about to attach a third-party MCP server in production. What worries you, and what do you check?你要把一个第三方维护的 MCP server 接进生产环境,会担心什么、做哪些检查?
Common in ChinaCommon overseasDeep dive#mcp#security#operationsHow to reason about it · think before answering
- This stacks yesterday's security topic onto today's openness topic, and it discriminates hard: every benefit of MCP rests on the capability being maintained by someone else, and that is also its biggest risk.
- First name the new trust assumptions: you put someone else's code into your own process tree, you feed its returned text straight into the model, and you let it add entries to your tool list. Each maps to a class of risk.
- Then go through the checks. Execution: the server is a process that runs, so constrain which files it can read, whether it has network access, its timeout and the identity it runs as — the least-privilege and sandbox story from yesterday. Data: treat everything it returns as untrusted input, which is exactly the indirect-injection scenario where instructions hide in a field of a tool result. Tool output is never instructions, and the permission gate must live in your process and fire before the call.
- Third, governance, the part most people miss: the tool list can change at runtime — one listChanged notification and a new tool appears. So pin your allowlist by tool name, keep newly appearing tools out of the model's list until a human approves, and pin the server version instead of tracking upstream latest.
- Fourth, availability and cost: this is a new external dependency. If it is down your agent silently loses a set of capabilities, so you need timeouts, graceful degradation (tell the model the capability is temporarily unavailable rather than failing the whole turn), and its calls on your observability dashboard.
- Expect the follow-up: how do you decide it is worth attaching at all? Back to the three criteria — if only one host uses it and you could implement it yourself, you are taking third-party risk with no matching benefit.
分析过程 · 先想清楚再作答
- 这题是把昨天的安全和今天的开放性叠在一起考,区分度极高:接 MCP 的全部好处,都建立在「能力由别人维护」这一点上,而这一点同时就是它最大的风险。
- 第一层想清楚新增了什么信任假设:你把一段别人写的代码放进了自己的进程树,把它返回的文本直接喂给了模型,还允许它往你的工具列表里加条目。这三件事各自对应一类风险。
- 第二层逐条给检查项。执行侧:server 是一个会跑起来的进程,要限制它能读哪些文件、能不能联网、超时多久、以什么身份运行,也就是昨天讲的最小权限和沙箱那一套。数据侧:**它的返回结果一律当不可信输入**,这正是昨天间接注入的固定现场——工具返回的备注字段里可以藏指令;所以工具结果不能当指令执行,权限闸门必须在你自己的进程里、在调用之前判。
- 第三层是治理,最容易被漏掉:工具列表可以在运行中变化,server 发一条 listChanged 通知就能加一个新工具。所以你的白名单要按工具名固定,新出现的工具默认不进模型的工具列表,要有人点头;server 的版本要锁定,不能跟着上游 latest 漂。
- 第四层是可用性与成本:这是一个新的外部依赖,它挂了你的 Agent 就少一批能力,所以要有超时、要有降级(工具不可用时告诉模型「这个能力暂时不可用」而不是整轮失败),要把它的调用计入你的可观测面板。这三条正好复用前面几周讲过的东西。
- 可以预期的追问:怎么判断它值不值得接?答案回到那三条判据——如果这个能力只有你一个宿主用,而且你完全可以自己实现,那接一个第三方 server 承担的风险没有对应的收益。
Key points
- Three new trust assumptions: their code in your process tree, their text in your model context, their entries in your tool list
- Execution: least privilege — restrict filesystem and network, set timeouts, run as a low-privilege identity, sandbox where warranted
- Data: treat every result as untrusted input; tool output is never instructions, and the permission gate must fire in your process before the call
- Governance: allowlist by tool name so newly appearing tools stay out until approved, and pin the server version rather than tracking latest
- Availability: treat it as an external dependency with timeouts, graceful degradation and dashboard coverage
答题要点
- 三个新增信任假设:别人的代码进了你的进程树、它的返回文本进了模型上下文、它能往你的工具列表里加条目
- 执行侧按最小权限收紧:限制文件访问与网络、设超时、以低权限身份运行,必要时进沙箱
- 数据侧一律当不可信输入:工具返回结果不能当指令执行,权限闸门必须在自己的进程里、在调用之前判
- 治理侧锁死变化面:按工具名做白名单,新出现的工具默认不进模型的工具列表;锁定 server 版本,不跟 latest
- 可用性侧当外部依赖对待:超时、降级、把它的调用与失败计入可观测面板
Comments
Sign in to join the discussion
No comments yet — be the first.