从一个 URL 到一组可自然语言调用的工具:我的 FastMCP 配置与工具清单实践
最近正在参与一个 MCP 项目的开发。在真正把 MCP 接入业务系统、交给业务人员使用的过程中,我越来越明显地感受到,MCP 很可能会成为大模型连接业务数据、系统能力和外部工具的重要基础设施,也是未来 Agent 应用的一条重要技术路线。
因此,我想把开发过程中学到的知识、踩过的坑、验证过的方案和总结出的经验,以帖子的形式记录下来。一方面是给自己留一份完整的项目复盘,另一方面也希望能给正在开发或准备落地 MCP 的朋友提供一些参考。
如果你也在做 MCP 项目,欢迎在评论区一起交流,讨论工具设计、OAuth 鉴权、客户端兼容、权限隔离和生产部署等问题。
这篇文章主要记录两个问题:
- 一个远程 MCP 到底应该怎么配置到客户端;
- 怎样设计工具清单,才能让业务人员直接用自然语言调用。
我会以一个“企业简历筛选只读 MCP”为例展开。文中的域名、邮箱和业务数据都经过了脱敏,不涉及真实生产信息。
一、这个项目想解决什么问题
原来的系统是一个正常运行的 Web 应用,已经具备登录、简历查询、AI 分析、统计看板等能力。
我希望增加一个 MCP 入口,让 HR 不必打开后台逐层点击,而是可以直接在 WorkBuddy 里提问:
帮我看看最近的10份简历。
我目前一共能查看多少份简历?整体通过率是多少?
这个月哪个岗位的候选人最多?
同时,我给自己设了几个边界:
- 不能影响原有网页功能;
- 不能修改原来的轮询、AI 分析和消息推送逻辑;
- MCP 第一版只读;
- 每个 HR 只能看到自己有权限查看的数据;
- 不能使用一个共享管理员 Token;
- 不能把账号、密码或固定 Token 写入客户端配置。
最后采用的架构是:
业务用户
↓ 自然语言
WorkBuddy / MCP Client
↓ OAuth + MCP
独立 FastMCP 服务
↓ 当前 HR 的后端 Session
现有 Web 后端 API
↓
数据库和业务系统
这里最重要的一点是:MCP 不直接访问数据库,而是调用现有后端 API。
这样做的好处是,原系统已有的身份校验、数据权限、参数校验和业务规则都可以继续复用,MCP 只是增加了一个新的交互入口。
二、我踩的第一个坑:客户端配置不等于工具清单
刚开始做的时候,我一度以为 WorkBuddy 的配置文件里既要写服务器地址,也要写每个工具的定义,例如:
{
"name": "search_resumes",
"title": "搜索简历",
"description": "用户询问最近简历或候选人时使用"
}
后来真正跑通协议后才发现,这两个东西完全不是一层。
客户端配置解决的是“去哪里连接 MCP”。
工具清单解决的是“这个 MCP 能做什么”。
WorkBuddy 侧只需要保存服务器名称和地址:
{
"mcpServers": {
"ai-resume-screening": {
"type": "http",
"url": "https://mcp.example.com/mcp"
}
}
}
真正的工具定义全部放在 MCP 服务端。客户端连接成功后,会调用 tools/list 获取工具名称、标题、描述、输入参数和行为标记。
所以,工具清单不是发给业务人员安装的 JSON 文件,也不需要每台电脑维护一份。
只要服务端更新了工具定义并重新部署,客户端重新加载工具清单后,就能看到新版本。
这个认识很关键。否则,工具一多,就会陷入在每台客户端手工同步配置的麻烦里。
三、用 FastMCP 启动一个远程服务
这个项目使用的是 FastMCP。最小的 HTTP 服务可以这样启动:
from fastmcp import FastMCP
mcp = FastMCP(name="AI Resume Screening")
if __name__ == "__main__":
mcp.run(
transport="http",
host="0.0.0.0",
port=8001,
path="/mcp",
stateless_http=True,
json_response=True,
)
最终的远程地址类似:
https://mcp.example.com/mcp
这里使用 Streamable HTTP,而不是本地 stdio,原因也比较直接:
stdio更适合客户端在本机启动一个 MCP 子进程;- HTTP MCP 可以独立部署,多个业务用户共同访问;
- OAuth 也更适合通过远程 HTTP 服务完成;
- MCP 服务可以和原后端放在同一个 Docker 网络中,但使用独立端口运行。
这里还有一个很容易误判的问题:直接在浏览器里打开 /mcp,可能看到空白页或者 405 Method Not Allowed。
这不一定是错误。
MCP 客户端主要通过 POST 向该地址发送 JSON-RPC 请求,浏览器地址栏发起的是普通 GET。服务器没有提供对应 GET 页面时,返回405是合理的。
因此,测试 MCP 不能只靠“浏览器能不能打开 /mcp”来判断。
四、先写服务器总说明,再写单个工具
工具清单解决的是单个工具的能力,服务器 instructions 解决的是整组工具之间应该如何协作。
我的做法是先把整个服务的定位、工具选择规则和安全边界写清楚:
SERVER_INSTRUCTIONS = """
这是一个面向 HR 的企业只读 MCP,用于查询当前登录用户有权查看的
候选人简历、岗位、AI 评估结果和筛选统计。
业务用户可以直接使用自然语言,不需要知道工具名、HTTP 地址、Token、
Header 或分页参数。
工具选择规则:
- 查询最近简历、候选人或处理状态时,使用 search_resumes;
- 用户只有姓名但希望查看完整分析时,先搜索,再调用 get_resume;
- 查询可见岗位时,使用 list_resume_jobs;
- 查询整体筛选情况时,使用 get_screening_overview;
- 对比岗位数据时,使用 get_screening_stats_by_job;
- 查询学历、年龄或性别分布时,使用 get_candidate_demographics。
“我、我的、我负责的”均指当前 OAuth 登录用户。
所有回答必须基于本次工具调用返回的数据,不得根据历史对话猜测。
本服务只读,不能修改简历、启动任务或发送通知。
""".strip()
然后把它传给 FastMCP:
mcp = FastMCP(
name="AI Resume Screening",
version="1.0.0",
instructions=SERVER_INSTRUCTIONS,
strict_input_validation=True,
mask_error_details=True,
)
我以前会觉得服务器说明只是一个“产品介绍”,实际使用后发现,它更像整套工具的路由规则。
例如,用户说“张三这份简历怎么样”,模型需要知道:
- 用户只有姓名,没有系统简历 ID;
- 应该先调用搜索工具;
- 从搜索结果中取得简历 ID;
- 再调用详情工具;
- 如果搜索出多个张三,应该先让用户确认。
这些信息只写在单个工具里也能工作,但放在服务器总说明中,可以让整套工具的组合行为更稳定。
五、工具清单是怎么定义出来的
FastMCP 可以根据 Python 函数签名和类型注解自动生成 inputSchema。
一个工具通常会向客户端暴露这些信息:
name:协议层工具名;title:展示给用户看的标题;description:模型用于判断调用时机的说明;inputSchema:参数类型、默认值和校验规则;outputSchema:可选的返回结构;annotations:只读、幂等、破坏性等行为提示。
1. 统一定义只读标记
READ_ONLY_ANNOTATIONS = {
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
}
这些标记的含义是:
- 工具不会修改系统状态;
- 不包含删除或其他破坏性操作;
- 相同参数重复查询不会产生额外副作用;
- 工具只访问明确限定的内部业务域。
但这里必须强调:annotations 只是给客户端和模型看的提示,不是权限系统。
真正的只读限制和数据隔离,必须在后端代码中落实。
2. 定义搜索工具
from typing import Annotated, Literal
from pydantic import Field
ResumeStatus = Literal[
"pending",
"processing",
"passed",
"rejected",
"failed",
]
@mcp.tool(
name="search_resumes",
title="搜索候选人与简历",
description=(
"当用户询问最近简历、某位候选人、某岗位候选人,或希望按通过、"
"淘汰、处理中、待处理等状态筛选时调用。只返回当前 OAuth 登录 HR"
"有权查看的数据。若用户随后需要完整分析,应从结果中取得简历 ID,"
"再调用 get_resume。"
),
annotations=READ_ONLY_ANNOTATIONS,
)
async def search_resumes(
status: Annotated[
list[ResumeStatus] | None,
Field(description="可选处理状态,可以同时选择多个状态。"),
] = None,
keyword: Annotated[
str | None,
Field(description="候选人姓名、岗位名称或外部申请 ID。"),
] = None,
page: Annotated[
int,
Field(ge=1, description="页码,从 1 开始。"),
] = 1,
page_size: Annotated[
int,
Field(ge=1, le=100, description="每页数量,默认 20,最大 100。"),
] = 20,
) -> dict:
return await backend.search_resumes(
status=status,
keyword=keyword,
page=page,
page_size=page_size,
)
客户端通过 tools/list 取得的结果大致会是:
{
"name": "search_resumes",
"title": "搜索候选人与简历",
"description": "当用户询问最近简历、候选人或处理状态时调用……",
"inputSchema": {
"type": "object",
"properties": {
"status": {
"description": "可选处理状态,可以同时选择多个状态。"
},
"keyword": {
"description": "候选人姓名、岗位名称或外部申请 ID。"
},
"page": {
"type": "integer",
"minimum": 1,
"default": 1
},
"page_size": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
}
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
}
不同版本的 Pydantic 和 JSON Schema 生成器,对可空类型的具体序列化形式可能不同,所以调试时应该以客户端真正获取到的 tools/list 为准。
六、我最终保留了哪6个工具
一开始我也考虑过把后端所有 GET 接口都变成 MCP 工具。
后来发现,工具不是越多越好。
如果把大量语义相近、边界模糊的接口全部暴露出去,模型反而更容易选错。最后我按照业务意图进行合并,只保留6个只读工具:
| 工具名 | 展示标题 | 用户通常会怎么问 | 主要参数 |
|---|---|---|---|
search_resumes |
搜索候选人与简历 | 最近有哪些简历、帮我找某位候选人 | 状态、关键词、分页 |
get_resume |
查看候选人 AI 简历分析 | 这位候选人怎么样、有什么风险 | 系统简历 ID |
list_resume_jobs |
查看当前 HR 可见岗位 | 我能看到哪些岗位 | 无 |
get_screening_overview |
查看简历筛选总体概览 | 一共有多少简历、整体通过率是多少 | 开始日期、结束日期 |
get_screening_stats_by_job |
按岗位查看筛选统计 | 哪个岗位简历最多、岗位通过率对比 | 开始日期、结束日期 |
get_candidate_demographics |
查看候选人画像分布 | 学历、年龄和性别分布怎么样 | 开始日期、结束日期 |
在 WorkBuddy 界面中,工具名有时会带上连接器前缀,例如:
ai-resume-screening_search_resumes
这只是客户端为了避免多个 MCP 出现同名工具而增加的展示前缀。
服务端真正注册的工具名仍然是:
search_resumes
七、工具描述应该怎么写,模型才会自动调用
这一部分是我觉得最值得记录的经验。
业务人员最终不会这样提问:
请调用 search_resumes,page=1,page_size=10。
他们只会说:
帮我看看最近的10份简历。
所以,不能要求用户学习 MCP 工具名。我们要做的是让模型通过工具元数据理解业务意图。
一个好的工具描述,至少应该写清楚四件事:
- 用户通常会怎么表达;
- 工具能查询什么数据;
- 什么时候需要先调用其他工具;
- 出现多个匹配结果时怎么处理。
例如,下面这种描述就太弱了:
按 ID 获取简历。
模型虽然知道它能按 ID 查询,但不知道用户只给姓名时怎么办。
我后来改成:
当用户询问“这位候选人怎么样”“是否推荐”“有什么优势或风险”时调用。
如果用户只提供姓名或岗位,必须先调用 search_resumes 找到系统简历 ID;
匹配到多位候选人时,先让用户确认目标。始终隐藏手机号和原始简历下载地址。
参数说明也要尽量使用业务语言:
resume_id: Annotated[
int,
Field(
gt=0,
description="search_resumes 返回的系统内部简历 ID,不是外部申请 ID。",
),
]
当服务器说明、工具描述和参数 Schema 都足够清晰后,用户自然语言调用的成功率会明显提高。
八、WorkBuddy 应该怎么安装这个 MCP
WorkBuddy 侧只需要配置服务器:
{
"mcpServers": {
"ai-resume-screening": {
"type": "http",
"url": "https://mcp.example.com/mcp"
}
}
}
我的安装流程最终简化为:
- 把这段内容合并到 WorkBuddy 当前生效的用户级 MCP 配置;
- 保留原有其他
mcpServers; - 保存配置;
- 用户本人打开连接器管理页面;
- 点击 Trust/连接;
- 在浏览器弹出的 OAuth 页面中登录。
这里我还踩过一个很实际的坑:不要让自动化助手尝试代替用户完成 Trust 和 OAuth 登录。
自动化助手可以帮忙写配置,但用户界面的信任确认和网页登录应该由用户本人完成。否则,助手很容易开始检查 Cookies、Local Storage、审批缓存甚至加密密钥,最后把一个很简单的安装任务做得非常复杂。
所以我现在给安装助手的边界非常明确:
只写入 MCP 配置并停止。
不要修改审批记录、Cookies、Local Storage、OAuth Token 或加密密钥。
信任、连接和网页登录由用户本人完成。
另外,不同客户端的配置字段并不完全相同。有的使用:
{"type": "http"}
有的使用:
{"transport": "http"}
还有的可能使用:
{"type": "streamableHttp"}
这部分不能凭经验混用,必须以目标客户端当前版本支持的格式为准。
九、为什么不能共用管理员 Token
最初为了快速验证 MCP,使用一个固定后端 Token 确实很方便。
但只要系统准备交给多个业务用户,这种方式就必须替换掉。
如果所有人共用管理员 Token,那么无论谁连接 MCP,后端看到的都是同一个管理员身份,业务数据就无法真正隔离。
我最后采用的是 OAuth 授权码流程:
WorkBuddy 访问 MCP
↓
发现服务器需要 OAuth
↓
读取授权服务器元数据
↓
注册或识别客户端,并生成 PKCE 参数
↓
浏览器登录
↓
Authorization Code
↓
Access Token + Refresh Token
↓
MCP 绑定当前 HR 身份调用后端
在本项目对接的客户端版本中,仍然使用动态客户端注册,也就是 DCR。较新的 MCP 规范已经开始优先推荐 CIMD,DCR 主要作为兼容机制保留。
无论使用哪种客户端注册方式,核心原则都一样:
- 不在客户端配置中写固定账号密码;
- 不共享管理员 Token;
- Token 必须绑定真实登录用户;
- 后端必须根据当前用户做权限校验。
一份脱敏后的环境配置如下:
AI_RESUME_MCP_ENABLED=true
AI_RESUME_MCP_BACKEND_BASE_URL=http://backend:8000
AI_RESUME_MCP_PUBLIC_BASE_URL=https://mcp.example.com
AI_RESUME_MCP_OAUTH_ALLOWED_EMAILS=hr-a@example.com,hr-b@example.com
AI_RESUME_MCP_OAUTH_ALLOW_INSECURE_HTTP=false
AI_RESUME_MCP_OAUTH_LOGIN_REQUEST_TTL_SECONDS=300
AI_RESUME_MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS=3600
AI_RESUME_MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS=86400
SESSION_IDLE_TIMEOUT=86400
SESSION_MAX_AGE=86400
AI_RESUME_MCP_BACKEND_SESSION_MAX_AGE_SECONDS=86400
AI_RESUME_MCP_REQUEST_TIMEOUT_SECONDS=30
AI_RESUME_MCP_VERIFY_BACKEND_TLS=true
AI_RESUME_MCP_HOST=0.0.0.0
AI_RESUME_MCP_PORT=8001
AI_RESUME_MCP_PATH=/mcp
这几个时间配置需要一起理解:
- 登录请求有效期300秒:登录页需要在5分钟内完成提交;
- Access Token 有效期1小时;
- Access Token 过期后,客户端可以用 Refresh Token 自动续期;
- Refresh Token 和后端 Session 最长24小时;
- 后端空闲超时也必须允许24小时,否则只修改 Refresh Token 没有意义。
如果 OAuth grant 只保存在 MCP 进程内存中,那么服务重启后,客户端仍然需要重新登录。这不是 Token TTL 配置错误,而是 Token 没有持久化导致的。
生产环境如果需要持久化 OAuth 状态,应该使用加密存储,不能把 Token 明文写进配置文件或日志。
十、权限隔离必须在后端完成
OAuth 登录成功,只能说明“系统知道当前用户是谁”。
至于“这个用户能看到什么”,仍然要由后端决定。
最不可靠的方案是让 MCP 用管理员身份查询所有数据,然后在提示词里写:
不要展示其他 HR 的数据。
提示词不是权限系统。
正确做法应该是:
- OAuth Token 绑定当前用户;
- MCP 调用后端时携带该用户对应的 Session;
- 后端查询层按照当前用户增加数据范围条件;
- 列表、详情和统计接口使用同一套权限逻辑;
- MCP 返回结果前再次移除敏感字段。
后端的核心逻辑可以类似这样:
def apply_data_scope(query, current_user):
if current_user.role == "admin":
return query
return query.where(
Resume.owner_email == current_user.email
)
这样,即使有人不通过自然语言,直接调用 MCP 工具,也只能取得后端允许当前身份访问的数据。
对于手机号、原始简历下载地址等敏感内容,我还会在 MCP 输出层再做一次脱敏:
sanitized = dict(data)
sanitized["phone"] = None
sanitized["resume_download_url"] = None
return sanitized
权限隔离和字段脱敏最好分别做。前者决定“能不能看这条数据”,后者决定“这条数据里的哪些字段可以通过 MCP 暴露”。
十一、工具清单会不会每次调用都更新
这个问题我也专门研究过。
答案是:不会要求每次工具调用之前都重新拉取清单。
当前项目对接的客户端仍使用兼容版连接流程,大致是:
建立连接
↓
initialize
↓
tools/list
↓
客户端展示并缓存工具
↓
tools/call
最新的 MCP 2026-07-28 规范已经取消 initialize/initialized 握手,客户端可以按需调用 server/discover,也可以直接请求 tools/list。
新规范还允许工具清单返回缓存时间和缓存范围,让客户端知道一份工具清单可以使用多久。
但无论新旧版本,都不是每次调用工具前先执行一次 tools/list。
所以,修改工具名称、标题或描述后,我通常会执行:
- 重新部署 MCP 服务;
- 在 WorkBuddy 中刷新连接器;
- 如果没有更新,断开后重新连接;
- 必要时新建一个聊天窗口,让客户端重新加载工具上下文。
如果工具本身支持变更通知,客户端也可以在收到工具清单变化通知后主动刷新。
十二、我是怎么测试工具清单的
1. 测试健康接口
我单独增加了一个普通健康检查接口:
curl https://mcp.example.com/mcp-health
预期返回:
{
"status": "ok",
"authentication": "oauth",
"read_only": true
}
健康接口成功,只能说明 MCP 进程和路由可访问,不代表 OAuth 和工具调用一定正常。
2. 检查 OAuth 发现信息
curl https://mcp.example.com/.well-known/oauth-authorization-server
curl https://mcp.example.com/.well-known/oauth-protected-resource/mcp
这一步用于验证客户端能否发现授权端点和资源信息。
3. 自动验证工具数量和只读标记
import asyncio
from fastmcp import Client
EXPECTED_TOOLS = {
"search_resumes",
"get_resume",
"list_resume_jobs",
"get_screening_overview",
"get_screening_stats_by_job",
"get_candidate_demographics",
}
async def verify_tools(server):
async with Client(server) as client:
tools = await client.list_tools()
assert {tool.name for tool in tools} == EXPECTED_TOOLS
for tool in tools:
assert tool.annotations.readOnlyHint is True
assert tool.annotations.destructiveHint is False
asyncio.run(verify_tools(mcp))
除了工具数量,我还会检查:
- 工具标题和描述是否存在;
- 参数说明是否足够清晰;
- 分页上限是否生效;
- 日期格式是否正确;
- 无权限数据是否返回403或404;
- 手机号和下载地址是否被移除;
- OAuth Token 是否绑定正确用户;
- Refresh Token 是否会重新验证后端 Session。
4. 用自然语言测试
技术测试通过后,还必须用真正的业务表达测试。
例如:
帮我看看最近的10份简历,列出候选人姓名、岗位、处理状态和综合评分。
我目前一共能查看多少份简历?请统计通过、淘汰、待处理和失败的数量。
我能看到哪些岗位?哪个岗位的简历最多、通过率最高?
分析一下我能查看的候选人学历、年龄和性别分布。
还要测试只读边界:
请把最近一份待处理简历直接标记为通过。
当前 MCP 应该明确拒绝,因为它没有写操作工具。
十三、几个很容易误判的问题
1. 浏览器打开 /mcp 是白屏或405
不一定是服务故障。普通浏览器 GET 和 MCP JSON-RPC POST 不是同一种请求。
2. 未登录时 POST /mcp 返回401
如果服务启用了 OAuth,这是正常行为。客户端应该进入 OAuth 发现和登录流程,而不是要求用户提供固定 Token。
3. 登录成功,但客户端看不到工具
需要继续检查:
- OAuth 回调有没有真正回到当前客户端;
- Authorization Code 是否成功交换 Token;
- 客户端有没有重新请求
tools/list; - MCP 日志里是否仍然出现401;
- 工具是否真的完成注册。
4. 登录一段时间后突然失效
需要同时检查:
- Access Token TTL;
- Refresh Token TTL;
- 后端 Session 空闲超时;
- 后端 Session 绝对超时;
- MCP 服务是否刚刚重启;
- OAuth 状态是否只保存在进程内存。
只修改其中一个时间配置,可能并不能得到预期的登录时长。
5. 服务端已经改了工具说明,客户端还是旧的
工具清单可能被客户端缓存。重新部署后,还需要让客户端刷新或重连。
十四、写在最后
做完这个项目后,我对 MCP 的理解发生了一个变化。
刚开始我把 MCP 理解成“让大模型调用接口的统一协议”。后来真正交给业务使用后发现,接口封装只是最基础的一层。
一个能够上线的企业 MCP,还要同时处理:
- 远程传输;
- 客户端兼容;
- OAuth 登录;
- 用户身份绑定;
- 数据权限隔离;
- 敏感字段脱敏;
- 工具描述和自然语言路由;
- Token 生命周期;
- 自动化测试;
- 服务重启和客户端缓存。
其中任何一个环节没有处理好,最终表现出来的都可能是“用户连不上”“模型不会调用”或者“看到了不该看的数据”。
对我来说,MCP 真正有价值的地方,是让业务人员可以继续使用自己最熟悉的语言,而不必学习新的后台操作方式。
当用户只需要说一句:
帮我看看最近的简历。
系统就能自动完成工具选择、参数组织、身份校验、权限过滤和结果整理,这才是 MCP 从“技术 Demo”走向真实业务的关键一步。
后面如果继续完善这个项目,我还准备记录 OAuth 登录兼容、权限隔离测试、生产环境部署和 MCP 可观测性等内容。
如果你也正在开发 MCP,或者遇到了工具无法加载、OAuth 回调失败、自然语言不会触发工具等问题,欢迎一起交流。
更多推荐


所有评论(0)