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 MCPWhat'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: JobDataLake MCP Server
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.
| Name | Required | Description | Default |
|---|---|---|---|
| skill | No | ||
| location | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| max | Yes | |
| min | Yes | |
| p25 | Yes | |
| p75 | Yes | |
| scope | Yes | |
| median | Yes | |
| postings_with_salary | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations declare readOnlyHint=true, which covers the safety profile. The description adds behavioral details beyond that: it warns that narrow slices get 'thin fast' (small sample sizes) and states that it uses all matching postings rather than a limited subset. It does not disclose potential edge cases (e.g., invalid location format) but provides meaningful context beyond the annotation.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is tightly structured: a one-sentence purpose, output summary, clearly labeled Args block, and a final usage recommendation. Every sentence earns its place, and the most important alternative is front-loaded in the last paragraph. No redundancy.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a tool with two optional params and an output schema, the description covers the essential context: what it returns, how to invoke it, and when to prefer it over a sibling. The output schema exists and likely details return fields, so the description need not do so exhaustively. The given details are sufficient for an agent to call it correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0%, so the description is the sole source of parameter meaning. It fully compensates: location is specified as 'US metro in "City, ST" form' with an omission default, and skill lists the exact valid values ('python, sql, excel, tableau, aws, spark') with omission behavior. This adds substantial value beyond the bare string schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb and resource: 'Get the salary distribution for a slice of the job market.' It then details the exact outputs (median, percentiles, min/max, count) and explicitly contrasts with the sibling search_jobs, making the tool's unique contribution unmistakable.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides explicit guidance: 'Prefer this over calling search_jobs and averaging the results yourself' and explains why (uses all postings vs. max 25). It also clarifies how to scope the query via location and skill and when to omit them, giving clear conditions for use.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
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.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | ||
| skill | No | ||
| location | No | ||
| min_salary | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| filters | Yes | |
| postings | Yes | |
| returned | Yes | |
| truncated | Yes | |
| total_matches | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond the annotations (readOnlyHint: true, openWorldHint: false), the description discloses several behaviors: results are ordered by estimated salary descending, skill values are restricted to a fixed set with rejection on others, location accepts partial names when unambiguous, min_salary represents average annual USD, limit defaults to 5 with a range of 1-25, and the return includes total_matches alongside the returned slice. It also notes that 'each posting costs context,' which is a valuable operational detail. No contradiction with annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is front-loaded with the core purpose, followed by a clearly structured list of arguments. Each parameter entry is concise yet complete, with examples and caveats. The note about context cost is brief but relevant. No wasted words; the structure aids quick parsing.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool has 4 parameters with no schema descriptions and an output schema (which likely covers the return shape), the description covers all necessary aspects: what it does, how to specify each filter, expected return info (total_matches and slice), and operational nuances. The agent has enough to correctly invoke the tool without ambiguity. The description even prompts reading a snapshot for location values, indicating a richer context.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description carries the full burden for parameter meaning. It explains each parameter in detail: skill lists allowed values and rejection behavior, location specifies format and partial matching, min_salary gives an example value and unit, and limit states range, default, and usage advice. This is exceptional compensation for the missing schema documentation.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a clear verb+resource+scope: 'Find Data Analyst job postings, highest estimated salary first.' It specifies the resource (job postings), the target role (Data Analyst), and the ordering. This distinguishes it from sibling tools like salary_benchmark and skill_demand by topic, even without naming them.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides usage tips for parameters, such as 'Read market://snapshot for the most common values before guessing' for location and 'Ask for more only when the user explicitly wants a long list' for limit. However, it does not explicitly say when to use this tool versus its siblings (salary_benchmark or skill_demand), nor does it mention any exclusions or alternatives. The context is implied by the purpose but not explicitly stated.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
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.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
Output Schema
| Name | Required | Description |
|---|---|---|
| skills | Yes | |
| total_postings | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true, so the safety profile is covered. The description adds valuable behavioral context: it explicitly notes the tool always covers the whole dataset (no filtering), and details the exact metrics returned (count, share, median salary with/without). This goes beyond the annotations and helps the agent understand the scope and output characteristics. No contradictions.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is two short paragraphs: the first immediately states the purpose, the second details the exact output fields, and the third clarifies the no-argument behavior. Every sentence contributes new information—there is no fluff, and the key message is front-loaded.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool has an output schema (the return values are structured) and no parameters, the description covers everything an agent needs to decide whether and how to call it: the exact skills, the metrics, and the fact that it operates on the whole dataset. There are no missing operational details (e.g., rate limits, authorization) and the scope is unambiguous.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
There are no parameters, and the schema confirms an empty properties object. The description repeats that it 'Takes no arguments' and 'always covers the whole dataset', which adds a small semantic meaning (the scope is fixed). Since there are zero parameters, the baseline of 4 is appropriate; the description does not need to explain parameter formatting.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb ('Show') and resource ('how often each tracked skill appears in postings, and its salary effect'), and explicitly enumerates the skills covered (python, sql, excel, tableau, aws, spark). It is clearly distinct from siblings: search_jobs is about filtering postings, and salary_benchmark likely benchmarks salaries, while this aggregates skill demand across the whole dataset.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description states that it takes no arguments and always covers the whole dataset, which implies it's for a global overview. However, it does not explicitly explain when to choose this over the sibling tools (search_jobs, salary_benchmark) or mention any exclusions. The usage context is implied but not directly contrasted with alternatives, leaving the agent to infer.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
3 tool updates
v0.1.0- First observed
salary_benchmark - First observed
search_jobs - First observed
skill_demand
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
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)
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.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables searching and analyzing H-1B visa sponsoring companies using U.S. Department of Labor data. Supports filtering by job role, location, and salary with natural language queries to find direct employers and export results.16MIT
- AlicenseAqualityBmaintenanceEnables 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.4432MIT
- AlicenseNot gradedqualityAmaintenanceEnables job search and scraping across multiple job boards (LinkedIn, Indeed, Glassdoor, etc.) with advanced filtering, directly from Claude Desktop or other MCP clients.5MIT
- FlicenseNot gradedqualityCmaintenanceEnables 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.-