job-agent-mcp
job-agent-mcp
Async job aggregator that fetches listings from multiple public job sources, dedupes and caches them in Redis, ranks them against a resume, and exposes the whole thing as an MCP server — usable from Claude Desktop, or from bot/, a custom floating desktop assistant with its own MCP client and Gemini-driven tool-calling loop.
Stack
Python 3.11+, FastAPI,
httpx(async fetch)Redis via Upstash (free tier) — shared cache, TTL-based
MCP Python SDK (
app/mcp_server.py) — exposessearch_jobs,get_job_details,refresh_source,explain_fit,track_application,list_applicationsas MCP tools over stdioGoogle Gemini (
gemini-flash-lite-latest, free tier) — resume skill extraction + job scoring/reasoning, 2 batched calls per searchSQLite — local application-status tracking
Sources
Source | Auth |
RemoteOK | none (public JSON) |
WeWorkRemotely | none (RSS) |
HN "Who's Hiring" | none (Algolia HN API) |
Adzuna | free API key, paginated (2×50/page = up to 100 results per search) |
Jooble | free API key (instant, jooble.org/api/about) |
LinkedIn, Wellfound, and Naukri are intentionally excluded — none have a public self-serve API, and scraping them is a real ToS/legal risk (LinkedIn specifically has an active history of suing scrapers), not just a technicality. Jooble and Careerjet were added/considered instead as legitimate aggregator APIs with the same free, ToS-compliant access pattern as Adzuna.
Resilience: if any one source fails (bad/missing key, rate limit, outage), app/search.py logs a warning and returns results from the sources that worked rather than failing the whole search. Verified live — Jooble (missing key) and Adzuna (rate-limited from heavy testing) both failed simultaneously and the search still returned real results from RemoteOK + HN.
Latency benchmark
The core engineering story of this project: same job search, three execution strategies.
Strategy | Latency |
Sequential fetch | 7.78s (94 jobs, query="python", 2026-07-24) |
Parallel ( | 3.09s (94 → 89 jobs after dedupe) — 2.5x faster |
Parallel + Redis cache hit | 0.14s — ~22x faster than cache miss, ~57x faster than sequential |
The first two numbers use identical fetcher code (app/fetchers/) — the only variable is await-in-a-loop vs asyncio.gather, so the 2.5x is purely the effect of concurrency. The cache-hit number replaces all 4 network calls with 4 parallel Redis GETs (Upstash), keyed jobs:{source}:{query}:v1, TTL 30 min, with a refresh=True bypass for manual invalidation.
Reproduce: python scripts/bench_sequential.py python / python scripts/bench_parallel.py python / python scripts/bench_cached.py python — raw numbers saved to benchmarks/*.json.
Resume ranking
Your resume PDF stays on your machine — RESUME_PATH in .env points to it, it is never copied into the repo. Before any text reaches Gemini, app/resume.py strips the name line, email, phone number, and LinkedIn/GitHub URLs (redact_contact_info). This matters specifically because Gemini's free API tier allows Google to use submitted content for training/human review — the paid tier doesn't, but redaction removes the actual PII either way at zero cost.
Pipeline: one Gemini call extracts a skills + years_experience + summary profile from the redacted resume (cached in Redis, 30-day TTL so an onboarded/edited profile doesn't silently get overwritten by a fresh unaudited re-extraction); one batched Gemini call scores every fetched job 0-100, given {id, title, company, tags, description_snippet} per job.
For a specific job, explain_fit(job, profile) (app/rank.py) goes deeper — using the job's full description (now captured by all 4 fetchers, Job.description) plus the resume profile, it returns a verdict, concrete strengths, concrete gaps, and actionable recommendations for closing them, grounded in the actual posting text rather than generic advice.
Deterministic experience guard, tallied against the JD's actual requirement: LLM scoring alone doesn't reliably enforce "needs more experience than the candidate has = low score" — the same "Lead AI Engineer" posting scored 100 in one run and 75 in another with identical inputs (Gemini isn't fully deterministic even at temperature=0, see below). The batched scoring call now also extracts required_years per job — its best estimate of the minimum years that specific posting expects, grounded in the actual title/description snippet, not a generic label. app/rank.py's _apply_seniority_guard then compares this number against the candidate's resume-derived years (1-year margin for reasonable stretch roles) and force-caps the score at 35 if it's a clear mismatch, regardless of what the LLM's own score said. A title-keyword fallback (senior/staff/lead/director/... → assume ~5 years) only kicks in on the rare posting where the LLM couldn't extract any signal at all.
This is a real improvement over an earlier title-only version: verified live that required_years genuinely varies with the posting's actual content, not just its title — "Senior Staff Software Engineer" extracted 8, plain "Senior Software Engineer" postings mostly 5, a non-senior "Frontend Product Software Engineer" got 3 — and the final ranking correctly put senior/staff/CTO-level postings at the bottom (25-30) while non-senior roles took the top spots, for a profile with 2 years of experience.
Try it: python scripts/rank_demo.py python
Known data-quality fixes:
HN "Who's Hiring" threads occasionally contain meta-comments (e.g. someone replying "can you change this to the following:" to edit an earlier post) that aren't real job postings.
app/fetchers/hn.pynow requires a|-delimited header (the convention nearly every real posting follows) before treating a comment as a job — filters this noise out before it ever reaches scoring.The batch scoring pass originally sent only
{title, company, tags}per job — enough for topical overlap, but blind to seniority requirements (a "Lead, 6+ years" role could score 100 purely on keyword match). Fixed by including adescription_snippetand an explicityears_experiencefield in the prompt, so a topically-perfect but experience-mismatched role now correctly scores lower with reasoning that says why.
Editing your profile: the CLI/rank_demo.py path reads RESUME_PATH from .env and auto-extracts on first use. The floating bot (bot/) has a proper onboarding flow instead — pick a PDF, review the extracted skills/experience/summary, edit anything that's off, then confirm — see bot/README.md.
MCP server
Nine tools, all backed by the same app/search.py + app/rank.py + app/resume.py + app/tracking.py code the benchmarks and demos above already exercise — the MCP layer is a thin wrapper, not a separate implementation:
Tool | Does |
| Deduped, cached, resume-ranked results across all 4 sources. |
| Full listing incl. description, for a job id from |
| Force a live re-fetch for one source, bypassing the cache |
| Verdict + specific strengths/gaps/recommendations for one job |
| Draft skills/years_experience/summary from a resume PDF, unsaved |
| Persist a (possibly user-edited) profile as the one used going forward |
| The currently saved profile, or null if never onboarded |
| Upsert status: saved / applied / interviewing / rejected / offer. Snapshots the optional job fields permanently in SQLite (see below) |
| Tracked applications, most recent first; optionally filtered to one status |
Every job returned by search_jobs is also cached individually (job:{id}:v1), so get_job_details/explain_fit/track_application can reference it afterward regardless of which query originally surfaced it.
Applications outlive the cache: track_application originally stored only {job_id, status, timestamp}, relying on the 30-min job cache for title/company/url — fine for a same-session lookup, broken for tracking meant to last weeks. app/tracking.py now snapshots title/company/url/location into SQLite at the moment you track a job; a later status-only update (e.g. applied → interviewing) preserves that snapshot via COALESCE rather than blanking it out.
Verify without Claude Desktop: python scripts/test_mcp_client.py python spawns the server over stdio (the same transport Claude Desktop uses) and drives the full search → details → explain_fit → track_application → list_applications loop with a real MCP client.
Connect to Claude Desktop: add to claude_desktop_config.json:
{
"mcpServers": {
"job-agent-mcp": {
"command": "C:\\path\\to\\job-agent-mcp\\.venv\\Scripts\\job-agent-mcp.exe"
}
}
}(Installing the project with pip install -e . registers job-agent-mcp as a console script — app/mcp_server.py:main. Alternatively use command: python, args: ["-m", "app.mcp_server"], cwd: <project path>.)
Floating desktop bot
bot/ is a second MCP client for this same server — an always-on-top Electron + React chat widget with its own Gemini-driven tool-calling loop, for daily use without opening Claude Desktop. See its README for setup and how the client-side tool-calling loop works.
Setup
pip install -e ".[dev]"
cp .env.example .env # fill in ADZUNA_*, JOOBLE_API_KEY, UPSTASH_REDIS_URL, GEMINI_API_KEY, RESUME_PATH
uvicorn app.main:app --reloadCheck http://localhost:8000/health — redis_connected: true confirms your Upstash credentials are wired up correctly.
Status
Phase 0 — project scaffold
Phase 1 — sequential fetchers per source
Phase 2 — parallel fetch + fuzzy dedupe
Phase 3 — Redis caching layer
Phase 4 — resume-ranking
Phase 5 — MCP server
Bonus — floating desktop bot (
bot/), a second MCP client with its own Gemini tool-calling loopPhase 6 — deploy + write-up