job-search
by galmrt
README.md
# job-search MCP server
A local MCP server that scrapes job boards (Indeed, LinkedIn, ZipRecruiter,
Google Jobs — via [python-jobspy](https://github.com/speedyapply/JobSpy)),
filters postings against your saved criteria, dedupes against what you've
already been shown, and tracks application status. Runs on your machine,
registered with Claude Desktop.
## 1. Install dependencies
This has to be done in a real Terminal on your Mac (Cowork's sandboxed shell
can't reach PyPI, so I couldn't run this step for you). One time:
```bash
cd ~/Projects/job-search-mcp
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
That installs `mcp`, `python-jobspy`, and `pandas` into a virtualenv scoped
to this folder.
## 2. Smoke-test it
```bash
cd ~/Projects/job-search-mcp
.venv/bin/python -c "import server; print('imports ok')"
```
If that prints `imports ok` with no traceback, the code is wired up
correctly. (It won't try to scrape anything yet — that only happens when a
tool is actually called.)
Optional: try a real scrape from the command line before wiring it into
Claude Desktop:
```bash
.venv/bin/python -c "
from scraper import run_search
jobs = run_search('machine learning engineer', 'Remote', ['indeed'], hours_old=72, results_wanted=5)
for j in jobs:
print(j.get('title'), '-', j.get('company'), '-', j.get('job_url'))
"
```
## 3. Register with Claude Desktop
Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
(create it if it doesn't exist) and add an entry under `mcpServers`:
```json
{
"mcpServers": {
"job-search": {
"command": "/Users/mrtgaliakberov/Projects/job-search-mcp/.venv/bin/python",
"args": ["/Users/mrtgaliakberov/Projects/job-search-mcp/server.py"]
}
}
}
```
If you already have other `mcpServers` entries, just add `"job-search"` as
another key alongside them — don't replace the whole file.
Then fully quit and reopen Claude Desktop. The server starts automatically
whenever Claude Desktop launches it (you don't need to run it manually).
## 4. Try it
In a Claude Desktop chat:
- "What are my saved job search criteria?" → calls `list_criteria`
- "Get today's job matches" → calls `get_daily_matches`
- "Search for AI engineer jobs in Seattle" → calls `search_jobs`
- "Mark <job_url> as applied" → calls `mark_applied`
- "What have I marked as applied so far?" → calls `list_tracked`
## 5a. Extra sources: ZipRecruiter, Dice, Indeed (connectors)
Beyond Indeed/LinkedIn (JobSpy), you can pull in results from Claude's
native ZipRecruiter, Dice, and Indeed connectors if you've connected
them (claude.ai connector settings). They hit real provider APIs, not
scraping -- no blocking, no ToS risk.
They're a different mechanism than the job-search MCP server: Claude
calls the connector's `search_jobs` tool itself, then passes the raw
response to one of this server's `record_zip_recruiter_matches` /
`record_dice_matches` / `record_indeed_matches` tools, which normalizes
it and runs it through the same filter/dedupe/store pipeline
`get_daily_matches` uses -- same `seen_jobs` table, same dashboard, same
application tracker. Just ask in chat, e.g.:
> "Search ZipRecruiter for machine learning engineer jobs, remote,
> entry-level, and record the matches"
ZipRecruiter's connector has real `seniority_classes`
(NO_EXPERIENCE/JUNIOR/MID/SENIOR) and `location_types` (REMOTE/HYBRID)
filters -- worth leaning on those directly rather than relying on this
project's title-keyword filtering, since they're much more precise.
Dice and Indeed's connectors don't have that, so the usual
`title_exclude_keywords` filtering still applies to their results.
The daily scheduled task also tries all three connectors automatically
if they're available in that session (see `CLAUDE.md` for the current
status of that -- it's unverified as of when this was written).
## 5. Browse matches in a dashboard
Once you've run `get_daily_matches` at least once, browse everything it's
found so far in a local web page:
```bash
python3 dashboard.py
```
Opens `http://127.0.0.1:8765` in your browser automatically. Search,
filter by status or remote-only, sort any column, and change a job's
status (interested / applied / interviewing / rejected / skipped) right
from the table -- it writes to the same `state.db` `mark_applied` uses,
so status stays in sync whichever way you set it.
No install needed for this part -- it's stdlib only, doesn't touch
`.venv`. `--port <n>` to use a different port, `--no-browser` to skip
auto-opening.
## Tools
| Tool | Purpose |
|---|---|
| `search_jobs` | Ad-hoc search, no dedupe/state side effects |
| `get_daily_matches` | Full saved-criteria search → filter → dedupe → mark seen. This is what the daily scheduled check calls. |
| `list_criteria` / `update_criteria` / `reset_criteria` | View/edit what counts as a match |
| `mark_applied` / `list_tracked` | Lightweight application tracker |
| `get_stats` | Counts: jobs seen, applications tracked, by status |
## Default criteria
- **Titles matched:** anything containing an AI/ML term (machine learning,
AI engineer, applied AI/ML, research engineer, etc. — any seniority), OR
a general SWE term (software engineer, developer) AND an entry-level
signal (new grad, junior, entry-level, associate, early career).
- **Excluded:** senior/staff/principal/lead/director/manager/architect
titles, regardless of the above.
- **Search terms:** "machine learning engineer", "AI engineer", "software
engineer new grad"
- **Locations:** "Remote", "San Jose, CA" — no hard location filter beyond
that (I left this broad since you said Remote / Bay Area / anywhere in
the US all worked for you); edit `locations` via `update_criteria` to
narrow it.
- **Sites:** Indeed, LinkedIn, ZipRecruiter, Google Jobs, Glassdoor. In
practice, **only Indeed and LinkedIn actually return results right
now** — ZipRecruiter, Google, and Glassdoor all come back empty (no
error, just 0 results) due to upstream JobSpy scraper issues (see
[GitHub #302](https://github.com/speedyapply/JobSpy/issues/302) for
ZipRecruiter/Google; Glassdoor tested the same way with the same
result). No free fix exists — the workaround JobSpy documents is a
paid rotating-residential-proxy subscription. They're left in `sites`
in case JobSpy fixes this upstream (nothing to configure if it does).
- **Non-US "Remote" listings are filtered out.** LinkedIn's "Remote"
search isn't country-scoped and returns postings from anywhere in the
world — `filters.py`'s `is_non_us_location()` drops anything whose
location names a non-US country.
- **Window:** last 48 hours of postings, dedup'd against everything
previously shown (stored in `~/.job-search-mcp/state.db`).
All of this is editable at runtime with `update_criteria` — no code
changes or restart needed.
## A note on LinkedIn/Indeed scraping
JobSpy scrapes these sites directly (no official API), which is against
their terms of service. It mimics ordinary browser traffic rather than
doing anything like credential stuffing or session hijacking, and this
server is meant to run about once a day, not be polled — but there's
still some risk of the scraper breaking or getting rate-limited if these
sites change their defenses. If you'd rather drop LinkedIn/Indeed
entirely, remove them from `sites` via `update_criteria` and keep
ZipRecruiter/Google/Indeed... (well, keep whichever subset you're
comfortable with).
## Data
Everything is stored locally in `~/.job-search-mcp/state.db` (SQLite) —
seen-job history, your criteria, and your application tracker. Nothing
leaves your machine except the scrape requests themselves.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues