@pipeworx/mcp-ats-boards 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -10
- package/package.json +4 -4
- package/server.json +2 -2
- package/src/index.ts +383 -8
- package/src/server.ts +5 -2
package/README.md
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
# @pipeworx/ats-boards
|
|
2
2
|
|
|
3
|
-
Public job-board postings across
|
|
4
|
-
Lever, Ashby, SmartRecruiters, Recruitee, BambooHR, Workday,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
Public job-board postings across fifteen applicant-tracking systems
|
|
4
|
+
(Greenhouse, Lever, Ashby, SmartRecruiters, Recruitee, BambooHR, Workday,
|
|
5
|
+
Workable, Teamtailor, Personio, Jobvite, Breezy, Pinpoint, Rippling, iCIMS) —
|
|
6
|
+
detect which ATS a company publishes its careers page on, then read any
|
|
7
|
+
company's open roles without a key. This is NOT the `greenhouse`/`ashby`
|
|
8
|
+
packs, which read the caller's OWN private hiring data with their own API
|
|
9
|
+
key; this pack reads the same public board any browser can see, for any
|
|
10
|
+
company.
|
|
11
|
+
|
|
12
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1807+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
|
|
13
|
+
|
|
14
|
+
Dover was evaluated and dropped: its public board is a client-rendered SPA
|
|
15
|
+
behind a Cloudflare Turnstile challenge, and the underlying jobs API
|
|
16
|
+
(`app.dover.com/api/v1/jobs`) answers HTTP 401 "Authentication credentials
|
|
17
|
+
were not provided" for every query-param combination tried — there is no
|
|
18
|
+
discoverable keyless endpoint.
|
|
11
19
|
|
|
12
20
|
## Tools
|
|
13
21
|
|
|
@@ -31,7 +39,7 @@ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents
|
|
|
31
39
|
|
|
32
40
|
Keyless. Every endpoint below is the same public board data a browser sees on
|
|
33
41
|
a company's own careers page — no account, token or registration on any of
|
|
34
|
-
the
|
|
42
|
+
the fifteen supported ATS vendors.
|
|
35
43
|
|
|
36
44
|
The one exception is Workable: its public widget is currently blocked by
|
|
37
45
|
Cloudflare from this platform's edge (see "Data sources" below) — not an auth
|
|
@@ -49,6 +57,13 @@ rather than returning an empty result.
|
|
|
49
57
|
- `https://{board_token}.bamboohr.com/careers/list` — BambooHR's public careers-widget endpoint.
|
|
50
58
|
- `https://{tenant}.wd{N}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobs` (POST) — Workday's public CXS jobs search. `board_token` encodes all three path parts as `"{tenant}.wd{N}/{site}"`, e.g. `"workday.wd5/Workday"` (read the tenant/wd-number/site straight off the company's own `myworkdayjobs.com` careers URL).
|
|
51
59
|
- `https://apply.workable.com/api/v1/widget/accounts/{board_token}` — Workable's public widget API. **Currently unreachable from this platform's edge**, confirmed live via a throwaway `wrangler dev --remote` Worker on Pipeworx's production Cloudflare account: every one of 5+ attempts (two endpoint shapes — the v1 widget and the v3 `/api/v3/accounts/{account}/jobs`, with and without an `Accept` header, against several real account names, several minutes apart) returned HTTP 429 with `Content-Type: text/plain` and body `error code: 1015` — Cloudflare's OWN bot-mitigation, not a Workable application response. That reads as apply.workable.com's Cloudflare WAF specifically rate-limiting Cloudflare-Worker-to-Cloudflare traffic. The adapter is implemented per Workable's documented widget shape and is ready the moment this changes; until then every live call throws loud, naming the 429, rather than returning an empty board.
|
|
60
|
+
- `https://{board_token}.teamtailor.com/jobs.json` — Teamtailor's keyless JSON Feed (jsonfeed.org v1.1).
|
|
61
|
+
- `https://{board_token}.jobs.personio.com/xml?language=en`, falling back to `.jobs.personio.de` on a 404 — Personio's public XML feed. A company lives on exactly one of the two TLDs with no redirect between them.
|
|
62
|
+
- `https://jobs.jobvite.com/{board_token}` — Jobvite's server-rendered HTML board (no JSON API; the vendor's own feed is per-customer and off by default). Parsed by regex from the `jv-job-list-*` table markup.
|
|
63
|
+
- `https://{board_token}.breezy.hr/json` — Breezy's keyless JSON array.
|
|
64
|
+
- `https://{board_token}.pinpointhq.com/postings.json` — Pinpoint's keyless, officially documented postings JSON.
|
|
65
|
+
- `https://ats.rippling.com/{board_token}/jobs` — Rippling has no separate JSON API (every guessed `/api/...` path 404s); the job list is parsed out of the page's `<script id="__NEXT_DATA__">` hydration data.
|
|
66
|
+
- `https://{board_token}.icims.com/jobs/search?ss=1&in_iframe=1` — iCIMS's documented Talent-Cloud API is partner-gated, but the public career-search page itself is plain server-rendered HTML with no login; parsed by regex. `board_token` is the FULL subdomain label (e.g. `"careers-acme"`) — iCIMS customers don't share one fixed prefix.
|
|
52
67
|
|
|
53
68
|
### The silent-zero shape, per ATS (each measured live, not assumed from docs)
|
|
54
69
|
|
|
@@ -65,6 +80,13 @@ from "real board, zero jobs right now" at all:
|
|
|
65
80
|
| BambooHR | HTTP 200 (not 404!), `Content-Type: text/html` — BambooHR's own marketing homepage, byte-identical across different nonsense subdomains (a wildcard catch-all, not a per-company 404) | HTTP 200 JSON `{"meta":{"totalCount":0},"result":[]}` (confirmed live: `helpscout`, `pantheon`) |
|
|
66
81
|
| Workday | HTTP 404 JSON `{"errorCode":"S21",...,"message":"not found: Job_Posting_Site_ID=..."}` | Not independently confirmed (every real tenant found had jobs); `limit` over 20 is HTTP 400, not an empty page; `offset` past 10,000 returns HTTP 200 with the SAME page as `offset:0` rather than erroring — this pack reports `capped:true` when either cap is hit |
|
|
67
82
|
| Workable | Blocked (HTTP 429, Cloudflare) before any board-shaped answer is reachable from this platform | — |
|
|
83
|
+
| Teamtailor | HTTP 404, `Content-Type: application/json`, body EMPTY (0 bytes) | HTTP 200 JSON Feed `{"items":[...]}` (confirmed live: `career`, `arsenalfc`) |
|
|
84
|
+
| Personio | HTTP 404 `text/html` on BOTH `.com` and `.de` | HTTP 200 XML `<workzag-jobs>` (confirmed live: `kertos` on `.de`, `1nce` on `.com`) |
|
|
85
|
+
| Jobvite | HTTP 200 but 4 silent redirects to a DIFFERENT host (`www.jobvite.com/support/...?invalid=1`) — `fetch`'s default `redirect:'follow'` hides this unless you check `res.redirected` | HTTP 200, no redirect, server-rendered job table (confirmed live: `bagleybagelbites`, 65 rows) |
|
|
86
|
+
| Breezy | HTTP 404, `Content-Type: text/html`, title "Career portal not found" | HTTP 200 JSON array (confirmed live: `breezy`, `duolingo`) |
|
|
87
|
+
| Pinpoint | HTTP 404, `Content-Type: text/html` (Pinpoint's own 404 page) | Not independently confirmed (every real board found had postings); expected HTTP 200 `{"data":[]}` by the same convention |
|
|
88
|
+
| Rippling | HTTP 404, Next.js's own 404 page, no `__NEXT_DATA__` job-posts query | HTTP 200 with a `job-posts` query present but `items:[]` — not independently confirmed (every real board found had postings) |
|
|
89
|
+
| iCIMS | HTTP 404, title `"gone: {subdomain}.icims.com : dc409"` | Not independently confirmed (every real portal found had postings) |
|
|
68
90
|
|
|
69
91
|
Treating any non-200 as "zero jobs," or SmartRecruiters' `totalFound:0` as a
|
|
70
92
|
confirmed empty board, is the exact silent-zero failure this pack exists to
|
|
@@ -127,7 +149,7 @@ directly, instead of just this one's:
|
|
|
127
149
|
}
|
|
128
150
|
```
|
|
129
151
|
|
|
130
|
-
Both URLs reach the same gateway and the same
|
|
152
|
+
Both URLs reach the same gateway and the same 1807+ data sources. The
|
|
131
153
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
132
154
|
reaches all of them from either one.
|
|
133
155
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-ats-boards",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "ATS Boards MCP — public job-board postings across eight applicant-tracking",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "ATS Boards MCP — public job-board postings across eight applicant-tracking systems (keyless).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@cloudflare/workers-types": "^4.20260405.1"
|
|
27
27
|
},
|
|
28
28
|
"pipeworx": {
|
|
29
|
-
"sourceHash": "v1-
|
|
30
|
-
"sourceCommit": "
|
|
29
|
+
"sourceHash": "v1-9e7d256f25a7cd59758e98b7272d9697ce44231a57bd7488b6dea1142ee97a44",
|
|
30
|
+
"sourceCommit": "8dbe088fb6f9d2c88f934173742329b4e20a67d6"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/server.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.pipeworx-io/ats-boards",
|
|
4
4
|
"title": "Ats Boards",
|
|
5
|
-
"description": "ATS Boards MCP — public job-board postings across eight applicant-tracking",
|
|
6
|
-
"version": "0.1.
|
|
5
|
+
"description": "ATS Boards MCP — public job-board postings across eight applicant-tracking systems (keyless).",
|
|
6
|
+
"version": "0.1.1",
|
|
7
7
|
"websiteUrl": "https://pipeworx.io/packs/ats-boards",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/pipeworx-io/mcp-ats-boards",
|
package/src/index.ts
CHANGED
|
@@ -597,14 +597,14 @@ function xmlFaultText(raw: string): string | null {
|
|
|
597
597
|
* and a leak is a leak regardless of which branch produced it.
|
|
598
598
|
*/
|
|
599
599
|
function stripMarkup(s: string): string {
|
|
600
|
-
return collapse(decodeEntities(s.replace(/<[^>]*>/g, ' ')).replace(/[<>]/g, ' '));
|
|
600
|
+
return collapse(decodeEntities$shared(s.replace(/<[^>]*>/g, ' ')).replace(/[<>]/g, ' '));
|
|
601
601
|
}
|
|
602
602
|
|
|
603
603
|
/** The handful of entities that show up in error-page titles. Decoded AFTER
|
|
604
604
|
* tags are stripped and BEFORE the angle-bracket sweep, so `<script>`
|
|
605
605
|
* in a title cannot decode into markup that survives — EMBL-EBI's ChEMBL 500
|
|
606
606
|
* page renders as `500 Internal Server Error < EMBL-EBI` otherwise. */
|
|
607
|
-
function decodeEntities(s: string): string {
|
|
607
|
+
function decodeEntities$shared(s: string): string {
|
|
608
608
|
return s
|
|
609
609
|
.replace(/&(?:amp|#0*38);/gi, '&')
|
|
610
610
|
.replace(/&(?:lt|#0*60);/gi, '<')
|
|
@@ -1380,6 +1380,83 @@ async function safeFetchExtended(
|
|
|
1380
1380
|
* list. Do not spend more probing time rediscovering this; it is
|
|
1381
1381
|
* consistent and documented.
|
|
1382
1382
|
*
|
|
1383
|
+
* ── Second wave (fleet #3086, 2026-10-14) — Teamtailor, Personio, Jobvite,
|
|
1384
|
+
* Breezy, Pinpoint, Rippling, iCIMS added; Dover dropped ──────────────────
|
|
1385
|
+
* Measured live via curl from a laptop AND a throwaway Worker (see
|
|
1386
|
+
* `_edge_probe_worker` note below). Endpoints:
|
|
1387
|
+
*
|
|
1388
|
+
* TEAMTAILOR GET {token}.teamtailor.com/jobs.json — a keyless JSON Feed
|
|
1389
|
+
* (jsonfeed.org v1.1). Unknown subdomain -> HTTP 404, Content-Type
|
|
1390
|
+
* application/json, body EMPTY (0 bytes, not even `{}`) — status is the
|
|
1391
|
+
* only signal, never parse-then-check. Confirmed real w/ jobs:
|
|
1392
|
+
* career.teamtailor.com (Teamtailor itself) and arsenalfc.teamtailor.com.
|
|
1393
|
+
* Each item carries a schema.org `_jobposting.jobLocation[0].address`;
|
|
1394
|
+
* no department/remote field in this feed.
|
|
1395
|
+
*
|
|
1396
|
+
* PERSONIO GET {token}.jobs.personio.com/xml?language=en, falling back to
|
|
1397
|
+
* .jobs.personio.de on a 404 — a company is hosted on exactly one of the
|
|
1398
|
+
* two TLDs and there is no redirect between them, confirmed live
|
|
1399
|
+
* (1nce -> .com; kertos -> .de; hometogo.com 404'd, never probed .de).
|
|
1400
|
+
* Unknown subdomain (either TLD) -> HTTP 404 text/html. Real feed is XML
|
|
1401
|
+
* (`<workzag-jobs><position>...`), parsed by regex (Workers have no
|
|
1402
|
+
* DOMParser) with basic entity decoding. No per-job URL field; this pack
|
|
1403
|
+
* builds one as `{token}.jobs.personio.{tld}/job/{id}?language=en`,
|
|
1404
|
+
* matching the pattern on every indexed Personio job link.
|
|
1405
|
+
*
|
|
1406
|
+
* JOBVITE GET jobs.jobvite.com/{token} — server-rendered HTML table
|
|
1407
|
+
* (`<td class="jv-job-list-name"><a href="/{token}/job/{code}">`), no
|
|
1408
|
+
* JSON API (the vendor's own feed is per-customer and off by default).
|
|
1409
|
+
* Unknown token -> HTTP 200 but 4 silent redirects to
|
|
1410
|
+
* www.jobvite.com/support/job-seeker-support/?invalid=1 — a DIFFERENT
|
|
1411
|
+
* host, confirmed live (`num_redirects:4`). `fetch()`'s default
|
|
1412
|
+
* `redirect:'follow'` makes this invisible unless you check
|
|
1413
|
+
* `res.redirected`; this pack treats any redirect as board_not_found.
|
|
1414
|
+
* Confirmed real w/ jobs: jobs.jobvite.com/bagleybagelbites (65 rows).
|
|
1415
|
+
*
|
|
1416
|
+
* BREEZY GET {token}.breezy.hr/json — keyless JSON array (not wrapped).
|
|
1417
|
+
* Unknown subdomain -> HTTP 404, Content-Type text/html, title "Career
|
|
1418
|
+
* portal not found | Breezy HR". Confirmed real w/ jobs: breezy.breezy.hr
|
|
1419
|
+
* and duolingo.breezy.hr.
|
|
1420
|
+
*
|
|
1421
|
+
* PINPOINT GET {token}.pinpointhq.com/postings.json — keyless JSON
|
|
1422
|
+
* `{data:[...]}`, per Pinpoint's own published docs. Unknown subdomain ->
|
|
1423
|
+
* HTTP 404, Content-Type text/html (Pinpoint's own 404 page, not JSON).
|
|
1424
|
+
* Confirmed real w/ jobs: workwithus.pinpointhq.com. A real board with
|
|
1425
|
+
* zero current postings would be HTTP 200 `{data:[]}` by the same
|
|
1426
|
+
* 200-success-envelope convention every sibling ATS here uses — NOT
|
|
1427
|
+
* independently confirmed live (every real board probed had postings),
|
|
1428
|
+
* flagged as such like Recruitee's empty-board case above.
|
|
1429
|
+
*
|
|
1430
|
+
* RIPPLING GET ats.rippling.com/{token}/jobs — a Next.js page with NO
|
|
1431
|
+
* separate JSON API; every guessed `/api/...` path 404'd. The job data is
|
|
1432
|
+
* server-rendered into a `<script id="__NEXT_DATA__" type="application/
|
|
1433
|
+
* json">` tag as hydrated react-query state — this pack parses that
|
|
1434
|
+
* script tag and reads the query whose `queryKey` is
|
|
1435
|
+
* `["board", token, "job-posts"]`. Unknown token -> HTTP 404,
|
|
1436
|
+
* Content-Type text/html, Next.js's own 404 page (no `__NEXT_DATA__`
|
|
1437
|
+
* job-posts query to find). Confirmed real w/ jobs:
|
|
1438
|
+
* ats.rippling.com/skillable-careers/jobs and .../rearc/jobs.
|
|
1439
|
+
*
|
|
1440
|
+
* ICIMS GET {token}.icims.com/jobs/search?ss=1&in_iframe=1 — iCIMS's
|
|
1441
|
+
* documented Talent-Cloud API is gated behind a partner application, but
|
|
1442
|
+
* the public career-SEARCH page itself is plain server-rendered HTML
|
|
1443
|
+
* with no login (`<li class="iCIMS_JobCardItem">` blocks: title via
|
|
1444
|
+
* `<h3>`, location via the "Job Locations" field, department via the
|
|
1445
|
+
* "Category" dt/dd pair) — parsed by regex, same category as this pack's
|
|
1446
|
+
* existing HTML scraping for ats_detect. `token` is the FULL subdomain
|
|
1447
|
+
* label a company's careers portal uses (e.g. "careers-intrepidsolutions"
|
|
1448
|
+
* — iCIMS customers don't share one fixed prefix). Unknown subdomain ->
|
|
1449
|
+
* HTTP 404, title "gone: {subdomain}.icims.com : dc409". Confirmed real
|
|
1450
|
+
* w/ jobs: careers-intrepidsolutions.icims.com (14 postings).
|
|
1451
|
+
*
|
|
1452
|
+
* DOVER — DROPPED. app.dover.com/jobs/{slug} is a client-rendered SPA
|
|
1453
|
+
* behind a Cloudflare Turnstile challenge (the page ships
|
|
1454
|
+
* `challenges.cloudflare.com/turnstile/v0/api.js`); its underlying jobs
|
|
1455
|
+
* API, app.dover.com/api/v1/jobs, answers HTTP 401 `{"detail":
|
|
1456
|
+
* "Authentication credentials were not provided."}` for every query-
|
|
1457
|
+
* param combination tried (bare, `?company=`, `?careers_page_slug=`) —
|
|
1458
|
+
* there is no discoverable keyless public endpoint, only a walled one.
|
|
1459
|
+
*
|
|
1383
1460
|
* ── Persistence (fleet #2754: never module scope) ──────────────────────────
|
|
1384
1461
|
* The gateway injects its one shared KV namespace as `_cacheKv`
|
|
1385
1462
|
* (`injectCacheKv: true` on this pack's pack-manifest.json row — same
|
|
@@ -1494,7 +1571,23 @@ export type AtsJobsResult = AtsJobsFound | AtsJobsNotFound;
|
|
|
1494
1571
|
// re-fetching boards themselves — see workers/scraper/src/subscriptions.ts'
|
|
1495
1572
|
// evaluateJobPost, fleet #2858) can validate/enumerate the same board types
|
|
1496
1573
|
// without duplicating this list.
|
|
1497
|
-
export const VALID_ATS = [
|
|
1574
|
+
export const VALID_ATS = [
|
|
1575
|
+
'greenhouse',
|
|
1576
|
+
'lever',
|
|
1577
|
+
'ashby',
|
|
1578
|
+
'smartrecruiters',
|
|
1579
|
+
'recruitee',
|
|
1580
|
+
'bamboohr',
|
|
1581
|
+
'workday',
|
|
1582
|
+
'workable',
|
|
1583
|
+
'teamtailor',
|
|
1584
|
+
'personio',
|
|
1585
|
+
'jobvite',
|
|
1586
|
+
'breezy',
|
|
1587
|
+
'pinpoint',
|
|
1588
|
+
'rippling',
|
|
1589
|
+
'icims',
|
|
1590
|
+
] as const;
|
|
1498
1591
|
export type Ats = (typeof VALID_ATS)[number];
|
|
1499
1592
|
|
|
1500
1593
|
function requireAts(v: unknown): Ats {
|
|
@@ -1804,6 +1897,267 @@ async function fetchWorkable(token: string): Promise<AtsJobsResult> {
|
|
|
1804
1897
|
return { found: true, ats: 'workable', board_token: token, total: jobs.length, jobs, data_as_of: nowIso() };
|
|
1805
1898
|
}
|
|
1806
1899
|
|
|
1900
|
+
// -- Minimal entity decoder for regex-parsed HTML/XML (Workers have no DOMParser) --
|
|
1901
|
+
function decodeEntities(s: string): string {
|
|
1902
|
+
return s
|
|
1903
|
+
.replace(/&/g, '&')
|
|
1904
|
+
.replace(/</g, '<')
|
|
1905
|
+
.replace(/>/g, '>')
|
|
1906
|
+
.replace(/"/g, '"')
|
|
1907
|
+
.replace(/'|'/g, "'")
|
|
1908
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
1909
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
|
1910
|
+
.trim();
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
async function fetchTeamtailor(token: string): Promise<AtsJobsResult> {
|
|
1914
|
+
const sub = assertHostLabel(token, 'board_token');
|
|
1915
|
+
const res = await pwFetch(`https://${sub}.teamtailor.com/jobs.json`, { headers: { Accept: 'application/json' } });
|
|
1916
|
+
if (res.status === 404) {
|
|
1917
|
+
return { found: false, ats: 'teamtailor', board_token: token, reason: 'board_not_found', evidence: 'HTTP 404, empty body — Teamtailor serves no jobs.json for an unknown subdomain.', data_as_of: nowIso() };
|
|
1918
|
+
}
|
|
1919
|
+
if (!res.ok) {
|
|
1920
|
+
throw new Error(`Teamtailor board "${token}" returned HTTP ${res.status}: ${summarizeErrorBody(await res.text())}`);
|
|
1921
|
+
}
|
|
1922
|
+
const data = (await res.json()) as { items?: unknown[] };
|
|
1923
|
+
const jobs: NormalizedJob[] = (data.items ?? []).map((raw) => {
|
|
1924
|
+
const j = raw as Record<string, unknown>;
|
|
1925
|
+
const jp = j._jobposting as Record<string, unknown> | undefined;
|
|
1926
|
+
const locs = jp?.jobLocation as Array<Record<string, unknown>> | undefined;
|
|
1927
|
+
const addr = locs?.[0]?.address as Record<string, unknown> | undefined;
|
|
1928
|
+
const locationParts = [addr?.addressLocality, addr?.addressRegion].filter((v) => typeof v === 'string' && v);
|
|
1929
|
+
return {
|
|
1930
|
+
title: String(j.title ?? ''),
|
|
1931
|
+
location: locationParts.length ? locationParts.join(', ') : null,
|
|
1932
|
+
dept: null,
|
|
1933
|
+
remote: null,
|
|
1934
|
+
comp: null,
|
|
1935
|
+
posted_at: (j.date_published as string) ?? null,
|
|
1936
|
+
url: (j.url as string) ?? null,
|
|
1937
|
+
};
|
|
1938
|
+
});
|
|
1939
|
+
return { found: true, ats: 'teamtailor', board_token: token, total: jobs.length, jobs, data_as_of: nowIso() };
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
function parsePersonioXml(xml: string, token: string, tld: 'com' | 'de'): NormalizedJob[] {
|
|
1943
|
+
const positions = xml.match(/<position>[\s\S]*?<\/position>/g) ?? [];
|
|
1944
|
+
return positions.map((block) => {
|
|
1945
|
+
const field = (name: string): string | null => {
|
|
1946
|
+
const m = new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`).exec(block);
|
|
1947
|
+
return m ? decodeEntities(m[1]) : null;
|
|
1948
|
+
};
|
|
1949
|
+
const id = field('id');
|
|
1950
|
+
const office = field('office');
|
|
1951
|
+
const additional = Array.from(block.matchAll(/<additionalOffices>([\s\S]*?)<\/additionalOffices>/g)).flatMap((m) =>
|
|
1952
|
+
Array.from(m[1].matchAll(/<office>([\s\S]*?)<\/office>/g)).map((o) => decodeEntities(o[1])),
|
|
1953
|
+
);
|
|
1954
|
+
const location = [office, ...additional].filter(Boolean).join(', ') || null;
|
|
1955
|
+
return {
|
|
1956
|
+
title: field('name') ?? '',
|
|
1957
|
+
location,
|
|
1958
|
+
dept: field('department'),
|
|
1959
|
+
remote: location ? /remote/i.test(location) : null,
|
|
1960
|
+
comp: null,
|
|
1961
|
+
posted_at: field('createdAt'),
|
|
1962
|
+
url: id ? `https://${token}.jobs.personio.${tld}/job/${id}?language=en` : null,
|
|
1963
|
+
};
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
async function fetchPersonio(token: string): Promise<AtsJobsResult> {
|
|
1968
|
+
const sub = assertHostLabel(token, 'board_token');
|
|
1969
|
+
for (const tld of ['com', 'de'] as const) {
|
|
1970
|
+
const res = await pwFetch(`https://${sub}.jobs.personio.${tld}/xml?language=en`, { headers: { Accept: 'text/xml' } });
|
|
1971
|
+
if (res.status === 404) continue; // try the other TLD — a company lives on exactly one
|
|
1972
|
+
if (!res.ok) {
|
|
1973
|
+
throw new Error(`Personio board "${token}" (.${tld}) returned HTTP ${res.status}: ${summarizeErrorBody(await res.text())}`);
|
|
1974
|
+
}
|
|
1975
|
+
const xml = await res.text();
|
|
1976
|
+
const jobs = parsePersonioXml(xml, sub, tld);
|
|
1977
|
+
return { found: true, ats: 'personio', board_token: token, total: jobs.length, jobs, data_as_of: nowIso() };
|
|
1978
|
+
}
|
|
1979
|
+
return { found: false, ats: 'personio', board_token: token, reason: 'board_not_found', evidence: 'HTTP 404 on both jobs.personio.com and jobs.personio.de — Personio hosts a company on exactly one TLD with no redirect between them, and neither had this subdomain.', data_as_of: nowIso() };
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
async function fetchJobvite(token: string): Promise<AtsJobsResult> {
|
|
1983
|
+
const slug = assertHostLabel(token, 'board_token');
|
|
1984
|
+
const res = await pwFetch(`https://jobs.jobvite.com/${slug}`, { headers: { Accept: 'text/html' } });
|
|
1985
|
+
if (res.redirected) {
|
|
1986
|
+
// CONFIRMED LIVE: an unknown slug 4-hop redirects to
|
|
1987
|
+
// www.jobvite.com/support/job-seeker-support/?invalid=1 — a different
|
|
1988
|
+
// host — rather than answering 404 on jobs.jobvite.com itself.
|
|
1989
|
+
return { found: false, ats: 'jobvite', board_token: token, reason: 'board_not_found', evidence: `Request redirected to ${res.url} instead of serving the board — Jobvite's "invalid slug" signal.`, data_as_of: nowIso() };
|
|
1990
|
+
}
|
|
1991
|
+
if (!res.ok) {
|
|
1992
|
+
throw new Error(`Jobvite board "${token}" returned HTTP ${res.status}: ${summarizeErrorBody(await res.text())}`);
|
|
1993
|
+
}
|
|
1994
|
+
const html = await res.text();
|
|
1995
|
+
const rows = Array.from(
|
|
1996
|
+
html.matchAll(/<tr>\s*<td class="jv-job-list-name">\s*<a href="([^"]+)"[^>]*>([^<]*)<\/a>\s*<\/td>\s*<td class="jv-job-list-location">([\s\S]*?)<\/td>\s*<\/tr>/g),
|
|
1997
|
+
);
|
|
1998
|
+
const jobs: NormalizedJob[] = rows.map((m) => ({
|
|
1999
|
+
title: decodeEntities(m[2]),
|
|
2000
|
+
location: decodeEntities(m[3].replace(/\s+/g, ' ')) || null,
|
|
2001
|
+
dept: null,
|
|
2002
|
+
remote: null,
|
|
2003
|
+
comp: null,
|
|
2004
|
+
posted_at: null,
|
|
2005
|
+
url: m[1].startsWith('http') ? m[1] : `https://jobs.jobvite.com${m[1]}`,
|
|
2006
|
+
}));
|
|
2007
|
+
return { found: true, ats: 'jobvite', board_token: token, total: jobs.length, jobs, data_as_of: nowIso() };
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
async function fetchBreezy(token: string): Promise<AtsJobsResult> {
|
|
2011
|
+
const sub = assertHostLabel(token, 'board_token');
|
|
2012
|
+
const res = await pwFetch(`https://${sub}.breezy.hr/json`, { headers: { Accept: 'application/json' } });
|
|
2013
|
+
if (res.status === 404) {
|
|
2014
|
+
return { found: false, ats: 'breezy', board_token: token, reason: 'board_not_found', evidence: 'HTTP 404, Content-Type text/html — Breezy: "Career portal not found".', data_as_of: nowIso() };
|
|
2015
|
+
}
|
|
2016
|
+
if (!res.ok) {
|
|
2017
|
+
throw new Error(`Breezy board "${token}" returned HTTP ${res.status}: ${summarizeErrorBody(await res.text())}`);
|
|
2018
|
+
}
|
|
2019
|
+
const data = (await res.json()) as unknown[];
|
|
2020
|
+
const jobs: NormalizedJob[] = (Array.isArray(data) ? data : []).map((raw) => {
|
|
2021
|
+
const j = raw as Record<string, unknown>;
|
|
2022
|
+
const location = j.location as Record<string, unknown> | undefined;
|
|
2023
|
+
const salary = typeof j.salary === 'string' ? j.salary.trim() : '';
|
|
2024
|
+
return {
|
|
2025
|
+
title: String(j.name ?? ''),
|
|
2026
|
+
location: (location?.name as string) ?? null,
|
|
2027
|
+
dept: (j.department as string) ?? null,
|
|
2028
|
+
remote: typeof location?.is_remote === 'boolean' ? (location.is_remote as boolean) : null,
|
|
2029
|
+
comp: salary || null,
|
|
2030
|
+
posted_at: (j.published_date as string) ?? null,
|
|
2031
|
+
url: (j.url as string) ?? null,
|
|
2032
|
+
};
|
|
2033
|
+
});
|
|
2034
|
+
return { found: true, ats: 'breezy', board_token: token, total: jobs.length, jobs, data_as_of: nowIso() };
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
async function fetchPinpoint(token: string): Promise<AtsJobsResult> {
|
|
2038
|
+
const sub = assertHostLabel(token, 'board_token');
|
|
2039
|
+
const res = await pwFetch(`https://${sub}.pinpointhq.com/postings.json`, { headers: { Accept: 'application/json' } });
|
|
2040
|
+
if (res.status === 404) {
|
|
2041
|
+
return { found: false, ats: 'pinpoint', board_token: token, reason: 'board_not_found', evidence: 'HTTP 404, Content-Type text/html — Pinpoint\'s own 404 page, not a JSON response.', data_as_of: nowIso() };
|
|
2042
|
+
}
|
|
2043
|
+
if (!res.ok) {
|
|
2044
|
+
throw new Error(`Pinpoint board "${token}" returned HTTP ${res.status}: ${summarizeErrorBody(await res.text())}`);
|
|
2045
|
+
}
|
|
2046
|
+
const data = (await res.json()) as { data?: unknown[] };
|
|
2047
|
+
const jobs: NormalizedJob[] = (data.data ?? []).map((raw) => {
|
|
2048
|
+
const j = raw as Record<string, unknown>;
|
|
2049
|
+
const location = j.location as Record<string, unknown> | undefined;
|
|
2050
|
+
const job = j.job as Record<string, unknown> | undefined;
|
|
2051
|
+
const dept = job?.department as Record<string, unknown> | undefined;
|
|
2052
|
+
const workplaceType = typeof j.workplace_type === 'string' ? j.workplace_type.toLowerCase() : undefined;
|
|
2053
|
+
const min = j.compensation_minimum as number | undefined;
|
|
2054
|
+
const max = j.compensation_maximum as number | undefined;
|
|
2055
|
+
const currency = j.compensation_currency as string | undefined;
|
|
2056
|
+
const comp = j.compensation_visible && (min || max) ? `${currency ?? ''} ${min ?? ''}${max ? `-${max}` : ''}`.trim() : null;
|
|
2057
|
+
return {
|
|
2058
|
+
title: String(j.title ?? ''),
|
|
2059
|
+
location: (location?.city as string) ?? (location?.name as string) ?? null,
|
|
2060
|
+
dept: (dept?.name as string) ?? null,
|
|
2061
|
+
remote: workplaceType ? workplaceType === 'remote' : null,
|
|
2062
|
+
comp,
|
|
2063
|
+
posted_at: null, // not confirmed live — Pinpoint's postings.json carries no date field in any probed response
|
|
2064
|
+
url: (j.url as string) ?? null,
|
|
2065
|
+
};
|
|
2066
|
+
});
|
|
2067
|
+
// Not independently confirmed live for a real board with ZERO current
|
|
2068
|
+
// postings (every probed board had jobs); documented by analogy with every
|
|
2069
|
+
// sibling ATS's 200-success-envelope convention, same as Recruitee above.
|
|
2070
|
+
return { found: true, ats: 'pinpoint', board_token: token, total: jobs.length, jobs, data_as_of: nowIso() };
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
interface RippleQuery {
|
|
2074
|
+
queryKey?: unknown[];
|
|
2075
|
+
state?: { data?: { items?: unknown[] } };
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
function parseRipplingNextData(html: string): unknown[] | null {
|
|
2079
|
+
const m = /<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/.exec(html);
|
|
2080
|
+
if (!m) return null;
|
|
2081
|
+
let parsed: unknown;
|
|
2082
|
+
try {
|
|
2083
|
+
parsed = JSON.parse(m[1]);
|
|
2084
|
+
} catch {
|
|
2085
|
+
return null;
|
|
2086
|
+
}
|
|
2087
|
+
const queries = (parsed as { props?: { pageProps?: { dehydratedState?: { queries?: RippleQuery[] } } } })?.props?.pageProps?.dehydratedState?.queries ?? [];
|
|
2088
|
+
const jobPosts = queries.find((q) => Array.isArray(q.queryKey) && q.queryKey[0] === 'board' && q.queryKey[2] === 'job-posts');
|
|
2089
|
+
return jobPosts?.state?.data?.items ?? null;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
async function fetchRippling(token: string): Promise<AtsJobsResult> {
|
|
2093
|
+
const slug = assertHostLabel(token, 'board_token');
|
|
2094
|
+
const res = await pwFetch(`https://ats.rippling.com/${slug}/jobs`, { headers: { Accept: 'text/html' } });
|
|
2095
|
+
if (res.status === 404) {
|
|
2096
|
+
return { found: false, ats: 'rippling', board_token: token, reason: 'board_not_found', evidence: 'HTTP 404 — Rippling\'s own Next.js 404 page, no __NEXT_DATA__ job-posts query present.', data_as_of: nowIso() };
|
|
2097
|
+
}
|
|
2098
|
+
if (!res.ok) {
|
|
2099
|
+
throw new Error(`Rippling board "${token}" returned HTTP ${res.status}: ${summarizeErrorBody(await res.text())}`);
|
|
2100
|
+
}
|
|
2101
|
+
const html = await res.text();
|
|
2102
|
+
const items = parseRipplingNextData(html);
|
|
2103
|
+
if (items === null) {
|
|
2104
|
+
throw new Error(`Rippling board "${token}" returned HTTP 200 but no parseable __NEXT_DATA__ job-posts query was found — the page shape may have changed.`);
|
|
2105
|
+
}
|
|
2106
|
+
const jobs: NormalizedJob[] = items.map((raw) => {
|
|
2107
|
+
const j = raw as Record<string, unknown>;
|
|
2108
|
+
const dept = j.department as Record<string, unknown> | undefined;
|
|
2109
|
+
const locs = j.locations as Array<Record<string, unknown>> | undefined;
|
|
2110
|
+
const loc0 = locs?.[0];
|
|
2111
|
+
return {
|
|
2112
|
+
title: String(j.name ?? ''),
|
|
2113
|
+
location: (loc0?.name as string) ?? null,
|
|
2114
|
+
dept: (dept?.name as string) ?? null,
|
|
2115
|
+
remote: typeof loc0?.workplaceType === 'string' ? (loc0.workplaceType as string).toUpperCase() === 'REMOTE' : null,
|
|
2116
|
+
comp: null,
|
|
2117
|
+
posted_at: null,
|
|
2118
|
+
url: (j.url as string) ?? null,
|
|
2119
|
+
};
|
|
2120
|
+
});
|
|
2121
|
+
return { found: true, ats: 'rippling', board_token: token, total: jobs.length, jobs, data_as_of: nowIso() };
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function parseIcimsCards(html: string): NormalizedJob[] {
|
|
2125
|
+
const cards = html.split('<li class="iCIMS_JobCardItem">').slice(1);
|
|
2126
|
+
return cards.map((card) => {
|
|
2127
|
+
const titleMatch = /<h3[^>]*>\s*([^<]+?)\s*<\/h3>/.exec(card);
|
|
2128
|
+
const urlMatch = /<a href="([^"]+)" class="iCIMS_Anchor"/.exec(card);
|
|
2129
|
+
const locMatch = /Job Locations<\/span>\s*<span[^>]*>\s*([^<]*?)\s*<\/span>/.exec(card);
|
|
2130
|
+
const deptMatch = /<dt class="iCIMS_JobHeaderField">Category<\/dt>\s*<dd class="iCIMS_JobHeaderData"><span[^>]*>\s*([^<]*?)\s*<\/span>/.exec(card);
|
|
2131
|
+
const rawUrl = urlMatch ? urlMatch[1] : null;
|
|
2132
|
+
return {
|
|
2133
|
+
title: titleMatch ? decodeEntities(titleMatch[1]) : '',
|
|
2134
|
+
location: locMatch && locMatch[1] ? decodeEntities(locMatch[1]) : null,
|
|
2135
|
+
dept: deptMatch && deptMatch[1] ? decodeEntities(deptMatch[1]) : null,
|
|
2136
|
+
remote: null,
|
|
2137
|
+
comp: null,
|
|
2138
|
+
posted_at: null,
|
|
2139
|
+
url: rawUrl ? rawUrl.replace(/\?in_iframe=1$/, '') : null,
|
|
2140
|
+
};
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
async function fetchIcims(token: string): Promise<AtsJobsResult> {
|
|
2145
|
+
const sub = assertHostLabel(token, 'board_token');
|
|
2146
|
+
const res = await pwFetch(`https://${sub}.icims.com/jobs/search?ss=1&in_iframe=1`, { headers: { Accept: 'text/html' } });
|
|
2147
|
+
if (res.status === 404) {
|
|
2148
|
+
return { found: false, ats: 'icims', board_token: token, reason: 'board_not_found', evidence: `HTTP 404 — iCIMS: "gone: ${sub}.icims.com : dc409".`, data_as_of: nowIso() };
|
|
2149
|
+
}
|
|
2150
|
+
if (!res.ok) {
|
|
2151
|
+
throw new Error(`iCIMS board "${token}" returned HTTP ${res.status}: ${summarizeErrorBody(await res.text())}`);
|
|
2152
|
+
}
|
|
2153
|
+
const html = await res.text();
|
|
2154
|
+
const jobs = parseIcimsCards(html);
|
|
2155
|
+
// Not independently confirmed live for a real portal with ZERO current
|
|
2156
|
+
// postings — every probed portal had jobs. Documented by analogy, same
|
|
2157
|
+
// disclaimer as Pinpoint/Recruitee above.
|
|
2158
|
+
return { found: true, ats: 'icims', board_token: token, total: jobs.length, jobs, data_as_of: nowIso() };
|
|
2159
|
+
}
|
|
2160
|
+
|
|
1807
2161
|
async function fetchRaw(ats: Ats, token: string, filters: Record<string, unknown> = {}): Promise<AtsJobsResult> {
|
|
1808
2162
|
switch (ats) {
|
|
1809
2163
|
case 'greenhouse':
|
|
@@ -1822,6 +2176,20 @@ async function fetchRaw(ats: Ats, token: string, filters: Record<string, unknown
|
|
|
1822
2176
|
return fetchWorkday(token, filters as { title_query?: string; limit?: number; offset?: number });
|
|
1823
2177
|
case 'workable':
|
|
1824
2178
|
return fetchWorkable(token);
|
|
2179
|
+
case 'teamtailor':
|
|
2180
|
+
return fetchTeamtailor(token);
|
|
2181
|
+
case 'personio':
|
|
2182
|
+
return fetchPersonio(token);
|
|
2183
|
+
case 'jobvite':
|
|
2184
|
+
return fetchJobvite(token);
|
|
2185
|
+
case 'breezy':
|
|
2186
|
+
return fetchBreezy(token);
|
|
2187
|
+
case 'pinpoint':
|
|
2188
|
+
return fetchPinpoint(token);
|
|
2189
|
+
case 'rippling':
|
|
2190
|
+
return fetchRippling(token);
|
|
2191
|
+
case 'icims':
|
|
2192
|
+
return fetchIcims(token);
|
|
1825
2193
|
}
|
|
1826
2194
|
}
|
|
1827
2195
|
|
|
@@ -1873,6 +2241,13 @@ function scanHtmlForAts(html: string): DetectCandidate[] {
|
|
|
1873
2241
|
{ ats: 'bamboohr', re: /([a-z0-9][a-z0-9-]{0,61})\.bamboohr\.com/i },
|
|
1874
2242
|
{ ats: 'workable', re: /apply\.workable\.com\/(?:api\/v\d\/widget\/accounts\/|j\/)?([a-z0-9][a-z0-9_-]{0,63})/i },
|
|
1875
2243
|
{ ats: 'workday', re: /([a-z0-9-]+)\.(wd\d{1,2})\.myworkdayjobs\.com\/(?:[a-z]{2}-[A-Z]{2}\/)?([A-Za-z0-9_.-]+)/i },
|
|
2244
|
+
{ ats: 'teamtailor', re: /([a-z0-9][a-z0-9-]{0,61})\.teamtailor\.com/i },
|
|
2245
|
+
{ ats: 'personio', re: /([a-z0-9][a-z0-9-]{0,61})\.jobs\.personio\.(?:com|de)/i },
|
|
2246
|
+
{ ats: 'jobvite', re: /jobs\.jobvite\.com\/([a-z0-9][a-z0-9_-]{0,63})/i },
|
|
2247
|
+
{ ats: 'breezy', re: /([a-z0-9][a-z0-9-]{0,61})\.breezy\.hr/i },
|
|
2248
|
+
{ ats: 'pinpoint', re: /([a-z0-9][a-z0-9-]{0,61})\.pinpointhq\.com/i },
|
|
2249
|
+
{ ats: 'rippling', re: /ats\.rippling\.com\/([a-z0-9][a-z0-9_-]{0,63})/i },
|
|
2250
|
+
{ ats: 'icims', re: /([a-z0-9][a-z0-9-]{0,61})\.icims\.com/i },
|
|
1876
2251
|
];
|
|
1877
2252
|
for (const { ats, re } of patterns) {
|
|
1878
2253
|
const m = re.exec(html);
|
|
@@ -1952,7 +2327,7 @@ async function atsDetect(args: Record<string, unknown>, kv: KvLike | undefined):
|
|
|
1952
2327
|
|
|
1953
2328
|
if (domain) {
|
|
1954
2329
|
const slug = domain.split('.')[0];
|
|
1955
|
-
const probeOrder: Ats[] = ['greenhouse', 'lever', 'ashby', 'smartrecruiters', 'recruitee', 'bamboohr'];
|
|
2330
|
+
const probeOrder: Ats[] = ['greenhouse', 'lever', 'ashby', 'smartrecruiters', 'recruitee', 'bamboohr', 'teamtailor', 'personio', 'breezy', 'pinpoint'];
|
|
1956
2331
|
for (const ats of probeOrder) {
|
|
1957
2332
|
if (await probeSlug(ats, slug)) {
|
|
1958
2333
|
const result = {
|
|
@@ -1972,7 +2347,7 @@ async function atsDetect(args: Record<string, unknown>, kv: KvLike | undefined):
|
|
|
1972
2347
|
reason: 'no_ats_detected',
|
|
1973
2348
|
checked_scrape: candidates.map((c) => `${c.ats}:${c.board_token}`),
|
|
1974
2349
|
checked_probe: probeOrder.map((a) => `${a}:${slug}`),
|
|
1975
|
-
note: 'Scraped the careers page and the homepage for ATS links, then slug-probed Greenhouse/Lever/Ashby/SmartRecruiters/Recruitee/BambooHR using the domain label as the board token. None matched. Workday needs a tenant + wd-number + site slug that cannot be guessed from a domain alone, so it is not blind-probed. Workable is not blind-probed either — its public widget is currently blocked by Cloudflare from this platform\'s edge (see ats_jobs with ats:"workable" for the measured 429).',
|
|
2350
|
+
note: 'Scraped the careers page and the homepage for ATS links, then slug-probed Greenhouse/Lever/Ashby/SmartRecruiters/Recruitee/BambooHR/Teamtailor/Personio/Breezy/Pinpoint using the domain label as the board token. None matched. Workday needs a tenant + wd-number + site slug that cannot be guessed from a domain alone, so it is not blind-probed. Jobvite, Rippling and iCIMS use slugs/subdomain prefixes that often do not match the bare domain label either, so they are not blind-probed — pass board_token directly if you already know it. Workable is not blind-probed either — its public widget is currently blocked by Cloudflare from this platform\'s edge (see ats_jobs with ats:"workable" for the measured 429).',
|
|
1976
2351
|
data_as_of: nowIso(),
|
|
1977
2352
|
};
|
|
1978
2353
|
}
|
|
@@ -1986,7 +2361,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
1986
2361
|
{
|
|
1987
2362
|
name: 'ats_detect',
|
|
1988
2363
|
description:
|
|
1989
|
-
'Detect which applicant-tracking system (ATS) a company publishes its public job board on, and its board token. Reads the company\'s careers page (or a given careers_url) for a Greenhouse/Lever/Ashby/SmartRecruiters/Recruitee/BambooHR/Workday/Workable link; falls back to slug-probing the domain name against each board API. Does not read any private/Harvest-key ATS data — only what a public job board already exposes. Discoveries persist so repeat lookups for the same domain are instant.',
|
|
2364
|
+
'Detect which applicant-tracking system (ATS) a company publishes its public job board on, and its board token. Reads the company\'s careers page (or a given careers_url) for a Greenhouse/Lever/Ashby/SmartRecruiters/Recruitee/BambooHR/Workday/Workable/Teamtailor/Personio/Jobvite/Breezy/Pinpoint/Rippling/iCIMS link; falls back to slug-probing the domain name against each board API that supports it. Does not read any private/Harvest-key ATS data — only what a public job board already exposes. Discoveries persist so repeat lookups for the same domain are instant.',
|
|
1990
2365
|
inputSchema: {
|
|
1991
2366
|
type: 'object' as const,
|
|
1992
2367
|
properties: {
|
|
@@ -1998,7 +2373,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
1998
2373
|
{
|
|
1999
2374
|
name: 'ats_jobs',
|
|
2000
2375
|
description:
|
|
2001
|
-
'Fetch normalized job postings from a PUBLIC ATS board you already have the ats + board_token for (use ats_detect or ats_company_jobs if you only have a domain). Returns {found:false, reason:"board_not_found"} for an unknown board — never an empty jobs[] for that case. Lever/Ashby/BambooHR/Recruitee DO distinguish a real zero-job board (found:true, jobs:[]) from an unknown one; SmartRecruiters cannot
|
|
2376
|
+
'Fetch normalized job postings from a PUBLIC ATS board you already have the ats + board_token for (use ats_detect or ats_company_jobs if you only have a domain). Returns {found:false, reason:"board_not_found"} for an unknown board — never an empty jobs[] for that case. Lever/Ashby/BambooHR/Recruitee/Teamtailor/Breezy DO distinguish a real zero-job board (found:true, jobs:[]) from an unknown one; SmartRecruiters cannot (ambiguous:true); Pinpoint and iCIMS have not had a confirmed-empty real board observed live, so a 0-job result from those two is NOT independently confirmed to mean "real board, no jobs" versus a mis-detected token. Workday board_token is "<tenant>.wd<N>/<site>" (e.g. "workday.wd5/Workday"); its response states capped:true if your limit/offset hit Workday\'s own pagination limits (limit<=20, offset<=10000). Workable is currently blocked by Cloudflare from this platform\'s edge and will throw a loud error naming the 429, not an empty result. Dover is NOT supported: its public board is a Cloudflare-Turnstile-gated SPA with no keyless jobs endpoint.',
|
|
2002
2377
|
inputSchema: {
|
|
2003
2378
|
type: 'object' as const,
|
|
2004
2379
|
properties: {
|
|
@@ -2006,7 +2381,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
2006
2381
|
board_token: {
|
|
2007
2382
|
type: 'string',
|
|
2008
2383
|
description:
|
|
2009
|
-
'The board identifier: Greenhouse/Lever/Ashby/SmartRecruiters company slug or
|
|
2384
|
+
'The board identifier: Greenhouse/Lever/Ashby/SmartRecruiters/Teamtailor/Personio/Breezy/Pinpoint company slug or subdomain label; Recruitee/BambooHR subdomain label; Jobvite slug (path segment after jobs.jobvite.com/); Rippling slug (path segment after ats.rippling.com/); iCIMS full subdomain label before .icims.com (e.g. "careers-acme" — prefixes vary by customer); Workable account slug; Workday "<tenant>.wd<N>/<site>".',
|
|
2010
2385
|
},
|
|
2011
2386
|
filters: {
|
|
2012
2387
|
type: 'object',
|
package/src/server.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import pack from './index.js';
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
const server = new Server(
|
|
12
|
-
{ name: '@pipeworx/mcp-ats-boards', version: '0.1.
|
|
13
|
+
{ name: '@pipeworx/mcp-ats-boards', version: '0.1.1' },
|
|
13
14
|
{ capabilities: { tools: {} } },
|
|
14
15
|
);
|
|
15
16
|
|
|
@@ -24,7 +25,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
24
25
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
25
26
|
const { name, arguments: args } = request.params;
|
|
26
27
|
try {
|
|
27
|
-
const
|
|
28
|
+
const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
|
|
29
|
+
|
|
30
|
+
const result = await pack.callTool(name, callArgs);
|
|
28
31
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
29
32
|
} catch (err) {
|
|
30
33
|
return {
|