Career Copilot MCP
Career Copilot MCP
Un servidor MCP sobre 2,253 ofertas de trabajo de analista de datos en EE. UU. — y con un cliente MCP hecho desde cero, porque la forma más rápida de dejar de tratar un protocolo como magia es implementarlo.
Semana 5 de mi hoja de ruta Learning in Public. La Semana 2 entrenó un modelo salarial en un notebook. La Semana 3 puso un modelo detrás de un servicio FastAPI asíncrono para que una persona pudiera llamarlo. Esta semana: ¿qué hace falta para que un agente de IA lo llame?
Qué es esto
Un servidor deliberadamente pequeño que ejercita las tres primitivas de MCP, porque la mayoría de los ejemplos solo incluyen tools, lo que, silenciosamente, reduce MCP a "llamada a funciones con pasos extra".
Primitiva | Controlado por | En este servidor |
Herramientas | el modelo |
|
Recursos | la aplicación cliente |
|
Prompts | el humano |
|
La distinción es el protocolo real. Una herramienta es algo que el modelo decide llamar, con argumentos que él mismo elige. Un recurso son datos direccionables de solo lectura, sin argumentos: el cliente lo adjunta al contexto como un GET, así que obligar al modelo a "llamarlo" desperdicia una ida y vuelta. Un prompt es una plantilla que el usuario selecciona de un menú; el modelo nunca lo invoca.
Related MCP server: JobDataLake MCP Server
Inicio rápido
uv sync && uv pip install -e .Observa cómo se ejecuta el protocolo entero, sin SDK y sin LLM en el bucle:
uv run python client/raw_client.py --verboseEjecuta la suite:
uv run python -m pytest tests/ -qConéctalo a 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 no es magia
Es JSON-RPC 2.0 como JSON delimitado por saltos de línea sobre la stdin/stdout de un subproceso, con un vocabulario de métodos acordado. Esta es una sesión real, capturada de client/raw_client.py --verbose (truncada para ancho):
→ {"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,…}}}Ocho llamadas es toda la superficie que usa este servidor: initialize, notifications/initialized, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get.
El apretón de manos hace el trabajo de compatibilidad
El cliente pide 2026-07-28. El servidor responde 2025-11-25 — la versión más reciente que él habla. Nadie da un error y nadie se actualiza:
el cliente pide | respuesta del servidor |
|
|
|
|
|
|
|
|
|
|
Por eso un cliente MCP escrito hace meses sigue funcionando hoy con un servidor publicado hoy. La compatibilidad está en el apretón de manos, no en tu código.
Cinco cosas que me costaron tiempo
1. La descripción de la tool es el prompt
Es lo único que el modelo lee cuando decide si llamar a una herramienta y qué pasarle. location: str no le dice nada. Esto sí:
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.Un test lo verifica, porque las descripciones se vuelven obsoletas y nadie detecta de qué modo:
assert len(tool["description"]) > 80, f"{tool['name']} description is too thin"2. -> dict no te da un esquema de salida
Mis tools devolvían un string JSON dentro de un bloque de texto. El cliente tenía que json.loads y adivinar la forma. El SDK no te deja tapar esto con azúcar:
InvalidSignature: Function search_jobs: return type <class 'dict'> is not
serializable for structured outputLos valores de retorno tipados (TypedDict) generan un outputSchema que viaja con la tool en tools/list, y los resultados regresan en structuredContent — legible para máquinas, no texto para reparsear.
3. Un error es un resultado, no un crash
Un agente puede reintentar la operación con una sugerencia. No puede reintentar contra el silencio. Así, una ubicación desconocida devuelve un mensaje que nombra ubicaciones válidas:
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, …La conexión sigue activa, isError: true regresa como resultado normal, y un test prueba que el servidor sigue respondiendo después.
4. El modelo no puede validar tus datos
Esta es la lección real, y no era un bug de MCP en absoluto — era un bug de datos que MCP volvía peligroso.
La Semana 2 detectaba habilidades con una coincidencia ingenua de substring. "excel" in descripcion también coincide con "excellent". "aws" coincide con "laws", "draws", "flaws".
habilidad | coincidencia por substring | coincidencia por palabra | inflación |
excel | 1,354 (60.1%) | 903 (40.4%) | +50% |
aws | 275 (12.2%) | 132 (5.9%) | +108% |
spark | 89 | 71 | +25% |
sql | 1,389 | 1,387 | — |
En un notebook, un número equivocado es un gráfico que miro con los ojos bizcos. En una MCP tool, un número equivocado es algo que el modelo le repite al usuario en una frase con total confianza, con mi nombre en el servidor. No hay error, no hay excepción, no hay señal; solo una respuesta incorrecta entregada con buenos modales.
SQL mantiene esa excepción de substring adrede: mysql y postgresql realmente significansign SQL.
Cada test se gana su lugar
La regla de la Semana 3 se ha aplicado aquí: un test que aún pasa cuando eliminas el código que cubre no estaba probando nada en realidad. scripts/verify_tests.py elimina cada corrección y comprueba que la suite se da cuenta.
uv run python scripts/verify_tests.pycorrección eliminada | ¿la suite sospecha? |
coincidencia de habilidades con límite de palabra | sí a reyes |
límite ( | sí |
error de ubicación desconocida con sugerencia | sí |
informe de truncamiento | sí |
anotaciones | sí |
un | no — y ese es el hallazgo |
Al ejecutarlo se detectaron dos tests que no probaban nada:
Los tests de habilidades verificaban la constante
SKILL_PATTERNS, no los datos reales cargados. Demostraban que la regex estaba bien formada, no que el pipeline la usara. Alterar el punto de llamada no los rompía. Ahora apuntan contra ofertas reales.El test de stdout solo llamaba a
tools/list, así que unprint()dentro del cuerpo de una tool nunca se ejecutaba. Ahora ejecuta todos los handlers.
La trampa que no existe
Todos los tutoriales de MCP dicen lo mismo: con stdio, tu stdout es el cable, así que un print() suelto corrompe el flujo y mata al cliente. Escribí un test para ello. Con print("stray print", flush=True) añadido al cuerpo de una tool, el test pasó — y el cliente siguió funcionando.
mcp/server/stdio.py explica por qué. Mientras sirve, el transporte se apodera del fd 1: duplica el cable real a un descriptor privado, luego apunta fd 1 a un duplicado de 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)Comprobado en upstream completo: el print() suelto no llega nunca al protocolo y acaba en el stderr. (stdin recibe el mismo tratamiento contra /dev/null, así que los handlers y subprocesos leen EOF en vez de comerse los bytes del protocolo.)
Por eso registrar en stderr sigue siendo correcto — la especificación lo pide y es lo que el cliente muestra como logs del server. Pero la razón que se suele dar para ello, en esta versión del SDK, es folclor. Habría incluido ese folclor y un comentario si no hubiera intentado romper mi propio test.
Estructura
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 no tiene imports de MCP de manera intencionada. La capa de protocolo debe ser un adaptador fino sobre funciones planas: la misma lógica podría servirse sobre HTTP o una CLI sin tener que tocarla.
Datos
data/DataAnalyst.csv — 2,253 ofertas de empleoce-analista de datos de Glassdoor, el mismo dataset de la Semanas 1-12. Una foto instantánea de 2020 en Estados Unidos: una referencia histórica, no datos de mercado en vivo. El server lo indica en su campo instructions, para que el modelo también se lo diga a los usuarios.
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)
Search Thousands of jobs, Thousands of companies. MCP for Claude, ChatGPT, Codex and Cursor.
JobsPipe — data pipeline of every job posting on the web. Search live, normalized job postings from 30+ ATS feeds and job boards for AI agents via MCP.
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.17MIT
- 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.476 npm2MIT
- AlicenseNot gradedqualityBmaintenanceEnables job search and scraping across multiple job boards (LinkedIn, Indeed, Glassdoor, etc.) with advanced filtering, directly from Claude Desktop or other MCP clients.6MIT
- 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.-