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 CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  # HireLayer MCP Server
6
6
 
7
- **Resume parsing, candidate matching and candidate ranking for AI agents.**
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, then score and rank candidates from Claude, ChatGPT, Cursor, VS Code, Codex or any MCP client.
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, extracts the job criteria, scores every candidate criterion by criterion and explains the shortlist.
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.** 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.** Get weighted, explained matching criteria, with mandatory requirements flagged.
45
- - **Match candidates to jobs.** Score a resume against a job from 0 to 1, with an explanation for each criterion.
46
- - **Rank candidates.** Order up to 10 candidates for the same job in one call, with a score and a rationale for each.
47
- - **Normalize skills.** Map free-text skills in French or English to a skills taxonomy with stable IDs.
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 file into structured JSON: contact details, experience, education, languages, skills and the full text | A local `file_path` or a public `file_url`. Accepts PDF, DOC, DOCX, ODT, RTF, TXT, PPT, PPTX, ODP, XLS, JPG, PNG or BMP files under 4.5 MB. |
220
- | `extract_job_criteria` | Turns a job description into weighted criteria: a weight from 1 to 3, a mandatory flag and a rationale for each | Job description text |
221
- | `match_candidate` | Scores one candidate against a job from 0 to 1, with a summary and a status and explanation for each criterion | Job text, resume text and criteria |
222
- | `rank_candidates` | Ranks up to 10 candidates for one job, with a score and a rationale for each | Job text and up to 10 resume texts |
223
- | `resolve_skills` | Maps free-text skills in French or English to taxonomy skills, with their families and domains | Free text, from one skill to a whole skills section |
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
- All tools only read and analyse data. They never change anything in your systems.
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` | Runs the full screening workflow (criteria, parsing, matching) and writes a shortlist |
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] --> C[extract_job_criteria]
245
+ J[Job description] --> S[score_resumes]
240
246
  R[Resume files] --> P[parse_resume]
241
- C --> M[match_candidate]
242
- P --> M
243
- P --> K[rank_candidates]
244
- J --> K
245
- M --> S[Shortlist with explanations]
246
- K --> S
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>match_candidate</code></b></summary>
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
- "score": 0.89,
255
- "summary": "Profil très aligné : React, TypeScript et l’expérience demandée sont démontrés. Le niveau d’anglais reste à confirmer.",
256
- "evaluated_criteria": [
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
- "id": "crit_1",
259
- "label": "Maîtrise de React",
260
- "weight": 3,
261
- "is_mandatory": true,
262
- "match_status": "ideal",
263
- "match_explanation": "Le CV décrit une équipe React dirigée depuis 2022 sur une plateforme en production."
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 labels, rationales, summaries and explanations are written in French. Your assistant translates them when it answers you in another language.
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 must-haves."
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 mandatory criterion? If not, which ones are missing?"
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
- Each successful tool call costs **1 HireLayer credit**. A `rank_candidates` call costs 1 credit whatever the number of candidates. Failed calls are not charged.
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/#pricing](https://hirelayer.co/#pricing) |
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. By default HireLayer stores the original file and returns a link to it in `info_resume.url`. Set `do_not_store_data: true` in a call so the file is not stored.
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
- | Parsing seems slow | Parsing usually takes about 35 seconds, and longer for scans that need OCR. The server sends progress updates so clients don't time out. |
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, matching, ranking 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).
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 labels and rationales, match summaries and explanations, ranking rationales) is in French; your assistant translates it when it answers in another language. Skills resolution returns French or English labels.
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
- constructor(message, status) {
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
- constructor({ apiKey, baseUrl, fetch: fetchImpl, retryDelayMs }) {
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('/api/v3/parser', { method: 'POST', body: form, signal: options.signal });
62
+ return this.request('/resumes/parse', { method: 'POST', body: form, signal: options.signal }, options.workspace);
40
63
  }
41
- extractJobCriteria(jobText, options = {}) {
42
- return this.postJson('/api/v1/jobs/extract-criteria', { job_text: jobText }, options);
64
+ /** Job Criteria Extractor: POST /jobs/extract-criteria. */
65
+ extractJobCriteria(body, options = {}) {
66
+ return this.postJson('/jobs/extract-criteria', body, options);
43
67
  }
44
- matchCandidate(body, options = {}) {
45
- return this.postJson('/api/v1/matching/job-candidate', body, options);
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
- rankCandidates(body, options = {}) {
48
- return this.postJson('/api/v1/matching/job-candidates/rank', body, options);
93
+ /** Job Recommendations: POST /jobs/search. */
94
+ searchJobs(body, options = {}) {
95
+ return this.postJson('/jobs/search', body, options);
49
96
  }
50
- resolveSkills(text, language, options = {}) {
51
- return this.postJson('/api/v1/skills/resolve', language ? { text, language } : { text }, options);
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
- postJson(path, body, { signal }) {
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
- const detail = typeof payload === 'string' ? payload.slice(0, 500) : JSON.stringify(payload);
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
- if (Boolean(filePath) === Boolean(fileUrl)) {
98
- throw new HireLayerError('Provide exactly one of file_path or file_url.');
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
- function errorHint(status, detail) {
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; see https://hirelayer.co/#pricing for more.';
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
- // 35 s and scans needing OCR take longer, so long calls report progress at this interval.
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: resume (CV) parsing, job criteria extraction, candidate matching, candidate ranking and skills normalization.
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: extract_job_criteria on the job description, parse_resume on each resume, then match_candidate for each one (pass the criteria and info_resume.text). Use rank_candidates to order up to 10 candidates in one call.
11
- - Read a resume: parse_resume returns contact details, experience, education, languages, skills and the full text.
12
- - Normalize skills: resolve_skills maps free text in French or English to taxonomy skills.
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
- - The text HireLayer writes (criteria labels and rationales, match summaries and explanations, ranking rationales) is in French: translate it when you answer the user in another language.
16
- - info_resume.text can reach 100,000 characters; match_candidate and rank_candidates accept 50,000 characters per resume, so truncate longer texts.
17
- - Pass do_not_store_data: true to parse_resume when the user does not want HireLayer to keep the file.
18
- - Every successful tool call costs 1 HireLayer credit. Resume parsing usually takes about 35 seconds.`;
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
- id: z.string().min(1).describe('Criterion ID, e.g. crit_1 from extract_job_criteria.'),
21
- label: z.string().min(1).describe('What is evaluated, in a short phrase.'),
22
- weight: z.number().int().min(1).max(3).describe('3 essential, 2 important, 1 nice to have.'),
23
- is_mandatory: z.boolean().describe('Whether the job states it as a hard requirement.'),
24
- rationale: z.string().min(1).describe('Why the criterion matters for the job.'),
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 only read and analyse data; they never change anything in the user's systems.
27
- const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
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, ...readOnly };
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: 'Parse a resume',
56
- description: 'Parse a resume / CV (PDF, DOC, DOCX, ODT, PPT, PPTX, ODP, XLS, RTF, TXT, JPG, PNG or BMP, under 4.5 MB) into structured JSON: contact details, work experience, education, languages, skills and the full resume text in info_resume.text. Pass a local file_path or a public file_url. Takes about 35 seconds. Costs 1 HireLayer credit.',
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
- application_id: z.string().optional().describe('Your own reference, echoed back in the result.'),
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 does not keep the file after parsing.'),
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('Parse a resume'),
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: 'Extract job criteria',
70
- description: 'Turn a job description (any language) into weighted matching criteria (matching_criteria[]), each with a weight from 1 to 3, a mandatory flag and a rationale (labels and rationales are written in French). Feed the result to match_candidate. Costs 1 HireLayer credit.',
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: z.string().min(1).max(50000).describe('Full job description.'),
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('Extract job criteria'),
75
- }, ({ job_text }, extra) => run(extra, (signal) => client.extractJobCriteria(job_text, { signal })));
76
- server.registerTool('match_candidate', {
77
- title: 'Match a candidate to a job',
78
- description: 'Score one candidate against a job: returns a score between 0 and 1, a summary and an explained evaluation of each criterion (written in French). Use the criteria from extract_job_criteria and the resume text from parse_resume (info_resume.text). Costs 1 HireLayer credit.',
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: z.string().min(1).max(50000).describe('Job description.'),
81
- candidate_text: z.string().min(1).max(50000).describe('Resume as plain text.'),
82
- matching_criteria: z
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
- .default([])
85
- .describe('Criteria to evaluate, usually from extract_job_criteria.'),
86
- },
87
- annotations: annotations('Match a candidate to a job'),
88
- }, (args, extra) => run(extra, (signal) => client.matchCandidate(args, { signal })));
89
- server.registerTool('rank_candidates', {
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
- id: z.string().min(1).describe('Your candidate ID, unique in the request.'),
97
- candidate_text: z.string().min(1).max(50000).describe('Resume as plain text.'),
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(10),
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('Rank candidates for a job'),
103
- }, (args, extra) => run(extra, (signal) => client.rankCandidates(args, { signal })));
104
- server.registerTool('resolve_skills', {
105
- title: 'Resolve skills',
106
- description: 'Map free-text skills in French or English (one skill, a compound string or a whole skills section) to skills of the HireLayer taxonomy, with their IDs, families and domains. Unmatched parts are listed in unresolved. Costs 1 HireLayer credit.',
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('Resolve skills'),
112
- }, ({ text, language }, extra) => run(extra, (signal) => client.resolveSkills(text, language, { signal })));
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: 'Parse resumes, score each candidate against a job description and build a shortlist.',
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.string().describe('Resume file paths or URLs, one per line, or a folder that holds them.'),
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. Call extract_job_criteria on the job description.
123
- 2. Call parse_resume on each resume.
124
- 3. Call match_candidate for each candidate with the criteria and info_resume.text.
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('normalize_skills', {
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 resolve_skills on the text below (language en), then list the normalized skills grouped by domain and the parts that could not be resolved.
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": "1.1.1",
3
+ "version": "2.1.0",
4
4
  "mcpName": "co.hirelayer/hirelayer",
5
- "description": "Official HireLayer MCP server: resume and CV parsing, job criteria extraction, candidate matching and ranking, and skills normalization for AI agents in Claude, Cursor, VS Code and other MCP clients.",
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",