Career Copilot MCP
Career Copilot MCP
미국 데이터 분석가(Data Analyst) 채용 공고 2,253건을 처리하는 MCP 서버 — 그리고 처음부터 직접 만든 MCP 클라이언트. 프로토콜을 마법처럼 취급하지 않게 되는 가장 빠른 길은 직접 구현하는 것입니다.
제 Learning in Public 로드맵의 Week 5입니다. Week 2는 노트북에서 연봉 모델을 훈련했습니다. Week 3는 모델을 비동기 FastAPI 서비스 뒤에 올려서 사람이 호출할 수 있게 했습니다. 이번 주의 질문: AI 에이전트가 호출하려면 무엇이 필요할까?
이게 어떤 것인가
일부러 작게 만든 서버로 MCP의 두 가지 기본 요소를 모두 다룹니다. 대부분의 예제는 도구만 제공해서, MCP를 조용히 "단계만 몇 개 더 붙은 함수 호출"로 격하시키기 때문입니다.
원시 유형 | 제어 주체 | 이 서버에서의 구현 |
도구(Tools) | 모델 |
|
리소스(Resources) | 클라이언트 앱 |
|
프롬프트(Prompts) | 사용자 |
|
이 구별이 바로 실제 프로토콜입니다. 도구는 모델이 스스로 결정해서 호출하는 것으로, 전달할 인자도 모델이 고릅니다. 리소스는 인자가 없는 주소 지정 가능한 읽기 전용 데이터입니다. 클라이언트가 GET처럼 컨텍스트에 첨부하는 것이므로, 모델에게 이것을 "호출"하게 만드는 것은 왕복을 한 번 낭비하는 셈입니다. 프롬프트는 사용자가 메뉴에서 고르는 템플릿이며, 모델은 절대 호출하지 않습니다.
빠른 시작
uv sync && uv pip install -e .SDK도 LLM도 개입시키지 않고 프로토콜 전체 실행을 지켜보세요.
uv run python client/raw_client.py --verbose테스트 스위트를 실행하세요.
uv run python -m pytest tests/ -qClaude 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는 마법이 아닙니다
서브프로세스의 stdin/stdout을 통해 줄바꿈 구분 JSON으로 주고받는 JSON-RPC 2.0이며, 합의된 메서드 어휘를 사용합니다. 실제 세션 하나를 보여드리면 다음과 같습니다. 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 — 서버가 말할 수 있는 가장 최신 버전 — 으로 답합니다. 오류도 없고 업그레이드도 없습니다:
클라이언트가 요청한 것 | 서버가 답한 것 |
|
|
|
|
|
|
|
|
|
|
그래서 몇 달 전에 작성된 MCP 클라이언트가 오늘 출시된 서버와도 여전히 동작합니다. 호환성은 핸드셰이크에 들어 있고, 여러분의 코드에는 없습니다.
시간을 잡아먹은 네 가지
1. 도구 설명이 곧 프롬프트다
모델이 도구를 호출할지, 무엇을 넘길지를 결정할 때 읽는 것은 도구 설명뿐입니다. 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는 출력 스키마를 주지 못합니다
제 도구들은 텍스트 블록 안에 JSON 문자열을 반환했습니다. 클라이언트는 그것을 매번 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 버그가 아니라, MCP가 위험하게 만들어 버린 데이터 버그였습니다.
Week 2는 스킬을 단순 부분 문자열 매칭으로 검출했습니다. "excel" in description 은 **"excellent"**도 매칭합니다. "aws"는 "laws", "draws", **"flaws"**도 매칭합니다.
스킬 | 부분 문자열 매칭 | 단어 경계 매칭 | 부정함 |
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 | — |
노트북 안에서 잘못된 숫자는 애매한 차트입니다. MCP 도구 뒤에서는, 그 숫자는 모델이 제 서버 이름을 앞세워 자신 있는 문장으로 사용자에게 전해주는 숫자입니다. 오류도, 예외도, 신호도 없습니다 — 잘 전달된 틀린 답일 뿐입니다.
SQL은 부분 문자열 예외를 의도적으로 유지합니다. mysql과 postgresql이 실제로는 SQL을 의미하기 때문입니다.
모든 테스트는 자리를 증명합니다
Week 3의 규칙을 이어갑니다: 자신이 커버하는 코드를 지운 뒤에도 여전히 통과하는 테스트는 처음부터 아무것도 검증하지 않았습니다. scripts/verify_tests.py는 각 수정을 제거하고, 수트가 그것을 알아채는지 검사합니다.
uv run python scripts/verify_tests.py제거한 수정 | 스위트의 감지 |
단어 경계 스킬 매칭 | 예 |
답변 상한 ( | 예 |
행동 가능한 알 수 없는 위치 오류 | 예 |
절단(truncation) 보고 | 예 |
| 예 |
도구 본문에 남아 있는 | 아니요 — 그리고 그것이 핵심 발견입니다. |
실행하면 아무것도 테스트하지 않는 테스트 두 개가 걸렸습니다:
스킬 매칭 테스트들은 로드된 데이터가 아니라
SKILL_PATTERNS상수를 기준으로 단언했습니다. 정규식이 잘 짜였다는 것만 보여줬지, 파이프라인이 그 정규식을 사용한다는 것은 보여주지 못했습니다. 호출부를 바꿔도 테스트가 깨지지 않았습니다. 이제는 실제 채용 공고를 기준으로 단언합니다.stdout 테스트는
tools/list만 호출했습니다. 그래서 도구 본문 안의print()는 실행된 적이 없습니다. 이제는 모든 핸들러를 실행합니다.
오히려 없는 함정
모든 MCP 가이드가 같은 말을 합니다: stdio에서는 stdout만이 곧 wire고, 하나의 쓰레기 print()가 스트림을 망치며 클라이언트를 죽일 것입니다. 그래서 그에 대한 테스트를 썼습니다. 도구 본문에 print("stray print", flush=True)를 넣었는데 테스트는 통과했고 — 클라이언트도 계속 동작했습니다.
mcp/server/stdio.py가 이유를 설명합니다. 서빙 중에는 트랜스포트가 fd 1을 가져갑니다: 실제 wire를 개인 전용 디스크립터로 복제한 다음, 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()는 wire에 도달하지 않고, 대신 stderr로 떨어졌습니다. (stdin도 /dev/null로 동일한 조치를 받으므로, 핸들러와 자식 프로세스는 프로토콜 바이트를 먹어버리는 대신 EOF를 읽습니다.)
그래서 stderr로 로그를 보내는 것은 여전히 올바릅니다. 스펙이 원하는 방식이고, 클라이언트가 서버 로그로 여러분에게 그대로 보여줍니다. 그러나 그렇게 하는 이유로 주로 들려오는 설명은, 이 SDK와 이 버전에서는 그저 동문(folk)에 불과합니다. 내 테스트를 직접 깨보려고 하지 않았다면, 나는 그 주장을 코드 주석으로 그대로 실어 보냈을 것입니다.
구성
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 import를 하지 않습니다. 프로토콜 계층은 일반 함수 위의 얇은 어댑터가 되어야 합니다. 같은 로직을 HTTP 완전 위에서 자르지 않고도 서빙할 수 있습니다.
데이터
data/DataAnalyst.csv — 2,253개의 Glassdoor Data Analyst 채용 공고, Week 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