AI协作工程(二):从最小执行循环到 MCP 工具设计

本文承接上一篇关于 Prompt → Agent 的讨论,关注点从“prompt 为什么有效”转向一个更贴近工程的问题:当模型开始具备读取文件、遍历目录、修改内容乃至连接外部服务的能力时,一个 coding agent 本质上是由哪些部分构成的。

本文将从最小可用结构入手,逐步拆解 agent 的组成;再结合两个案例,说明工具接入如何从“本地函数注册”演进为“协议化服务”;最后补充认证机制、registry 与 tool design 等关键要素——这些才是真正决定系统能否走向工程化的核心因素。

如果用一句话概括本文的中心思想,可以这样表达:Coding Agent 不是“会写代码的 LLM”,而是一个能够感知环境、调用工具、维护状态,并在循环中持续执行任务的系统。

引言

上一篇文章中,我们主要建立了一条基础理解路径:如果不理解模型如何处理上下文,以及 prompt 为什么会影响输出,就很容易把 AI协作 开发误解为一套零散的技巧。本文会对问题做一步推进——即便已经掌握了 few-shot、chain-of-thought 或 tool calling等,这些能力依然不足以回答一个更核心的工程问题:为什么有些系统只是“按要求生成内容”,而另一些却已经开始像 agent 一样执行任务?

关键差异并不在于模型是否会写代码,而在于执行结构是否成立。单轮问答只涉及输入与输出,而 agent 则必须引入一套循环机制:模型需要判断何时调用工具,系统负责执行工具,结果再回流到上下文中,模型基于新信息继续决策,而不是在第一次响应后结束。换句话说,agent 的本质不在于生成更长的答案,而在于形成一个完整的执行闭环。

如果进一步明确边界,可以将三种形态区分为:

  • single call = input → output
    一次性调用,模型只基于当前输入生成结果,没有状态延续,也不会进行后续决策或动作。
  • workflow = 预定义步骤
    多步执行,但流程是事先写死的(if/else 或 pipeline),模型只在固定节点被调用,不参与整体路径的决策。
  • agent = 模型在 loop 中自主决定下一步(是否调用工具)
    存在执行循环,模型根据当前上下文动态判断下一步行动(调用工具 / 继续推理 / 结束),系统围绕这个决策不断推进任务。

这个区分之所以重要,是因为它揭示了一个常见误区:许多系统看似在“多步执行”,但如果每一步都是预先定义好的流程,而非模型在上下文中动态决策,那么它本质上仍然只是一个更复杂的脚本,而不是真正的 agent。

下面的两个案例正好提供了一个清晰的对照:第一个案例是手工构建了一个最小化的 coding agent,第二个案例则是将类似能力封装为 MCP tool。将两者放在一起,可以看到一条明确的演进路径——如何从 prompt 驱动的本地工具调用,逐步走向具备可发现、可组合、可治理能力的工具服务体系。

---
title: 最小 Coding Agent 执行闭环
---
flowchart LR
    A["用户任务
User Task"] --> B["系统提示词
System Prompt"] B --> C["模型决策
Model Response"] C --> D{"需要工具
Need Tool?"} D -->|否| G["最终回答
Final Answer"] D -->|是| E["工具调用
Tool Call"] E --> F["执行器
Executor"] F --> H["工具结果回注
Tool Result"] H --> C

1. 一个最小 Coding Agent 由哪些部分组成

如果把一个 coding agent 拆开来看,最小可运行版本通常至少包含六个部分:

  1. system prompt:定义模型的角色、可用工具以及调用规则
  2. tools 定义:描述工具的名称、用途、参数与返回边界
  3. 工具调用解析层:判断模型输出中是否包含可执行的工具请求
  4. 执行层:真正运行工具并返回结果
  5. conversation 状态:保存用户输入、模型响应与工具结果
  6. 循环与停止条件:控制系统是继续执行还是输出最终答案

把这些压缩成一个公式:Agent = LLM + Tools + Loop,这条公式明确了 agent 与单次模型调用的区别:LLM 负责理解与决策,tools 提供外部能力,loop 则把“判断 → 执行 → 回注 → 再判断”串成一个闭环。缺少任意一环,都还不能称为真正的 agent。

但如果进一步靠近真实工程,这个结构还需要继续扩充。通常会补上三类关键能力:

  • 更持久的状态(memory):不仅是对话历史,还包括任务阶段、中间产物与上下文信息
  • 错误处理与重试机制:出错或失败是常态,需要明确的恢复策略
  • 终止策略(stop condition):区分“继续尝试”与“返回结果”的边界

因此,从工程视角看,更完整的表达:Agent ≈ LLM + Tools + Memory + Execution

这里的 memory 不一定是复杂的长期记忆,也可以是当前仓库上下文和任务中间状态;execution 则是实际执行动作的一层,例如读写文件、运行命令或调用外部服务。补上这两层之后,agent 才真正具备持续处理任务的能力。

在这几个组成部分中,最容易被忽略的是执行。很多人在初次接触 agent 时,会把注意力集中在 prompt 或工具描述上,仿佛只要告诉模型“可以使用这些工具”,系统就可以自动升级。但实际上:

  • 没有解析层 → 模型输出无法转化为可执行动作
  • 没有结果回注 → 模型无法获取执行反馈
  • 没有循环结构 → 系统只能做一次决策

因此,判断一个系统是否是 agent,关键不在“能不能调用工具”,而在是否具备完整的:判断 → 执行 → 回注 → 再判断。tool calling 只是入口,真正的差别在于闭环是否成立。这个最小结构可以用一段伪代码表达:

1
2
3
4
5
6
7
8
conversation = [system_prompt, user_task]

while True:
response = model(conversation)
if not needs_tool(response):
return response
result = execute_tool(parse_tool_call(response))
conversation.append(tool_result_message(result))

这段逻辑虽然简单,但已经区分了两类系统:前者只生成一轮文本,后者则在“生成 ↔ 执行”之间形成循环。

不过,“能跑”并不等于“能用”。一个可用的最小 agent,至少还需要补上两个关键点:

  • stop condition:什么时候应该结束,而不是无限循环
  • failure handling:当工具失败时,是重试、降级还是直接返回

很多 demo 到这里就结束了,但在真实工程中,难点才从这里开始。

2. 从零实现一个 Coding Agent:关键不是模型调用,而是闭环成立

先看完整代码。这段代码实现了一个最小可运行的 coding agent:它可以读取文件、查看目录、修改代码,并在模型的决策下自动调用这些工具完成任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
import inspect  
import json
from pathlib import Path
from typing import Any, Dict, List, Tuple
from logprint import P

from openai import OpenAI

# 1. 模型客户端
# 这里使用 OpenAI Python SDK,但把 base_url 指向本地 Ollama,请求会发到本地模型服务。
openai_client = OpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama", # 对 Ollama 兼容接口来说,任意非空字符串即可
)

