最近正在参与一个 MCP 项目的开发。在真正把 MCP 接入业务系统、交给业务人员使用的过程中,我越来越明显地感受到,MCP 很可能会成为大模型连接业务数据、系统能力和外部工具的重要基础设施,也是未来 Agent 应用的一条重要技术路线。

因此,我想把开发过程中学到的知识、踩过的坑、验证过的方案和总结出的经验,以帖子的形式记录下来。一方面是给自己留一份完整的项目复盘,另一方面也希望能给正在开发或准备落地 MCP 的朋友提供一些参考。

如果你也在做 MCP 项目,欢迎在评论区一起交流,讨论工具设计、OAuth 鉴权、客户端兼容、权限隔离和生产部署等问题。

这篇文章主要记录两个问题:

  1. 一个远程 MCP 到底应该怎么配置到客户端;
  2. 怎样设计工具清单,才能让业务人员直接用自然语言调用。

我会以一个“企业简历筛选只读 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,
)

我以前会觉得服务器说明只是一个“产品介绍”,实际使用后发现,它更像整套工具的路由规则。

例如,用户说“张三这份简历怎么样”,模型需要知道:

  1. 用户只有姓名,没有系统简历 ID;
  2. 应该先调用搜索工具;
  3. 从搜索结果中取得简历 ID;
  4. 再调用详情工具;
  5. 如果搜索出多个张三,应该先让用户确认。

这些信息只写在单个工具里也能工作,但放在服务器总说明中,可以让整套工具的组合行为更稳定。

五、工具清单是怎么定义出来的

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 工具名。我们要做的是让模型通过工具元数据理解业务意图。

一个好的工具描述,至少应该写清楚四件事:

  1. 用户通常会怎么表达;
  2. 工具能查询什么数据;
  3. 什么时候需要先调用其他工具;
  4. 出现多个匹配结果时怎么处理。

例如,下面这种描述就太弱了:

按 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"
    }
  }
}

我的安装流程最终简化为:

  1. 把这段内容合并到 WorkBuddy 当前生效的用户级 MCP 配置;
  2. 保留原有其他 mcpServers
  3. 保存配置;
  4. 用户本人打开连接器管理页面;
  5. 点击 Trust/连接;
  6. 在浏览器弹出的 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 的数据。

提示词不是权限系统。

正确做法应该是:

  1. OAuth Token 绑定当前用户;
  2. MCP 调用后端时携带该用户对应的 Session;
  3. 后端查询层按照当前用户增加数据范围条件;
  4. 列表、详情和统计接口使用同一套权限逻辑;
  5. 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

所以,修改工具名称、标题或描述后,我通常会执行:

  1. 重新部署 MCP 服务;
  2. 在 WorkBuddy 中刷新连接器;
  3. 如果没有更新,断开后重新连接;
  4. 必要时新建一个聊天窗口,让客户端重新加载工具上下文。

如果工具本身支持变更通知,客户端也可以在收到工具清单变化通知后主动刷新。

十二、我是怎么测试工具清单的

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 回调失败、自然语言不会触发工具等问题,欢迎一起交流。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