Career Copilot MCP

Career Copilot MCP

Enables exploring and analyzing US Data Analyst job postings, offering job search, salary benchmarking, and skill-demand insights through MCP tools, resources, and a career gap review prompt.

Category
Visit Server

README

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.

Python MCP Tests

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 search_jobs, salary_benchmark, skill_demand
Resources the client app market://snapshot, market://locations
Prompts the human career_gap_review

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.

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 --verbose

Run the suite:

uv run python -m pytest tests/ -q

Connect it to Claude Code

claude mcp add career-copilot -- uv --directory /absolute/path/to/mcp-week-5 run python -m career_copilot_mcp.server

<details> <summary>Claude Desktop config</summary>

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

</details>


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
2026-07-28 (newer than the server) 2025-11-25
2025-11-25 2025-11-25
2025-06-18 2025-06-18
2024-11-05 2024-11-05
1999-01-01 (nonsense) 2025-11-25

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 output

Typed 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.py
fix removed suite notices
word-boundary skill matching yes
limit clamp (1 ≤ limit ≤ 25) yes
actionable unknown-location error yes
truncation reporting yes
readOnlyHint annotations yes
stray print() in a tool body no — and that's the finding

Running it caught two tests that tested nothing:

  • The skill-matching tests asserted against the SKILL_PATTERNS constant, 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 a print() 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 it

market.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.

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured