Skip to main content
Glama
README.md
# hsm-mcp

Remote MCP server that mirrors the [IND public register of recognised sponsors
(Work)](https://ind.nl/en/public-register-recognised-sponsors/public-register-work)
— the list of Dutch organisations that can sponsor work / highly-skilled-migrant
residence permits — and makes it queryable by AI assistants.

IND publishes the register as a monthly-updated HTML page with no API. This
Worker scrapes it daily (write-on-change), stores it in KV with monthly
snapshots, and serves two MCP tools over Streamable HTTP:

- **`search_sponsors`** — company name or 8-digit KvK number in, ranked candidate
  matches out (exact / base-name / substring / fuzzy). Deliberately returns
  candidates, not a yes/no verdict: the register lists registered legal names,
  queries use trade names, and the calling LLM is the right judge of whether
  "Adyen N.V." is the "Adyen" you meant. See `docs/adr/0001`.
- **`get_register_status`** — register date, scrape health, staleness flag.

Runs entirely on the Cloudflare free tier: Workers + KV + SQLite-backed Durable
Objects (McpAgent) + cron + Email Routing for scrape-failure alerts.

## Use

Connect the server (no auth; rate limited to 30 req/min per IP):

**Claude Code**

```bash
claude mcp add --transport http ind-sponsors https://hsm.codealan.com/mcp
```

**claude.ai / Claude Desktop** — Settings → Connectors → Add custom connector →
`https://hsm.codealan.com/mcp`

**Any MCP client** (Cursor, etc.)

```json
{ "mcpServers": { "ind-sponsors": { "url": "https://hsm.codealan.com/mcp" } } }
```

Then just ask in plain language — the assistant calls the tools itself:

- *"Is Adyen a recognised sponsor for highly skilled migrants?"*
- *"Check these five companies from my shortlist against the IND register."*
- *"KvK 56317441 — who is this and can they sponsor?"*
- *"How fresh is the sponsor data?"* (hits `get_register_status`)

**Reading the answers:** tools return ranked candidates, never a yes/no
verdict. A `base_name`/`exact_name` match at score ≥0.95 is a solid yes; a
KvK-number match is identity-grade; an **empty result is not a "no"** — the
register lists registered legal names while companies go by trade names, so try
the legal name (often ends in B.V./N.V.) or the KvK number. Always confirm
against the [official register](https://ind.nl/en/public-register-recognised-sponsors)
before acting on it.

## Architecture

```mermaid
flowchart LR
    client["AI client<br/>(Claude, any MCP client)"]
    ind["ind.nl Work register<br/>(HTML page, updated ~monthly)"]
    inbox["Owner's inbox"]

    subgraph gh["GitHub Actions"]
        ci["CI: push to main<br/>test → deploy"]
        health["Health check (daily)<br/>GET /health, 503 = stale"]
    end

    subgraph cf["Cloudflare (free plan)"]
        worker["Worker: routing +<br/>30 req/min per-IP rate limit"]
        mcpdo["HsmMcp Durable Object<br/>McpAgent tools:<br/>search_sponsors · get_register_status<br/>(cached fuzzy-match index)"]
        scrdo["Scraper Durable Object<br/>parse + sanity gates<br/>(DO dodges 10 ms Worker CPU cap)"]
        kv[("KV<br/>register:current<br/>snapshot:YYYY-MM-DD<br/>status")]
        cron["Cron 06:17 UTC daily"]
        mail["Email Routing"]
    end

    client -- "MCP (streamable HTTP) /mcp" --> worker --> mcpdo
    mcpdo -- "read + cache per version" --> kv
    cron --> scrdo
    worker -- "POST /admin/scrape (bearer)" --> scrdo
    scrdo -- "fetch HTML" --> ind
    scrdo -- "write-on-change<br/>+ monthly snapshot" --> kv
    scrdo -- "3 consecutive failures" --> mail --> inbox
    health -- "probe" --> worker
    ci -- "wrangler deploy" --> worker
```

Key design points, in one breath: the scraper polls daily but writes only when
IND's own "last updated" date changes; sanity gates (row count, KvK format,
date parse) mean a broken scrape can never replace good data with garbage;
searches run against an in-memory index rebuilt only when the register version
changes; and every snapshot is kept forever so a future diff feature can answer
"when did company X appear?".

| Path | What happens |
| ---- | ------------ |
| Query | client → `/mcp` → `HsmMcp` DO → ranked candidates from cached index |
| Refresh | cron (or `POST /admin/scrape`) → `Scraper` DO → gates → KV |
| Monitoring | GitHub Action probes `/health` daily; 503 fails the run → GitHub emails the owner |
| Deploy | push to `main` → tests → `wrangler deploy` (config from repo secrets) |

Setup, file-by-file architecture notes, and deploy steps: see [CLAUDE.md](./CLAUDE.md).

Unofficial project; data © IND, republished for easier checking. Always verify
with the [official register](https://ind.nl/en/public-register-recognised-sponsors)
before acting on it.