hirelayer-mcp 1.1.1 → 2.1.0
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 +92 -48
- package/dist/client.js +125 -30
- package/dist/server.js +229 -67
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
# HireLayer MCP Server
|
|
6
6
|
|
|
7
|
-
**Resume parsing,
|
|
7
|
+
**Resume parsing, resume scoring and talent pool search for AI agents.**
|
|
8
8
|
|
|
9
|
-
The official [Model Context Protocol](https://modelcontextprotocol.io) server for [HireLayer](https://hirelayer.co). Parse resumes and CVs, turn job descriptions into criteria,
|
|
9
|
+
The official [Model Context Protocol](https://modelcontextprotocol.io) server for [HireLayer](https://hirelayer.co). Parse resumes and CVs, turn job descriptions into criteria, score and rank resumes against a job, and search the candidates and jobs you already have, from Claude, ChatGPT, Cursor, VS Code, Codex or any MCP client.
|
|
10
10
|
|
|
11
11
|
**Server URL: `https://hirelayer.co/mcp`** · sign in with your HireLayer account, no API key to copy.
|
|
12
12
|
|
|
@@ -24,7 +24,7 @@ The official [Model Context Protocol](https://modelcontextprotocol.io) server fo
|
|
|
24
24
|
|
|
25
25
|
> "Screen these 3 resumes against the Senior React job and tell me who to interview."
|
|
26
26
|
>
|
|
27
|
-
> Your assistant parses each CV,
|
|
27
|
+
> Your assistant parses each CV, scores every candidate against the job criterion by criterion and explains the shortlist.
|
|
28
28
|
|
|
29
29
|
## Contents
|
|
30
30
|
|
|
@@ -40,11 +40,12 @@ The official [Model Context Protocol](https://modelcontextprotocol.io) server fo
|
|
|
40
40
|
|
|
41
41
|
## What you can do
|
|
42
42
|
|
|
43
|
-
- **Parse resumes and CVs
|
|
44
|
-
- **Turn a job description into criteria
|
|
45
|
-
- **
|
|
46
|
-
- **
|
|
47
|
-
- **
|
|
43
|
+
- **Parse resumes and CVs** (Resume Parser). Turn PDF, Word, image and other files into structured JSON: contact details, work experience, education, languages, skills and the full text. Scanned resumes go through OCR, and the resume language is detected.
|
|
44
|
+
- **Turn a job description into criteria** (Job Criteria Extractor). Get the typed, weighted criteria the posting states, each marked required, preferred or bonus, in English, French, German, Spanish or Portuguese. Free.
|
|
45
|
+
- **Score and rank resumes** (Resume Scorer). Score up to 50 resumes against a job in one call, from 0 to 1, with a verdict, a summary and the evidence for each criterion, best first. Ask for a precise ranking to re-rank the 10 best with a reason each.
|
|
46
|
+
- **Search your talent pool** (Talent Pool Search). Find the resumes already in your HireLayer database closest to a job, around a city if you like.
|
|
47
|
+
- **Recommend jobs** (Job Recommendations). Find your open jobs closest to a candidate.
|
|
48
|
+
- **Normalize skills** (Skills Normalizer). Map free-text skills to a skills taxonomy with stable IDs, named in French or English.
|
|
48
49
|
|
|
49
50
|
Use it to screen applicants in a chat, build a recruiting agent, enrich an ATS, or prototype HR tech features without writing integration code.
|
|
50
51
|
|
|
@@ -208,27 +209,32 @@ docker build -t hirelayer-mcp .
|
|
|
208
209
|
docker run -i --rm -e HIRELAYER_API_KEY=your-api-key hirelayer-mcp
|
|
209
210
|
```
|
|
210
211
|
|
|
211
|
-
In Docker, `parse_resume` only reads files that you mount into the container. Otherwise, pass `file_url`.
|
|
212
|
+
In Docker, `parse_resume` only reads files that you mount into the container. Otherwise, pass `file_url` or `resume_text`.
|
|
212
213
|
|
|
213
214
|
</details>
|
|
214
215
|
|
|
215
216
|
## Tools
|
|
216
217
|
|
|
217
|
-
| Tool | What it does | Typical input |
|
|
218
|
-
|
|
219
|
-
| `parse_resume` | Parses a resume or CV
|
|
220
|
-
| `extract_job_criteria` | Turns a job description into
|
|
221
|
-
| `
|
|
222
|
-
| `
|
|
223
|
-
| `
|
|
218
|
+
| Tool | Product | What it does | Typical input |
|
|
219
|
+
|---|---|---|---|
|
|
220
|
+
| `parse_resume` | Resume Parser | Parses a resume or CV into structured JSON: contact details, experience, education, languages, skills and the full text, stored under an `application_id` | A local `file_path`, a public `file_url` or the `resume_text`. Accepts PDF, DOC, DOCX, ODT, RTF, TXT, HTML, Markdown, PPT, PPTX, ODP, XLS, JPG, PNG or BMP files under 4.5 MB. |
|
|
221
|
+
| `extract_job_criteria` | Job Criteria Extractor | Turns a job description into the typed criteria it states, each with a requirement (required, preferred or bonus) and a weight from 0 to 3, and stores the job under a `job_id` | Job description text, at least 30 characters |
|
|
222
|
+
| `score_resumes` | Resume Scorer | Scores 1 to 50 resumes against one job, best first: a score from 0 to 1, a verdict, a summary, the required criteria missing and the evidence for each criterion. `precise_ranking` re-ranks the 10 best with a reason each | The job as `job_text` (optionally with your own `criteria`) or a `job_id`, and each resume as `text` or an `application_id`; at least one resume of 30 characters or more |
|
|
223
|
+
| `search_resumes` | Talent Pool Search | Finds the stored resumes closest to a job, with optional city, radius and date filters. Proximity only: score the ones worth it with `score_resumes` | A `job_id` or `job_text` |
|
|
224
|
+
| `search_jobs` | Job Recommendations | Finds the stored open jobs closest to a resume. Proximity only | An `application_id` or `resume_text` |
|
|
225
|
+
| `normalize_skills` | Skills Normalizer | Maps free-text skills to taxonomy skills, with their families and domains, named in French or English | Free text, from one skill to a whole skills section |
|
|
226
|
+
|
|
227
|
+
HireLayer keeps the resumes, jobs and scores in your HireLayer account, as the REST API does, so a resume or a job-resume pair already processed costs nothing the second time and the two searches have something to search. `normalize_skills` stores nothing. No tool deletes anything or changes your other systems.
|
|
228
|
+
|
|
229
|
+
**Several client companies?** Every tool except `normalize_skills` takes an optional `workspace` (for example `acme-corp`), sent as the `HireLayer-Workspace` header: each client's jobs, resumes and scores stay apart. A new id creates a new workspace; without one, calls use your default workspace.
|
|
224
230
|
|
|
225
|
-
|
|
231
|
+
Upgrading from 1.x: `match_candidate` and `rank_candidates` became `score_resumes`, and `resolve_skills` became `normalize_skills`. See the [changelog](CHANGELOG.md).
|
|
226
232
|
|
|
227
233
|
The server also ships **prompts** that clients show as ready-made commands:
|
|
228
234
|
|
|
229
235
|
| Prompt | What it does |
|
|
230
236
|
|---|---|
|
|
231
|
-
| `screen_candidates` |
|
|
237
|
+
| `screen_candidates` | Parses the resume files, scores every resume against the job in one call with a precise ranking, and writes a shortlist |
|
|
232
238
|
| `summarize_resume` | Parses one resume and writes a recruiter summary |
|
|
233
239
|
| `normalize_skills` | Normalizes a skills section and groups it by domain |
|
|
234
240
|
|
|
@@ -236,37 +242,61 @@ The server also ships **prompts** that clients show as ready-made commands:
|
|
|
236
242
|
|
|
237
243
|
```mermaid
|
|
238
244
|
flowchart LR
|
|
239
|
-
J[Job description] -->
|
|
245
|
+
J[Job description] --> S[score_resumes]
|
|
240
246
|
R[Resume files] --> P[parse_resume]
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
+
P -- application_id --> S
|
|
248
|
+
T[Resume texts] --> S
|
|
249
|
+
J -. review criteria first .-> C[extract_job_criteria]
|
|
250
|
+
C -. job_id .-> S
|
|
251
|
+
D[(Your talent pool)] --> Q[search_resumes]
|
|
252
|
+
Q -- application_ids --> S
|
|
253
|
+
S --> L[Shortlist with evidence]
|
|
247
254
|
```
|
|
248
255
|
|
|
249
256
|
<details>
|
|
250
|
-
<summary><b>Example output from <code>
|
|
257
|
+
<summary><b>Example output from <code>score_resumes</code></b></summary>
|
|
258
|
+
|
|
259
|
+
Trimmed to one resume and one criterion:
|
|
251
260
|
|
|
252
261
|
```json
|
|
253
262
|
{
|
|
254
|
-
"
|
|
255
|
-
|
|
256
|
-
|
|
263
|
+
"job": {
|
|
264
|
+
"job_id": "job_8f14e45fceea167a5a36dedd4bea2543",
|
|
265
|
+
"title": "Senior React Developer",
|
|
266
|
+
"language": "en"
|
|
267
|
+
},
|
|
268
|
+
"precise_ranking": true,
|
|
269
|
+
"scores": [
|
|
257
270
|
{
|
|
258
|
-
"
|
|
259
|
-
"
|
|
260
|
-
"
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
"
|
|
271
|
+
"rank": 1,
|
|
272
|
+
"application_id": "text_c4ca4238a0b923820dcc509a6f75849b",
|
|
273
|
+
"reference": "alex-morgan",
|
|
274
|
+
"evaluation_status": "completed",
|
|
275
|
+
"score": 0.91,
|
|
276
|
+
"verdict": "highly_relevant",
|
|
277
|
+
"summary": "Strong fit: 8 years of React and TypeScript, team lead, automated testing with Jest and Playwright. Health tech experience to confirm.",
|
|
278
|
+
"ranking_reason": "The only candidate who has led a React and TypeScript team in production.",
|
|
279
|
+
"required_missing": [],
|
|
280
|
+
"to_verify": [],
|
|
281
|
+
"criteria": [
|
|
282
|
+
{
|
|
283
|
+
"criterion_id": "crit_2",
|
|
284
|
+
"label": "React experience",
|
|
285
|
+
"requirement": "required",
|
|
286
|
+
"weight": 3,
|
|
287
|
+
"status": "met",
|
|
288
|
+
"score": 1,
|
|
289
|
+
"evidence": "Lead Front-End Engineer, Medisoft: 'Lead a team of 4 developers on a React 18 + TypeScript appointment platform'",
|
|
290
|
+
"explanation": "Over 3 years of React, the last ones leading a team on a production platform."
|
|
291
|
+
}
|
|
292
|
+
]
|
|
264
293
|
}
|
|
265
|
-
]
|
|
294
|
+
],
|
|
295
|
+
"credits_used": 3
|
|
266
296
|
}
|
|
267
297
|
```
|
|
268
298
|
|
|
269
|
-
Criteria
|
|
299
|
+
Criteria, summaries and explanations are written in the job's language (English, French, German, Spanish or Portuguese; English for any other) unless you pass `language`; evidence quotes the resume in its own language.
|
|
270
300
|
|
|
271
301
|
</details>
|
|
272
302
|
|
|
@@ -275,10 +305,13 @@ Criteria labels, rationales, summaries and explanations are written in French. Y
|
|
|
275
305
|
**Recruiters and hiring managers**
|
|
276
306
|
|
|
277
307
|
- "Parse `~/Downloads/jane-doe.pdf` and summarize her experience in five bullet points."
|
|
278
|
-
- "Here is our job description for a Senior Data Engineer. Extract the criteria, then tell me which ones are
|
|
308
|
+
- "Here is our job description for a Senior Data Engineer. Extract the criteria, then tell me which ones are required."
|
|
279
309
|
- "Score the resumes in `~/candidates/` against this job and give me a shortlist table with scores and main gaps."
|
|
280
|
-
- "Rank these 8 candidates for the Account Executive role and explain why the top 3 stand out."
|
|
281
|
-
- "Does this candidate meet every
|
|
310
|
+
- "Rank these 8 candidates for the Account Executive role with a precise ranking and explain why the top 3 stand out."
|
|
311
|
+
- "Does this candidate meet every required criterion? If not, which ones are missing?"
|
|
312
|
+
- "Which candidates already in my HireLayer database fit this job? Keep those within 50 km of Lyon."
|
|
313
|
+
- "Which of our open jobs suit this candidate?"
|
|
314
|
+
- "For our client Acme Corp (workspace `acme-corp`), score these resumes against their Product Manager job."
|
|
282
315
|
|
|
283
316
|
**Developers and HR tech teams**
|
|
284
317
|
|
|
@@ -292,18 +325,27 @@ Criteria labels, rationales, summaries and explanations are written in French. Y
|
|
|
292
325
|
|
|
293
326
|
## Pricing and credits
|
|
294
327
|
|
|
295
|
-
|
|
328
|
+
| Tool | Credits |
|
|
329
|
+
|---|---|
|
|
330
|
+
| `parse_resume` | 1 per resume, 0 for a file already stored |
|
|
331
|
+
| `extract_job_criteria` | Free (a new text for a job already scored evaluates its scores again, 1 credit each) |
|
|
332
|
+
| `score_resumes` | 1 per resume evaluated, 0 for a job-resume pair already scored; 1 more per call with `precise_ranking` |
|
|
333
|
+
| `search_resumes`, `search_jobs` | 1 per search |
|
|
334
|
+
| `normalize_skills` | 1 |
|
|
335
|
+
|
|
336
|
+
Failed calls and resumes that fail are not charged, and a call refused for lack of credits processes nothing.
|
|
296
337
|
|
|
297
338
|
| Plan | Credits | Price |
|
|
298
339
|
|---|---|---|
|
|
299
340
|
| Free | 50 a month | Free, no card required |
|
|
300
|
-
| Paid plans | More credits and higher limits | See [hirelayer.co
|
|
341
|
+
| Paid plans | More credits and higher limits | See [hirelayer.co/pricing](https://hirelayer.co/pricing) |
|
|
301
342
|
|
|
302
343
|
## Data and privacy
|
|
303
344
|
|
|
304
345
|
- The hosted server receives only what your assistant sends to HireLayer tools. Each app gets access only after you sign in and allow it, and you can disconnect it at any time.
|
|
305
346
|
- The local server runs **on your machine** and calls the HireLayer API over HTTPS with your API key. It has no telemetry.
|
|
306
|
-
- `parse_resume` reads only the file you name.
|
|
347
|
+
- `parse_resume` reads only the file you name. HireLayer stores the resume in your account under its `application_id`, keeps the original file and returns a link to it in `resume.info_resume.url`. On the Enterprise plan, set `do_not_store_data: true` in a call so nothing of the parse is kept.
|
|
348
|
+
- The jobs, resumes and scores the tools process stay in your HireLayer account, in the workspace of the call, so you can score and search them again later.
|
|
307
349
|
- See the [privacy policy](https://hirelayer.co/privacy-policy) and the [security policy](SECURITY.md).
|
|
308
350
|
|
|
309
351
|
Resumes contain personal data. Use the tools in line with your hiring process and the rules that apply to you, such as GDPR. Scores support human decisions; they don't replace them.
|
|
@@ -321,8 +363,10 @@ Resumes contain personal data. Use the tools in line with your hiring process an
|
|
|
321
363
|
|---|---|
|
|
322
364
|
| `HIRELAYER_API_KEY is not set` | Add the `env` block with your key to the client config, then restart the client. |
|
|
323
365
|
| `HireLayer API returned 401` | The key is wrong or revoked. Copy it again from **Dashboard → API keys**. |
|
|
324
|
-
| `HireLayer API returned 403` | You have used your monthly credits. Wait for the reset or upgrade your plan. |
|
|
325
|
-
|
|
|
366
|
+
| `HireLayer API returned 403 (insufficient_credits)` | You have used your monthly credits. Wait for the reset or upgrade your plan. |
|
|
367
|
+
| `HireLayer API returned 403 (enterprise_plan_required)` | `do_not_store_data` needs the Enterprise plan. Call again without it if storing the resume is fine. |
|
|
368
|
+
| `HireLayer API returned 410` | You run a 1.x version, which calls retired endpoints. Update to the latest version with `npx -y hirelayer-mcp@latest`. |
|
|
369
|
+
| Parsing or scoring seems slow | Parsing usually takes about 30 seconds, and longer for scans that need OCR; scoring 50 resumes takes about a minute. The server sends progress updates so clients don't time out. |
|
|
326
370
|
| `npx` not found or an old Node.js | Install Node.js 20 or later from [nodejs.org](https://nodejs.org). |
|
|
327
371
|
| The file is not found | Use an absolute path, for example `/Users/me/Downloads/cv.pdf` rather than `~/Downloads/cv.pdf`. |
|
|
328
372
|
|
|
@@ -335,13 +379,13 @@ HIRELAYER_API_KEY=your-api-key npx @modelcontextprotocol/inspector npx -y hirela
|
|
|
335
379
|
## FAQ
|
|
336
380
|
|
|
337
381
|
**Is HireLayer an ATS?**
|
|
338
|
-
No. HireLayer provides the AI building blocks of recruiting software: resume parsing,
|
|
382
|
+
No. HireLayer provides the AI building blocks of recruiting software: resume parsing, scoring and ranking, talent pool search and skills. Use them on their own through MCP, or plug them into your ATS or HR tech product through the [REST API](https://hirelayer.co/api-docs).
|
|
339
383
|
|
|
340
384
|
**Which language are the results in?**
|
|
341
|
-
The text that HireLayer writes (criteria
|
|
385
|
+
The text that HireLayer writes (criteria, summaries, explanations and ranking reasons) is in the job's language: English, French, German, Spanish or Portuguese, and English for a job written in another language. Choose it with `language` (`en`, `fr`, `de`, `es` or `pt`). Evidence quotes the resume in its own language. Skills normalization returns French or English labels.
|
|
342
386
|
|
|
343
387
|
**Which resume languages are supported?**
|
|
344
|
-
The parser detects the main language of each resume and returns it in `info_resume.language`.
|
|
388
|
+
The parser detects the main language of each resume and returns it in `resume.info_resume.language`.
|
|
345
389
|
|
|
346
390
|
**Can I use the REST API directly?**
|
|
347
391
|
Yes. See the [API reference](https://hirelayer.co/api-docs), the [OpenAPI spec](https://hirelayer.co/openapi.json) and [`llms.txt`](https://hirelayer.co/llms.txt) for agents.
|
package/dist/client.js
CHANGED
|
@@ -3,74 +3,124 @@ import { createRequire } from 'node:module';
|
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
4
|
export const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
5
5
|
const DEFAULT_BASE_URL = 'https://hirelayer.co';
|
|
6
|
+
const API_PREFIX = '/api/v4';
|
|
7
|
+
/** Keeps one client company's jobs, resumes and scores apart from another's. */
|
|
8
|
+
export const WORKSPACE_HEADER = 'HireLayer-Workspace';
|
|
6
9
|
// The API rejects resumes above 4.5 MB.
|
|
7
10
|
export const MAX_RESUME_BYTES = 4.5 * 1024 * 1024;
|
|
8
11
|
// 502 and 503 are transient and never charged, so one retry is safe.
|
|
9
12
|
const RETRYABLE_STATUSES = new Set([502, 503]);
|
|
10
13
|
const MAX_RETRY_DELAY_MS = 10_000;
|
|
14
|
+
// A scoring call whose evaluations outlast about 100 seconds answers 202 with a results_url,
|
|
15
|
+
// read until no score is pending, for this long at most.
|
|
16
|
+
const MAX_SCORING_WAIT_MS = 10 * 60_000;
|
|
11
17
|
export class HireLayerError extends Error {
|
|
12
18
|
status;
|
|
13
|
-
|
|
19
|
+
code;
|
|
20
|
+
param;
|
|
21
|
+
requestId;
|
|
22
|
+
constructor(message, status,
|
|
23
|
+
/** The code of the API error, such as insufficient_credits; undefined when the API sent none. */
|
|
24
|
+
code,
|
|
25
|
+
/** The input at fault, such as job.criteria, when the API names one. */
|
|
26
|
+
param,
|
|
27
|
+
/** The id of the failed call, to quote to HireLayer support. */
|
|
28
|
+
requestId) {
|
|
14
29
|
super(message);
|
|
15
30
|
this.status = status;
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.param = param;
|
|
33
|
+
this.requestId = requestId;
|
|
16
34
|
this.name = 'HireLayerError';
|
|
17
35
|
}
|
|
18
36
|
}
|
|
37
|
+
/** The languages a job's criteria, summaries and explanations are written in. */
|
|
38
|
+
export const JOB_LANGUAGES = ['en', 'fr', 'de', 'es', 'pt'];
|
|
19
39
|
export class HireLayerClient {
|
|
20
40
|
apiKey;
|
|
21
41
|
baseUrl;
|
|
22
42
|
fetch;
|
|
23
43
|
retryDelayMs;
|
|
24
|
-
|
|
44
|
+
pollIntervalMs;
|
|
45
|
+
constructor({ apiKey, baseUrl, fetch: fetchImpl, retryDelayMs, pollIntervalMs }) {
|
|
25
46
|
this.apiKey = apiKey;
|
|
26
47
|
this.baseUrl = (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
27
48
|
this.fetch = fetchImpl ?? globalThis.fetch;
|
|
28
49
|
this.retryDelayMs = retryDelayMs ?? 2_000;
|
|
50
|
+
this.pollIntervalMs = pollIntervalMs ?? 5_000;
|
|
29
51
|
}
|
|
52
|
+
/** Resume Parser: POST /resumes/parse. */
|
|
30
53
|
async parseResume(source, options = {}) {
|
|
31
|
-
const { bytes, filename } = await this.loadResume(source, options.signal);
|
|
54
|
+
const { bytes, filename, type } = await this.loadResume(source, options.signal);
|
|
32
55
|
const form = new FormData();
|
|
33
|
-
form.append('file', new Blob([bytes]), filename);
|
|
56
|
+
form.append('file', new Blob([bytes], type ? { type } : {}), filename);
|
|
34
57
|
if (options.applicationId)
|
|
35
58
|
form.append('application_id', options.applicationId);
|
|
36
59
|
if (options.doNotStoreData !== undefined) {
|
|
37
60
|
form.append('do_not_store_data', String(options.doNotStoreData));
|
|
38
61
|
}
|
|
39
|
-
return this.request('/
|
|
62
|
+
return this.request('/resumes/parse', { method: 'POST', body: form, signal: options.signal }, options.workspace);
|
|
40
63
|
}
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
/** Job Criteria Extractor: POST /jobs/extract-criteria. */
|
|
65
|
+
extractJobCriteria(body, options = {}) {
|
|
66
|
+
return this.postJson('/jobs/extract-criteria', body, options);
|
|
43
67
|
}
|
|
44
|
-
|
|
45
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Resume Scorer: POST /resumes/score. When the evaluations outlast the API's wait (202),
|
|
70
|
+
* reads results_url until no score is pending and returns that read: the call's resumes, best first.
|
|
71
|
+
*/
|
|
72
|
+
async scoreResumes(body, options = {}) {
|
|
73
|
+
const answer = await this.postJson('/resumes/score', body, options);
|
|
74
|
+
const resultsPath = pendingResultsPath(answer);
|
|
75
|
+
if (!resultsPath)
|
|
76
|
+
return answer;
|
|
77
|
+
const deadline = Date.now() + MAX_SCORING_WAIT_MS;
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
await sleep(this.pollIntervalMs, options.signal);
|
|
80
|
+
const read = await this.request(resultsPath, { method: 'GET', signal: options.signal }, options.workspace);
|
|
81
|
+
if (!hasPendingScores(read))
|
|
82
|
+
return read;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
...answer,
|
|
86
|
+
next_step: 'HireLayer is still scoring these resumes. Call score_resumes again later with the same job and resumes: the resumes already scored come back at no extra cost.',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Talent Pool Search: POST /resumes/search. */
|
|
90
|
+
searchResumes(body, options = {}) {
|
|
91
|
+
return this.postJson('/resumes/search', body, options);
|
|
46
92
|
}
|
|
47
|
-
|
|
48
|
-
|
|
93
|
+
/** Job Recommendations: POST /jobs/search. */
|
|
94
|
+
searchJobs(body, options = {}) {
|
|
95
|
+
return this.postJson('/jobs/search', body, options);
|
|
49
96
|
}
|
|
50
|
-
|
|
51
|
-
|
|
97
|
+
/** Skills Normalizer: POST /skills/normalize. Nothing is stored, so it takes no workspace. */
|
|
98
|
+
normalizeSkills(text, language, options = {}) {
|
|
99
|
+
return this.postJson('/skills/normalize', language ? { text, language } : { text }, options);
|
|
52
100
|
}
|
|
53
|
-
|
|
101
|
+
// JSON.stringify leaves out the fields left undefined, so the API sees only what was given.
|
|
102
|
+
postJson(path, body, { signal, workspace }) {
|
|
54
103
|
return this.request(path, {
|
|
55
104
|
method: 'POST',
|
|
56
105
|
headers: { 'Content-Type': 'application/json' },
|
|
57
106
|
body: JSON.stringify(body),
|
|
58
107
|
signal,
|
|
59
|
-
});
|
|
108
|
+
}, workspace);
|
|
60
109
|
}
|
|
61
|
-
async request(path, init, attempt = 1) {
|
|
62
|
-
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
110
|
+
async request(path, init, workspace, attempt = 1) {
|
|
111
|
+
const response = await this.fetch(`${this.baseUrl}${API_PREFIX}${path}`, {
|
|
63
112
|
...init,
|
|
64
113
|
headers: {
|
|
65
114
|
...init.headers,
|
|
66
115
|
'X-API-Key': this.apiKey,
|
|
67
116
|
'User-Agent': `hirelayer-mcp/${VERSION}`,
|
|
117
|
+
...(workspace ? { [WORKSPACE_HEADER]: workspace } : {}),
|
|
68
118
|
},
|
|
69
119
|
});
|
|
70
120
|
if (attempt === 1 && RETRYABLE_STATUSES.has(response.status) && !init.signal?.aborted) {
|
|
71
121
|
await response.body?.cancel();
|
|
72
122
|
await sleep(this.retryDelay(response.headers.get('retry-after')), init.signal);
|
|
73
|
-
return this.request(path, init, attempt + 1);
|
|
123
|
+
return this.request(path, init, workspace, attempt + 1);
|
|
74
124
|
}
|
|
75
125
|
const text = await response.text();
|
|
76
126
|
let payload = text;
|
|
@@ -80,10 +130,8 @@ export class HireLayerClient {
|
|
|
80
130
|
catch {
|
|
81
131
|
// Keep the raw text: gateways can answer with HTML or plain text.
|
|
82
132
|
}
|
|
83
|
-
if (!response.ok)
|
|
84
|
-
|
|
85
|
-
throw new HireLayerError(`HireLayer API returned ${response.status}: ${detail}${errorHint(response.status, detail)}`, response.status);
|
|
86
|
-
}
|
|
133
|
+
if (!response.ok)
|
|
134
|
+
throw apiError(response.status, payload, response.headers.get('hirelayer-request-id'));
|
|
87
135
|
return payload;
|
|
88
136
|
}
|
|
89
137
|
retryDelay(retryAfter) {
|
|
@@ -93,17 +141,19 @@ export class HireLayerClient {
|
|
|
93
141
|
}
|
|
94
142
|
return this.retryDelayMs;
|
|
95
143
|
}
|
|
96
|
-
async loadResume({ filePath, fileUrl }, signal) {
|
|
97
|
-
|
|
98
|
-
|
|
144
|
+
async loadResume({ filePath, fileUrl, text }, signal) {
|
|
145
|
+
const given = [filePath, fileUrl, text].filter((value) => value !== undefined && value !== '');
|
|
146
|
+
if (given.length !== 1) {
|
|
147
|
+
throw new HireLayerError('Provide exactly one of file_path, file_url or resume_text.');
|
|
99
148
|
}
|
|
100
149
|
let bytes;
|
|
101
150
|
let filename;
|
|
151
|
+
let type;
|
|
102
152
|
if (filePath) {
|
|
103
153
|
bytes = new Uint8Array(await readFile(filePath));
|
|
104
154
|
filename = basename(filePath);
|
|
105
155
|
}
|
|
106
|
-
else {
|
|
156
|
+
else if (fileUrl) {
|
|
107
157
|
const response = await this.fetch(fileUrl, { signal });
|
|
108
158
|
if (!response.ok) {
|
|
109
159
|
throw new HireLayerError(`Could not download ${fileUrl}: HTTP ${response.status}`);
|
|
@@ -111,18 +161,63 @@ export class HireLayerClient {
|
|
|
111
161
|
bytes = new Uint8Array(await response.arrayBuffer());
|
|
112
162
|
filename = basename(new URL(fileUrl).pathname) || 'resume';
|
|
113
163
|
}
|
|
164
|
+
else {
|
|
165
|
+
bytes = new TextEncoder().encode(text);
|
|
166
|
+
filename = 'resume.txt';
|
|
167
|
+
type = 'text/plain';
|
|
168
|
+
}
|
|
114
169
|
if (bytes.byteLength > MAX_RESUME_BYTES) {
|
|
115
170
|
throw new HireLayerError(`The resume is ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB; HireLayer accepts files under 4.5 MB.`);
|
|
116
171
|
}
|
|
117
|
-
return { bytes, filename };
|
|
172
|
+
return { bytes, filename, type };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The API answers errors as {"error": {"code", "message", "param", "doc_url"}, "request_id"}; gateways
|
|
177
|
+
* in front of it can still answer with HTML or plain text, kept as they come.
|
|
178
|
+
*/
|
|
179
|
+
function apiError(status, payload, headerRequestId) {
|
|
180
|
+
const body = payload && typeof payload === 'object' ? payload : null;
|
|
181
|
+
const error = body?.error;
|
|
182
|
+
const requestId = typeof body?.request_id === 'string' ? body.request_id : headerRequestId || undefined;
|
|
183
|
+
const support = requestId ? ` (request_id ${requestId})` : '';
|
|
184
|
+
if (error && typeof error === 'object') {
|
|
185
|
+
const { code, message, param } = error;
|
|
186
|
+
const errorCode = typeof code === 'string' ? code : undefined;
|
|
187
|
+
const errorParam = typeof param === 'string' ? param : undefined;
|
|
188
|
+
const label = [errorCode, errorParam && `param ${errorParam}`].filter(Boolean).join(', ');
|
|
189
|
+
const text = typeof message === 'string' ? message : JSON.stringify(error);
|
|
190
|
+
return new HireLayerError(`HireLayer API returned ${status}${label ? ` (${label})` : ''}: ${text}${errorHint(status, errorCode, text)}${support}`, status, errorCode, errorParam, requestId);
|
|
118
191
|
}
|
|
192
|
+
const detail = typeof payload === 'string' ? payload.slice(0, 500) : JSON.stringify(payload);
|
|
193
|
+
return new HireLayerError(`HireLayer API returned ${status}: ${detail}${errorHint(status, undefined, detail)}${support}`, status, undefined, undefined, requestId);
|
|
119
194
|
}
|
|
120
|
-
|
|
195
|
+
/** The path of results_url under /api/v4 when a scoring call answered 202 without a webhook, else null. */
|
|
196
|
+
function pendingResultsPath(answer) {
|
|
197
|
+
if (!answer || typeof answer !== 'object')
|
|
198
|
+
return null;
|
|
199
|
+
const { status, results_url: resultsUrl, webhook_url: webhookUrl } = answer;
|
|
200
|
+
if (status !== 'pending' || typeof resultsUrl !== 'string' || webhookUrl)
|
|
201
|
+
return null;
|
|
202
|
+
// Read through the client's own base URL: results_url names the production host.
|
|
203
|
+
const url = new URL(resultsUrl);
|
|
204
|
+
if (!url.pathname.startsWith(`${API_PREFIX}/`))
|
|
205
|
+
return null;
|
|
206
|
+
return `${url.pathname.slice(API_PREFIX.length)}${url.search}`;
|
|
207
|
+
}
|
|
208
|
+
function hasPendingScores(read) {
|
|
209
|
+
const data = read && typeof read === 'object' ? read.data : undefined;
|
|
210
|
+
return Array.isArray(data) && data.some((score) => score?.evaluation_status === 'pending');
|
|
211
|
+
}
|
|
212
|
+
function errorHint(status, code, detail) {
|
|
121
213
|
if (status === 401) {
|
|
122
214
|
return ' Check HIRELAYER_API_KEY: copy your key from https://hirelayer.co/dashboard/api-keys.';
|
|
123
215
|
}
|
|
124
|
-
if (status === 403 && /credit/i.test(detail)) {
|
|
125
|
-
return ' Credits reset every month;
|
|
216
|
+
if (code === 'insufficient_credits' || (status === 403 && !code && /credit/i.test(detail))) {
|
|
217
|
+
return ' Credits reset every month; more at https://hirelayer.co/pricing.';
|
|
218
|
+
}
|
|
219
|
+
if (code === 'enterprise_plan_required') {
|
|
220
|
+
return ' do_not_store_data needs the Enterprise plan. Without it HireLayer stores the resume: check with the user before parsing again without the option.';
|
|
126
221
|
}
|
|
127
222
|
return '';
|
|
128
223
|
}
|
package/dist/server.js
CHANGED
|
@@ -1,33 +1,87 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { VERSION } from './client.js';
|
|
3
|
+
import { HireLayerError, JOB_LANGUAGES, VERSION } from './client.js';
|
|
4
4
|
// Clients drop calls that stay silent too long (often after 60 s); parsing takes about
|
|
5
|
-
//
|
|
5
|
+
// 30 s, scans needing OCR and scoring 50 resumes longer, so long calls report progress at this interval.
|
|
6
6
|
const PROGRESS_INTERVAL_MS = 10_000;
|
|
7
|
-
const INSTRUCTIONS = `HireLayer gives you recruiting tools:
|
|
7
|
+
const INSTRUCTIONS = `HireLayer gives you recruiting tools, one per product: Resume Parser (parse_resume), Job Criteria Extractor (extract_job_criteria), Resume Scorer (score_resumes), Talent Pool Search (search_resumes), Job Recommendations (search_jobs) and Skills Normalizer (normalize_skills).
|
|
8
8
|
|
|
9
9
|
Typical workflows:
|
|
10
|
-
- Screen candidates for a job:
|
|
11
|
-
-
|
|
12
|
-
-
|
|
10
|
+
- Screen candidates for a job: score_resumes with the job description (job_text) and the resumes (their text, or the application_id of resumes already parsed). It extracts the job's criteria on the way and returns every resume scored, best first. Set precise_ranking to re-rank the 10 best against each other, with a one-sentence reason each.
|
|
11
|
+
- Review or edit the criteria first: extract_job_criteria, then score_resumes with the job_id it returned, or with job_text and your edited criteria.
|
|
12
|
+
- Read a resume: parse_resume returns contact details, experience, education, languages, skills and the full text in resume.info_resume.text, stored under application_id.
|
|
13
|
+
- Find candidates already in the user's resume database for a job: search_resumes, then score_resumes on the application_ids it returns.
|
|
14
|
+
- Find stored open jobs for a candidate: search_jobs.
|
|
15
|
+
- Normalize skills: normalize_skills maps free-text skills to taxonomy skills, named in French or English.
|
|
16
|
+
|
|
17
|
+
Resume input:
|
|
18
|
+
- parse_resume reads a local file (file_path, an absolute path), downloads a public file_url, or takes the resume text as resume_text.
|
|
19
|
+
- score_resumes and search_jobs take resume text directly: when you can already read the resume, pass its text instead of parsing it first. For a resume file, parse it with parse_resume and pass the application_id it returns.
|
|
13
20
|
|
|
14
21
|
Notes:
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
22
|
+
- Criteria, summaries and explanations are written in the job's language (English, French, German, Spanish or Portuguese; English for any other) unless language says otherwise; evidence quotes the resume in its own language.
|
|
23
|
+
- A job text holds at least 30 characters, and score_resumes needs at least one resume of 30 characters or more.
|
|
24
|
+
- Job and resume texts are accepted up to 50,000 characters each: truncate longer ones. score_resumes takes up to 50 resumes per call.
|
|
25
|
+
- HireLayer keeps the jobs, resumes and scores in the user's account, as the REST API does, unless the workspace runs without storage. A pair already scored is not evaluated again and costs nothing.
|
|
26
|
+
- Pass do_not_store_data: true to parse_resume when the user does not want HireLayer to keep the resume (Enterprise plan). Such a resume gets no application_id: score it by its text.
|
|
27
|
+
- When the user recruits for several client companies, pass the client's workspace id so each client's jobs and candidates stay apart. Reuse the exact id the user gives: a new id creates a new workspace.
|
|
28
|
+
- Credits: parse_resume 1 (0 for a file already stored), extract_job_criteria free (a new text under a job_id already scored re-evaluates its scores, 1 credit each), score_resumes 1 per resume evaluated and 1 more with precise_ranking, search_resumes and search_jobs 1 per search, normalize_skills 1. A parse takes about 30 seconds; scoring 50 resumes about a minute.
|
|
29
|
+
- Scores support human hiring decisions; they never replace them.`;
|
|
30
|
+
// The ids HireLayer accepts for resumes, jobs and workspaces.
|
|
31
|
+
const CLIENT_ID_PATTERN = /^[A-Za-z0-9._:~-]{1,200}$/;
|
|
32
|
+
const CLIENT_ID_RULE = 'letters, digits and . _ : ~ -, up to 200 characters';
|
|
33
|
+
const isClientId = (value) => CLIENT_ID_PATTERN.test(value) && !/^\.\.?$/.test(value);
|
|
34
|
+
const APPLICATION_ID_MESSAGE = `application_id must contain ${CLIENT_ID_RULE}`;
|
|
35
|
+
const WORKSPACE_MESSAGE = `workspace must contain ${CLIENT_ID_RULE}`;
|
|
36
|
+
const JOB_ID_MESSAGE = `job_id must contain ${CLIENT_ID_RULE}`;
|
|
37
|
+
const APPLICATION_ID_RULE = z
|
|
38
|
+
.string()
|
|
39
|
+
.regex(CLIENT_ID_PATTERN, APPLICATION_ID_MESSAGE)
|
|
40
|
+
.refine(isClientId, APPLICATION_ID_MESSAGE);
|
|
41
|
+
const JOB_ID_RULE = z.string().regex(CLIENT_ID_PATTERN, JOB_ID_MESSAGE).refine(isClientId, JOB_ID_MESSAGE);
|
|
42
|
+
// The rule of the HireLayer-Workspace header, which the API also checks before any work.
|
|
43
|
+
const workspaceArg = z
|
|
44
|
+
.string()
|
|
45
|
+
.regex(CLIENT_ID_PATTERN, WORKSPACE_MESSAGE)
|
|
46
|
+
.optional()
|
|
47
|
+
.describe(`The client company this call belongs to, when the user recruits for several companies: its HireLayer workspace id (${CLIENT_ID_RULE}), e.g. acme-corp. Jobs, resumes and scores stay apart from other clients'. A new id creates a new workspace, so reuse the exact id. Omit it for the user's default workspace.`);
|
|
48
|
+
const languageArg = z
|
|
49
|
+
.enum(JOB_LANGUAGES)
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Language of the criteria, summaries and explanations: en, fr, de, es or pt. Defaults to the language of the job's text (English when it is another).");
|
|
52
|
+
// The API refuses a job text under 30 characters.
|
|
53
|
+
const jobText = z
|
|
54
|
+
.string()
|
|
55
|
+
.trim()
|
|
56
|
+
.min(30, 'A job text holds at least 30 characters: send the full job description.')
|
|
57
|
+
.max(50000);
|
|
58
|
+
const resumeText = z.string().min(1).max(50000);
|
|
59
|
+
// The format extract_job_criteria returns; criterion_id and source, sent back, are ignored by the API.
|
|
19
60
|
const criterion = z.object({
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
61
|
+
label: z.string().min(1).max(300).describe('What is evaluated.'),
|
|
62
|
+
type: z
|
|
63
|
+
.enum(['skill', 'experience', 'education', 'language', 'certification', 'location', 'soft_skill', 'other'])
|
|
64
|
+
.optional(),
|
|
65
|
+
requirement: z
|
|
66
|
+
.enum(['required', 'preferred', 'bonus'])
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('required: a resume that clearly lacks it loses 25 % of its score. preferred and bonus count through their weight only.'),
|
|
69
|
+
weight: z.number().int().min(0).max(3).optional().describe('3 essential, 2 important, 1 nice to have, 0 off.'),
|
|
70
|
+
threshold: z
|
|
71
|
+
.string()
|
|
72
|
+
.max(100)
|
|
73
|
+
.nullable()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe('Minimum level in short text, such as 3 years or B2.'),
|
|
25
76
|
});
|
|
26
|
-
// The tools
|
|
27
|
-
|
|
77
|
+
// The tools read and analyse the data they receive and call the HireLayer API (open world).
|
|
78
|
+
// Every product but normalize_skills stores what it processes in the user's HireLayer account,
|
|
79
|
+
// as the REST API does, unless the workspace runs without storage: they are not read-only.
|
|
80
|
+
// No tool deletes anything.
|
|
81
|
+
const hints = { destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
28
82
|
// Directories (Claude, ChatGPT) read the human-readable name from annotations.title.
|
|
29
|
-
function annotations(title) {
|
|
30
|
-
return { title, ...
|
|
83
|
+
function annotations(title, options = {}) {
|
|
84
|
+
return { title, readOnlyHint: !options.writes, ...hints };
|
|
31
85
|
}
|
|
32
86
|
export function createServer(client) {
|
|
33
87
|
const server = new McpServer({
|
|
@@ -52,77 +106,168 @@ export function createServer(client) {
|
|
|
52
106
|
}
|
|
53
107
|
};
|
|
54
108
|
server.registerTool('parse_resume', {
|
|
55
|
-
title: '
|
|
56
|
-
description:
|
|
109
|
+
title: 'Resume Parser',
|
|
110
|
+
description: "Parse a resume / CV (PDF, DOC, DOCX, ODT, PPT, PPTX, ODP, XLS, RTF, TXT, HTML, Markdown, JPG, PNG or BMP, under 4.5 MB) into structured JSON, in resume: contact details, work experience, education, languages, skills and the full resume text in resume.info_resume.text. Give exactly one input: file_path (a local file), file_url (a public URL) or resume_text. HireLayer keeps the resume in the user's resume database under application_id, unless do_not_store_data is set. Takes about 30 seconds. Costs 1 HireLayer credit, 0 for a file already stored.",
|
|
57
111
|
inputSchema: {
|
|
58
112
|
file_path: z.string().optional().describe('Absolute path of a local resume file.'),
|
|
59
113
|
file_url: z.string().url().optional().describe('Public URL of a resume file to download.'),
|
|
60
|
-
|
|
114
|
+
resume_text: z
|
|
115
|
+
.string()
|
|
116
|
+
.min(1)
|
|
117
|
+
.max(100000)
|
|
118
|
+
.optional()
|
|
119
|
+
.describe('Resume as plain text, when no file is available.'),
|
|
120
|
+
application_id: APPLICATION_ID_RULE.optional().describe(`Your id for this resume, such as the application or candidate id of your ATS (${CLIENT_ID_RULE}). The resume is stored under it, replacing a resume stored under the same id. Omit it when you have no such id: the resume is stored under an id drawn from the file.`),
|
|
61
121
|
do_not_store_data: z
|
|
62
122
|
.boolean()
|
|
63
123
|
.optional()
|
|
64
|
-
.describe('true: HireLayer
|
|
124
|
+
.describe('true: HireLayer keeps nothing of this parse and application_id comes back null. Enterprise plan only.'),
|
|
125
|
+
workspace: workspaceArg,
|
|
65
126
|
},
|
|
66
|
-
annotations: annotations('
|
|
67
|
-
}, ({ file_path, file_url, application_id, do_not_store_data }, extra) => run(extra, (signal) => client.parseResume({ filePath: file_path, fileUrl: file_url }, { applicationId: application_id, doNotStoreData: do_not_store_data, signal })));
|
|
127
|
+
annotations: annotations('Resume Parser', { writes: true }),
|
|
128
|
+
}, ({ file_path, file_url, resume_text, application_id, do_not_store_data, workspace }, extra) => run(extra, (signal) => client.parseResume({ filePath: file_path, fileUrl: file_url, text: resume_text }, { applicationId: application_id, doNotStoreData: do_not_store_data, workspace, signal })));
|
|
68
129
|
server.registerTool('extract_job_criteria', {
|
|
69
|
-
title: '
|
|
70
|
-
description:
|
|
130
|
+
title: 'Job Criteria Extractor',
|
|
131
|
+
description: "Turn a job description (any language, at least 30 characters) into the typed criteria it states (usually 4 to 10, fewer for a short posting), in the posting's order, each with a criterion_id, a label, a type, a requirement (required, preferred or bonus), a weight from 0 to 3 and an optional threshold. They are written in the job's language (English, French, German, Spanish or Portuguese; English for any other) unless language says otherwise. The job is stored under job_id, so score_resumes can reuse it. Free, except a new text sent under the job_id of a job already scored: its scores are evaluated again, 1 credit each.",
|
|
71
132
|
inputSchema: {
|
|
72
|
-
job_text:
|
|
133
|
+
job_text: jobText.describe('Full job description, as published: at least 30 characters.'),
|
|
134
|
+
title: z.string().max(300).optional().describe('Job title.'),
|
|
135
|
+
location: z.string().max(300).optional().describe('Where the job is based.'),
|
|
136
|
+
language: languageArg,
|
|
137
|
+
job_id: JOB_ID_RULE.optional().describe(`Your id for this job (${CLIENT_ID_RULE}). Omit it: the job is stored under an id drawn from its text.`),
|
|
138
|
+
workspace: workspaceArg,
|
|
73
139
|
},
|
|
74
|
-
annotations: annotations('
|
|
75
|
-
}, ({ job_text }, extra) => run(extra, (signal) => client.extractJobCriteria(job_text, { signal })));
|
|
76
|
-
server.registerTool('
|
|
77
|
-
title: '
|
|
78
|
-
description:
|
|
140
|
+
annotations: annotations('Job Criteria Extractor', { writes: true }),
|
|
141
|
+
}, ({ job_text, title, location, language, job_id, workspace }, extra) => run(extra, (signal) => client.extractJobCriteria({ text: job_text, title, location, language, job_id }, { workspace, signal })));
|
|
142
|
+
server.registerTool('score_resumes', {
|
|
143
|
+
title: 'Resume Scorer',
|
|
144
|
+
description: "Evaluate 1 to 50 resumes against a job, criterion by criterion, and return them best first: for each, a score between 0 and 1, a verdict (highly_relevant, relevant, marginally_relevant or not_relevant), a summary, the required criteria missing or to verify, and the evidence for each criterion. Give the job as job_text (its criteria are extracted on the way, or pass your own criteria) or as the job_id of a stored job. Give each resume as text or as the application_id of a stored resume; at least one resume of the call holds 30 characters or more. Summaries and explanations are written in the job's language. With precise_ranking, the 10 best are re-ranked against each other, each with a ranking_reason. The result is decision support for a human recruiter, not an automated hiring decision. Costs 1 HireLayer credit per resume evaluated, and 1 more with precise_ranking; a pair already scored costs 0.",
|
|
79
145
|
inputSchema: {
|
|
80
|
-
job_text:
|
|
81
|
-
|
|
82
|
-
|
|
146
|
+
job_text: jobText.optional().describe('The job description, at least 30 characters. Give job_text or job_id.'),
|
|
147
|
+
job_id: JOB_ID_RULE.optional().describe('Alone: a job already stored, such as the job_id extract_job_criteria returned. With job_text: store the job under your own id; a new text or new criteria for a job already scored evaluate its stored scores again, 1 credit each.'),
|
|
148
|
+
criteria: z
|
|
83
149
|
.array(criterion)
|
|
84
|
-
.
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
title: 'Rank candidates for a job',
|
|
91
|
-
description: 'Rank up to 10 candidates against the same job description, from their resume texts: returns each candidate with a rank, a score between 0 and 1 and a rationale (written in French). Costs 1 HireLayer credit per call, whatever the number of candidates.',
|
|
92
|
-
inputSchema: {
|
|
93
|
-
job_text: z.string().min(1).max(50000).describe('Job description.'),
|
|
94
|
-
candidates: z
|
|
150
|
+
.min(1)
|
|
151
|
+
.max(20)
|
|
152
|
+
.optional()
|
|
153
|
+
.describe("The job's own criteria, in the format extract_job_criteria returns; they replace the extraction. Only with job_text."),
|
|
154
|
+
language: languageArg.describe("Language of the criteria, summaries and explanations: en, fr, de, es or pt. Only with job_text; defaults to the language of the job's text (English when it is another)."),
|
|
155
|
+
resumes: z
|
|
95
156
|
.array(z.object({
|
|
96
|
-
|
|
97
|
-
|
|
157
|
+
text: resumeText.optional().describe('The resume as plain text.'),
|
|
158
|
+
application_id: APPLICATION_ID_RULE.optional().describe('Alone: a resume already stored, such as the application_id parse_resume returned. With text: store the resume under your own id.'),
|
|
159
|
+
reference: z.string().max(200).optional().describe('Your label for this resume, returned as is.'),
|
|
98
160
|
}))
|
|
99
161
|
.min(1)
|
|
100
|
-
.max(
|
|
162
|
+
.max(50)
|
|
163
|
+
.describe('The resumes to score. Each needs text or application_id.'),
|
|
164
|
+
precise_ranking: z
|
|
165
|
+
.boolean()
|
|
166
|
+
.optional()
|
|
167
|
+
.describe('Re-rank the 10 best against each other and explain each place in ranking_reason. 1 credit more for the call.'),
|
|
168
|
+
workspace: workspaceArg,
|
|
101
169
|
},
|
|
102
|
-
annotations: annotations('
|
|
103
|
-
}, (args, extra) => run(extra, (signal) =>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
170
|
+
annotations: annotations('Resume Scorer', { writes: true }),
|
|
171
|
+
}, (args, extra) => run(extra, async (signal) => {
|
|
172
|
+
const job = scoreJob(args);
|
|
173
|
+
const resumes = args.resumes.map((resume, index) => {
|
|
174
|
+
if (resume.text !== undefined) {
|
|
175
|
+
return { text: resume.text, application_id: resume.application_id, reference: resume.reference };
|
|
176
|
+
}
|
|
177
|
+
if (resume.application_id !== undefined) {
|
|
178
|
+
return { application_id: resume.application_id, reference: resume.reference };
|
|
179
|
+
}
|
|
180
|
+
throw new HireLayerError(`resumes[${index}] needs text or application_id.`);
|
|
181
|
+
});
|
|
182
|
+
return client.scoreResumes({ job, resumes, precise_ranking: args.precise_ranking }, { workspace: args.workspace, signal });
|
|
183
|
+
}));
|
|
184
|
+
server.registerTool('search_resumes', {
|
|
185
|
+
title: 'Talent Pool Search',
|
|
186
|
+
description: "Find the resumes of the user's resume database closest to a job, by proximity only: application_ids with a similarity between 0 and 1, best first, and whether each is already scored for the job. No evaluation: pass the ones worth it to score_resumes. Give the job as the job_id of a stored job or as job_text (at least 30 characters; the job is then stored). Costs 1 HireLayer credit per search.",
|
|
187
|
+
inputSchema: {
|
|
188
|
+
job_id: JOB_ID_RULE.optional().describe('A stored job. Give job_id or job_text.'),
|
|
189
|
+
job_text: jobText.optional().describe('A job description not stored yet, at least 30 characters.'),
|
|
190
|
+
limit: z.number().int().min(1).max(500).optional().describe('How many resumes to return, 50 by default.'),
|
|
191
|
+
city: z
|
|
192
|
+
.string()
|
|
193
|
+
.min(1)
|
|
194
|
+
.optional()
|
|
195
|
+
.describe('Keep the resumes located around this city. Resumes without a known location are left out.'),
|
|
196
|
+
country_code: z
|
|
197
|
+
.string()
|
|
198
|
+
.regex(/^[A-Z]{2}$/)
|
|
199
|
+
.optional()
|
|
200
|
+
.describe("The city's country, ISO 3166-1 alpha-2 such as FR."),
|
|
201
|
+
radius_km: z.number().int().min(1).max(500).optional().describe('Around the city, 30 km by default.'),
|
|
202
|
+
added_after: z
|
|
203
|
+
.string()
|
|
204
|
+
.datetime({ offset: true })
|
|
205
|
+
.optional()
|
|
206
|
+
.describe('Keep the resumes added after this date (ISO 8601).'),
|
|
207
|
+
workspace: workspaceArg,
|
|
208
|
+
},
|
|
209
|
+
annotations: annotations('Talent Pool Search', { writes: true }),
|
|
210
|
+
}, (args, extra) => run(extra, async (signal) => {
|
|
211
|
+
if ((args.job_id === undefined) === (args.job_text === undefined)) {
|
|
212
|
+
throw new HireLayerError('Give exactly one of job_id or job_text.');
|
|
213
|
+
}
|
|
214
|
+
if (args.city === undefined && args.radius_km !== undefined) {
|
|
215
|
+
throw new HireLayerError('radius_km needs city.');
|
|
216
|
+
}
|
|
217
|
+
const location = args.city === undefined
|
|
218
|
+
? undefined
|
|
219
|
+
: { city: args.city, country_code: args.country_code, radius_km: args.radius_km };
|
|
220
|
+
const filters = location || args.added_after ? { location, added_after: args.added_after } : undefined;
|
|
221
|
+
return client.searchResumes({
|
|
222
|
+
job_id: args.job_id,
|
|
223
|
+
job: args.job_text === undefined ? undefined : { text: args.job_text },
|
|
224
|
+
limit: args.limit,
|
|
225
|
+
filters,
|
|
226
|
+
}, { workspace: args.workspace, signal });
|
|
227
|
+
}));
|
|
228
|
+
server.registerTool('search_jobs', {
|
|
229
|
+
title: 'Job Recommendations',
|
|
230
|
+
description: "Find the user's stored open jobs closest to a resume, by proximity only: job_ids with their title and a similarity between 0 and 1, best first, and whether the resume is already scored for each. No evaluation: score the ones worth it with score_resumes. Give the resume as the application_id of a stored resume or as resume_text (the resume is then stored). Costs 1 HireLayer credit per search.",
|
|
231
|
+
inputSchema: {
|
|
232
|
+
application_id: APPLICATION_ID_RULE.optional().describe('A stored resume. Give application_id or resume_text.'),
|
|
233
|
+
resume_text: resumeText.optional().describe('A resume not stored yet, as plain text.'),
|
|
234
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many jobs to return, 10 by default.'),
|
|
235
|
+
workspace: workspaceArg,
|
|
236
|
+
},
|
|
237
|
+
annotations: annotations('Job Recommendations', { writes: true }),
|
|
238
|
+
}, (args, extra) => run(extra, async (signal) => {
|
|
239
|
+
if ((args.application_id === undefined) === (args.resume_text === undefined)) {
|
|
240
|
+
throw new HireLayerError('Give exactly one of application_id or resume_text.');
|
|
241
|
+
}
|
|
242
|
+
return client.searchJobs({
|
|
243
|
+
application_id: args.application_id,
|
|
244
|
+
resume: args.resume_text === undefined ? undefined : { text: args.resume_text },
|
|
245
|
+
limit: args.limit,
|
|
246
|
+
}, { workspace: args.workspace, signal });
|
|
247
|
+
}));
|
|
248
|
+
server.registerTool('normalize_skills', {
|
|
249
|
+
title: 'Skills Normalizer',
|
|
250
|
+
description: 'Map free-text skills (one skill, a compound string or a whole skills section) to skills of the HireLayer taxonomy, with their IDs, families and domains, named in French or English. Unmatched parts are listed in unresolved. Costs 1 HireLayer credit.',
|
|
107
251
|
inputSchema: {
|
|
108
252
|
text: z.string().min(1).max(5000).describe('Free text containing one or more skills.'),
|
|
109
253
|
language: z.enum(['fr', 'en']).optional().describe('Language of the labels returned: fr (default) or en.'),
|
|
110
254
|
},
|
|
111
|
-
annotations: annotations('
|
|
112
|
-
}, ({ text, language }, extra) => run(extra, (signal) => client.
|
|
255
|
+
annotations: annotations('Skills Normalizer'),
|
|
256
|
+
}, ({ text, language }, extra) => run(extra, (signal) => client.normalizeSkills(text, language, { signal })));
|
|
113
257
|
server.registerPrompt('screen_candidates', {
|
|
114
258
|
title: 'Screen candidates for a job',
|
|
115
|
-
description: '
|
|
259
|
+
description: 'Score each candidate against a job description and build a shortlist.',
|
|
116
260
|
argsSchema: {
|
|
117
261
|
job_description: z.string().describe('The job description, or the path of a file that contains it.'),
|
|
118
|
-
resumes: z
|
|
262
|
+
resumes: z
|
|
263
|
+
.string()
|
|
264
|
+
.describe('Resume file paths or URLs, one per line, a folder that holds them, or resume texts separated by ---.'),
|
|
119
265
|
},
|
|
120
266
|
}, ({ job_description, resumes }) => userPrompt(`Screen these candidates for the job below with the HireLayer tools.
|
|
121
267
|
|
|
122
|
-
1.
|
|
123
|
-
2. Call
|
|
124
|
-
3.
|
|
125
|
-
4. Present a shortlist table sorted by score: candidate, score, mandatory criteria met, main gaps. Then recommend who to interview and why.
|
|
268
|
+
1. For each resume given as a file path or URL, call parse_resume and keep its application_id; use resume texts as they are.
|
|
269
|
+
2. Call score_resumes once, with the job description as job_text, every resume (text or application_id) and precise_ranking set to true.
|
|
270
|
+
3. Present a shortlist table in the order returned: candidate, score, verdict, required criteria missing, main gaps. Then recommend who to interview and why.
|
|
126
271
|
|
|
127
272
|
Job description:
|
|
128
273
|
${job_description}
|
|
@@ -133,22 +278,39 @@ ${resumes}`));
|
|
|
133
278
|
title: 'Summarize a resume',
|
|
134
279
|
description: 'Parse one resume and write a short recruiter-style candidate summary.',
|
|
135
280
|
argsSchema: {
|
|
136
|
-
resume: z.string().describe('Path or URL of the resume file.'),
|
|
281
|
+
resume: z.string().describe('Path or URL of the resume file, or the resume text.'),
|
|
137
282
|
},
|
|
138
|
-
}, ({ resume }) => userPrompt(`Parse this resume with parse_resume, then write a recruiter summary: current role, years of experience, key skills, education, languages, location and availability. Flag missing contact details.
|
|
283
|
+
}, ({ resume }) => userPrompt(`Parse this resume with parse_resume (file_path for a local file, file_url for a URL, resume_text for text), then write a recruiter summary: current role, years of experience, key skills, education, languages, location and availability. Flag missing contact details.
|
|
139
284
|
|
|
140
285
|
Resume: ${resume}`));
|
|
141
|
-
server.registerPrompt('
|
|
286
|
+
server.registerPrompt('normalize_skills_list', {
|
|
142
287
|
title: 'Normalize a skills list',
|
|
143
288
|
description: 'Map a free-text skills section to standard taxonomy skills.',
|
|
144
289
|
argsSchema: {
|
|
145
290
|
skills: z.string().describe('Free-text skills, e.g. a resume skills section.'),
|
|
146
291
|
},
|
|
147
|
-
}, ({ skills }) => userPrompt(`Call
|
|
292
|
+
}, ({ skills }) => userPrompt(`Call normalize_skills on the text below (language en), then list the normalized skills grouped by domain and the parts that could not be resolved.
|
|
148
293
|
|
|
149
294
|
${skills}`));
|
|
150
295
|
return server;
|
|
151
296
|
}
|
|
297
|
+
/** The job of score_resumes: its text (with criteria) or a stored job_id. */
|
|
298
|
+
function scoreJob(args) {
|
|
299
|
+
if (args.job_text !== undefined) {
|
|
300
|
+
return { text: args.job_text, job_id: args.job_id, criteria: args.criteria, language: args.language };
|
|
301
|
+
}
|
|
302
|
+
if (args.criteria !== undefined) {
|
|
303
|
+
throw new HireLayerError('criteria go with job_text, never alone.');
|
|
304
|
+
}
|
|
305
|
+
// The API refuses it next to a job_id alone: a stored job keeps its language.
|
|
306
|
+
if (args.language !== undefined) {
|
|
307
|
+
throw new HireLayerError('language goes with job_text: a stored job keeps its own language.');
|
|
308
|
+
}
|
|
309
|
+
if (args.job_id === undefined) {
|
|
310
|
+
throw new HireLayerError('Give job_text or job_id.');
|
|
311
|
+
}
|
|
312
|
+
return { job_id: args.job_id };
|
|
313
|
+
}
|
|
152
314
|
function userPrompt(text) {
|
|
153
315
|
return { messages: [{ role: 'user', content: { type: 'text', text } }] };
|
|
154
316
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hirelayer-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"mcpName": "co.hirelayer/hirelayer",
|
|
5
|
-
"description": "Official HireLayer MCP server: resume and CV parsing, job criteria extraction,
|
|
5
|
+
"description": "Official HireLayer MCP server: resume and CV parsing, job criteria extraction, resume scoring and ranking, talent pool search, job recommendations and skills normalization for AI agents in Claude, Cursor, VS Code and other MCP clients.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hirelayer-mcp": "dist/index.js"
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
"resume-parsing",
|
|
29
29
|
"candidate-matching",
|
|
30
30
|
"candidate-ranking",
|
|
31
|
+
"resume-scoring",
|
|
32
|
+
"talent-search",
|
|
33
|
+
"job-recommendations",
|
|
31
34
|
"job-matching",
|
|
32
35
|
"resume-screening",
|
|
33
36
|
"recruiting",
|