jobfinder-mcp
# jobfinder-mcp
A local [MCP](https://modelcontextprotocol.io) server for searching job listings
and tracking applications. Runs entirely on your machine over stdio; saved jobs
live in a local SQLite file.
## Install
```bash
python3 -m venv .venv
.venv/bin/pip install -e .
```
Requires Python 3.10+. The SDK is pinned to `mcp>=1.2,<2`: mcp 2.x removed
`mcp.server.fastmcp`, which this server is built on.
## Run
```bash
.venv/bin/jobfinder-mcp # or: .venv/bin/python -m jobfinder_mcp
```
The server speaks MCP over stdio, so running it by hand just blocks waiting for
a client. To inspect it interactively:
```bash
.venv/bin/mcp dev src/jobfinder_mcp/server.py
```
## Web UI
```bash
.venv/bin/jobfinder-web # http://127.0.0.1:8765, opens a browser
.venv/bin/jobfinder-web --port 0 --no-browser
```
Two panes: tracked jobs on the left (search box, status filters, a badge showing
the tailored resume's file type), the selected job on the right with tabs for the
full **Description** and its **Tailored resume** — copy it, download the file, or
take it as a self-contained `data:application/x-tex;base64,…` link (shown under
the buttons, with *copy data URI* next to it). The whole document lives inside
that link, so it can be pasted anywhere and downloaded with this server stopped.
For `.tex` output there is also **Open in Overleaf**, which POSTs that data URI to
`overleaf.com/docs` as `snip_uri` and opens the compiled CV in a new tab — the way
to get a PDF without a local TeX install. It uploads the resume to Overleaf, so it
only fires when you click it. Note that Chrome refuses `data:` URIs typed into the
address bar; only the in-page controls work.
The status dropdown writes straight back to the tracker, so the MCP tools and the
UI stay in sync; hit *refresh* after Claude saves something new.
It reads the same SQLite file and tailored directory as the MCP server, and binds
to loopback only — it serves your resume and application notes, so do not expose
it on a LAN interface. The MCP tool `open_web_ui(port)` starts the same UI on a
background thread inside the running server.
## Register with Claude Code
```bash
claude mcp add jobfinder -- /Users/juansalzar/Documents/git/jobfinder/.venv/bin/jobfinder-mcp
```
Or add it to `~/.claude.json` / `.mcp.json` manually:
```json
{
"mcpServers": {
"jobfinder": {
"command": "/Users/juansalzar/Documents/git/jobfinder/.venv/bin/jobfinder-mcp"
}
}
}
```
For Claude Desktop, the same block goes in
`~/Library/Application Support/Claude/claude_desktop_config.json`.
## Tools
| Tool | Purpose |
| --- | --- |
| `search_jobs(query, location, tags, source, limit)` | Search listings across all sources, ranked by where the terms hit |
| `get_job_details(job_id)` | Full listing text for an id from a search or the tracker |
| `save_job(job_id, notes)` | Save a listing from the last search into the tracker |
| `add_job(title, company, description, location, url, salary, tags, notes, job_id)` | Add a job found elsewhere by pasting its details |
| `set_job_description(job_id, description)` | Replace a saved job's description with pasted text |
| `list_saved_jobs(status, limit)` | List tracked jobs, newest first |
| `update_job_status(job_id, status, notes)` | Move a job through the pipeline |
| `delete_saved_job(job_id)` | Remove a tracked job |
| `pipeline_summary()` | Counts by status |
| `add_resume(name, text, path)` | Store a resume from pasted text or a `.md/.txt/.pdf/.docx/.tex` file |
| `list_resumes()` | Stored resumes, with the default marked |
| `get_resume(name, raw)` | Show a resume; `raw=true` returns the original LaTeX source |
| `set_default_resume(name)` | Pick the resume used when a call names none |
| `delete_resume(name)` | Remove a resume and its original file |
| `tailor_resume(job_id, resume)` | Build the tailoring brief for a job |
| `save_tailored_resume(job_id, content, extension)` | Store the tailored CV as LaTeX |
| `get_tailored_resume(job_id)` | Read back the tailored CV |
| `tailored_resume_link(job_id)` | The tailored CV as a base64 `data:` URI |
| `list_tailored_resumes()` | Every tailored CV written so far |
| `open_web_ui(port)` | Start the local browser UI and return its URL |
Resource `jobfinder://saved` returns every tracked job as JSON.
Prompt `tailor_application(job_id, resume_summary)` drafts a cover letter; with no
`resume_summary` it uses the default stored resume.
Statuses: `saved`, `applied`, `interviewing`, `offer`, `rejected`, `archived`.
## Tailoring a resume
The server does not call an LLM. `tailor_resume` returns a brief — the listing's
must-have terms, its most frequent terms, and which of them the resume already
evidences — and the model driving the client writes the CV from it. Nothing is
sent anywhere, and there is no API key or per-call cost.
```
add_resume(name="main", path="~/Documents/cv.pdf")
search_jobs(query="python backend", location="remote")
save_job(job_id="remoteok:123456")
tailor_resume(job_id="remoteok:123456") # -> brief; the model writes the CV
save_tailored_resume(job_id="remoteok:123456", content="\\documentclass...")
```
PDF, DOCX and LaTeX files are converted to text for matching, and the original is
kept next to it. A scanned, image-only PDF extracts to nothing; paste the text
with `add_resume(name, text=...)` instead.
### LaTeX output
Tailored resumes are always written as `.tex`. `tailor_resume` ends its brief
with the LaTeX to write into:
- the stored resume **is** a `.tex` file → its source is included, to be rewritten
in place with the preamble, macros and layout left alone;
- otherwise → a plain `article`-class template is included for the model to fill.
`save_tailored_resume` rejects content with no `\documentclass` or
`\begin{document}`; pass an explicit `extension` to store another format anyway.
There is no LaTeX toolchain in this project — compile the result with
`pdflatex`/`tectonic`, or upload it to Overleaf.
To use your own layout instead of the built-in template, drop a `.tex` file at
`~/.jobfinder/resume_template.tex` or point `JOBFINDER_LATEX_TEMPLATE` at one.
Term matching is deterministic string work: known multi-word phrases plus
non-stopword tokens, normalized through an alias table so `k8s` in a listing
matches `Kubernetes` in a resume. It surfaces gaps; it does not judge fit.
## Sources
- **remoteok** — RemoteOK's public JSON feed. No API key. Cached 15 minutes.
- **local** — set `JOBFINDER_FEED=/path/to/jobs.json` to read your own listings.
The file is a JSON list of objects with at least `id`, `title` and `company`;
`location`, `url`, `salary`, `tags`, `description` are optional.
Add another source by writing a `fetch_*` function in `sources.py` that returns
normalized job dicts, then registering it in the `SOURCES` map.
## Configuration
| Env var | Default | Meaning |
| --- | --- | --- |
| `JOBFINDER_DB` | `~/.jobfinder/jobs.db` | SQLite file for saved jobs |
| `JOBFINDER_FEED` | unset | JSON file for the `local` source |
| `JOBFINDER_HOME` | `~/.jobfinder` | Base directory for resumes and tailored output |
| `JOBFINDER_RESUME_DIR` | `$JOBFINDER_HOME/resumes` | Where stored resumes live |
| `JOBFINDER_TAILORED_DIR` | `$JOBFINDER_HOME/tailored` | Where tailored resumes are written |
| `JOBFINDER_LATEX_TEMPLATE` | built-in template | `.tex` skeleton used for tailored output |
TDQS
Scored across 20 tools
Most tools have distinct purposes, but save_job and add_job both create a saved job, which could cause confusion. The descriptions clarify the source, so an agent can differentiate with careful reading.
Most tools follow verb_noun naming (search_jobs, get_job_details, save_job). However, pipeline_summary and tailored_resume_link are noun-phrase names, breaking the pattern slightly.
At 20 tools, this is on the heavier side. The dual purpose (job tracking and resume tailoring) justifies many tools, but it borders on overwhelming for an agent.
The job lifecycle (search, save, update, delete, summaries) and resume tailoring workflow (add, tailor, save, retrieve) are well covered. Missing a dedicated delete for tailored resumes is a minor gap.