Skip to main content
Glama
Aniruddha-Shukla

Career Copilot MCP

Career Copilot MCP

一个基于 2,253 条美国数据分析师职位公告的 MCP 服务器——外加一个从零手写的 MCP 客户端,因为不再把协议当成魔法的最好方式,就是亲手把它实现出来。

Python MCP Tests

我的 Learning in Public 路线图的第 5 周。第 2 周在 notebook 里训练了一个薪资模型。第 3 周把模型放到了一个异步 FastAPI 服务后面,让一个 可以调用它。这一周围绕的问题是:如果要让一个 AI 智能体 调用它,需要做到哪些事?


这是什么

一个刻意保持小巧的服务器,用到了 MCP 的全部三种原语——因为大多数示例只提供了 tools,这等于悄悄地把 MCP 降格成“多了一步函数调用”。

原语

由谁控制

本服务器中的实现

工具

模型

search_jobssalary_benchmarkskill_demand

资源

客户端应用

market://snapshotmarket://locations

提示

career_gap_review

这三种原语之间的区别就是协议本身。工具是模型决定调用、并且自带参数的东西。资源是无参数的可寻址只读数据——客户端把它像 GET 一样附加到上下文中,所以让模型去“调用”它只会浪费一次往返。提示是用户从菜单中选模板;模型永远不会调用它。

Quick start

uv sync && uv pip install -e .

不需要 SDK,也不需要在循环中放进 LLM,就能看到整个协议完整运行:

uv run python client/raw_client.py --verbose

运行测试套件:

uv run python -m pytest tests/ -q

将它与 Claude Code 连接起来

claude mcp add career-copilot -- uv --directory /absolute/path/to/mcp-week-5 run python -m career_copilot_mcp.server
{
  "mcpServers": {
    "career-copilot": {
      "command": "uv",
      "args": ["--directory", "/absolute/path/to/mcp-week-5", "run", "python", "-m", "career_copilot_mcp.server"]
    }
  }
}

MCP 并不是魔法

它是 JSON-RPC 2.0,以换行符分隔的 JSON 形式通过子进程的 stdin/stdout 传输,并采用约定好的方法词汇表。以下是一个真实会话,取自 client/raw_client.py --verbose(为了宽度被略写):

→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"raw-client","version":"0.1.0"}}}
← {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"prompts":{…},"resources":{…},"tools":{…}},"protocolVersion":"2025-11-25","serverInfo":{"name":"career-copilot"}}}

→ {"jsonrpc":"2.0","method":"notifications/initialized","params":{}}          // a notification: no id, no reply

→ {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
← {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search_jobs","description":"Find Data Analyst job postings…","inputSchema":{…},"outputSchema":{…},"annotations":{"readOnlyHint":true}}, …]}}

→ {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"salary_benchmark","arguments":{"location":"San Francisco, CA","skill":"python"}}}
← {"jsonrpc":"2.0","id":6,"result":{"content":[…],"isError":false,"structuredContent":{"median":92500,"p25":80500,"p75":126000,…}}}

这个服务器使用的全部交互面就是这些调用:initializenotifications/initializedtools/listtools/callresources/listresources/readprompts/listprompts/get

握手负责兼容性

客户端请求 2026-07-28。服务器回复 2025-11-25——它是的最新版本. Nobody errors and nobody upgrades:

客户端请求

服务器回复

2026-07-28(比服务器新)

2025-11-25

2025-11-25

2025-11-25

2025-06-18

2025-06-18

2024-11-05

2024-11-05

1999-01-01(无意义)

2025-11-25

所以,很久之前写的 MCP 客户端至今仍能正常使用今天交付的服务端。兼容性是在握手这一层完成的,而不是靠你的代码。


四件消耗我时间的事

1. 工具描述就是 prompt

它是模型在决定是否调用某个工具、传入什么参数时唯一会读取的信息。location: str 无法让它理解任何信息。而下面这样才行:

location: US metro in "City, ST" form, e.g. "New York, NY" or "Austin, TX".
    A partial name like "Austin" is accepted when it is unambiguous. Read
    market://snapshot for the most common values before guessing.

有一个测试专门保证这一点,因为描述会在静静无声中腐烂:

assert len(tool["description"]) > 80, f"{tool['name']} description is too thin"

2. -> dict 不能提供输出 schema

我的工具曾返回一个字符串放在文本块中。客户端必须 json.loads 再猜它的结构。SDK 不会让你草率处理这一点:

InvalidSignature: Function search_jobs: return type <class 'dict'> is not
serializable for structured output

类型化返回(TypedDict)会生成一个 outputSchema,并随着工具在 tools/list 中暴露;结果会通过 structuredContent 返回——机器可读,而不是重新解析解析的文本。

3. 错误是一种结果,而不是崩溃

智能体可以针对一次建议进行重试。它无法面对着沉默重试。所以当遇到未知位置时,应该返回一条消息,列出合法值:

No postings found for location 'Bangalore'. This dataset covers US metros only.
Try one of: New York, NY, Chicago, IL, San Francisco, CA, Austin, TX, …

连接保持不断,isError: true 作为正常结果返回;并且测试程序断言服务器随后仍能正常应答。

4. 模型只能被数据,无法对数据做合理性检查

这才是真正的教训,它其实不是 MCP 的 bug——而是数据 bug,但 MCP 让它变得危险。

第 2 周用很粗糙的子串匹配来检测技能。"excel" in description 也会匹配到 “excellent”"aws" 会匹配到 “laws”“draws”“flaws”

skill

子串匹配

词边界匹配

高估幅度

excel

1,354 (60.1%)

903 (40.1%)

+50%

aws

275 (12.2%)

132 (5.9%)

+108%

spark

89

71

+25%

sql

1,389

1,387

在 notebook 里,一个错误的数字可能只是让我眯眼观察的图表。但放到 MCP 工具后面,它就成了一个模型会带着自信的句子传播出的数字,服务也得写上我的名字。这里没有 error 没有异常、没有信号,只有精心传递的错误答案。

SQL 保留子串匹配特例是有意为之:mysqlpostgresql 确实都表示 SQL。


每个测试都应该有价值

沿用第 3 周的规则:一个测试如果在它所覆盖的代码被删除之后仍然通过,就说明它从来没有真正测试过任何东西。scripts/verify_tests.tests.py 会移除每个修复,并检查测试套件是否能感知到。

uv run python scripts/verify_tests.py

移除的修复

套件是否察觉

词边界技能匹配

限制钳制(1 ≤ limit ≤ 25

可操作的未知位置错误

截断报告

readOnlyHint 注解

工具函数体以外的多余 print()

否——而这正是发现所在

运行它,结果抓住了 两个没有测试任何内容的测试

  • 技能匹配测试是针对 SKILL_PATTERNS 常量进行断言,而不是针对加载后的真实数据。它们证明了正则表达式构造本身是正常的,但没有证明流水线真的使用了它。改动调用位置并不会让它们失败。现在它们改为对真实职位数据断言。

  • stdout 测试之前只调用了 tools/list,所以工具函数体内部的 print() 永远不会走到。现在测试会真实运行每个 handler。

这个“陷阱”其实并不存在

每个 MCP 指南都会重复同一句话:通过 stdio 时,你的 stdout 就是传输线路,哪怕多出一个 print() 也会污染整个流,导致客户端不可用。为此我专门写了一个测试。当我在工具函数体中加入 print("stray print", flush=True) 后,测试居然是通过的,客户端也仍然正常工作。

mcp/stdio.py 解释了原因。在服务期间,传输层会接管 fd 1:它把真实的传输线路复制成一个私有描述符,然后把 fd 1 指向 stderr 的一个副本。

def _open_stdout_diversion() -> int:
    try:
        return os.dup(2)          # fd 1 now goes wherever stderr goes
    except OSError:
        return os.open(os.devnull, os.O_WRONLY)

我做了端到端的验证:那个多余的 print() 并没有到达真正的线路,而是落在了 stderr 上。(stdin 也做了同样的处理,被重定向到 /dev/null;这样 handler 和子进程读到的是 EOF,而不是搞清楚协议字节。)

所以,把日志写入 stderr 依然是正确做法——规范也是这样要求的;客户端也会把这些日志作为服务器日志展示给你。但通常所谓的“stdout 会弄坏协议”的理由,对于这个版本的 SDK 来说,更像是一种民间传说。如果我没有尝试破坏自己的测试,恐怕就会把这个传说当成注释写进代码里了。


目录结构

src/career_copilot_mcp/
  market.py     data layer — no MCP imports, so the logic is testable without a server
  server.py     the protocol adapter: 3 tools, 2 resources, 1 prompt
client/
  raw_client.py a ~200-line MCP client. No SDK. Speaks JSON-RPC at a subprocess.
scripts/
  verify_tests.py  deletes each fix, checks the suite notices
tests/
  test_market.py    the data layer
  test_protocol.py  spawns the real server and speaks JSON-RPC at it

market.py 有意不包含任何 MCP 的导入。协议层应该只是包在纯函数外面的薄二层——同样的逻辑也可以放在 HTTP 或者 CLI 上服务,而不必改动.

数据

data/DataAnalyst.csv —— 2,253 条 Glassdoor 数据分析师职位,与第 1-2 周使用相同数据集。一份 2020 年美国都会区快照,属于历史参考数据,而不是实时市场数据。服务器会在 instructions 字段中说明这一点,因此模型也会把这点告诉用户。

-
license - not tested
Not graded
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Aniruddha-Shukla/week-5-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server