# 替换成本地模型名
OLLAMA_MODEL = "mistral-nemo:12b"

# 2. 系统提示词
# 这部分是整个最小 agent 的“协议层”:告诉模型当前是什么角色、有哪些工具、以什么格式调用工具
# 这里的工具调用还不是原生 function calling,而是通过 prompt 约束模型输出固定格式字符串。它本质上是一种“语言协议”。
SYSTEM_PROMPT = """
你是一个编程助手,目标是帮助用户解决编程相关任务。
你可以使用一组工具来完成任务,下面是你可以使用的工具列表:
{tool_list_repr}
【工具使用规则】
当你需要使用工具时,必须严格按照以下格式输出(且只能输出一行):
tool: 工具名({{JSON参数}})
要求:
- 必须是单行
- JSON 必须是紧凑格式(不能换行)
- 使用双引号
示例:
tool: read_file({{"filename": "main.py"}})
当你收到 tool_result(...) 后,需要继续思考并完成任务。
如果不需要使用工具,请直接用自然语言回答用户。
"""

# 终端颜色,仅用于交互显示
YOU_COLOR = "\u001b[94m"
ASSISTANT_COLOR = "\u001b[93m"
RESET_COLOR = "\u001b[0m"

# 3. 路径处理
# 把用户传入的相对路径转成绝对路径,方便后续文件操作。这类小工具函数在 agent 系统里很常见:模型只负责决策,环境相关的细节由外部函数统一处理。
def resolve_abs_path(path_str: str) -> Path:
path = Path(path_str).expanduser()
if not path.is_absolute():
path = (Path.cwd() / path).resolve()
return path

# 4. 工具定义
# 这些函数就是真正能操作本地环境的“手脚”。模型本身不会读文件、列目录、改代码;它只能决定“要不要调用这些工具”。
def read_file_tool(filename: str) -> Dict[str, Any]:
"""
读取文件完整内容
""" full_path = resolve_abs_path(filename)
with open(full_path, "r", encoding="utf-8") as f:
content = f.read()
return {
"文件路径": str(full_path),
"内容": content
}


def list_files_tool(path: str) -> Dict[str, Any]:
"""
列出目录下的文件
""" full_path = resolve_abs_path(path)
all_files = []
for item in full_path.iterdir():
all_files.append({
"文件名": item.name,
"类型": "文件" if item.is_file() else "目录"
})
return {
"路径": str(full_path),
"文件列表": all_files
}


def edit_file_tool(path: str, old_str: str, new_str: str) -> Dict[str, Any]:
"""
替换文件中的字符串;若 old_str 为空,则创建/覆盖文件
""" full_path = resolve_abs_path(path)

# old_str 为空时,直接把 new_str 写入文件
# 这意味着这个工具同时具备“创建文件”和“局部编辑”两种用途
if old_str == "":
full_path.write_text(new_str, encoding="utf-8")
return {
"路径": str(full_path),
"操作": "已创建文件"
}

original = full_path.read_text(encoding="utf-8")

# 没找到目标字符串时,返回错误信息
# 这里并没有抛异常中断,而是把执行结果作为普通返回值交还给模型。这体现了 agent 的一个特点:工具执行结果也是模型后续判断的上下文。
if old_str not in original:
return {
"路径": str(full_path),
"操作": "未找到要替换的内容"
}

# 只替换第一次出现的位置,尽量把编辑控制在最小范围
edited = original.replace(old_str, new_str, 1)
full_path.write_text(edited, encoding="utf-8")
return {
"路径": str(full_path),
"操作": "已修改"
}

# 5. 工具注册表
# 这一层负责把“模型看到的工具名”映射到“程序实际执行的函数”。它是提示层和执行层之间的桥梁。
# 没有它,prompt 里的工具只是文字描述;有了它,模型输出的 read_file 才能真正触发 read_file_tool()。
TOOL_REGISTRY = {
"read_file": read_file_tool,
"list_files": list_files_tool,
"edit_file": edit_file_tool
}

# 6. 生成工具说明
# 这里会把每个工具的名字、说明、参数签名拼接进 system prompt。这样模型在决策时能“看到”自己有哪些可用能力。
# 注意,这里的“看到”并不等于真正拥有能力;真正的能力仍然来自 TOOL_REGISTRY 和后面的执行逻辑。
def get_tool_str_representation(tool_name: str) -> str:
tool = TOOL_REGISTRY[tool_name]
return f"""
工具名: {tool_name}
说明: {tool.__doc__}
参数签名: {inspect.signature(tool)}
"""

def get_full_system_prompt() -> str:
tool_str_repr = ""
for tool_name in TOOL_REGISTRY:
tool_str_repr += "工具\n===\n" + get_tool_str_representation(tool_name)
tool_str_repr += "=" * 15 + "\n"
return SYSTEM_PROMPT.format(tool_list_repr=tool_str_repr)


# 7. 工具调用解析器
# 模型如果要调用工具,会输出:tool: read_file({"filename": "main.py"})
# 这里的任务是把这段文本解析成:("read_file", {"filename": "main.py"})
# 这一步也暴露了最小 agent 的脆弱性:只要模型输出格式稍微偏一点,解析就会失败。所以它适合理解原理,但不适合作为复杂系统的长期方案。
def extract_tool_invocations(text: str) -> List[Tuple[str, Dict[str, Any]]]:
invocations = []

for raw_line in text.splitlines():
line = raw_line.strip()

if not line.startswith("tool:"):
continue

try:
after = line[len("tool:"):].strip()
name, rest = after.split("(", 1)
name = name.strip()

if not rest.endswith(")"):
continue

json_str = rest[:-1].strip()
args = json.loads(json_str)
invocations.append((name, args))

except Exception:
# 解析失败时,这里直接跳过,真正工程化时,通常会加入更强的校验与错误处理
continue

return invocations

# 8. 模型调用
# 单看这部分,其实只是一次普通的 chat completion。真正让系统成为 agent 的,不是这一行调用本身,而是前后的工具协议、执行逻辑、状态回写和循环。
def execute_llm_call(conversation: List[Dict[str, str]]) -> str:
response = openai_client.chat.completions.create(
model=OLLAMA_MODEL,
messages=conversation,
max_tokens=2000,
temperature=0
)
return response.choices[0].message.content or ""

# 9. Agent 主循环
# 这是整段代码最关键的部分。
# 它做的不是“一问一答”,而是:
# 1)接收用户输入
# 2)把输入送给模型
# 3)检查模型是否请求工具
# 4)如果请求了,就执行工具
# 5)把工具结果重新写回 conversation# 6)让模型基于新上下文继续判断
# 也就是说,这里真正建立了:用户任务 -> 模型决策 -> 外部执行 -> 结果回注 -> 再决策
# 闭环一旦成立,系统就不再只是聊天,而开始具备 agent 性质。
def run_coding_agent_loop():
system_prompt = get_full_system_prompt()
P.print(system_prompt)

# conversation 不只是保存“人类聊天记录”
# 它还要保存:system prompt、assistant 的工具调用请求、tool_result 形式的外部执行反馈
# 这正是 agent 和普通聊天程序的重要区别之一。
conversation = [{
"role": "system",
"content": system_prompt
}]

while True:
try:
user_input = input(f"{YOU_COLOR}{RESET_COLOR}: ")
except (KeyboardInterrupt, EOFError):
P.print("\n已退出")
break

user_input = user_input.strip()
if not user_input:
continue

conversation.append({
"role": "user",
"content": user_input
})

# 防止意外死循环
step_count = 0

while True:
step_count += 1
if step_count > 10:
P.print("⚠️ 超过最大执行步数,终止")
break

assistant_response = execute_llm_call(conversation)
tool_invocations = extract_tool_invocations(assistant_response)

# 如果没有工具调用,说明模型这轮已经准备给出自然语言答案
if not tool_invocations:
P.print(f"{ASSISTANT_COLOR}助手{RESET_COLOR}: {assistant_response}")
conversation.append({
"role": "assistant",
"content": assistant_response
})
break

