ATS MCP Server
# ats-mcp-server
An MCP server over job boards. Ask it for a company's open roles and it answers
from Greenhouse, Workday, or LinkedIn, normalized into one shape.
Greenhouse and Workday are public JSON feeds — no browser, no login, no API key.
LinkedIn is different: it needs an authenticated browser session, so this server
delegates to a running `linkedin-mcp-server` rather than reimplementing it.
## Run it
```bash
pip install -e .
ats-mcp-server # stdio, for MCP clients that spawn a subprocess
ats-mcp-server --transport http # streamable HTTP on 127.0.0.1:8100/mcp
```
## Testing it
**1. Without any setup** — the public sources need nothing running:
```bash
python -m venv .venv && .venv/Scripts/python -m pip install -e .
.venv/Scripts/python -c "
import asyncio, json
from fastmcp import Client
from ats_mcp.server import mcp
async def main():
async with Client(mcp) as c:
r = json.loads((await c.call_tool('search_jobs',
{'company': 'Stripe', 'ai_ml_only': True, 'limit': 5})).content[0].text)
print(r['found_per_source'], '->', r['returned'], 'kept')
for j in r['data']:
print(' ', j['source'], j['title'])
asyncio.run(main())
"
```
**2. With the MCP Inspector** — a UI to click through every tool:
```bash
npx @modelcontextprotocol/inspector .venv/Scripts/ats-mcp-server
```
**3. Including LinkedIn** — start the delegated server first, in its own window:
```bash
uvx --with "fastmcp<4" "mcp-server-linkedin==4.23.1" \
--transport streamable-http --host 127.0.0.1 --port 8000 --path /mcp
```
Then call `source_status` to confirm `reachable: true`, and pass
`sources: ["greenhouse", "workday", "linkedin"]` to any search. LinkedIn is
never searched unless you name it — see below.
**4. From an MCP client** (Claude Desktop, Claude Code) — point it at the stdio
entry point:
```json
{ "mcpServers": { "ats": { "command": "C:/path/to/.venv/Scripts/ats-mcp-server" } } }
```
## Tools
| Tool | What it does |
|---|---|
| `find_board` | Locate a company's board on Greenhouse or Workday. Cached for the process lifetime. |
| `register_board` | Pin a board explicitly when discovery can't guess it. Verified before it's accepted. |
| `search_jobs` | Search one company's roles, with optional AI/ML-only and junior filters. |
| `search_many` | Search several companies, merged into one list. Unresolvable names are reported, not fatal. |
| `get_job` | Full posting, including the description. |
| `classify_title` | Run the title heuristics without touching the network. |
| `source_status` | Which sources are configured, whether LinkedIn is reachable, quota left. |
## Why LinkedIn is opt-in
Greenhouse and Workday are searched by default. LinkedIn is not, and has to be
named explicitly in `sources`.
Public feeds cost nothing to query. LinkedIn runs through a real logged-in
account, where volume and machine-like regularity are what get accounts
restricted. So it carries a throttle the public sources don't: a randomized
4-10s gap between calls and a self-imposed ceiling of 60 calls/day, both
enforced in `throttle.py`. Hitting the ceiling returns a readable error rather
than a platform-imposed restriction.
A source failing never fails the search. Ask for LinkedIn while its server is
down and you still get the Greenhouse and Workday results, with the LinkedIn
problem reported in `errors`.
## How the sources differ
**Greenhouse** — one `GET` returns a company's entire board (Stripe's is ~630
postings). There's no search parameter, so the board is fetched once, cached
for 15 minutes, and matched locally.
**Workday** — a `POST` with real server-side `searchText`, so a targeted query
never pulls the whole board. Results are relevance-ranked by Workday, and how
strictly it interprets a query varies by tenant: a small career site may return
loosely-related postings. Titles are worth checking before trusting the set.
## Board discovery
Greenhouse boards are addressed by a single token that's usually the company
name (`figma`, `stripe`), so those resolve on the first guess.
Workday needs three coordinates — tenant, numbered host, and career-site name —
and the site name is often company-specific (`NvidiaExternalCareerSite`).
Discovery tries name-derived combinations first and gives up after a bounded
number of probes rather than hammering a careers endpoint to establish that a
company isn't on Workday. When it can't find one, read the coordinates off the
careers URL and call `register_board`:
```
https://nvidia.wd5.myworkdayjobs.com/NvidiaExternalCareerSite/...
└tenant┘ └───── host ─────┘ └────── site ──────┘
```
## Filters
`ai_ml_only` keeps AI/ML/MLOps/GenAI/Data-Science titles and drops management
and Data Engineer roles. Junior/intern/new-grad titles are excluded by default;
`include_junior` keeps them. These are heuristics over titles, which is all a
board search result usually gives you — `classify_title` shows how any title
will be treated.
## Notes
`fastmcp` is pinned `>=4.0,<5` on purpose. It has removed decorator keyword
arguments across majors before (4.0 dropped `exclude_args`), so an unbounded
upper bound is a future crash on a fresh install.
TDQS
Scored across 7 tools
Each tool has a clearly distinct role: discovery (find_board), manual pinning (register_board), single-company search (search_jobs), multi-company search (search_many), detail retrieval (get_job), local classification (classify_title), and source diagnostics (source_status). There is minimal overlap, and the descriptions clarify boundaries like one vs. many companies.
Almost all tools use a consistent snake_case verb_noun pattern: find_board, register_board, search_jobs, search_many, get_job, classify_title. source_status breaks the pattern by starting with a noun, a minor inconsistency. Overall readable and predictable.
Seven tools is well-scoped for an ATS/job-board search server; each tool covers a distinct operation without redundancy. The count supports core discovery, search, detail, classification, and status workflows.
The surface covers discovery, source registration, single/multi-company search, job detail, title classification, and source status, which are the main lifecycle operations for this domain. Minor gaps exist, such as no explicit board-removal or broader source-listing operation, but agents can work around these.