job-search-mcp
# job-search-mcp
An MCP server for job hunting under a visa constraint.
If you need sponsorship, most job search tooling answers the wrong question. It tells
you a role exists. It does not tell you whether the employer can legally hire you, or
whether the "mid-level" title is hiding a senior brief, or that the company posting the
role is a recruitment agency that holds no licence at all.
This server answers those questions. It exposes four tools over the [Model Context
Protocol](https://modelcontextprotocol.io) so an AI assistant can check them itself
instead of guessing.
## Tools
| Tool | Answers |
|---|---|
| `check_sponsor` | Does this employer hold a UK Skilled Worker licence, and how much can I trust the match? |
| `screen_posting` | Are there hard bars here? Is the title telling the truth about seniority? |
| `search_roles` | What is live right now, minus the agencies? |
| `application_history` | Have I already applied here? |
## Why confidence grading exists
The obvious way to check a sponsor licence is to search the register for the company
name. That is how this started, and it is wrong often enough to be dangerous.
Company names collide. To match *Pimberly* against its registered name *Pimberly
Software Development Limited*, you have to strip words like "Software" and "Limited".
That same normalisation turns *Minerva Defence* into *minerva*, which matches thirteen
unrelated companies: a furnishing business, a credit agency, a supported-living
provider.
So `check_sponsor` returns a confidence grade rather than a boolean:
- **`high`** — the distinctive part of the name matched exactly, once.
- **`verify`** — something matched, but it could be coincidence. Confirm the employer's
registered legal entity, then look *that* name up.
- **`none`** — nothing matched. **This is not proof they cannot sponsor.** Many licensed
employers trade under a name unlike their registered one.
Real cases that shaped this:
| Searched | Matched | What was true |
|---|---|---|
| Minerva Defence | 13 unrelated "Minerva" rows | `MINERVA DEFENCE LTD` is not on the register, nor its former name `PARABELLUM TECHNOLOGIES LTD` |
| Prevail | Prevail Technology Limited (Poole) | The employer is `Prevail Partners Ltd`, which is absent. Same town, different company |
| MAGIC | MAGIC SOFTWARE SERVICES LTD | Right answer, wrong evidence. The employer is `MAGIC TECH LTD`, also licensed |
| TransPerfect | PERFECT DIGITAL LTD | Matched on the word "perfect" |
| eFinancialCareers | eFinancialCareers Ltd | Correct **and useless**: licensed, but a job board. Roles under its name belong to unnamed third parties |
## Why `screen_posting` reads the body, not the title
A posting titled "Software Engineer" opened with *"As a Senior Software Engineer you
will"* and asked for someone to join *"the founding team"*. Another titled "Software
Engineer (Java Mid)" named a senior grade three paragraphs down. Title-based filtering
misses both.
`screen_posting` also catches hard bars that no amount of tailoring survives:
```
You must be a UK citizen and have lived in the UK for the past 10 years.
You must already hold high-level UK security clearance.
```
and the one that matters most when you need sponsorship, from a company that
**holds an A-rated licence**:
```
We are unable to offer visa sponsorship for this role. Candidates who need visa
sponsorship now or will need it in the future will not be considered.
```
A licence means a company *can* sponsor. It does not mean it will.
## Why `search_roles` returns a funnel
UK job boards are dominated by recruitment agencies, and an agency does not hold the
sponsor licence for a role it advertises. Filtering them out is essential and brutal:
a typical search drops from seven results to one.
One result with no explanation looks like a bug. So the tool returns what each stage
removed:
```json
{
"totalFromSource": 7,
"funnel": { "fromSource": 7, "afterAgencyFilter": 2, "afterSalaryAndAge": 1 },
"notes": ["5 of 7 results were recruitment agencies or job boards. ..."]
}
```
Unpublished salaries are **kept**, never filtered out. Silence about pay is not evidence
of low pay.
## Install
```bash
npm install && npm run build
```
Download the current register (about 11 MB, updated regularly):
<https://www.gov.uk/government/publications/register-of-licensed-sponsors-workers>
Save it as `data/register.csv`, or point `SPONSOR_REGISTER_PATH` at it.
### Claude Desktop / Claude Code
```json
{
"mcpServers": {
"job-search": {
"command": "node",
"args": ["/absolute/path/to/job-search-mcp/dist/index.js"],
"env": {
"SPONSOR_REGISTER_PATH": "/absolute/path/to/data/register.csv",
"REED_API_KEY_FILE": "/absolute/path/to/.reed-api-key"
}
}
}
}
```
| Variable | Required | Purpose |
|---|---|---|
| `SPONSOR_REGISTER_PATH` | no | Register CSV. Defaults to `./data/register.csv` |
| `REED_API_KEY_FILE` | for `search_roles` | Path to a file containing the key. Preferred: the secret lives in one place and the client config holds only a path |
| `REED_API_KEY` | alternative | The key inline. Simpler, but copies the secret into your MCP config |
| `APPLICATION_LEDGER_PATH` | no | NDJSON application history |
No credential is ever read from a file inside the repo, and the register is gitignored.
Or with the Claude Code CLI:
```bash
claude mcp add job-search --scope user \
--env SPONSOR_REGISTER_PATH=/path/to/data/register.csv \
--env REED_API_KEY_FILE=/path/to/.reed-api-key \
-- node /path/to/job-search-mcp/dist/index.js
```
## Tests
```bash
npm test
```
54 tests. Every fixture is a real posting or a real register entry that defeated an
earlier version of this code. The Reed tests mock `fetch`, so the suite runs offline
and costs no API quota.
Two bugs the suite caught while it was being written:
- `£40,000-85,000` parsed as a flat £40,000, because the second figure omits the
currency symbol. Against a salary threshold, that is the difference between "clears
it" and "does not".
- An agency filter written as `\brecruit\b` never matched **Recruit**ment or
**Consultan**cy. Roughly fifty agencies passed straight through.
## Design notes
**`core()` deliberately destroys information.** Stripping descriptors is what makes
brand-to-legal-name matching work, and it is exactly what causes false positives. The
confidence grade prices that trade-off instead of hiding it.
**Absence is not a negative.** `none` carries a caveat saying so. The costliest error in
this domain is concluding that an unlisted employer cannot sponsor.
**Read-only.** The server reads the application ledger; it never writes to it. Recording
an outcome is a decision a person should make.
## Licence
MIT
TDQS
Scored across 4 tools
Each tool addresses a clearly distinct aspect: sponsor verification, posting screening, application history, and job searching. No overlap in purpose and each has a specific trigger condition.
Three tools follow a verb_noun pattern (check_sponsor, screen_posting, search_roles), but application_history deviates as noun_noun. The naming is still descriptive and predictable, with only minor inconsistency.
Four tools is well-scoped for a focused job-search assistant, covering the core workflow without bloat. Each tool contributes a distinct function and none seem redundant.
The surface covers search, screening, sponsor checks, and local history, which addresses the primary workflow. A minor gap is the lack of a tool to update or add to the application ledger, but this may be handled externally.