job-search-mcp
by edefice
README.md
# Job Search MCP Server
An MCP (Model Context Protocol) server that lets Claude search [justjoin.it](https://justjoin.it)
job postings and track your applications, so you can ask things like *"find me new AI Engineer
roles in Warsaw"* or *"what's the status of the application I sent last week"* directly in a chat with
Claude Desktop or Claude Code.
## Why
Built as a hands-on MCP project for my own AI Engineer job search β real tool, real use, not a
toy demo.
## Tools
| Tool | Description |
|---|---|
| `search_jobs(keyword, location?)` | Searches justjoin.it listings. Returns title, company, URL, salary, tags, location. `location` accepts a city name (`"Warsaw"`, `"Krakow"`, ...) or `"remote"`. |
| `get_job_details(url)` | Fetches a single job offer page and returns a cleaned-up description plus tech stack tags. |
| `track_application(url, status)` | Saves/updates an application's status locally. `status` is one of `saved`, `applied`, `interviewing`, `rejected`, `offer`. |
| `list_applications()` | Lists all tracked applications, most recently updated first. |
Application data is stored locally in a JSON file at `~/.job-search-mcp/applications.json` β
no database, no external service.
## Setup
Requires Node.js 18+.
```bash
git clone <this-repo-url>
cd job-search-mcp
npm install
npm run build
```
This produces `dist/index.js`, a stdio MCP server.
## Connecting to Claude Desktop
Add the server to your Claude Desktop config
(`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS), replacing the
path below with the absolute path to `dist/index.js` in your clone (run `pwd` inside the
project directory to get it):
```json
{
"mcpServers": {
"job-search": {
"command": "node",
"args": ["/absolute/path/to/job-search-mcp/dist/index.js"]
}
}
}
```
Restart Claude Desktop. The four tools above should appear as available tools (look for the
π¨ tools icon in the chat input).
## Connecting to Claude Code
```bash
claude mcp add job-search -- node /absolute/path/to/job-search-mcp/dist/index.js
```
Or add it to a project's `.mcp.json`:
```json
{
"mcpServers": {
"job-search": {
"command": "node",
"args": ["/absolute/path/to/job-search-mcp/dist/index.js"]
}
}
}
```
## Example prompts
- "Find AI Engineer roles in Warsaw"
- "Get me the full details and tech stack for this listing: `<url>`"
- "Mark that job as applied"
- "What's the status of all my job applications?"
- "Show me everything I've marked as interviewing"
## Development
```bash
npm run dev # tsc --watch
npm run build # one-off compile
npm start # run the compiled server directly (for manual stdio testing)
npm test # build, then run the test suite (node's built-in test runner)
```
## Tests
Tests run with Node's built-in test runner (`node:test`) β no extra test framework dependency.
`npm test` compiles first, then runs everything under `dist/**/*.test.js`.
- `justjoin.test.ts` β `buildSearchUrl` against known/unmapped/diacritic city names, and
`parseOfferCards`/`parseJobDetails` against trimmed-but-verbatim fixtures of real justjoin.it
markup (including the `<object>` logo wrapper that makes the tag-pill parsing heuristic
necessary), so the parsing logic is verified without hitting the live site.
- `store.test.ts` β `trackApplication`/`listApplications` against a temp file (via the
`JOB_SEARCH_STORE_PATH` env var override), so tests never touch your real
`~/.job-search-mcp/applications.json`.
## Tricky bits
- **justjoin.it has no public API.** It's a Next.js app that server-renders offer cards, but
the CSS classes are build-hashed (e.g. `mui-1cks7or`) and change on every deploy, so scraping
can't rely on fixed selectors. `src/justjoin.ts` anchors on stable landmarks instead: the
`offer_list_offer_title_link` class, Lucide icon classes (`svg.lucide-map-pin`,
`svg.lucide-building`), and β for the tag pills, where even sibling/depth assumptions turned
out to be unreliable β a structural heuristic that detects "a `<div>` whose children are all
plain-text, childless `<div>`s," a shape unique to that row on the card.
- **Job detail pages are easier**: they embed proper `schema.org/JobPosting` JSON-LD, which is
used for title/company/location/salary/employment type. The visible HTML is still used for
the description and tech stack, since the JSON-LD description is a single run-on string with
no paragraph breaks.
- **City slugs are Polish-only** (`warszawa`, not `warsaw`) β `search_jobs` maps common English
city names to their Polish slugs. Writing the test for this caught a real bug: Polish `Ε`
(as in `ΕΓ³dΕΊ`) isn't a combining-mark diacritic, so `String.normalize("NFD")` doesn't strip
it the way it does `Γ³` or `ΕΊ` β it needs an explicit `Ε`/`Ε` β `l`/`L` replacement first.
- **Salary is often undisclosed.** Both tools fall back to `"Not specified"` rather than
erroring when a listing doesn't list a salary.
- **Scraping is inherently brittle.** If justjoin.it changes its page structure enough to break
these heuristics, `search_jobs`/`get_job_details` will return an explicit tool error rather
than silently returning garbage.
## Roadmap (not yet built)
- `match_score(job_url)` β score a job description against a CV/skills profile via the
Anthropic API, with a rationale.
- A second job board as a data source, so results aren't tied to justjoin.it alone.
TDQS
B3.4/5.0
Scored across 2 tools
Disambiguation5/5
The two tools have clearly distinct purposes: one fetches job details, the other tracks applications. No overlap or confusion.
Naming Consistency5/5
Both tool names follow a consistent verb_noun pattern: 'get_job_details' and 'track_application'.
Tool Count2/5
With only 2 tools for a 'job-search-mcp', the scope is far too narrow. A job search server typically needs search, list, and filter capabilities.
Completeness1/5
The tool surface is severely incomplete for a job search server. There is no way to discover or search for jobs, only view details of a known posting and track application status.
Maintenance
ActivityInactive
ResponsivenessNo issues