Skip to main content
Glama
pendevster

job-search-mcp

by pendevster
README.md
# 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

A4.1/5.0

Scored across 4 tools

Disambiguation5/5

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.

Naming Consistency4/5

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.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues