Career Copilot MCP
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Career Copilot MCP What's the average salary for data analysts in Austin, TX?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Career Copilot MCP
An MCP server over 2,253 US Data Analyst job postings — plus a from-scratch MCP client, because the fastest way to stop treating a protocol as magic is to implement it.
Week 5 of my Learning in Public roadmap. Week 2 trained a salary model in a notebook. Week 3 put a model behind an async FastAPI service so a person could call it. This week: what does it take for an AI agent to call it?
What this is
A deliberately small server that exercises all three MCP primitives, because most examples only ship tools — which quietly reduces MCP to "function calling with extra steps".
Primitive | Controlled by | In this server |
Tools | the model |
|
Resources | the client app |
|
Prompts | the human |
|
The distinction is the actual protocol. A tool is something the model decides to call, with arguments it chooses. A resource is addressable read-only data with no arguments — the client attaches it to context like a GET, so making the model "call" for it wastes a round trip. A prompt is a template the user picks from a menu; the model never invokes it.
Related MCP server: Labor Market Intelligence
Quick start
uv sync && uv pip install -e .Watch the entire protocol run, with no SDK and no LLM in the loop:
uv run python client/raw_client.py --verboseRun the suite:
uv run python -m pytest tests/ -qConnect it to 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 is not magic
It is JSON-RPC 2.0 as newline-delimited JSON over a subprocess's stdin/stdout, with an agreed method vocabulary. Here is a real session, captured from client/raw_client.py --verbose (truncated for width):
→ {"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,…}}}Eight calls is the entire surface this server uses: initialize, notifications/initialized, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get.
The handshake does the compatibility work
The client asks for 2026-07-28. The server answers 2025-11-25 — the newest version it speaks. Nobody errors and nobody upgrades:
client asks | server answers |
|
|
|
|
|
|
|
|
|
|
That is why an MCP client written months ago still works against a server shipped today. The compatibility lives in the handshake, not in your code.
Four things that cost me time
1. The tool description is the prompt
It's the only thing the model reads when deciding whether to call a tool and what to pass. location: str tells it nothing. This does:
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.A test enforces it, because descriptions rot silently:
assert len(tool["description"]) > 80, f"{tool['name']} description is too thin"2. -> dict gives you no output schema
My tools returned a JSON string inside a text block. The client had to json.loads it and guess at the shape. The SDK won't let you paper over this:
InvalidSignature: Function search_jobs: return type <class 'dict'> is not
serializable for structured outputTyped returns (TypedDict) generate an outputSchema that ships with the tool in tools/list, and results come back in structuredContent — machine-readable, not text to re-parse.
3. An error is a result, not a crash
An agent can retry against a suggestion. It cannot retry against silence. So an unknown location returns a message naming valid ones:
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, …The connection stays up, isError: true comes back as a normal result, and a test asserts the server is still answering afterwards.
4. The model cannot sanity-check your data
This one is the real lesson, and it wasn't an MCP bug at all — it was a data bug that MCP made dangerous.
Week 2 detected skills with a naive substring match. "excel" in description also matches "excellent". "aws" matches "laws", "draws", "flaws".
skill | substring match | word-boundary match | inflation |
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 | — |
In a notebook, a wrong number is a chart I squint at. Behind an MCP tool, it is a number the model repeats to a user in a confident sentence, with my name on the server. There is no error, no exception, no signal — just a wrong answer delivered well.
SQL keeps its substring exception on purpose: mysql and postgresql really do mean SQL.
Every test earns its place
Week 3's rule, carried forward: a test that still passes after you delete the code it covers was never testing anything. scripts/verify_tests.py removes each fix and checks the suite notices.
uv run python scripts/verify_tests.pyfix removed | suite notices |
word-boundary skill matching | yes |
limit clamp ( | yes |
actionable unknown-location error | yes |
truncation reporting | yes |
| yes |
stray | no — and that's the finding |
Running it caught two tests that tested nothing:
The skill-matching tests asserted against the
SKILL_PATTERNSconstant, not against loaded data. They proved the regex was well-formed, not that the pipeline used it. Mutating the call site didn't break them. They now assert on real postings.The stdout test only called
tools/list— so aprint()inside a tool body never ran. It now exercises every handler.
The footgun that isn't
Every MCP guide says the same thing: over stdio your stdout is the wire, so one stray print() corrupts the stream and kills the client. I wrote a test for it. With print("stray print", flush=True) added to a tool body, the test passed — and the client kept working.
mcp/server/stdio.py explains why. While serving, the transport claims fd 1: it duplicates the real wire to a private descriptor, then points fd 1 at a duplicate of 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)Verified end to end: the stray print never reaches the wire, and lands on stderr instead. (stdin gets the same treatment against /dev/null, so handlers and child processes read EOF rather than eating protocol bytes.)
So logging to stderr is still correct — the spec asks for it, and it's what a client surfaces to you as server logs. But the reason usually given for it is, for this SDK at this version, folklore. I'd have shipped that folklore in a comment if I hadn't tried to break my own test.
Layout
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 has no MCP imports on purpose. The protocol layer should be a thin adapter over plain functions — the same logic could be served over HTTP or a CLI without touching it.
Data
data/DataAnalyst.csv — 2,253 Glassdoor Data Analyst postings, the same dataset as Weeks 1–2. A 2020 US-metro snapshot: a historical reference, not live market data. The server says so in its instructions field, so the model tells users that too.
Available Tools
3 toolssalary_benchmarkARead-only
Get the salary distribution for a slice of the job market.
Returns median, 25th and 75th percentile, min and max — plus how many postings the numbers are based on, which matters because narrow slices get thin fast.
Args: location: US metro in "City, ST" form. Omit for a nationwide figure. skill: One of python, sql, excel, tableau, aws, spark. Omit to include all postings regardless of skill.
Prefer this over calling search_jobs and averaging the results yourself: this uses all matching postings, while search_jobs returns at most 25.
Inputs: skill, location. Returns: max, min, p25, p75, +3 more
search_jobsARead-only
Find Data Analyst job postings, highest estimated salary first.
Args: skill: Filter to postings whose description mentions this skill. Must be one of: python, sql, excel, tableau, aws, spark. Any other value is rejected. 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. min_salary: Minimum estimated average annual salary in USD, e.g. 90000. limit: How many postings to return, 1-25. Defaults to 5. Ask for more only when the user explicitly wants a long list — each posting costs context.
Returns total_matches (how many postings matched overall) alongside the returned slice, so you can tell the user how much you are not showing them.
Inputs: limit, skill, location, min_salary. Returns: filters, postings, returned, truncated, +1 more
skill_demandARead-only
Show how often each tracked skill appears in postings, and its salary effect.
For each of python, sql, excel, tableau, aws and spark, returns the number of postings mentioning it, its share of the corpus, and the median salary both with and without that skill — so the pay gap is directly comparable.
Takes no arguments; it always covers the whole dataset.
Inputs: no named parameters. Returns: skills, total_postings
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 3 tools
Each tool has a clearly distinct purpose: searching job postings, computing salary benchmarks, and analyzing skill demand. No overlap in functionality; an agent can easily select the right tool based on the user's query.
All tool names follow a consistent lowercase snake_case pattern (search_jobs, salary_benchmark, skill_demand). While the first word is not always a verb, the naming style is uniform and predictable, avoiding any mixing of conventions.
With only 3 tools, the set is tightly scoped to the server's purpose of job market analytics. Each tool covers a distinct high-level capability and none feel redundant, making the count appropriate for the narrow domain.
The tool set covers the core workflows of job searching, salary benchmarking, and skill demand analysis. Minor gaps exist, such as no direct method to fetch a single job posting's full detail beyond search results, but agents can work around this by using the returned data. Overall, the surface is complete for the stated purpose.
Maintenance
Related MCP Connectors
Salary and H-1B disclosed-wage data as agent tools.
Job-posting search (jobicy, HN Who is Hiring, arbeitnow) and US H-1B salary data as agent tools.
MCP for 8,700+ current AI jobs. 13 tools: search, match, salaries, companies, commerce quotes.
CareerProof MCP gives AI agents direct access to a professional-grade career and workforce intelligence platform. Two namespaces: atlas_* for HR/TA teams (candidate evaluation, batch shortlisting, competency scoring, interview generation, JD analysis, custom eval frameworks, research reports) and ceevee_* for professionals (CV optimization, career positioning, salary intelligence, market reports). Backed by RAG knowledge from 50+ premium research sources (McKinsey, BCG, HBR, Gartner, WEF)
Related MCP Servers
- AlicenseAqualityCmaintenanceEnables searching over 1 million enriched job listings from 20,000+ companies directly from MCP-compatible AI tools. Provides tools for job search, company profiles, and AI-powered similar job recommendations with real-time data updates.4102 npm2MIT
- FlicenseNot gradedqualityDmaintenanceEnables Claude users to research U.S. labor-market trends through live BLS and FRED data, covering employment, unemployment, wages, job openings, occupational outlook, and industry comparisons.-
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to crawl, analyze, and query job description (JD) data from recruitment sites via MCP, with tools for searching jobs, viewing statistics and portraits, and running local analysis on privately stored data.MIT
- AlicenseNot gradedqualityBmaintenanceEnables AI clients to serve as a personal career analyst by searching, matching, and explaining job recommendations, managing job applications, and syncing public job boards through standardized MCP tools.MIT