使用说明
jobsearch-mcp
A self-hosted MCP server that turns a LibreChat agent into a full job search assistant — from searching across multiple boards, to building and storing a resume profile, to scoring fit against listings, to tracking applications through a pipeline. Built with FastMCP for multi-user LibreChat deployments.
Search across Adzuna, Remotive, WeWorkRemotely, Jobicy, USAJobs, and more. Enrich listings with full job descriptions via a multi-tier extraction pipeline. Profile your resume once and every scoring and tailoring tool uses it automatically. Score fit with a structured Claude-powered breakdown including ATS analysis. Match jobs semantically against your profile. Track the full pipeline per user in Postgres. Watch for new matches in the background and get email alerts.
Most job search MCP tools do one thing — scrape listings or generate cover letters. This one connects the entire workflow so an agent can drive it end-to-end.
Built with Claude Code using the multi-agent workflow from homelab-agent.
What you need
jobsearch-mcp is modular — start with the basics and add services as you need them.
| Capability | Required services |
|---|---|
| Search jobs + track applications | Adzuna key · docker compose up (JD extraction via raw HTTP fallback — quality varies without Firecrawl) |
| Full job description extraction | Firecrawl (self-hosted — either v1 firecrawl-simple or v2 upstream Firecrawl; see Prerequisites) |
| AI fit scoring + profile building | Anthropic API key |
| Semantic job matching | Ollama + bge-m3 |
| Background email alerts | SMTP relay |
Minimum to get started: an Adzuna API key and docker compose up. That gives you job search across 6 sources, full application tracking, and JD extraction via raw HTTP fallback. Everything else is additive. Tools that require an unconfigured service return a clear error message explaining what to set up.
How It Works
flowchart TD
subgraph profile["① Profile Setup (one-time)"]
BP["build_profile\nparse resume text via Claude"] --> SP["save_profile\nstore structured profile"]
end
subgraph search["② Search & Discover"]
SJ["search_jobs\nAdzuna · RSS · USAJobs · more"] --> CA["check_active"]
CA --> GJD["get_job_detail\nFirecrawl → Crawl4AI → rawFetch"]
GJD --> SI["salary_insights"]
end
subgraph index["③ Index & Match"]
GJD --> IJ["index_job\nOllama bge-m3 → Qdrant"]
IJ --> MJ["match_jobs\nsemantic search"]
end
subgraph score["④ Score & Tailor"]
GJD --> SF["score_fit\nClaude · ATS score · apply/maybe/skip"]
MJ --> SF
SF --> CLB["cover_letter_brief"]
SF --> TR["tailor_resume\nJD-tailored profile draft"]
end
subgraph track["⑤ Track"]
SF --> MA["mark_applied"]
MA --> AN["add_note"]
AN --> US["update_status\napplied → interviewing → offered"]
US --> GMJ["get_my_jobs"]
end
SP -. "auto-used by score_fit,\ncover_letter_brief, tailor_resume" .-> score
You don't have to use every step — an agent can search and score without ever touching the tracker, or use the tracker standalone for jobs found elsewhere.
Architecture
graph TB
subgraph clients["Clients"]
LC["LibreChat Agent"]
EMAIL["User Email"]
end
subgraph stack["Docker Stack · jobsearch-net"]
MCP["jobsearch-mcp\n:8383 streamable-http"]
JW["job-watcher\nbackground poller"]
PG[("Postgres 16\ntracking · profiles · notes")]
QD[("Qdrant\nvector index")]
VK[("Valkey\nenrichment cache")]
end
subgraph external["External Services"]
OLLAMA["Ollama\nbge-m3 embeddings"]
CLAUDE["Claude API\nHaiku"]
ENRICH["Firecrawl · Crawl4AI"]
SOURCES["Job Sources\nAdzuna · RSS · USAJobs · …"]
SMTP["SMTP Relay"]
end
LC -->|"streamable-http"| MCP
MCP --> PG & QD & VK
MCP --> OLLAMA & CLAUDE & ENRICH & SOURCES
JW --> PG & VK & SOURCES
JW --> SMTP --> EMAIL
Tools
Resume Profile
Build and store your profile once — score_fit, cover_letter_brief, and tailor_resume all use it automatically when no resume is passed explicitly.
| Tool | Description |
|---|---|
build_profile |
Parse raw resume or bio text into a structured profile using Claude. Returns the result for review — does not auto-save. |
save_profile |
Store the structured profile. All scoring and tailoring tools use it automatically from this point. |
get_profile |
Retrieve your stored profile. |
delete_profile |
Remove your stored profile and associated data. |
tailor_resume |
Rewrite your stored profile's highlights and summary to match a specific JD. Returns only what changed — tailored_summary, skills_reordered, experience_changes (per-role revised highlights) and changes_summary. Unchanged fields are omitted; does not overwrite your stored profile. |
Search & Discovery
| Tool | Description |
|---|---|
search_jobs |
Search across Adzuna, Remotive, WeWorkRemotely, Jobicy, and USAJobs (default). Supports query, location, remote_only, and sources params. Optional sources: findwork, themuse (tech/culture-focused), indeed, glassdoor, ziprecruiter (scraping-based via python-jobspy, not included by default). |
get_job_detail |
Fetch a full job description from a URL. Uses a multi-tier enrichment pipeline: Firecrawl → Crawl4AI → rawFetch. Results are cached in Valkey. |
check_active |
Check whether a listing is still active. Returns active=True/False/None and the signal that triggered it. |
salary_insights |
Salary intelligence for a role — min/max/avg/median from live listings, a distribution histogram, and a monthly trend. Powered by Adzuna. |
Vector Search & Matching
| Tool | Description |
|---|---|
index_job |
Fetch a job and store it in Qdrant using Ollama bge-m3 embeddings. Call this on listings worth tracking. |
match_jobs |
Find indexed jobs semantically similar to a resume or free-text description. Supports top_k and exclude_seen params. |
Fit Scoring & Application Prep
| Tool | Description |
|---|---|
score_fit |
Score how well a resume matches a job. Fetches the full JD, then uses Claude to return matched skills, missing skills, nice-to-haves met, seniority fit, ATS score (0–100), and an apply/maybe/skip recommendation. Uses stored profile if no resume is passed. |
cover_letter_brief |
Structured cover letter writing guide — opening angle, requirements mapped to your experience, gaps to acknowledge, recommended tone. A brief, not a finished letter. Uses stored profile if no resume is passed. |
Application Tracking
| Tool | Description |
|---|---|
mark_seen |
Mark a job as seen for the current user. |
mark_applied |
Mark a job as applied. |
update_status |
Move a job through the pipeline: seen → applied → interviewing → offered → rejected → closed. |
add_note |
Append a note to a tracked job. Notes accumulate — they are not replaced. |
get_my_jobs |
Get tracked jobs for the current user, ordered by pipeline stage. Filter by status param. |
Job Watcher
The job-watcher container runs independently from the MCP server. On a configurable interval (default: every 4 hours), it polls Adzuna, Remotive, WeWorkRemotely, and USAJobs, matches results against each user's stored profile (target_roles and skills fields), and sends an SMTP email listing new matches.
Deduplication is handled via Valkey — each user only gets alerted on listings they haven't seen before. Email goes to the address stored in the user's profile. No agent interaction needed; it runs entirely in the background.
Configure it via job-watcher.env (see job-watcher.env.example). To disable it without removing it from the stack, set JOB_WATCH_INTERVAL_SECONDS to a very large value.
Prerequisites
Required for basic use:
| Service | What it does | How to get it |
|---|---|---|
| Adzuna | Job search API + salary data | Free key at developer.adzuna.com |
Required for specific features:
| Service | Enables | How to get it |
|---|---|---|
| Anthropic | score_fit, build_profile, tailor_resume, cover_letter_brief |
console.anthropic.com |
| Ollama (bge-m3) | index_job, match_jobs — semantic search |
Run locally; ollama pull bge-m3 |
| Firecrawl | Full JD extraction (primary tier) | Self-host — firecrawl/firecrawl (/v2) or the legacy trieve-ai/firecrawl-simple (/v1) |
Set FIRECRAWL_API_VERSION to match whichever backend FIRECRAWL_URL points at — v1 for
firecrawl-simple, v2 for upstream Firecrawl 2.x. It defaults to v1, and anything else
raises at startup rather than falling back: the two APIs share no routes, so a mismatch fails
every scrape rather than degrading, and the enricher's Crawl4AI fallback would otherwise hide
that completely.
Optional:
| Service | What it adds | How to get it |
|---|---|---|
| Crawl4AI | Fallback JD extraction if Firecrawl is unavailable | Self-hosted — unclecode/crawl4ai |
| SMTP relay | Job watcher email alerts | Brevo free tier works |
| USAJobs | Government job listings | Optional key at developer.usajobs.gov; works without at reduced rate limits |
| Findwork / The Muse | Tech/culture-focused listings | findwork.dev / no key needed for The Muse |
Postgres, Qdrant, and Valkey are included in the Docker stack — no external setup needed for those.
Stack
| Component | Purpose |
|---|---|
| FastMCP (streamable-http) | MCP server transport |
| Postgres 16 | Per-user tracking state, application pipeline, profiles, notes |
| Qdrant | Vector index for semantic job matching |
| Valkey | Enrichment cache — avoids re-fetching recently seen JDs |
| Ollama (bge-m3) | Job and resume embeddings |
Firecrawl (v1 or v2, see FIRECRAWL_API_VERSION) / Crawl4AI |
Multi-tier full JD extraction |
Claude (claude-haiku-4-5) |
Profile parsing, fit scoring, resume tailoring |
Deployment
Docker Stack
Five containers. The datastores sit on a private jobsearch-net bridge network; the two
application containers additionally join the external forge-net so they can reach
Firecrawl, Crawl4AI and Ollama by container name.
This is the generic setup this repo's docker-compose.yml declares. A given deployment may
join the application containers to a differently-named external network instead — check your
own compose file's networks: block for the network Firecrawl, Crawl4AI and Ollama actually
sit on, rather than assuming forge-net by name.
| Container | Image | Networks | Port |
|---|---|---|---|
| jobsearch-mcp | Local build | forge-net, jobsearch-net | 127.0.0.1:8383 (MCP endpoint) |
| job-watcher | Local build | forge-net, jobsearch-net | Internal only |
| jobsearch-postgres | postgres:16 | jobsearch-net | Internal only |
| jobsearch-qdrant | qdrant/qdrant:v1.18.3 | jobsearch-net | Internal only |
| jobsearch-valkey | valkey/valkey:7-alpine | jobsearch-net | Internal only |
forge-net is expected to already exist (docker network create forge-net if not).
The MCP port is published on loopback only — the server has no built-in authentication,
so it must not be bound to a routable interface. Put an authenticating proxy in front of
it if you need remote access.
Setup
-
Clone the repo:
git clone https://github.com/TadMSTR/jobsearch-mcp.git cd jobsearch-mcp -
Create your
.envfile from the template:cp .env.example .envFill in your API keys. See
.env.examplefor details on each variable. -
If using job-watcher, create its env file too:
cp job-watcher.env.example job-watcher.env -
Start the stack:
docker compose up -d -
Verify it's running:
docker logs jobsearch-mcp --tail 20You should see the FastMCP server start on port 8383.
Rebuilding after code changes
docker compose build jobsearch-mcp
docker compose up -d jobsearch-mcp
Upgrading from v1
The embedding model changed from Voyage AI to Ollama bge-m3. The Qdrant jobs collection must be dropped before upgrading — the vector dimensions are incompatible:
docker exec jobsearch-qdrant curl -X DELETE http://localhost:6333/collections/jobs
The collection will be recreated automatically on the next index_job call.
Wiring to LibreChat
Add the following to your librechat.yaml under mcpServers:
mcpServers:
jobsearch:
type: streamable-http
url: http://host.docker.internal:8383/mcp
headers:
X-User-ID: "{{LIBRECHAT_USER_ID}}"
X-User-Email: "{{LIBRECHAT_USER_EMAIL}}"
X-User-Username: "{{LIBRECHAT_USER_USERNAME}}"
The server uses X-User-ID to partition all state per LibreChat user — each user gets their own pipeline, profile, notes, and seen/applied history.
If LibreChat runs in Docker, you need host.docker.internal to reach the MCP server on the host. Make sure your LibreChat compose file includes:
extra_hosts:
- "host.docker.internal:host-gateway"
Restart LibreChat after any librechat.yaml change:
docker compose restart librechat
Project Structure
jobsearch-mcp/
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── job-watcher.env.example
├── .gitignore
├── pyproject.toml # Packaging (hatchling), deps, ruff + pytest config
├── LICENSE
├── src/jobsearch_mcp/
│ ├── server.py # Thin FastMCP registry — registers tool modules
│ ├── db.py # Postgres schema, pipeline tracking, profiles (asyncpg)
│ ├── enricher.py # Multi-tier JD fetcher (Firecrawl → Crawl4AI → rawFetch) + Valkey cache
│ ├── vector.py # Qdrant + Ollama bge-m3 embedding and search
│ ├── scorer.py # Claude-powered fit scoring, profile parsing, resume tailoring
│ ├── usage.py # Token-usage logging + Valkey result cache for Claude calls
│ ├── job_watcher.py # Background poller — email alerts for new matches
│ ├── tools/
│ │ ├── jobs.py # Search, discovery, enrichment tools
│ │ ├── profile.py # Resume profile tools
│ │ ├── scoring.py # Fit scoring and cover letter tools
│ │ └── tracking.py # Application pipeline tools
│ └── sources/
│ ├── adzuna.py # Adzuna API
│ ├── rss.py # Remotive, WeWorkRemotely, Jobicy (RSS)
│ ├── usajobs.py # USAJobs API
│ ├── findwork.py # Findwork API (optional)
│ ├── themuse.py # The Muse API (optional)
│ └── jobspy.py # Indeed, Glassdoor, ZipRecruiter (python-jobspy, opt-in)
└── tests/
├── conftest.py
├── test_db.py
├── test_enricher.py
├── test_scorer.py
├── test_sources.py
└── test_usage.py
Install for development with pip install -e ".[dev]". The package is importable as
jobsearch_mcp and exposes a jobsearch-mcp console script.
Notes
- Multi-tier enrichment.
get_job_detailand any tool that fetches a JD internally tries Firecrawl first, falls back to Crawl4AI if Firecrawl fails or is unavailable, then falls back to a raw HTTP fetch. Results are cached in Valkey — repeat calls for the same URL are instant. UnderFIRECRAWL_API_VERSION=v2, a non-2xx page status now counts as a tier miss rather than a success: v2 reports the origin's own HTTP status indata.metadata.statusCode, and a 2xx from the Firecrawl API itself does not mean the underlying page loaded — a 404'd origin previously came back as HTTP 200 with the error page's text, which got cached for 6h as the job description.v1reports nostatusCode, so the guard is a no-op there. - Indeed, Glassdoor, ZipRecruiter are optional scraping-based sources via python-jobspy. Not in the default
search_jobscall — add them explicitly tosources. These sites fight scrapers aggressively; the server uses a global rate limiter (one jobspy call at a time, 12s minimum gap) and per-site exponential backoff (60s → 15min). - USAJobs is included in the default source list. An API key improves rate limits but isn't required.
score_fittruncates content. JDs are capped at 6,000 chars, resumes at 3,000 chars before passing to Claude. Works fine for most listings; very verbose JDs lose their tail.- Claude results are cached.
score_fitandcover_letter_briefresults are memoised in Valkey for 24h, keyed on a hash of the truncated JD + resume actually sent. Re-scoring a job you have already looked at costs nothing. When a stored profile is used as the resume, only the fields the rubric reads (summary,skills,experience,certifications,target_roles) are sent — contact details, salary bands, work authorization and education are not. - Token usage is logged. Every Claude call emits a
event=claude_usageline withinput_tokens,output_tokens, the tool name and the user ID. A cache hit logscached=Truewith no token counts at all — that absence is how you tell a hit from a miss. - Anthropic prompt caching is deliberately not used. Haiku 4.5 requires a 4,096-token minimum cacheable prefix; the largest prompt this server sends measures ~2,375. Adding
cache_controlwould silently no-op (cache_creation_input_tokens: 0) rather than error, so it is not wired up. Switching to a model with a lower minimum costs more than it saves at this volume. check_activereturnsactive=Nonewhen a page loads but no clear status signal is found — treat as probably active.- Postgres schema migrates automatically on startup (
ALTER TABLE ... ADD COLUMN IF NOT EXISTS). No manual migrations needed. - bge-m3 must be pulled before the first
index_jobcall:ollama pull bge-m3. - Qdrant collection (
jobs) is created automatically on first use. - Multi-user — all state is partitioned by
X-User-ID. Multiple LibreChat users on the same instance see only their own data.
Security
URL validation
All job listing URLs pass through _validate_url before enrichment. Only HTTPS URLs are accepted — HTTP is blocked to prevent cleartext credential exposure.
SSRF protection works on the resolved destination, not just the URL text:
- Hostnames are resolved and every address they map to is checked. Checking only literal-IP hosts would leave
https://a-name-that-resolves-to-10.0.0.1/open. - Anything not globally routable is rejected — RFC 1918, loopback, link-local, IPv6 ULA,
0.0.0.0/8, CGNAT, cloud metadata (169.254.169.254), multicast, reserved space, and IPv4-mapped IPv6. - Redirects are followed manually (maximum 5) and each hop is re-validated, so a public URL cannot redirect into private space.
Known limitation: validation resolves the host and then connects, so a DNS record that changes between those two steps (DNS rebinding) is not caught. Closing that requires pinning the connection to the validated address.
Do not remove these checks.
Container hardening
The two application containers (jobsearch-mcp, job-watcher) run with:
user: 1000:1000— no root processescap_drop: ALL— no Linux capabilitiesno-new-privileges: true— prevents privilege escalation
The three datastore containers (jobsearch-postgres, jobsearch-qdrant, jobsearch-valkey) run with no-new-privileges: true and are reachable only from the private jobsearch-net. They are not currently pinned to a non-root user or cap_drop: ALL — the upstream images manage their own privilege drop during initialisation, and forcing it here breaks first-run setup. They publish no host ports.
- Private
jobsearch-netbridge network for Postgres, Qdrant and Valkey — no database or cache port is exposed to the host or toforge-net - The MCP endpoint is published on
127.0.0.1:8383only. The server performs no authentication of its own; it trusts theX-User-IDheader its caller supplies, so it must sit behind a proxy that sets that header and authenticates the user
Credential handling
API keys (ANTHROPIC_API_KEY, ADZUNA_APP_KEY, USAJOBS_API_KEY, etc.) are read from environment variables and used only in outbound requests to their respective services. No credentials are stored or logged by the server.
Resume and profile data are stored in Postgres and embedded locally via Ollama — they are not sent to any cloud embedding service.
When a stored profile is used for scoring, only the fields the rubric actually reads (summary, skills, experience, certifications, target_roles) are sent to the Anthropic API. Your name, email, location, work authorization, salary expectations, notification address and education history are not transmitted.
Dependency auditing
CI runs pip-audit against the installed environment on every push. Dependencies are declared in pyproject.toml, with an upper bound on fastmcp (>=3.0,<4) so a major release cannot land unreviewed. ruff is pinned to 0.16.0 with an explicit rule set for the same reason.
License
MIT