# 这里把 assistant 的工具请求也写回 conversation 否则模型下一轮可能“忘记”自己刚刚调用了什么
conversation.append({
"role": "assistant",
"content": assistant_response
})

for name, args in tool_invocations:
P.print(f"[调用工具] {name} 参数: {args}")

tool = TOOL_REGISTRY.get(name)

if not tool:
resp = {"错误": f"未知工具: {name}"}
else:
try:
if name == "read_file":
resp = tool(args.get("filename", "."))
elif name == "list_files":
resp = tool(args.get("path", "."))
elif name == "edit_file":
resp = tool(
args.get("path", "."),
args.get("old_str", ""),
args.get("new_str", "")
)
else:
resp = {"错误": f"未处理工具: {name}"}
except Exception as e:
# 工具异常也不直接让系统崩掉,而是包装成结果回传给模型
resp = {"错误": str(e)}

# 这是整段案例最关键的动作之一:外部执行结果被重新写回到模型上下文中。
# 从这一刻开始,模型的下一轮回答就不再只基于用户原始提问,而是能基于刚刚“看到”的真实文件内容、目录结构、修改结果继续判断。
# 也正因为这一步,闭环成立了。
conversation.append({
"role": "user",
"content": f"tool_result({json.dumps(resp, ensure_ascii=False)})"
})

if __name__ == "__main__":
run_coding_agent_loop()

这段代码表面上是在用 OpenAI SDK 写一个命令行助手,但核心并不是 chat.completions.create(...) ,而是围绕它搭建起来的执行结构。也正是这层结构,让系统从“单次模型调用”转变为“可持续运转的 agent”。

我们首先看系统提示词。这里不仅包含了工具清单和工具描述,还明确规定了工具调用的输出格式。模型在需要使用工具时,必须严格输出一行固定结构:

1
tool: TOOL_NAME({"arg":"value"})

这一步揭示了一个关键点:在最小实现阶段,工具调用本质上仍然是一种语言协议。模型并不会直接调用本地 Python 函数,它只是根据提示词生成符合约定格式的文本;真正的执行,是由系统解析这段文本后完成的。换句话说,模型之所以“会用工具”,并不是因为它理解了工具的实现,而是因为提示词把可用能力编码成了一种它可以续写的结构。

接着,代码通过 TOOL_REGISTRY 将本地函数组织成一个稳定映射:

1
2
3
4
5
TOOL_REGISTRY = {
"read_file": read_file_tool,
"list_files": list_files_tool,
"edit_file": edit_file_tool,
}

这一层在结构上非常关键。它把“模型看到的工具名”与“系统实际可执行的函数”绑定在一起。没有这层映射,提示词中的工具只是一段描述;有了 TOOL_REGISTRY,模型输出的工具调用才有机会转化为真实执行。可以说,这一层完成了从“语言层能力声明”到“执行层具体动作”的连接。

再往下看,extract_tool_invocations 这样的解析函数承担了另一个核心职责:从模型输出中提取工具调用请求,并解析其中的 JSON 参数。这一步同样暴露了最小 agent 的一个重要特征——脆弱性。只要模型输出的格式稍有偏差,例如多出额外文本或 JSON 不合法,整个执行链路就会中断。因此,这种实现方式非常适合理解 agent 的第一性原理,但并不适合直接用于复杂、长期运行的系统。

真正让系统具备 agent 属性的,是最后的循环机制。代码在获取模型输出后,并不会立即结束,而是先判断是否存在工具调用请求。如果存在,就执行对应工具,并将结果重新写入对话上下文:

1
2
3
4
conversation.append({
"role": "user",
"content": f"tool_result({json.dumps(resp)})"
})

这一操作可以视为整个实现的关键节点。它意味着外部世界的执行结果被重新注入模型的上下文中,使模型能够基于最新状态继续决策。此时,模型的行为不再局限于对初始问题的回答,而是可以结合刚刚获得的文件内容、目录结构或修改结果,推进下一步行动。也正是在这一刻,系统形成了“决策—执行—反馈—再决策”的闭环。

从工程视角来看,这段代码至少传达了四个核心要点。

  • agent 并不会随着 prompt 的增加自然出现,它依赖一套明确的执行骨架。
  • 工具调用在初始阶段并不复杂,本质上只是可解析的字符串协议。
  • conversation 不仅需要保存人类对话,还必须纳入外部执行反馈,成为完整的状态载体。
  • 模型在 agent 中承担的是决策角色,而非独立完成所有工作的黑盒。

进一步来看,这个案例还有一个更重要的结论:在 AI coding 场景中,关键能力往往不在“生成代码”,而在“获取上下文并基于上下文修改代码”。系统需要先了解仓库结构、相关文件以及当前实现状态,后续的生成与修改才具备依据。因此,repo context acquisition 并不是附属能力,而是 coding agent 的核心执行能力之一。

3. 为什么只靠本地函数注册还不够

到这里,一个最小的 coding agent 已经可以运行:模型能够根据输入做出判断,选择是否调用工具,执行操作,并在结果回注之后继续推进任务。从结构上看,这套 LLM + tools + loop 的闭环已经成立,系统也从单次调用转变为可以持续运转的执行体。

