Career Copilot MCP
Career Copilot MCP
一个基于 2,253 条美国数据分析师职位公告的 MCP 服务器——外加一个从零手写的 MCP 客户端,因为不再把协议当成魔法的最好方式,就是亲手把它实现出来。
我的 Learning in Public 路线图的第 5 周。第 2 周在 notebook 里训练了一个薪资模型。第 3 周把模型放到了一个异步 FastAPI 服务后面,让一个 人 可以调用它。这一周围绕的问题是:如果要让一个 AI 智能体 调用它,需要做到哪些事?
这是什么
一个刻意保持小巧的服务器,用到了 MCP 的全部三种原语——因为大多数示例只提供了 tools,这等于悄悄地把 MCP 降格成“多了一步函数调用”。
原语 | 由谁控制 | 本服务器中的实现 |
工具 | 模型 |
|
资源 | 客户端应用 |
|
提示 | 人 |
|
这三种原语之间的区别就是协议本身。工具是模型决定调用、并且自带参数的东西。资源是无参数的可寻址只读数据——客户端把它像 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,…}}}这个服务器使用的全部交互面就是这些调用:initialize、notifications/initialized、tools/list、tools/call、resources/list、resources/read、prompts/list、prompts/get。
握手负责兼容性
客户端请求 2026-07-28。服务器回复 2025-11-25——它是它的最新版本. Nobody errors and nobody upgrades:
客户端请求 | 服务器回复 |
|
|
|
|
|
|
|
|
|
|
所以,很久之前写的 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 保留子串匹配特例是有意为之:mysql 和 postgresql 确实都表示 SQL。
每个测试都应该有价值
沿用第 3 周的规则:一个测试如果在它所覆盖的代码被删除之后仍然通过,就说明它从来没有真正测试过任何东西。scripts/verify_tests.tests.py 会移除每个修复,并检查测试套件是否能感知到。
uv run python scripts/verify_tests.py移除的修复 | 套件是否察觉 |
词边界技能匹配 | 是 |
限制钳制( | 是 |
可操作的未知位置错误 | 是 |
截断报告 | 是 |
| 是 |
工具函数体以外的多余 | 否——而这正是发现所在 |
运行它,结果抓住了 两个没有测试任何内容的测试:
技能匹配测试是针对
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 itmarket.py 有意不包含任何 MCP 的导入。协议层应该只是包在纯函数外面的薄二层——同样的逻辑也可以放在 HTTP 或者 CLI 上服务,而不必改动.
数据
data/DataAnalyst.csv —— 2,253 条 Glassdoor 数据分析师职位,与第 1-2 周使用相同数据集。一份 2020 年美国都会区快照,属于历史参考数据,而不是实时市场数据。服务器会在 instructions 字段中说明这一点,因此模型也会把这点告诉用户。
This server cannot be installed
Maintenance
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
AI job search MCP — fact-checked jobs, application tracker, alerts. ChatGPT, Claude, Cursor.
Search live startup jobs from Claude, Cursor, or ChatGPT via MCP. Free, no account needed.
Search AI-native jobs, inspect application forms, and fetch free interview-prep resources.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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