mcp-job-intel
Agentic Job Intelligence Pipeline (MCP + LLM)
An agent-driven pipeline that uses the Model Context Protocol to orchestrate external
tools for structured data retrieval, with an LLM scoring layer that ranks unstructured
job descriptions against a candidate profile. Context-window pressure is handled with a
staged metadata-first retrieval strategy (pipeline.py), and the same tools are also
exposed to a genuine tool-calling agent with its own planning loop (agent.py) and to a
REST + WebSocket API (api.py). See docs/ for the full write-up.
Run it
pip install -r requirements.txt
python pipeline.py --benchmark # token comparison, zero API calls
python pipeline.py --dry-run # real MCP subprocess handshake, no LLM
export OPENAI_API_KEY=sk-...
python pipeline.py --top 8 # fixed 3-stage pipeline
python agent.py --dry-run # agent tool discovery, no LLM calls
export OPENAI_API_KEY=sk-...
python agent.py --top 8 # tool-calling agent with a planning loop
python eval.py --prefilter-only # stage-1 recall, deterministic half, no key needed
pytest -q # in-process MCP server, no key needed
uvicorn api:app --reload # REST + WebSocket layer, http://localhost:8000
curl localhost:8000/health
curl -X POST localhost:8000/rank -H 'content-type: application/json' -d '{"use_llm": false}'Measured result
150-job corpus, shortlist of 8. prefilter() applies tag/title, seniority (drop senior
when candidate years < 4), and location (candidate's preferred city, alias-normalised, or
remote) gates, which cut the survivor count from 150 to 31:
Strategy | Prompt tokens | vs naive |
A — send all 150 full descriptions | 67,360 | — |
B — metadata-first, then fetch 8 | 13,802 | 4.9× cheaper |
C — prefilter → metadata → fetch 8 | 6,258 | 10.8× cheaper |
Reproduce with python pipeline.py --benchmark. Real numbers from this repo's
data/jobs.json today, not placeholders. Token counts use tiktoken's o200k_base
encoder (what gpt-4o / gpt-4o-mini actually use) — exact, not estimated. The old
chars ÷ 4 heuristic overestimated the naive-strategy cost by 17.4% on this corpus;
benchmark()'s heuristic_vs_real_tokens field reproduces that comparison.
Architecture
MCP SERVER (stdio subprocess) MCP CLIENT / pipeline.py
--------------------------------- ------------------------------------
tool list_jobs -> metadata <---- Stage 0 prefilter() [0 tokens]
tool get_job_details -> full text Stage 1 shortlist [~5k tokens]
tool get_candidate_profile Stage 2 score [~4k tokens]
tool corpus_stats
resource jobs://schema Meter tracks tokens per stage
prompt rank_jobsThe staged retrieval argument
Naive: hand every full description to the model and ask it to rank. Three problems.
Cost — 79k prompt tokens per run, and it grows linearly with the corpus.
Ceiling — past a few hundred postings it exceeds the context window outright. Not slow: impossible.
Quality — long-context recall degrades in the middle of a large prompt, so the ranking gets worse as you add more candidates.
Staged retrieval, cheapest filter first:
Stage | Mechanism | Cost | Why here |
0 | Deterministic tag/title/location filter in Python | free | Never let a model read what |
1 | LLM sees ~55 tokens of metadata per job, picks top 8 | ~5k | High-recall screen. Instructed to over-include, because stage 2 can reject. |
2 | Full descriptions for the 8 survivors only | ~4k | Full fidelity, paid for once, only where it changes the answer. |
The generalisable principle — and the thing to say out loud in an interview — is cascade by cost: order your filters cheapest-first, and set each stage's threshold for recall rather than precision, because a later stage can still reject but nothing can recover what an early stage dropped.
Agentic layer (agent.py)
pipeline.py is a fixed script: prefilter, then always shortlist, then always score.
agent.py hands the model the same MCP tools via OpenAI function calling and lets it plan
its own path — a genuine tool-calling agent, not a hardcoded sequence:
Structured final answer as a tool call. The agent doesn't "answer in JSON and hope" — finishing means calling a synthetic
submit_rankingstool whose parameter schema isschemas.RankingResult. Invalid arguments come back as a validation error the model can read and correct, for a bounded number of retries.Tool failures degrade, they don't crash. Any MCP tool exception becomes a normal
{"error": ...}tool result fed back to the model, so it can route around a bad call instead of taking the whole run down.A model that never converges still returns something. If it exhausts its step/retry budget without valid output, the agent falls back to the same deterministic
prefilter → shortlist → scorelogic aspipeline.py, and reportsfallback_used: true.Transient API errors get their own retry, via
tenacity, separate from the schema-retry loop above — a bad connection and a bad answer are different failure modes.
See docs/CODE_WALKTHROUGH.md for the step-by-step loop.
REST + WebSocket layer (api.py)
A FastAPI service wraps the MCP tools, pipeline.py, and agent.py so they're reachable
over HTTP instead of only as CLI scripts:
Endpoint | What it does |
| liveness check |
| metadata list / full detail, same no-description invariant as the MCP tool |
| corpus stats |
| run the ranking pipeline ( |
| same as |
One MCP stdio session is opened once at startup and shared behind a lock (api.MCPSession)
rather than spawning a subprocess per request — a deliberate simplification over a real
connection pool, documented as such in api.py's module docstring, not oversold as an
actual distributed system. Each request gets a correlation id (request_id), threaded
through logs and every streamed event, so a run can be traced across the async hops.
MCP notes worth knowing cold
Why it exists: N models × M integrations becomes N + M. One protocol, JSON-RPC 2.0 over stdio or Streamable HTTP.
Tools vs resources vs prompts: model-controlled / application-controlled / user-controlled. Getting this trio right is a common interview differentiator.
CallToolResultshape:content(blocks),structured_content(typed, wrapped as{"result": ...}for non-object returns),is_error. Seepipeline.call().Tool design is API design for a non-human caller.
list_jobsandget_job_detailsare split because that split is what enables staged retrieval. Docstrings are the tool description the model reads — vague docstring, wrong tool choice.Batch parameters over scalar ones:
get_job_details(job_ids: list[str])costs one round trip;get_job_detail(job_id: str)costs eight.
Known limits
Shortlist recall (does stage 1 keep the labelled-relevant jobs that survive prefilter?) needs a real
OPENAI_API_KEYto measure —python eval.py --top 8runs it; not run here for cost reasons.The corpus is synthetic. Real postings are messier — HTML, duplicates, stale listings.
No caching across runs, so repeated invocations pay stage 1 again.
Stage 1 recall — measured, not assumed
data/relevance_labels.json has 20 job IDs a human would call relevant to the candidate,
picked with a documented, reproducible rubric (see the file). eval.py checks two things
separately:
prefilter_recall— of the 20 labelled-relevant jobs, how many survive the deterministic prefilter? Free, no API key:python eval.py --prefilter-only→ 20/20, recall 1.0. The label rubric is a strict subset of prefilter's own gates, so this confirms prefilter isn't silently dropping the target role, rather than assuming it.shortlist_recall— of those, how many also survive the LLM shortlist at thetopyou actually run with?python eval.py --top 8— needsOPENAI_API_KEY, a real model call, so it isn't run in this repo; run it yourself when you have a key.
Your TODOs
prefilter()— add seniority and location gates. Re-run--benchmark, record the number.Done: 150 → 31 survivors, 10.8× reduction vs. naive.Swapapprox_tokensfor realtiktokencounting; note how far the ÷4 heuristic was off.Done: heuristic overestimated by 17.4%.Build a 20-job labelled relevance set and measure stage 1 recall.Done for the free half (prefilter_recall= 1.0); the paid half (shortlist_recall) is wired up ineval.py --top 8, run it with your own key.Wire the server into Claude Desktop's MCP config and call it by hand.Config snippet and restart instructions are indocs/OVERVIEW.md— actually registering it happens in your own Claude Desktop app, not something this repo can do for you.
Docs
docs/OVERVIEW.md— what this project is, what problem it solves and why, architecture, Claude Desktop wiring, local testability, known limitations.docs/CODE_WALKTHROUGH.md— every module, function by function.
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/jaideepdnaik/mcp-job-intel'
If you have feedback or need assistance with the MCP directory API, please join our Discord server