在最小案例中,agent 运行在命令行环境里,可用的工具也只有几个本地函数,例如读取文件、列出目录或修改内容。这样的环境足够简单,问题也相对收敛。但当 agent 被放入编辑器环境,例如 AI IDE 时,系统所面对的上下文会迅速扩展。文件树、当前打开的文件、选中的代码片段、编译错误、运行结果,这些都会成为模型可以消费的信息。agent 不再只处理一段输入文本,而是开始面对一个持续变化的环境。

然而,这种变化并没有改变底层结构。无论是在命令行还是在 IDE 中,系统仍然围绕同一套机制运行:模型基于上下文做决策,决定是否调用工具,执行操作,再将结果回注到上下文中继续判断。界面可以变化,交互可以变化,但底层始终是 LLM + tools + context + loop。理解这一点,比记住具体产品形态更重要。

在这样的系统中,一个容易被忽略但极其关键的问题开始浮现出来:在动手修改之前,系统读了什么。coding agent 的表现,很大程度上并不取决于生成能力本身,而取决于它在生成之前是否获得了足够且正确的上下文。如果没有先列出目录、没有定位相关文件、没有理解现有实现,那么后续生成的代码再流畅,也往往只是脱离真实仓库状态的猜测。

“先读什么,再改什么”本身是一个系统设计问题,而不是工具细节问题。工具只是提供了读取能力,但是否读取、读取哪些内容、以什么顺序读取,这些决策都属于 agent 的核心逻辑。在复杂工程中,这一问题会被进一步放大,因为相关信息往往分布在多个文件和多个模块之中,错误的上下文选择会直接导致后续决策偏离。

当系统规模继续扩大,本地函数注册的方式也开始显露出明显的局限。最小实现中,通过 TOOL_REGISTRY 将工具统一管理是完全可行的,但这种方式默认了一件事:工具属于 agent 的内部实现细节。只要工具数量有限,并且都在同一个进程内,这个假设是成立的。但一旦工具开始变多,或者需要跨进程、跨机器调用,这种方式就会迅速变得难以维护。

  • 首先,工具定义与执行是强耦合的。函数既是接口也是实现,难以独立演进,也难以被多个系统复用。
  • 其次,工具来源被限制在本地进程中,无法自然接入远程服务或已有系统能力。
  • 再进一步,系统缺乏工具发现机制,所有能力都需要手动注册,客户端无法动态感知当前可用工具。
  • 同时,调用边界与权限控制缺失,所有工具默认可用,缺乏清晰的访问约束。

在多客户端场景下,这些问题会更加明显,因为不同系统之间没有统一的接口约定,也无法进行集中治理。

这些问题本质上指向同一个事实:本地函数注册把工具当作“代码中的函数”,而不是“系统中的能力”。在最小场景中,这种视角足够,但在工程系统中,它会逐渐成为限制。

因此,工具需要发生一次角色转变。它不再只是写在某个脚本里的函数,而是需要变成可以被描述、被发现、被调用的独立能力。也就是说,工具需要具备统一的接口形式,使不同客户端能够以一致的方式访问它们,同时也为权限控制、调用约束和系统治理提供基础。

从这个角度看,MCP 的出现并不是为了重新定义 agent,而是针对工具层的工程化问题给出了一种解决路径。它关注的不是模型是否能够调用函数,而是在工具体系开始走向复用和共享时,如何用统一协议来描述和接入这些能力。换句话说,MCP 解决的是工具层的标准化问题,而不是模型能力问题

因此,将 MCP 理解为一个孤立的概念并不准确。更合理的理解方式是,它是 agent 系统在复杂度上升之后的自然演进结果。在最小实现中,工具可以是本地函数;在工程系统中,工具更需要成为可发现、可描述、可调用、可治理的能力接口。这种从“函数”到“能力”的转变,正是 coding agent 从原型走向工程化的关键一步。

4. 自定义 MCP Server:工具如何从函数变成能力接口

先看完整代码,包括 server 和 client。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# ===== server: chapter_4_1.py =====

from pathlib import Path
from typing import Any, Dict

from mcp.server.fastmcp import FastMCP

mcp = FastMCP(name="SimpleMCPTestServer")


def resolve_abs_path(path_str: str) -> Path:
"""
将相对路径转换为绝对路径
"""
path = Path(path_str).expanduser()
if not path.is_absolute():
path = (Path.cwd() / path).resolve()
return path


@mcp.tool()
def read_file_tool(filename: str) -> Dict[str, Any]:
"""
读取文件内容
"""
full_path = resolve_abs_path(filename)
with open(str(full_path), "r", encoding="utf-8") as f:
content = f.read()
return {
"file_path": str(full_path),
"content": content
}


@mcp.tool()
def list_files_tool(path: str) -> Dict[str, Any]:
"""
列出目录内容
"""
full_path = resolve_abs_path(path)
all_files = []
for item in full_path.iterdir():
all_files.append({
"filename": item.name,
"type": "file" if item.is_file() else "dir"
})
return {
"path": str(full_path),
"files": all_files
}


@mcp.tool()
def edit_file_tool(path: str, old_str: str, new_str: str) -> Dict[str, Any]:
"""
修改文件内容
"""
full_path = resolve_abs_path(path)
p = Path(full_path)

if old_str == "":
p.write_text(new_str, encoding="utf-8")
return {
"path": str(full_path),
"action": "created_file"
}

original = p.read_text(encoding="utf-8")

if old_str not in original:
return {
"path": str(full_path),
"action": "old_str not found"
}

edited = original.replace(old_str, new_str, 1)
p.write_text(edited, encoding="utf-8")

return {
"path": str(full_path),
"action": "edited"
}


if __name__ == "__main__":
mcp.run()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# ===== client: chapter_4_1_client.py =====

import asyncio
from pathlib import Path

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


async def main():
current_dir = Path(__file__).resolve().parent
server_file = current_dir / "chapter_4_1.py"

server_params = StdioServerParameters(
command="python",
args=[str(server_file)],
)

async with stdio_client(server_params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()

print("=== 可用工具 ===")
tools = await session.list_tools()
for tool in tools.tools:
print(f"{tool.name}: {(tool.description or '').strip()}")

print("\n=== 调用 list_files_tool ===")
result = await session.call_tool("list_files_tool", {"path": "."})
print(result.structuredContent["result"])

print("\n=== 调用 edit_file_tool ===")
result = await session.call_tool(
"edit_file_tool",
{
"path": "demo.txt",
"old_str": "",
"new_str": "Hello MCP\n"
}
)
print(result.structuredContent["result"])

print("\n=== 调用 read_file_tool ===")
result = await session.call_tool(
"read_file_tool",
{"filename": "demo.txt"}
)
print(result.structuredContent["result"])


if __name__ == "__main__":
asyncio.run(main())

这个案例用 FastMCP 把工具层的变化非常直接地表达了出来。相比第二章中基于 TOOL_REGISTRY 的本地注册方式,这里最大的变化并不在于“换了一个框架”,而在于系统边界发生了变化

在本地实现中,工具函数属于 agent 脚本的一部分,它们的生命周期、调用方式和可见范围都被限制在当前进程内。而在这里,随着 FastMCP 的引入,工具开始从“脚本内部函数”转变为 “服务对外能力”。这一变化,从 server 初始化那一刻就已经发生。

创建 FastMCP 实例,意味着系统不再只是组织函数,而是在构建一个可以被外部访问的能力提供方。工具不再附属于某个 agent,而是成为一个可以被连接、被发现、被调用的对象。这一点,决定了后续整个系统的扩展方式。

这种变化在 @mcp.tool() 上体现得更加明显。这个装饰器的意义,并不是简单的注册函数,而是为函数赋予协议语义。函数的名称、参数结构、返回格式都会被提取出来,形成结构化描述,供客户端通过 list_tools 查询。这意味着工具的描述不再写在 prompt 里,而是进入了协议层。

与此同时,client 的存在,让这种变化变得具体可见。在本地模式下,调用路径是直接执行函数;而在 MCP 中,调用路径变成通过协议发起请求。也就是说,调用方不再依赖函数本身,而是依赖工具接口。这一点,是“函数”与“能力接口”之间的本质区别。

从结构上看,这里至少拆出了几个清晰的角色。client 负责连接与调用,server 负责能力暴露,tool 是具体执行单元,tool result 承载执行结果。原本混在一个脚本里的逻辑,被拆成了清晰的系统边界。正是这种拆分,使得 agent、工具提供方和调用方之间可以相互独立。

这种独立性带来了几个直接结果。首先,工具可以被多个客户端复用,不再绑定于某一个 agent。其次,工具可以被动态发现,而不需要写死在代码中。再次,调用方式统一之后,权限控制、远程访问、调用约束才有落地空间。这些能力在本地函数注册模式中很难自然出现,但在协议化之后成为系统的一部分。

mcp.run() 进一步强化了这一点。它意味着工具不再随着脚本执行结束而消失,而是以服务形式持续存在。只有在这种模式下,客户端才能在任意时间连接、列出工具并发起调用。这看起来像运行方式的变化,但本质上是系统从“一次性执行”转向“长期存在能力”。

把这一章节与前面章节放在一起,可以看到一个清晰的演进路径。最初,工具只是本地函数,通过字典注册供 agent 使用;随后,工具获得结构化描述,并通过协议对外暴露;再进一步,工具成为独立能力,由不同客户端调用。这个过程不是从抽象理论出发设计的,而是随着执行链路不断拉长、系统复杂度不断提升,自然发生的结构变化。

因此,本章的重点并不在于如何使用 MCP,而在于理解一个关键转变:工具从“函数”变成“能力接口”。当这一转变发生之后,agent 系统的组织方式也随之改变,系统开始具备复用、扩展与治理的基础。这一步,标志着 coding agent 从原型阶段真正进入工程化阶段。

---
title: 本地工具注册与 MCP server 的对比图
---
flowchart LR
    A1["本地 Agent Script
TOOL_REGISTRY 注册工具"] --> B1["执行机制
直接函数调用
tool(...)"] --> C1["结果路径 A
工具 = 本地函数
强耦合
仅进程内可用
难复用"] A2["Agent Client
通过 MCP 连接"] --> B2["执行机制
协议调用
session.call_tool(...)"] --> C2["结果路径 B
工具 = 能力接口
发现
调用
解耦
治理"]

5. 好的 MCP Tool 该如何设计

理解 MCP 之后,一个很容易出现的误区,是把注意力过多放在协议本身,仿佛只要把工具挂到 MCP server 上,agent 就自然会变强。实际上,协议解决的是“如何接入”的问题,而真正决定 agent 使用体验的,往往不是有没有 MCP,而是 tool 本身是否设计得足够清晰、稳定、可调用

这也是为什么在讨论 MCP 时,有一个判断需要被单独强调:API 不等于好 tool

API 往往面向底层资源和完整操作集,追求的是功能覆盖与能力开放;但模型真正适合调用的 tool,通常不是底层能力的原样暴露,而是围绕具体任务抽象出来的动作单元。换句话说,tool 更像是为模型设计的任务接口,而不是给人类工程师看的系统说明书

对于模型来说,最友好的工具通常具备一种“任务导向”的形态。它不要求模型理解过多隐藏前提,也不要求模型在大量参数和分支之间做复杂判断,而是尽量把一个高频动作压缩成边界清楚、输入有限、返回稳定的调用单元。像第二章中的 read_file_toollist_files_tooledit_file_tool,就属于这一类典型例子。它们的名字直接表达用途,参数数量相对有限,返回结构也比较稳定,因此很容易被放进 agent loop 中持续使用。对于模型来说,这类工具更容易“做对”,因为它不需要在决策时同时处理太多分支判断。

如果把这个判断再往前推进一步,就会更容易看清 tool design 在 agent 系统中的位置。假设当前任务是“根据数据库表结构生成登录相关代码”,系统真正需要的,通常不是让模型凭空猜测数据库长什么样,而是先通过工具读取真实 schema,再基于返回结果生成代码框架。也就是说,agent 更合理的执行方式不是直接开始写代码,而是先调用类似 get_user_table_schema 这样的工具,获取 users 表的字段、类型和约束,再继续生成 API、数据模型和校验逻辑。

这个例子清楚地说明了一件事:tool use 的作用,不是给模型补一点“额外信息”,而是把原本不该靠猜测完成的任务,改造成“先读取外部事实,再继续执行”的受控链路。一旦这个链路建立起来,模型的输出就不再只是语言补全,而开始建立在真实上下文之上。

反过来看,如果只是把一个庞大 API 的全部细节原样暴露给模型,结果往往并不会更好。参数过多时,模型更容易拼错、漏传或误解字段含义;工具粒度过细时,模型需要在一堆相似选项里做不稳定决策;返回结果如果缺乏稳定结构,后续 prompt 也很难继续消费这些信息。也就是说,工具越“完整”,并不一定越容易被模型正确使用,很多时候恰恰相反。

因此,一个好的 MCP tool,通常至少要满足几个基本要求:

  • 名字要足够清楚,让模型一眼知道它是干什么的。
  • 参数边界要足够明确,不要让模型去猜哪些参数是必须的,哪些前提是隐含的。
  • 返回结构要足够稳定,这样模型才能把结果继续接进后续推理和执行中。
  • 失败模式也要清楚,因为对 agent 来说,失败并不是结束,关键是失败之后还能不能知道下一步该怎么调整。

从这个角度看,一个好 tool 的本质,并不是“把系统能力公开出来”,而是在为模型设计一个更容易调用正确的接口。它做的事情,不是单纯暴露能力,而是整理复杂性:把底层系统中那些原本只有工程师才能稳定处理的细节,压缩成模型也更容易理解和执行的任务单元。

如果把这件事换成一个更直观的例子,可以看一个常见的需求:

1
User: add login API

一个不成熟的系统,可能会让模型直接开始生成代码;但一个更像 agent 的系统,执行过程通常会更接近下面这样:

1
2
3
4
5
6
Agent:
read repo
inspect schema or existing auth code
plan change
edit files
return result

这个链路虽然很短,却刚好揭示了 agent 与普通代码生成的差异。真正发生的,不再是“模型一次性吐出答案”,而是系统先读取外部事实,再组织动作,再修改结果。也正因为如此,tool design 才会成为 agent 质量的关键变量。工具不是越多越好,关键在于它们是否能被稳定组合成连续动作。

这也是本章更想强调的地方。对于 agent 来说,真正决定效果上限的,往往不是工具数量,而是工具抽象是否足够合理。模型并不是一个真正理解系统全貌的工程师,它更像是在上下文中做近似决策的执行器。好的 tool design,本质上就是把外部世界整理成一种更适合它正确调用的形状。

6. MCP 的工程化外延:认证、Registry 与生态

当工具从本地脚本走向通过 MCP server 提供能力之后,问题的性质会发生变化。在本地环境中,tool 更像是 agent 的“内部函数”,调用关系清晰、边界隐含;但一旦跨进程、跨机器甚至跨组织调用,tool 就不再只是能力封装,而开始变成一种系统边界上的服务接口。也正是在这一刻,一组典型的工程问题会自然浮现出来。

首先出现的是认证问题。只要工具不再运行在完全信任的本地环境中,就必须回答一些基础但关键的问题:谁可以调用这个工具,哪些操作需要额外授权,不同用户是否拥有不同能力范围,调用失败时如何处理。这些问题本质上并不是“登录流程怎么实现”,而是能力边界的定义问题。也就是说,认证关心的不是身份本身,而是哪些能力可以被谁在什么条件下调用。从这个角度看,认证并不是 MCP 的附加模块,而是它走向真实生产环境时必须面对的核心组成部分。因为一旦 tool 可以访问数据库、修改代码或执行命令,它就已经成为系统行为的一部分,而不再只是一个被动工具。

随着工具数量增加,第二个问题很快出现:如何发现这些工具。如果系统中只有少量 MCP server,通过手工配置连接关系尚且可行;但当工具来自不同团队、不同服务甚至不同组织时,这种方式很快会变得不可维护。此时系统需要的不只是“能调用工具”,而是“能够在需要时找到合适的工具”。这正是 registry 层存在的意义。它并不只是一个工具目录,而是让工具的发布、发现和复用开始具备标准化的组织方式。如果说 MCP server 解决的是“工具如何被标准化暴露”,那么 registry 进一步解决的是“工具如何被标准化找到”。这两者分别对应接口问题和发现问题,当它们同时存在时,工具系统就不再是若干孤立 server 的集合,而开始具备网络化扩展的能力。

再往前一步,是实现成熟度的问题。MCP 不只是一个抽象协议,还逐渐配套了 SDK、示例 server、调用流程以及认证与扩展说明。这意味着 MCP 正在从“如何设计接口”的讨论,走向“如何在真实系统中落地”的阶段。只有当协议、实现和运行环境连在一起时,它才真正具备作为工具层标准的意义。

不过,放回本文主线,这些外延并不需要逐一展开细节。更重要的是建立一个更稳定的认识:当一个 agent 系统开始认真处理 tool use,它迟早会遇到同一组问题——如何描述工具、如何调用工具、如何控制权限、如何发现工具,以及如何进行治理。MCP 的价值,并不在于引入了多少新概念,而在于它为这些问题提供了一套统一的接口语言。

这也顺带带出一个重要的问题。很多人会把 agent 的能力理解为“能不能改文件、写代码、跑命令”,但一旦进入真实仓库,系统立刻会碰到权限边界、修改范围、失败回滚和安全控制这些问题。换句话说,执行能力和治理能力往往是一起增长的。一个只会“做事”的 agent,在真实环境中很难稳定工作;只有同时具备边界控制和结果约束的系统,才真正具备工程意义。

因此,当我们把 MCP 放回工程语境中,它的意义就不再只是一个 tool calling 协议,而更像是一套用于组织工具调用、权限边界与生态扩展的基础设施。它所连接的,并不是单个工具,而是 agent 从“能调用工具”走向“能在真实系统中运行”这一过程的关键。

7. 总结

coding agent 的关键不在于“更会生成代码”,而在于“更完整地组织执行”。

系统提示词、工具描述、调用解析、结果回调以及循环结构,这些部分组合在一起,才构成一个最小可运行的 agent。本文第二章的案例说明了这套执行骨架如何成立,而第四章的案例则进一步揭示:一旦工具开始参与执行,这套结构就不可避免地走向更稳定的接口与更清晰的边界。

从这个角度看,MCP 并不是一个突然出现的新中心,而是 agent 工程化过程中的自然延伸。它把“函数能不能被调用”的问题,推进成“外部能力如何被描述、接入并长期维护”的问题。也正是在这一层上,工具不再只是本地实现细节,而开始成为系统的一部分。

继续往前,认证会演变为权限边界的问题,registry 会演变为发现与分发机制,SDK 和 sample servers 会进入工程复用。但在这些变化之中,最值得抓住的并不是这些名词本身,而是更底层的一点:API 面向系统,而 tool 面向模型。

真正决定 agent 可用性的,往往不是能力是否齐全,而是这些能力是否被抽象成模型可以稳定调用的形状。也就是说,tool design 才是贯穿始终的核心变量。

本文先把几个基础的问题理清:agent 的执行结构是什么,工具层发生了什么变化,MCP 在这条链路中处于什么位置。只有在这一层结构被看清之后,后面无论进入 AI IDE、terminal agent、权限控制、安全机制还是复杂工作流,看到的就不再只是工具形态的差异,而是它们背后共享的那套执行逻辑。

8.备注

本文部分观点基于公开资料整理与个人实践总结,如有引用不准确之处欢迎指正。

参考材料: