@opthr/mcp-server 0.2.0 → 0.2.3

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.
Files changed (3) hide show
  1. package/README.md +166 -32
  2. package/package.json +4 -2
  3. package/src/index.js +1137 -189
package/src/index.js CHANGED
@@ -2,64 +2,56 @@
2
2
  /**
3
3
  * @opthr/mcp-server — Model Context Protocol bridge for OptHR.
4
4
  *
5
- * This is the SAME OptHR backend (FastAPI at OPTHR_API_BASE) that the
6
- * web prototype talks to, exposed as MCP tools so Claude Desktop, Cursor
7
- * and other MCP clients can run skill-gap and compensation analyses
8
- * without leaving the chat.
5
+ * Thin Node.js wrapper around the OptHR FastAPI backend (OPTHR_API_BASE).
6
+ * Same endpoints the web prototype uses no separate "MCP backend".
9
7
  *
10
- * Tools exposed:
11
- * - opthr_health → check the backend is reachable
12
- * - opthr_list_jobs → recent analyses for the tenant
13
- * - opthr_get_job → fetch one job (state, outputs, events)
14
- * - opthr_run_skill_gap → start a new skill-gap analysis
15
- * - opthr_run_compensation → start a new compensation analysis
16
- * - opthr_answer → respond to a paused-clarification
17
- * - opthr_export → download report (PDF) / data (XLSX|JSON)
8
+ * Environment:
9
+ * OPTHR_API_BASE FastAPI base URL (default http://localhost:8765)
10
+ * OPTHR_API_KEY sent as X-API-Key (production / hosted)
11
+ * OPTHR_BEARER_TOKEN sent as Authorization: Bearer
12
+ * OPTHR_DEV_ROLE fallback X-Role (default admin_hr)
13
+ * OPTHR_DEV_TENANT_ID fallback X-Tenant-ID (default tenant_demo)
14
+ * OPTHR_DOCUMENT_SEARCH_ROOT if set, restrict `document_path` to this dir
15
+ * OPTHR_DASHBOARD_URL dashboard / app URL exposed via resources
16
+ * OPTHR_LANDING_URL landing-site URL
17
+ * OPTHR_FIGMA_URL optional Figma handoff URL
18
18
  *
19
- * Auth comes from environment:
20
- * OPTHR_API_BASE — FastAPI base URL (default http://localhost:8765)
21
- * OPTHR_API_KEY — optional X-API-Key
22
- * OPTHR_BEARER_TOKEN — optional Bearer token
23
- * OPTHR_DEV_ROLE — fallback X-Role header (default admin_hr)
24
- * OPTHR_DEV_TENANT_ID — fallback X-Tenant-ID (default tenant_demo)
25
- *
26
- * Usage in Claude Desktop's claude_desktop_config.json:
27
- *
28
- * {
29
- * "mcpServers": {
30
- * "opthr": {
31
- * "command": "npx",
32
- * "args": ["-y", "@opthr/mcp-server"],
33
- * "env": {
34
- * "OPTHR_API_BASE": "http://localhost:8765",
35
- * "OPTHR_DEV_ROLE": "admin_hr",
36
- * "OPTHR_DEV_TENANT_ID": "tenant_demo"
37
- * }
38
- * }
39
- * }
40
- * }
19
+ * No personal/local paths are baked in. Source documents must be supplied
20
+ * by the caller via `document_url` (http/https/file://), `document_path`
21
+ * (absolute or under OPTHR_DOCUMENT_SEARCH_ROOT), or `use_sample_data=true`.
41
22
  */
42
23
 
43
24
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
44
25
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
45
26
  import {
46
27
  CallToolRequestSchema,
28
+ GetPromptRequestSchema,
29
+ ListPromptsRequestSchema,
30
+ ListResourcesRequestSchema,
47
31
  ListToolsRequestSchema,
32
+ ReadResourceRequestSchema,
48
33
  } from "@modelcontextprotocol/sdk/types.js";
49
- import { readFileSync } from "node:fs";
34
+ import { readFileSync, existsSync, statSync, realpathSync } from "node:fs";
50
35
  import { fileURLToPath } from "node:url";
51
- import { dirname, join } from "node:path";
36
+ import { dirname, isAbsolute, join, resolve, basename } from "node:path";
37
+
38
+ /* ----------------------------------------------------------------- */
39
+ /* Environment */
40
+ /* ----------------------------------------------------------------- */
52
41
 
42
+ const PKG_VERSION = "0.2.2";
53
43
  const BASE = (process.env.OPTHR_API_BASE || "http://localhost:8765").replace(/\/$/, "");
54
44
  const API_KEY = process.env.OPTHR_API_KEY || "";
55
45
  const BEARER = process.env.OPTHR_BEARER_TOKEN || "";
56
46
  const DEV_ROLE = process.env.OPTHR_DEV_ROLE || "admin_hr";
57
47
  const DEV_TENANT = process.env.OPTHR_DEV_TENANT_ID || "tenant_demo";
48
+ const DOC_SEARCH_ROOT = (process.env.OPTHR_DOCUMENT_SEARCH_ROOT || "").trim();
49
+ const DASHBOARD_URL = process.env.OPTHR_DASHBOARD_URL || `${BASE}/OptHR%20Redesign.html#/app`;
50
+ const LANDING_URL = process.env.OPTHR_LANDING_URL || "https://opthr-landing-site.vercel.app/";
51
+ const FIGMA_URL = process.env.OPTHR_FIGMA_URL || "";
58
52
 
59
53
  /* ----------------------------------------------------------------- */
60
- /* Branding — load OptHR logo and expose it via the MCP icons field */
61
- /* (supported by clients on MCP spec 2025-06-18+, e.g. Claude Desktop */
62
- /* 1.x). Falls back silently if the asset is missing. */
54
+ /* Branding — OptHR icon for clients on MCP spec 2025-06-18+ */
63
55
  /* ----------------------------------------------------------------- */
64
56
 
65
57
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -74,8 +66,83 @@ function loadOptHRIcon() {
74
66
  return undefined;
75
67
  }
76
68
  }
77
-
78
69
  const OPTHR_ICONS = loadOptHRIcon();
70
+ const ICON_FIELDS = OPTHR_ICONS ? { icons: OPTHR_ICONS } : {};
71
+
72
+ /* ----------------------------------------------------------------- */
73
+ /* Structured observability */
74
+ /* ----------------------------------------------------------------- */
75
+
76
+ const ERROR_CODES = {
77
+ BACKEND_UNREACHABLE: "BACKEND_UNREACHABLE",
78
+ AUTH_FAILED: "AUTH_FAILED",
79
+ DOCUMENT_NOT_FOUND: "DOCUMENT_NOT_FOUND",
80
+ INVALID_DOCUMENT_TYPE: "INVALID_DOCUMENT_TYPE",
81
+ JOB_TIMEOUT: "JOB_TIMEOUT",
82
+ BACKEND_ERROR: "BACKEND_ERROR",
83
+ };
84
+
85
+ // Telemetry sink: stderr always, plus optional fire-and-forget HTTP POST when
86
+ // OPTHR_TELEMETRY_URL is set. Payload is intentionally narrow — no PII, no
87
+ // salaries, no documents, no request/response bodies.
88
+ const TELEMETRY_URL = (process.env.OPTHR_TELEMETRY_URL || "").trim();
89
+ const TELEMETRY_AUTH = (process.env.OPTHR_TELEMETRY_AUTH || "").trim();
90
+
91
+ function postTelemetry(entry) {
92
+ if (!TELEMETRY_URL) return;
93
+ const headers = { "Content-Type": "application/json" };
94
+ if (TELEMETRY_AUTH) headers["Authorization"] = TELEMETRY_AUTH;
95
+ // Fire-and-forget; never await, never throw into the caller.
96
+ fetch(TELEMETRY_URL, {
97
+ method: "POST",
98
+ headers,
99
+ body: JSON.stringify(entry),
100
+ }).catch(() => {
101
+ /* swallow — telemetry must never affect tool calls */
102
+ });
103
+ }
104
+
105
+ function logEvent(event) {
106
+ // stderr only — stdout is reserved for the MCP transport.
107
+ try {
108
+ const entry = {
109
+ ts: new Date().toISOString(),
110
+ svc: "opthr-mcp-server",
111
+ version: PKG_VERSION,
112
+ tenant_id: DEV_TENANT || null,
113
+ backend_host: safeHost(BASE),
114
+ ...event,
115
+ };
116
+ console.error(JSON.stringify(entry));
117
+ postTelemetry(entry);
118
+ } catch {
119
+ // never let logging fail the call
120
+ }
121
+ }
122
+
123
+ function safeHost(url) {
124
+ try {
125
+ return new URL(url).host || null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ class OptHRError extends Error {
132
+ constructor(message, { code = ERROR_CODES.BACKEND_ERROR, status: httpStatus, details } = {}) {
133
+ super(message);
134
+ this.code = code;
135
+ this.status = httpStatus;
136
+ this.details = details;
137
+ }
138
+ }
139
+
140
+ function classifyStatus(status) {
141
+ if (status === 401 || status === 403) return ERROR_CODES.AUTH_FAILED;
142
+ if (status === 404) return ERROR_CODES.DOCUMENT_NOT_FOUND;
143
+ if (status === 422) return ERROR_CODES.INVALID_DOCUMENT_TYPE;
144
+ return ERROR_CODES.BACKEND_ERROR;
145
+ }
79
146
 
80
147
  /* ----------------------------------------------------------------- */
81
148
  /* HTTP helpers */
@@ -101,7 +168,6 @@ async function request(path, opts = {}) {
101
168
  } else if (typeof body === "string" && !headers["Content-Type"]) {
102
169
  headers["Content-Type"] = "application/json";
103
170
  }
104
- // For FormData, do NOT set Content-Type — fetch must set it with the boundary.
105
171
 
106
172
  if (BEARER) headers["Authorization"] = "Bearer " + BEARER;
107
173
  else {
@@ -110,11 +176,19 @@ async function request(path, opts = {}) {
110
176
  }
111
177
  if (API_KEY) headers["X-API-Key"] = API_KEY;
112
178
 
113
- const res = await fetch(url, {
114
- method: opts.method || "GET",
115
- headers,
116
- body,
117
- });
179
+ let res;
180
+ try {
181
+ res = await fetch(url, {
182
+ method: opts.method || "GET",
183
+ headers,
184
+ body,
185
+ });
186
+ } catch (networkErr) {
187
+ throw new OptHRError(
188
+ `Cannot reach OptHR backend at ${BASE} — ${networkErr.message || networkErr}`,
189
+ { code: ERROR_CODES.BACKEND_UNREACHABLE },
190
+ );
191
+ }
118
192
 
119
193
  const ct = res.headers.get("content-type") || "";
120
194
  let resBody = null;
@@ -122,18 +196,36 @@ async function request(path, opts = {}) {
122
196
  else if (!opts.raw) resBody = await res.text().catch(() => null);
123
197
 
124
198
  if (!res.ok) {
125
- const detail = (resBody && (resBody.detail || resBody.message)) || res.statusText || "request failed";
126
- const err = new Error(`OptHR API ${res.status}: ${detail}`);
127
- err.status = res.status;
128
- err.body = resBody;
129
- throw err;
199
+ const detail =
200
+ (resBody && (resBody.detail || resBody.message)) || res.statusText || "request failed";
201
+ throw new OptHRError(`OptHR API ${res.status}: ${detail}`, {
202
+ code: classifyStatus(res.status),
203
+ status: res.status,
204
+ details: resBody,
205
+ });
130
206
  }
131
207
  return opts.raw ? res : resBody;
132
208
  }
133
209
 
134
210
  const text = (s) => ({ type: "text", text: typeof s === "string" ? s : JSON.stringify(s, null, 2) });
135
- const ok = (s) => ({ content: [text(s)] });
136
- const fail = (e) => ({ content: [text(`Error: ${e.message || e}`)], isError: true });
211
+ const ok = (s) => ({ content: [text(s)] });
212
+
213
+ function fail(err) {
214
+ const code = err && err.code ? err.code : ERROR_CODES.BACKEND_ERROR;
215
+ const status = err && err.status;
216
+ return {
217
+ content: [
218
+ text({
219
+ error: {
220
+ code,
221
+ message: err && err.message ? err.message : String(err),
222
+ status: status || null,
223
+ },
224
+ }),
225
+ ],
226
+ isError: true,
227
+ };
228
+ }
137
229
 
138
230
  function filenameFromDisposition(value, fallback) {
139
231
  const raw = value || "";
@@ -143,113 +235,778 @@ function filenameFromDisposition(value, fallback) {
143
235
  return ascii ? ascii[1] : fallback;
144
236
  }
145
237
 
238
+ /* ----------------------------------------------------------------- */
239
+ /* Document resolution — file://, document_path, document_url */
240
+ /* ----------------------------------------------------------------- */
241
+
242
+ function resolveLocalPath(rawPath) {
243
+ if (!rawPath || typeof rawPath !== "string") {
244
+ throw new OptHRError("document_path must be a non-empty string", {
245
+ code: ERROR_CODES.DOCUMENT_NOT_FOUND,
246
+ });
247
+ }
248
+
249
+ let candidate = rawPath.trim();
250
+ if (candidate.startsWith("file://")) {
251
+ candidate = fileURLToPath(candidate);
252
+ }
253
+
254
+ if (!isAbsolute(candidate)) {
255
+ if (!DOC_SEARCH_ROOT) {
256
+ throw new OptHRError(
257
+ `Relative document_path '${rawPath}' is not allowed unless OPTHR_DOCUMENT_SEARCH_ROOT is configured.`,
258
+ { code: ERROR_CODES.DOCUMENT_NOT_FOUND },
259
+ );
260
+ }
261
+ candidate = resolve(DOC_SEARCH_ROOT, candidate);
262
+ }
263
+
264
+ let realPath;
265
+ try {
266
+ realPath = realpathSync(candidate);
267
+ } catch {
268
+ throw new OptHRError(`Document not found: ${rawPath}`, {
269
+ code: ERROR_CODES.DOCUMENT_NOT_FOUND,
270
+ });
271
+ }
272
+
273
+ if (DOC_SEARCH_ROOT) {
274
+ const rootReal = realpathSync(resolve(DOC_SEARCH_ROOT));
275
+ if (!realPath.startsWith(rootReal + "/") && realPath !== rootReal) {
276
+ throw new OptHRError(
277
+ `document_path '${rawPath}' resolves outside OPTHR_DOCUMENT_SEARCH_ROOT`,
278
+ { code: ERROR_CODES.DOCUMENT_NOT_FOUND },
279
+ );
280
+ }
281
+ }
282
+
283
+ let stats;
284
+ try {
285
+ stats = statSync(realPath);
286
+ } catch {
287
+ throw new OptHRError(`Document not found: ${rawPath}`, {
288
+ code: ERROR_CODES.DOCUMENT_NOT_FOUND,
289
+ });
290
+ }
291
+ if (!stats.isFile()) {
292
+ throw new OptHRError(`document_path '${rawPath}' is not a regular file`, {
293
+ code: ERROR_CODES.DOCUMENT_NOT_FOUND,
294
+ });
295
+ }
296
+ return realPath;
297
+ }
298
+
299
+ function guessMimeType(filename) {
300
+ const f = (filename || "").toLowerCase();
301
+ if (f.endsWith(".pdf")) return "application/pdf";
302
+ if (f.endsWith(".csv")) return "text/csv";
303
+ if (f.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
304
+ if (f.endsWith(".json")) return "application/json";
305
+ if (f.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
306
+ return "application/octet-stream";
307
+ }
308
+
309
+ /**
310
+ * Returns a { blob, filename } pair given:
311
+ * - document_url: http(s)://… or file://…
312
+ * - document_path: absolute local path or relative under OPTHR_DOCUMENT_SEARCH_ROOT
313
+ * Throws an OptHRError with a DOCUMENT_NOT_FOUND / INVALID_DOCUMENT_TYPE code on failure.
314
+ */
315
+ async function resolveDocument({ document_url, document_path, fallbackName = "document.bin" }) {
316
+ if (document_path) {
317
+ const path = resolveLocalPath(document_path);
318
+ const bytes = readFileSync(path);
319
+ const blob = new Blob([bytes], { type: guessMimeType(path) });
320
+ return { blob, filename: basename(path) };
321
+ }
322
+ if (document_url) {
323
+ const url = document_url.trim();
324
+ if (url.startsWith("file://")) {
325
+ const path = resolveLocalPath(url);
326
+ const bytes = readFileSync(path);
327
+ const blob = new Blob([bytes], { type: guessMimeType(path) });
328
+ return { blob, filename: basename(path) };
329
+ }
330
+ if (!/^https?:\/\//i.test(url)) {
331
+ throw new OptHRError(
332
+ `document_url must start with http://, https://, or file://. Got: ${url}`,
333
+ { code: ERROR_CODES.INVALID_DOCUMENT_TYPE },
334
+ );
335
+ }
336
+ let r;
337
+ try {
338
+ r = await fetch(url);
339
+ } catch (e) {
340
+ throw new OptHRError(`Cannot fetch document_url: ${e.message || e}`, {
341
+ code: ERROR_CODES.DOCUMENT_NOT_FOUND,
342
+ });
343
+ }
344
+ if (!r.ok) {
345
+ throw new OptHRError(`document_url returned ${r.status} ${r.statusText}`, {
346
+ code: ERROR_CODES.DOCUMENT_NOT_FOUND,
347
+ status: r.status,
348
+ });
349
+ }
350
+ const blob = await r.blob();
351
+ const name = url.split("/").pop() || fallbackName;
352
+ return { blob, filename: name };
353
+ }
354
+ throw new OptHRError("Provide either document_path or document_url.", {
355
+ code: ERROR_CODES.DOCUMENT_NOT_FOUND,
356
+ });
357
+ }
358
+
359
+ async function uploadJobDocument(jobId, blob, filename, documentType, { autoRun = false } = {}) {
360
+ const fd = new FormData();
361
+ fd.append("document_type", documentType);
362
+ fd.append("file", blob, filename || `${documentType}.bin`);
363
+ return request(
364
+ `/jobs/${encodeURIComponent(jobId)}/documents?auto_run=${autoRun ? "true" : "false"}`,
365
+ { method: "POST", body: fd },
366
+ );
367
+ }
368
+
369
+ /* ----------------------------------------------------------------- */
370
+ /* Product surfaces & static resources */
371
+ /* ----------------------------------------------------------------- */
372
+
373
+ function productSurfaces() {
374
+ return {
375
+ dashboard_url: DASHBOARD_URL,
376
+ landing_url: LANDING_URL,
377
+ api_base: BASE,
378
+ figma_url: FIGMA_URL || null,
379
+ figma_status: FIGMA_URL ? "configured" : "not_configured",
380
+ notes: [
381
+ "Use the dashboard URL for the current product UI and job workspace.",
382
+ "Set OPTHR_FIGMA_URL to expose the canonical Figma handoff link through MCP.",
383
+ "MCP analysis tools never attach bundled sample PDFs unless use_sample_data=true.",
384
+ ],
385
+ };
386
+ }
387
+
388
+ const TOOL_GUIDE_MARKDOWN = `# OptHR MCP — Tool guide
389
+
390
+ This MCP bridges Claude clients to the OptHR FastAPI backend. The backend is the source of truth; this server is a thin RPC layer plus a few document-resolution helpers.
391
+
392
+ ## Picking the right tool
393
+
394
+ - **opthr_health** — sanity check connectivity and active LLM. Call first when nothing else works.
395
+ - **opthr_run_compensation** — start a compensation + pay-equity job. Needs employee_name, job_title, ral_actual.
396
+ - **opthr_run_pay_equity** — pay-equity-only run from a payroll-style CSV.
397
+ - **opthr_run_skill_gap** — skill gap vs a target role from a competence-profile document.
398
+ - **opthr_run_rapporto_biennale** — Legge 162/2021 Rapporto Biennale workflow (preview).
399
+ - **opthr_wait_for_job** — poll a running job until it completes/fails or a timeout fires.
400
+ - **opthr_get_job_summary** — compact merged summary across compensation / pay equity / skill gap / recommendations. Best for "what did this job find?".
401
+ - **opthr_answer** — send a clarification when a job is paused waiting on the user.
402
+ - **opthr_export** — download a PDF / XLSX / DOCX / PPTX / JSON export of a completed job.
403
+ - **opthr_review_step** — generic reviewer action over compensation / pay_equity / skills / recommendations / manager_feedback.
404
+
405
+ ## Read-only browsing — use resources, not tools
406
+
407
+ - \`opthr://recent-jobs\` — latest 20 analyses for the tenant. Use to discover a \`job_id\` without spending a tool slot.
408
+ - \`opthr://dashboard\` and \`opthr://product-surfaces\` — dashboard, landing, Figma, API URLs.
409
+ - \`opthr://samples\` and \`opthr://sample-payroll-csv\` — bundled demo inputs.
410
+ - \`opthr://schemas\` — JobModule, DocumentType, error-code enums.
411
+ - \`opthr://rapporto-biennale-template\` — Legge 162/2021 column layout.
412
+
413
+ ## When NOT to use these tools
414
+
415
+ - Don't call any tool other than \`opthr_health\` if the backend is unreachable — surface the error to the user instead.
416
+ - Don't auto-attach sample data unless the user explicitly asks for "demo" / "sample" data.
417
+ - Don't call \`opthr_run_compensation\` if you only have a payroll CSV — use \`opthr_run_pay_equity\` or \`opthr_run_rapporto_biennale\`.
418
+ - Don't poll for job state with \`opthr_get_job_summary\` — use \`opthr_wait_for_job\`, then \`opthr_get_job_summary\` once.
419
+ - Don't approve a review step on the user's behalf without confirmation.
420
+ `;
421
+
422
+ const RESOURCES = [
423
+ {
424
+ uri: "opthr://dashboard",
425
+ name: "OptHR dashboard",
426
+ title: "OptHR Dashboard",
427
+ description: "Current dashboard/app URL for running analyses and inspecting jobs.",
428
+ mimeType: "application/json",
429
+ },
430
+ {
431
+ uri: "opthr://figma",
432
+ name: "OptHR Figma handoff",
433
+ title: "OptHR Figma",
434
+ description: "Configured Figma design handoff URL. Set OPTHR_FIGMA_URL if missing.",
435
+ mimeType: "application/json",
436
+ },
437
+ {
438
+ uri: "opthr://product-surfaces",
439
+ name: "OptHR product surfaces",
440
+ title: "OptHR Product Surfaces",
441
+ description: "Dashboard, landing, API base, and Figma handoff metadata.",
442
+ mimeType: "application/json",
443
+ },
444
+ {
445
+ uri: "opthr://samples",
446
+ name: "OptHR sample files",
447
+ title: "OptHR sample documents",
448
+ description: "List of bundled sample documents (skills profiles, performance reviews, payroll CSV).",
449
+ mimeType: "application/json",
450
+ },
451
+ {
452
+ uri: "opthr://schemas",
453
+ name: "OptHR schema reference",
454
+ title: "OptHR job & document schemas",
455
+ description: "Canonical JobState, JobModule, DocumentType, UserRole enums and review payload shapes.",
456
+ mimeType: "application/json",
457
+ },
458
+ {
459
+ uri: "opthr://tool-guide",
460
+ name: "OptHR MCP tool guide",
461
+ title: "OptHR MCP — when to use which tool",
462
+ description: "Guidance for picking the right OptHR tool and avoiding common mistakes.",
463
+ mimeType: "text/markdown",
464
+ },
465
+ {
466
+ uri: "opthr://recent-jobs",
467
+ name: "Recent OptHR jobs",
468
+ title: "OptHR recent jobs (live)",
469
+ description: "Live list of the last 20 analyses for the connected tenant.",
470
+ mimeType: "application/json",
471
+ },
472
+ {
473
+ uri: "opthr://sample-payroll-csv",
474
+ name: "Sample payroll CSV",
475
+ title: "Sample payroll CSV (pay equity / Rapporto Biennale)",
476
+ description: "Minimal CSV template used to seed a payroll/pay-equity dataset.",
477
+ mimeType: "text/csv",
478
+ },
479
+ {
480
+ uri: "opthr://rapporto-biennale-template",
481
+ name: "Rapporto Biennale template",
482
+ title: "Rapporto Biennale (Legge 162/2021) — column template",
483
+ description: "Header layout the backend equity_report agent expects from a payroll CSV.",
484
+ mimeType: "application/json",
485
+ },
486
+ ];
487
+
488
+ const SCHEMA_REFERENCE = {
489
+ job_states: [
490
+ "uploaded",
491
+ "queued",
492
+ "parsing",
493
+ "extracting",
494
+ "matching",
495
+ "analyzing",
496
+ "recommending",
497
+ "review_required",
498
+ "completed",
499
+ "failed",
500
+ ],
501
+ job_modules: ["compensation", "pay_equity", "skillgap", "equity_report"],
502
+ job_steps: ["parsing", "extracting", "matching", "analyzing", "recommending", "reporting"],
503
+ document_types: [
504
+ "jd_pdf",
505
+ "performance_pdf",
506
+ "pay_equity_csv",
507
+ "scheda_competenze_pdf",
508
+ "catalog_skilla",
509
+ "catalog_cegos",
510
+ "payroll_csv",
511
+ ],
512
+ user_roles: ["admin_hr", "manager", "reviewer"],
513
+ review_payloads: {
514
+ compensation: { acknowledged: "boolean", note: "string?" },
515
+ pay_equity: { acknowledged: "boolean", note: "string?" },
516
+ skills: { hard_skills: "ReviewSkillItem[]", soft_skills: "ReviewSkillItem[]" },
517
+ recommendations: { recommendations_by_skill: "Record<string, ReviewRecommendationItem[]>" },
518
+ manager_feedback: {
519
+ markdown: "string",
520
+ rating_bucket: "string?",
521
+ comp_recommendation: "string?",
522
+ hr_actions: "string[]",
523
+ sign: "boolean",
524
+ signer_name: "string?",
525
+ },
526
+ },
527
+ error_codes: Object.values(ERROR_CODES),
528
+ };
529
+
530
+ const SAMPLE_PAYROLL_CSV = `employee_id,nome,genere,job_title,area,esperienza,ral_attuale,bonus_anno,data_assunzione
531
+ EMP-0001,Anna Bianchi,F,Senior Controller,nord_est,5_10_anni,52000,4000,2019-04-01
532
+ EMP-0002,Marco Verdi,M,Senior Controller,nord_est,5_10_anni,58000,5000,2018-09-15
533
+ EMP-0003,Giulia Neri,F,Junior Controller,centro,0_3_anni,32000,1500,2023-01-10
534
+ EMP-0004,Luca Rossi,M,Junior Controller,centro,0_3_anni,34000,1500,2022-07-01
535
+ EMP-0005,Sara Conti,F,Plant Manager,sud,10_plus_anni,72000,8000,2014-03-12
536
+ EMP-0006,Andrea Russo,M,Plant Manager,sud,10_plus_anni,82000,9000,2013-11-04
537
+ `;
538
+
539
+ const RAPPORTO_TEMPLATE_INFO = {
540
+ required_columns: [
541
+ "employee_id",
542
+ "nome",
543
+ "genere",
544
+ "job_title",
545
+ "area",
546
+ "esperienza",
547
+ "ral_attuale",
548
+ ],
549
+ optional_columns: ["bonus_anno", "data_assunzione", "contract_type", "part_time_pct"],
550
+ gender_values: ["F", "M"],
551
+ notes: [
552
+ "Legge 162/2021 requires gender-coded rows; 'U' / blank values will not be counted in the Rapporto Biennale aggregation.",
553
+ "The backend `equity_report` agent (backend/agents/equity_report.py) builds the XLSX and Markdown summary; wiring this module into the workflow orchestrator is in progress.",
554
+ "Until that wiring lands, opthr_run_rapporto_biennale will create a job with modules=['equity_report'] and upload the payroll CSV, but the run will not produce a finished report.",
555
+ ],
556
+ };
557
+
558
+ async function readResource(uri) {
559
+ const surfaces = productSurfaces();
560
+ switch (uri) {
561
+ case "opthr://dashboard":
562
+ return {
563
+ uri,
564
+ mimeType: "application/json",
565
+ text: JSON.stringify(
566
+ {
567
+ dashboard_url: surfaces.dashboard_url,
568
+ api_base: surfaces.api_base,
569
+ description: "Open this URL for the current OptHR dashboard and job workspace.",
570
+ },
571
+ null,
572
+ 2,
573
+ ),
574
+ };
575
+ case "opthr://figma":
576
+ return {
577
+ uri,
578
+ mimeType: "application/json",
579
+ text: JSON.stringify(
580
+ {
581
+ figma_url: surfaces.figma_url,
582
+ status: surfaces.figma_status,
583
+ description: FIGMA_URL
584
+ ? "Open this URL for the current Figma design handoff."
585
+ : "No Figma URL is configured. Set OPTHR_FIGMA_URL in the MCP server environment.",
586
+ },
587
+ null,
588
+ 2,
589
+ ),
590
+ };
591
+ case "opthr://product-surfaces":
592
+ return { uri, mimeType: "application/json", text: JSON.stringify(surfaces, null, 2) };
593
+ case "opthr://schemas":
594
+ return { uri, mimeType: "application/json", text: JSON.stringify(SCHEMA_REFERENCE, null, 2) };
595
+ case "opthr://tool-guide":
596
+ return { uri, mimeType: "text/markdown", text: TOOL_GUIDE_MARKDOWN };
597
+ case "opthr://sample-payroll-csv":
598
+ return { uri, mimeType: "text/csv", text: SAMPLE_PAYROLL_CSV };
599
+ case "opthr://rapporto-biennale-template":
600
+ return {
601
+ uri,
602
+ mimeType: "application/json",
603
+ text: JSON.stringify(RAPPORTO_TEMPLATE_INFO, null, 2),
604
+ };
605
+ case "opthr://samples": {
606
+ const r = await request("/samples");
607
+ return { uri, mimeType: "application/json", text: JSON.stringify(r, null, 2) };
608
+ }
609
+ case "opthr://recent-jobs": {
610
+ const r = await request("/jobs?limit=20&offset=0");
611
+ const items = (r.items || []).map((j) => ({
612
+ job_id: j.job_id,
613
+ state: j.state,
614
+ modules: j.requested_modules || [],
615
+ employee_name: j.employee_name || null,
616
+ job_title: j.job_title || null,
617
+ updated_at: j.updated_at,
618
+ }));
619
+ return {
620
+ uri,
621
+ mimeType: "application/json",
622
+ text: JSON.stringify({ count: items.length, items }, null, 2),
623
+ };
624
+ }
625
+ default:
626
+ throw new OptHRError(`Unknown resource: ${uri}`, { code: ERROR_CODES.DOCUMENT_NOT_FOUND });
627
+ }
628
+ }
629
+
630
+ /* ----------------------------------------------------------------- */
631
+ /* Prompts */
632
+ /* ----------------------------------------------------------------- */
633
+
634
+ const PROMPTS = [
635
+ {
636
+ name: "opthr-quick-comp",
637
+ title: "OptHR · Quick compensation check",
638
+ description: "Run a one-shot compensation + pay-equity analysis for a single employee.",
639
+ arguments: [
640
+ { name: "employee_name", description: "Full name", required: true },
641
+ { name: "job_title", description: "Target role, e.g. 'Senior Controller'", required: true },
642
+ { name: "ral_actual", description: "Current annual gross salary (EUR)", required: true },
643
+ { name: "area", description: "Geo cluster (nord_ovest / nord_est / centro / sud)", required: false },
644
+ ],
645
+ },
646
+ {
647
+ name: "opthr-explain-job",
648
+ title: "OptHR · Explain a job",
649
+ description: "Pull a job's full state and outputs, then explain what was computed and what the next action is.",
650
+ arguments: [{ name: "job_id", description: "Job UUID", required: true }],
651
+ },
652
+ {
653
+ name: "opthr-rapporto-biennale",
654
+ title: "OptHR · Rapporto Biennale (Legge 162/2021)",
655
+ description: "Kick off a Rapporto Biennale run from a payroll CSV and walk the user through trasmissione.",
656
+ arguments: [
657
+ { name: "company_name", description: "Ragione sociale dichiarante", required: true },
658
+ { name: "payroll_path", description: "Absolute path or file:// URL to the payroll CSV", required: true },
659
+ ],
660
+ },
661
+ {
662
+ name: "opthr-calibrate-team",
663
+ title: "OptHR · Calibrate team comp",
664
+ description: "Loop through a list of employees and run compensation analyses, ready for calibration.",
665
+ arguments: [
666
+ { name: "team_name", description: "Team label, e.g. 'Controlling Italia'", required: true },
667
+ { name: "employees", description: "Comma-separated list of 'Name | Role | RAL'", required: true },
668
+ ],
669
+ },
670
+ ];
671
+
672
+ function getPrompt(name, args = {}) {
673
+ switch (name) {
674
+ case "opthr-quick-comp": {
675
+ const { employee_name, job_title, ral_actual, area } = args;
676
+ const message = [
677
+ `Run a compensation + pay-equity analysis for **${employee_name || "<employee>"}** in role **${job_title || "<role>"}**`,
678
+ `with RAL **${ral_actual || "<ral>"} EUR**${area ? ` (${area})` : ""}.`,
679
+ "",
680
+ "Steps:",
681
+ "1. Call `opthr_run_compensation` with the fields above.",
682
+ "2. Call `opthr_wait_for_job` to wait for completion.",
683
+ "3. Summarise the percentile band, gender-gap signal, and the top remediation action.",
684
+ ].join("\n");
685
+ return {
686
+ description: "Quick compensation + pay-equity workflow",
687
+ messages: [{ role: "user", content: { type: "text", text: message } }],
688
+ };
689
+ }
690
+ case "opthr-explain-job": {
691
+ const message = [
692
+ `Explain OptHR job \`${args.job_id || "<job_id>"}\`.`,
693
+ "",
694
+ "Steps:",
695
+ "1. Call `opthr_get_job_summary` for a compact view.",
696
+ "2. If state != completed, surface the last event and what's blocking.",
697
+ "3. If state == completed, summarise: percentile band, EU Pay Directive gap, top recommendations.",
698
+ ].join("\n");
699
+ return {
700
+ description: "Explain an existing OptHR job",
701
+ messages: [{ role: "user", content: { type: "text", text: message } }],
702
+ };
703
+ }
704
+ case "opthr-rapporto-biennale": {
705
+ const message = [
706
+ `Kick off a Rapporto Biennale (Legge 162/2021) for **${args.company_name || "<company>"}**.`,
707
+ "",
708
+ "Steps:",
709
+ `1. Call \`opthr_run_rapporto_biennale\` with company_name and document_path=\`${args.payroll_path || "<absolute payroll csv path>"}\`.`,
710
+ "2. Call `opthr_wait_for_job` to wait for completion.",
711
+ "3. Call `opthr_export` with format='xlsx' to download the Modello.",
712
+ "4. Walk the user through trasmissione alla Consigliera di parità.",
713
+ ].join("\n");
714
+ return {
715
+ description: "Rapporto Biennale workflow",
716
+ messages: [{ role: "user", content: { type: "text", text: message } }],
717
+ };
718
+ }
719
+ case "opthr-calibrate-team": {
720
+ const message = [
721
+ `Calibrate compensation for team **${args.team_name || "<team>"}**.`,
722
+ "",
723
+ `Employees (Name | Role | RAL): ${args.employees || "<list>"}`,
724
+ "",
725
+ "Steps:",
726
+ "1. For each row, call `opthr_run_compensation`.",
727
+ "2. Use `opthr_wait_for_job` and `opthr_get_job_summary` to collect results.",
728
+ "3. Produce a calibration table (employee, current RAL, percentile band, recommended action).",
729
+ ].join("\n");
730
+ return {
731
+ description: "Team calibration",
732
+ messages: [{ role: "user", content: { type: "text", text: message } }],
733
+ };
734
+ }
735
+ default:
736
+ throw new OptHRError(`Unknown prompt: ${name}`, { code: ERROR_CODES.BACKEND_ERROR });
737
+ }
738
+ }
739
+
146
740
  /* ----------------------------------------------------------------- */
147
741
  /* Tool definitions */
148
742
  /* ----------------------------------------------------------------- */
149
743
 
744
+ const GENDER_VALUES = ["male", "female", "unspecified", "F", "M"];
745
+
150
746
  const TOOLS = [
151
747
  {
152
748
  name: "opthr_health",
153
749
  title: "OptHR · Health check",
154
- description: "Check that the OptHR backend is reachable and report which LLM model it's using. Use this first if other tools fail.",
750
+ description:
751
+ "Sanity-check that the OptHR backend is reachable and report which LLM model it's wired to. " +
752
+ "Use this first when other tools fail. " +
753
+ "**Do NOT use this** to inspect job state or read outputs.",
155
754
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
156
- ...(OPTHR_ICONS ? { icons: OPTHR_ICONS } : {}),
755
+ outputSchema: {
756
+ type: "object",
757
+ properties: {
758
+ ok: { type: "boolean" },
759
+ version: { type: "string" },
760
+ api_base: { type: "string" },
761
+ },
762
+ required: ["ok"],
763
+ },
764
+ annotations: { readOnlyHint: true, openWorldHint: true },
765
+ ...ICON_FIELDS,
157
766
  },
158
767
  {
159
- name: "opthr_list_jobs",
160
- title: "OptHR · List recent jobs",
161
- ...(OPTHR_ICONS ? { icons: OPTHR_ICONS } : {}),
162
- description: "List recent analyses (compensation + skill-gap) for the current tenant. Returns id, state, type and last-update time.",
768
+ name: "opthr_get_job_summary",
769
+ title: "OptHR · Get job summary",
770
+ description:
771
+ "Compact merged summary across compensation, pay-equity, skill-gap, performance and recommendations. " +
772
+ "Best for 'what did this job find?' questions. " +
773
+ "**Do NOT use this** to poll — wait via `opthr_wait_for_job` first, then call this once.",
163
774
  inputSchema: {
164
775
  type: "object",
165
- properties: {
166
- limit: { type: "integer", minimum: 1, maximum: 100, default: 20, description: "Max number of jobs to return" },
167
- offset: { type: "integer", minimum: 0, default: 0 },
168
- },
776
+ properties: { job_id: { type: "string" } },
777
+ required: ["job_id"],
169
778
  additionalProperties: false,
170
779
  },
780
+ annotations: { readOnlyHint: true },
781
+ ...ICON_FIELDS,
171
782
  },
172
783
  {
173
- name: "opthr_get_job",
174
- title: "OptHR · Get job result",
175
- ...(OPTHR_ICONS ? { icons: OPTHR_ICONS } : {}),
176
- description: "Fetch the full record of one job: state, uploaded documents, agent outputs, event log. Use this after running an analysis to read the result.",
784
+ name: "opthr_wait_for_job",
785
+ title: "OptHR · Wait for job",
786
+ description:
787
+ "Poll a running job until it reaches `completed` or `failed`, or the timeout fires. " +
788
+ "Cheaper than polling `opthr_get_job_summary`. " +
789
+ "**Do NOT call** with a timeout longer than a few minutes — surface partial progress instead.",
177
790
  inputSchema: {
178
791
  type: "object",
179
792
  properties: {
180
- job_id: { type: "string", description: "Job UUID, e.g. from opthr_list_jobs" },
793
+ job_id: { type: "string" },
794
+ timeout_seconds: { type: "integer", minimum: 5, maximum: 600, default: 120 },
795
+ poll_interval_seconds: { type: "number", minimum: 0.5, maximum: 30, default: 3 },
181
796
  },
182
797
  required: ["job_id"],
183
798
  additionalProperties: false,
184
799
  },
800
+ annotations: { readOnlyHint: true, openWorldHint: true },
801
+ ...ICON_FIELDS,
185
802
  },
186
803
  {
187
804
  name: "opthr_run_skill_gap",
188
805
  title: "OptHR · Run Skill Gap analysis",
189
- ...(OPTHR_ICONS ? { icons: OPTHR_ICONS } : {}),
190
- description: "Start a new Skill Gap analysis. Compares an employee's skill profile against a target role and returns ranked course recommendations. Auto-attaches the OptHR sample profile (Senior Controller scheda_competenze_pdf) unless you pass document_url.",
806
+ description:
807
+ "Start a Skill Gap analysis from a competence-profile document. " +
808
+ "Pass `document_url` (http/https/file://), `document_path` (absolute or under OPTHR_DOCUMENT_SEARCH_ROOT), " +
809
+ "or `use_sample_data=true` for the bundled demo PDF. " +
810
+ "**Do NOT use this** if you only have payroll data — that's `opthr_run_pay_equity` / `opthr_run_rapporto_biennale`.",
191
811
  inputSchema: {
192
812
  type: "object",
193
813
  properties: {
194
- job_title: { type: "string", description: "Target role to compare against, e.g. 'Senior Backend Engineer'" },
195
- natural_language: { type: "string", description: "What you want the agent to do, in plain English" },
196
- document_url: { type: "string", description: "Optional URL to a CV/skills profile (PDF/CSV) to use INSTEAD of the sample" },
814
+ job_title: { type: "string" },
815
+ natural_language: { type: "string" },
816
+ document_url: { type: "string", description: "http(s):// or file:// URL" },
817
+ document_path: { type: "string", description: "Absolute path or under OPTHR_DOCUMENT_SEARCH_ROOT" },
818
+ use_sample_data: { type: "boolean", default: false },
197
819
  },
198
820
  required: ["job_title"],
199
821
  additionalProperties: false,
200
822
  },
823
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
824
+ ...ICON_FIELDS,
201
825
  },
202
826
  {
203
827
  name: "opthr_run_compensation",
204
828
  title: "OptHR · Run Compensation analysis",
205
- ...(OPTHR_ICONS ? { icons: OPTHR_ICONS } : {}),
206
- description: "Start a new Compensation analysis. Compares an employee's salary against EU market percentile bands (P25/P50/P75/P90), explains the delta, and proposes EU Pay Directive remediation.",
829
+ description:
830
+ "Start a new Compensation + Pay-Equity analysis. Compares the employee's salary against EU market " +
831
+ "percentile bands (P25/P50/P75/P90), explains the delta, and proposes EU Pay Directive remediation. " +
832
+ "**Do NOT use this** for org-wide pay-equity from a payroll CSV — use `opthr_run_pay_equity`.",
207
833
  inputSchema: {
208
834
  type: "object",
209
835
  properties: {
210
836
  employee_name: { type: "string" },
211
- employee_gender: { type: "string", enum: ["male", "female"], description: "Required for the EU Pay Directive gender-gap calculation" },
212
- job_title: { type: "string", description: "e.g. 'Senior Controller'" },
837
+ employee_gender: {
838
+ type: "string",
839
+ enum: GENDER_VALUES,
840
+ default: "unspecified",
841
+ description:
842
+ "Optional. Accepted values: male / female / unspecified / F / M. " +
843
+ "Required only for the EU Pay Directive single-employee gap calculation.",
844
+ },
845
+ job_title: { type: "string" },
213
846
  area: { type: "string", description: "Geo cluster, e.g. 'nord_est'" },
214
847
  experience: { type: "string", description: "e.g. '3_5_anni'" },
215
848
  ral_actual: { type: "number", description: "Annual gross salary EUR" },
216
849
  review_period: { type: "string", description: "e.g. 'Q2 2026'" },
217
- natural_language: { type: "string", description: "What you want the agent to do, in plain English" },
850
+ natural_language: { type: "string" },
851
+ document_url: { type: "string", description: "Optional performance PDF (http/https/file://)" },
852
+ document_path: { type: "string", description: "Optional performance PDF path" },
853
+ use_sample_data: { type: "boolean", default: false },
218
854
  },
219
855
  required: ["employee_name", "job_title", "ral_actual"],
220
856
  additionalProperties: false,
221
857
  },
858
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
859
+ ...ICON_FIELDS,
860
+ },
861
+ {
862
+ name: "opthr_run_pay_equity",
863
+ title: "OptHR · Run Pay-Equity analysis",
864
+ description:
865
+ "Run a pay-equity analysis over a payroll-style CSV (one row per employee, gendered). " +
866
+ "Pass `document_path` or `document_url` (CSV). Set `payroll_source` to hint the column " +
867
+ "layout (zucchetti, teamsystem, or generic) — the backend tolerates either way, but the " +
868
+ "hint helps when columns are ambiguous. " +
869
+ "**Do NOT use this** for a single employee — use `opthr_run_compensation`.",
870
+ inputSchema: {
871
+ type: "object",
872
+ properties: {
873
+ document_path: { type: "string" },
874
+ document_url: { type: "string" },
875
+ threshold_pct: { type: "number", minimum: 0, maximum: 100, default: 5 },
876
+ payroll_source: {
877
+ type: "string",
878
+ enum: ["zucchetti", "teamsystem", "generic"],
879
+ default: "generic",
880
+ description: "Optional column-mapping hint matching the customer's payroll export tool.",
881
+ },
882
+ natural_language: { type: "string" },
883
+ },
884
+ additionalProperties: false,
885
+ },
886
+ annotations: { readOnlyHint: false, openWorldHint: true },
887
+ ...ICON_FIELDS,
888
+ },
889
+ {
890
+ name: "opthr_run_rapporto_biennale",
891
+ title: "OptHR · Rapporto Biennale (Legge 162/2021)",
892
+ description:
893
+ "Start a Rapporto Biennale workflow for the supplied payroll CSV. Creates a job with " +
894
+ "`modules=['equity_report']`, attaches the payroll, runs the equity-report orchestrator " +
895
+ "node, and produces a Modello workbook downloadable via `opthr_export` with `format=xlsx`. " +
896
+ "Set `payroll_source` to hint the column layout. Pass `section_h` to populate the " +
897
+ "qualitative \"politiche di conciliazione\" sheet — anything omitted renders as " +
898
+ "\"(da compilare)\". " +
899
+ "**Do NOT use this** to compare a single employee's salary — use `opthr_run_compensation`.",
900
+ inputSchema: {
901
+ type: "object",
902
+ properties: {
903
+ company_name: { type: "string" },
904
+ document_path: { type: "string" },
905
+ document_url: { type: "string" },
906
+ payroll_source: {
907
+ type: "string",
908
+ enum: ["zucchetti", "teamsystem", "generic"],
909
+ default: "generic",
910
+ description: "Optional column-mapping hint matching the customer's payroll export tool.",
911
+ },
912
+ section_h: {
913
+ type: "object",
914
+ description:
915
+ "Sezione H — politiche di conciliazione vita-lavoro. Optional but recommended " +
916
+ "for companies with ≥50 employees (Legge 162/2021 art. 46).",
917
+ properties: {
918
+ smart_working: { type: "string" },
919
+ part_time_policy: { type: "string" },
920
+ parental_leave: { type: "string" },
921
+ childcare_support: { type: "string" },
922
+ flexible_hours: { type: "string" },
923
+ equal_opportunity_actions: { type: "string" },
924
+ training_on_diversity: { type: "string" },
925
+ career_progression_review: { type: "string" },
926
+ pay_review_process: { type: "string" },
927
+ notes: { type: "string" },
928
+ },
929
+ additionalProperties: false,
930
+ },
931
+ natural_language: { type: "string" },
932
+ },
933
+ additionalProperties: false,
934
+ },
935
+ annotations: { readOnlyHint: false, openWorldHint: true },
936
+ ...ICON_FIELDS,
222
937
  },
223
938
  {
224
939
  name: "opthr_answer",
225
- title: "OptHR · Answer paused job",
226
- ...(OPTHR_ICONS ? { icons: OPTHR_ICONS } : {}),
227
- description: "Send a clarification answer / follow-up question to a paused job. The agent re-runs the affected step and continues.",
940
+ title: "OptHR · Send follow-up",
941
+ description:
942
+ "Send a clarification answer or follow-up instruction to a job that's paused waiting on the user. " +
943
+ "The agent re-runs the affected step and continues. " +
944
+ "**Do NOT use this** to re-run a step on a failed job — re-create the job instead.",
228
945
  inputSchema: {
229
946
  type: "object",
230
947
  properties: {
231
948
  job_id: { type: "string" },
232
- answer: { type: "string", description: "Plain-English answer or follow-up instruction" },
949
+ answer: { type: "string" },
233
950
  },
234
951
  required: ["job_id", "answer"],
235
952
  additionalProperties: false,
236
953
  },
954
+ annotations: { readOnlyHint: false, destructiveHint: false },
955
+ ...ICON_FIELDS,
237
956
  },
238
957
  {
239
958
  name: "opthr_export",
240
959
  title: "OptHR · Export report",
241
- ...(OPTHR_ICONS ? { icons: OPTHR_ICONS } : {}),
242
- description: "Download an export of a completed job. format=pdf returns the audit-ready PDF report; format=xlsx returns the Excel; format=json returns the raw outputs. Returns a base64-encoded file.",
960
+ description:
961
+ "Download an export of a completed job. " +
962
+ "`format=pdf` returns the page-rendered report; `format=xlsx/docx/pptx/json` returns the matching structured export. " +
963
+ "**Do NOT use this** before the job is in state=completed — call `opthr_wait_for_job` first.",
243
964
  inputSchema: {
244
965
  type: "object",
245
966
  properties: {
246
967
  job_id: { type: "string" },
247
- format: { type: "string", enum: ["pdf", "xlsx", "json"], default: "pdf" },
248
- kind: { type: "string", enum: ["skillgap", "compensation"], description: "PDF report kind (only for format=pdf)" },
968
+ format: { type: "string", enum: ["pdf", "xlsx", "json", "docx", "pptx"], default: "pdf" },
969
+ kind: { type: "string", enum: ["skillgap", "compensation"] },
249
970
  },
250
971
  required: ["job_id"],
251
972
  additionalProperties: false,
252
973
  },
974
+ annotations: { readOnlyHint: true },
975
+ ...ICON_FIELDS,
976
+ },
977
+ {
978
+ name: "opthr_review_step",
979
+ title: "OptHR · Review a workflow step",
980
+ description:
981
+ "Generic reviewer action for an OptHR job. Approve or request a revision of a single step. " +
982
+ "Steps: `compensation`, `pay_equity`, `skills`, `recommendations`, `manager_feedback`. " +
983
+ "For `skills` / `recommendations` overrides, pass the full payload under `payload` " +
984
+ "(e.g. `{ \"hard_skills\": [...], \"soft_skills\": [...] }`). " +
985
+ "**Do NOT use this** on the user's behalf without explicit confirmation, and never sign " +
986
+ "manager feedback (decision=approve + step=manager_feedback) without a named human signer.",
987
+ inputSchema: {
988
+ type: "object",
989
+ properties: {
990
+ job_id: { type: "string" },
991
+ step: {
992
+ type: "string",
993
+ enum: ["compensation", "pay_equity", "skills", "recommendations", "manager_feedback"],
994
+ },
995
+ decision: { type: "string", enum: ["approve", "revise"], default: "approve" },
996
+ notes: { type: "string" },
997
+ payload: {
998
+ type: "object",
999
+ description:
1000
+ "Step-specific extras. For `skills`: { hard_skills, soft_skills }. " +
1001
+ "For `recommendations`: { recommendations_by_skill }. " +
1002
+ "For `manager_feedback`: { markdown, rating_bucket?, comp_recommendation?, hr_actions?, signer_name? }.",
1003
+ },
1004
+ },
1005
+ required: ["job_id", "step"],
1006
+ additionalProperties: false,
1007
+ },
1008
+ annotations: { readOnlyHint: false, destructiveHint: false },
1009
+ ...ICON_FIELDS,
253
1010
  },
254
1011
  ];
255
1012
 
@@ -257,51 +1014,65 @@ const TOOLS = [
257
1014
  /* Tool implementations */
258
1015
  /* ----------------------------------------------------------------- */
259
1016
 
1017
+ const PUBLIC_TOOL_NAMES = new Set(TOOLS.map((t) => t.name));
1018
+
260
1019
  async function handle(name, args = {}) {
1020
+ if (!PUBLIC_TOOL_NAMES.has(name)) {
1021
+ throw new OptHRError(`Unknown tool: ${name}`, { code: ERROR_CODES.BACKEND_ERROR });
1022
+ }
261
1023
  switch (name) {
262
1024
  case "opthr_health": {
263
1025
  const h = await request("/health");
264
- return ok({
265
- ok: h.ok,
266
- version: h.version,
267
- llm: h.llm,
268
- api_base: BASE,
269
- });
1026
+ return ok({ ok: h.ok, version: h.version, llm: h.llm, api_base: BASE });
270
1027
  }
271
1028
 
272
- case "opthr_list_jobs": {
273
- const limit = args.limit ?? 20;
274
- const offset = args.offset ?? 0;
275
- const r = await request(`/jobs?limit=${limit}&offset=${offset}`);
276
- const items = (r.items || []).map((j) => ({
277
- job_id: j.job_id,
278
- state: j.state,
279
- type: (j.requested_modules || []).includes("skillgap") ? "skill-gap" : "compensation",
280
- employee_name: j.employee_name || null,
281
- job_title: j.job_title || null,
282
- updated_at: j.updated_at,
283
- }));
284
- return ok({ count: items.length, items });
1029
+ case "opthr_get_job_summary": {
1030
+ const jobId = encodeURIComponent(args.job_id);
1031
+ const [job, comp, payEq, skill, rec, perf] = await Promise.all([
1032
+ request(`/jobs/${jobId}`).catch(() => null),
1033
+ request(`/jobs/${jobId}/compensation-summary`).catch(() => null),
1034
+ request(`/jobs/${jobId}/pay-equity-summary`).catch(() => null),
1035
+ request(`/jobs/${jobId}/skill-gap-summary`).catch(() => null),
1036
+ request(`/jobs/${jobId}/recommendations`).catch(() => null),
1037
+ request(`/jobs/${jobId}/performance-summary`).catch(() => null),
1038
+ ]);
1039
+ return ok({
1040
+ job_id: args.job_id,
1041
+ state: job ? job.state : null,
1042
+ requested_modules: job ? job.requested_modules : null,
1043
+ compensation: comp || null,
1044
+ pay_equity: payEq || null,
1045
+ skill_gap: skill || null,
1046
+ recommendations: rec || null,
1047
+ performance: perf || null,
1048
+ });
285
1049
  }
286
1050
 
287
- case "opthr_get_job": {
288
- const r = await request(`/jobs/${encodeURIComponent(args.job_id)}`);
289
- // Trim noisy fields the model doesn't need.
290
- const trimmed = {
291
- job_id: r.job_id,
292
- state: r.state,
293
- requested_modules: r.requested_modules,
294
- created_at: r.created_at,
295
- updated_at: r.updated_at,
296
- documents: Object.fromEntries(Object.entries(r.documents || {}).map(([k, v]) => [k, { filename: v.filename, uploaded_at: v.uploaded_at }])),
297
- outputs: r.outputs,
298
- last_event: (r.events || []).slice(-1)[0] || null,
299
- };
300
- return ok(trimmed);
1051
+ case "opthr_wait_for_job": {
1052
+ const jobId = encodeURIComponent(args.job_id);
1053
+ const timeoutMs = (args.timeout_seconds ?? 120) * 1000;
1054
+ const intervalMs = Math.max(500, (args.poll_interval_seconds ?? 3) * 1000);
1055
+ const deadline = Date.now() + timeoutMs;
1056
+ let job = await request(`/jobs/${jobId}`);
1057
+ while (Date.now() < deadline) {
1058
+ if (job && (job.state === "completed" || job.state === "failed")) break;
1059
+ await new Promise((r) => setTimeout(r, intervalMs));
1060
+ job = await request(`/jobs/${jobId}`);
1061
+ }
1062
+ if (!job || (job.state !== "completed" && job.state !== "failed")) {
1063
+ throw new OptHRError(`Job ${args.job_id} did not finish within ${args.timeout_seconds ?? 120}s`, {
1064
+ code: ERROR_CODES.JOB_TIMEOUT,
1065
+ });
1066
+ }
1067
+ return ok({
1068
+ job_id: args.job_id,
1069
+ state: job.state,
1070
+ last_event: (job.events || []).slice(-1)[0] || null,
1071
+ requested_modules: job.requested_modules || [],
1072
+ });
301
1073
  }
302
1074
 
303
1075
  case "opthr_run_skill_gap": {
304
- // 1) create the job
305
1076
  const created = await request("/jobs", {
306
1077
  method: "POST",
307
1078
  body: {
@@ -313,43 +1084,38 @@ async function handle(name, args = {}) {
313
1084
  });
314
1085
  const jobId = created.job_id;
315
1086
 
316
- // 2) attach a document (sample if not provided)
317
- if (args.document_url) {
318
- // Fetch user-supplied URL and upload
319
- const fileRes = await fetch(args.document_url);
320
- if (!fileRes.ok) throw new Error(`Cannot fetch ${args.document_url}: ${fileRes.status}`);
321
- const blob = await fileRes.blob();
322
- const fd = new FormData();
323
- fd.append("document_type", "scheda_competenze_pdf");
324
- fd.append("file", blob, args.document_url.split("/").pop() || "skills.pdf");
325
- await request(`/jobs/${encodeURIComponent(jobId)}/documents?auto_run=false`, {
326
- method: "POST",
327
- body: fd,
1087
+ if (args.document_url || args.document_path) {
1088
+ const { blob, filename } = await resolveDocument({
1089
+ document_url: args.document_url,
1090
+ document_path: args.document_path,
1091
+ fallbackName: "skills.pdf",
328
1092
  });
329
- } else {
330
- // Use sample
1093
+ await uploadJobDocument(jobId, blob, filename, "scheda_competenze_pdf");
1094
+ } else if (args.use_sample_data === true) {
331
1095
  const samples = await request("/samples");
332
- const sample = (samples.items || []).find(s => s.mode === "skill_learn");
1096
+ const sample = (samples.items || []).find((s) => s.mode === "skill_learn");
333
1097
  if (sample) {
334
1098
  const sres = await request(`/samples/${encodeURIComponent(sample.name)}`, { raw: true });
335
1099
  const blob = await sres.blob();
336
- const fd = new FormData();
337
- fd.append("document_type", sample.document_type);
338
- fd.append("file", blob, sample.name);
339
- await request(`/jobs/${encodeURIComponent(jobId)}/documents?auto_run=false`, {
340
- method: "POST",
341
- body: fd,
342
- });
1100
+ await uploadJobDocument(jobId, blob, sample.name, sample.document_type);
343
1101
  }
1102
+ } else {
1103
+ return ok({
1104
+ job_id: jobId,
1105
+ state: "needs_document",
1106
+ message:
1107
+ "Skill-gap job created, but no document was attached. Pass document_path / document_url, " +
1108
+ "set use_sample_data=true for the bundled demo, or open the dashboard to upload manually.",
1109
+ required_document_type: "scheda_competenze_pdf",
1110
+ dashboard_url: DASHBOARD_URL,
1111
+ });
344
1112
  }
345
1113
 
346
- // 3) start
347
1114
  await request(`/jobs/${encodeURIComponent(jobId)}/start`, { method: "POST" });
348
-
349
1115
  return ok({
350
1116
  job_id: jobId,
351
1117
  state: "running",
352
- message: `Skill-gap analysis started against role "${args.job_title}". Use opthr_get_job to read results once state=review_required or completed.`,
1118
+ message: `Skill-gap analysis started against role "${args.job_title}". Call opthr_wait_for_job to await completion.`,
353
1119
  });
354
1120
  }
355
1121
 
@@ -359,7 +1125,7 @@ async function handle(name, args = {}) {
359
1125
  body: {
360
1126
  modules: ["compensation", "pay_equity"],
361
1127
  employee_name: args.employee_name,
362
- employee_gender: args.employee_gender || "female",
1128
+ employee_gender: args.employee_gender || "unspecified",
363
1129
  job_title: args.job_title,
364
1130
  area: args.area || "nord_est",
365
1131
  experience: args.experience || "3_5_anni",
@@ -372,27 +1138,116 @@ async function handle(name, args = {}) {
372
1138
  });
373
1139
  const jobId = created.job_id;
374
1140
 
375
- // Sample performance PDF
376
- const samples = await request("/samples");
377
- const perf = (samples.items || []).find(s => s.document_type === "performance_pdf" && s.mode === "comp_pe");
378
- if (perf) {
379
- const sres = await request(`/samples/${encodeURIComponent(perf.name)}`, { raw: true });
380
- const blob = await sres.blob();
381
- const fd = new FormData();
382
- fd.append("document_type", perf.document_type);
383
- fd.append("file", blob, perf.name);
384
- await request(`/jobs/${encodeURIComponent(jobId)}/documents?auto_run=false`, {
385
- method: "POST",
386
- body: fd,
1141
+ if (args.document_url || args.document_path) {
1142
+ const { blob, filename } = await resolveDocument({
1143
+ document_url: args.document_url,
1144
+ document_path: args.document_path,
1145
+ fallbackName: "performance.pdf",
387
1146
  });
1147
+ await uploadJobDocument(jobId, blob, filename, "performance_pdf");
1148
+ } else if (args.use_sample_data === true) {
1149
+ const samples = await request("/samples");
1150
+ const perf = (samples.items || []).find(
1151
+ (s) => s.document_type === "performance_pdf" && s.mode === "comp_pe",
1152
+ );
1153
+ if (perf) {
1154
+ const sres = await request(`/samples/${encodeURIComponent(perf.name)}`, { raw: true });
1155
+ const blob = await sres.blob();
1156
+ await uploadJobDocument(jobId, blob, perf.name, perf.document_type);
1157
+ }
388
1158
  }
389
1159
 
390
1160
  await request(`/jobs/${encodeURIComponent(jobId)}/start`, { method: "POST" });
1161
+ return ok({
1162
+ job_id: jobId,
1163
+ state: "running",
1164
+ message: `Compensation analysis started for ${args.employee_name}. Call opthr_wait_for_job to await completion.`,
1165
+ });
1166
+ }
1167
+
1168
+ case "opthr_run_pay_equity": {
1169
+ const payrollSource = (args.payroll_source || "generic").toLowerCase();
1170
+ const created = await request("/jobs", {
1171
+ method: "POST",
1172
+ body: {
1173
+ modules: ["pay_equity"],
1174
+ pay_equity_threshold_pct: args.threshold_pct ?? 5,
1175
+ natural_language: args.natural_language || "Pay equity analysis",
1176
+ metadata: {
1177
+ natural_language: args.natural_language || "",
1178
+ payroll_source: payrollSource,
1179
+ },
1180
+ },
1181
+ });
1182
+ const jobId = created.job_id;
1183
+ if (!args.document_url && !args.document_path) {
1184
+ return ok({
1185
+ job_id: jobId,
1186
+ state: "needs_document",
1187
+ message:
1188
+ "Pay-equity job created. Attach a pay_equity_csv via document_path / document_url or upload via the dashboard.",
1189
+ required_document_type: "pay_equity_csv",
1190
+ dashboard_url: DASHBOARD_URL,
1191
+ });
1192
+ }
1193
+ const { blob, filename } = await resolveDocument({
1194
+ document_url: args.document_url,
1195
+ document_path: args.document_path,
1196
+ fallbackName: "pay_equity.csv",
1197
+ });
1198
+ await uploadJobDocument(jobId, blob, filename, "pay_equity_csv");
1199
+ await request(`/jobs/${encodeURIComponent(jobId)}/start`, { method: "POST" });
1200
+ return ok({
1201
+ job_id: jobId,
1202
+ state: "running",
1203
+ message: "Pay-equity analysis started. Call opthr_wait_for_job to await completion.",
1204
+ });
1205
+ }
391
1206
 
1207
+ case "opthr_run_rapporto_biennale": {
1208
+ const payrollSource = (args.payroll_source || "generic").toLowerCase();
1209
+ const sectionH =
1210
+ args.section_h && typeof args.section_h === "object" ? args.section_h : undefined;
1211
+ const created = await request("/jobs", {
1212
+ method: "POST",
1213
+ body: {
1214
+ modules: ["equity_report"],
1215
+ company_name: args.company_name || "",
1216
+ section_h: sectionH,
1217
+ natural_language: args.natural_language || `Rapporto Biennale per ${args.company_name || "azienda"}`,
1218
+ metadata: {
1219
+ natural_language: args.natural_language || "",
1220
+ company_name: args.company_name || "",
1221
+ workflow_kind: "rapporto_biennale",
1222
+ payroll_source: payrollSource,
1223
+ },
1224
+ },
1225
+ });
1226
+ const jobId = created.job_id;
1227
+ if (!args.document_url && !args.document_path) {
1228
+ return ok({
1229
+ job_id: jobId,
1230
+ state: "needs_document",
1231
+ message:
1232
+ "Rapporto Biennale job created. Attach a payroll_csv via document_path / document_url. " +
1233
+ "See resource opthr://rapporto-biennale-template for the column layout.",
1234
+ required_document_type: "payroll_csv",
1235
+ dashboard_url: DASHBOARD_URL,
1236
+ });
1237
+ }
1238
+ const { blob, filename } = await resolveDocument({
1239
+ document_url: args.document_url,
1240
+ document_path: args.document_path,
1241
+ fallbackName: "payroll.csv",
1242
+ });
1243
+ await uploadJobDocument(jobId, blob, filename, "payroll_csv");
1244
+ await request(`/jobs/${encodeURIComponent(jobId)}/start`, { method: "POST" });
392
1245
  return ok({
393
1246
  job_id: jobId,
394
1247
  state: "running",
395
- message: `Compensation analysis started for ${args.employee_name}. Use opthr_get_job to read results once state=review_required or completed.`,
1248
+ message:
1249
+ `Rapporto Biennale started for ${args.company_name || "azienda"}. ` +
1250
+ "Call opthr_wait_for_job, then opthr_export with format='xlsx' to download the Modello workbook.",
396
1251
  });
397
1252
  }
398
1253
 
@@ -404,32 +1259,94 @@ async function handle(name, args = {}) {
404
1259
  return ok(r);
405
1260
  }
406
1261
 
407
- case "opthr_export": {
408
- const fmt = args.format || "pdf";
409
- const path = fmt === "pdf"
410
- ? `/jobs/${encodeURIComponent(args.job_id)}/report${args.kind ? `?kind=${args.kind}` : ""}`
411
- : `/jobs/${encodeURIComponent(args.job_id)}/export?format=${fmt}`;
412
- const res = await request(path, { raw: true });
413
- const buf = Buffer.from(await res.arrayBuffer());
414
- return ok({
415
- job_id: args.job_id,
416
- format: fmt,
417
- filename: filenameFromDisposition(
418
- res.headers.get("content-disposition"),
419
- `OptHR-${args.job_id}.${fmt === "pdf" ? "pdf" : fmt}`,
420
- ),
421
- size_bytes: buf.length,
422
- content_type: res.headers.get("content-type") || "application/octet-stream",
423
- base64: buf.toString("base64"),
424
- message: `Downloaded ${buf.length} bytes. Decode the base64 field and save it using the filename above.`,
425
- });
426
- }
1262
+ case "opthr_export":
1263
+ return ok(await downloadExport(args.job_id, args.format || "pdf", args.kind));
1264
+
1265
+ case "opthr_review_step":
1266
+ return ok(await dispatchReviewStep(args));
427
1267
 
428
1268
  default:
429
- throw new Error(`Unknown tool: ${name}`);
1269
+ throw new OptHRError(`Unknown tool: ${name}`, { code: ERROR_CODES.BACKEND_ERROR });
430
1270
  }
431
1271
  }
432
1272
 
1273
+ async function dispatchReviewStep({ job_id, step, decision = "approve", notes, payload = {} }) {
1274
+ if (!job_id || !step) {
1275
+ throw new OptHRError("opthr_review_step requires job_id and step.", {
1276
+ code: ERROR_CODES.BACKEND_ERROR,
1277
+ });
1278
+ }
1279
+ const jobId = encodeURIComponent(job_id);
1280
+ const acknowledged = decision === "approve";
1281
+
1282
+ let path;
1283
+ let body;
1284
+ switch (step) {
1285
+ case "compensation":
1286
+ path = `/jobs/${jobId}/review/compensation`;
1287
+ body = { acknowledged, note: notes || null };
1288
+ break;
1289
+ case "pay_equity":
1290
+ path = `/jobs/${jobId}/review/pay-equity`;
1291
+ body = { acknowledged, note: notes || null };
1292
+ break;
1293
+ case "skills":
1294
+ path = `/jobs/${jobId}/review/skills`;
1295
+ body = {
1296
+ hard_skills: Array.isArray(payload.hard_skills) ? payload.hard_skills : [],
1297
+ soft_skills: Array.isArray(payload.soft_skills) ? payload.soft_skills : [],
1298
+ };
1299
+ break;
1300
+ case "recommendations":
1301
+ path = `/jobs/${jobId}/review/recommendations`;
1302
+ body = { recommendations_by_skill: payload.recommendations_by_skill || {} };
1303
+ break;
1304
+ case "manager_feedback":
1305
+ if (!payload.markdown) {
1306
+ throw new OptHRError("manager_feedback review requires payload.markdown.", {
1307
+ code: ERROR_CODES.BACKEND_ERROR,
1308
+ });
1309
+ }
1310
+ path = `/jobs/${jobId}/review/manager-feedback`;
1311
+ body = {
1312
+ markdown: payload.markdown,
1313
+ rating_bucket: payload.rating_bucket || null,
1314
+ comp_recommendation: payload.comp_recommendation || null,
1315
+ hr_actions: payload.hr_actions || [],
1316
+ sign: acknowledged && !!payload.signer_name,
1317
+ signer_name: payload.signer_name || null,
1318
+ };
1319
+ break;
1320
+ default:
1321
+ throw new OptHRError(`Unknown review step: ${step}`, { code: ERROR_CODES.BACKEND_ERROR });
1322
+ }
1323
+
1324
+ const r = await request(path, { method: "POST", body });
1325
+ return { job_id: r.job_id, state: r.state, step, decision };
1326
+ }
1327
+
1328
+ async function downloadExport(jobId, fmt, kind) {
1329
+ const path =
1330
+ fmt === "pdf"
1331
+ ? `/jobs/${encodeURIComponent(jobId)}/report?engine=page${kind ? `&kind=${kind}` : ""}`
1332
+ : `/jobs/${encodeURIComponent(jobId)}/export?format=${fmt}`;
1333
+ const res = await request(path, { raw: true });
1334
+ const buf = Buffer.from(await res.arrayBuffer());
1335
+ return {
1336
+ job_id: jobId,
1337
+ format: fmt,
1338
+ pdf_engine: fmt === "pdf" ? res.headers.get("x-opthr-pdf-engine") || "page" : undefined,
1339
+ filename: filenameFromDisposition(
1340
+ res.headers.get("content-disposition"),
1341
+ `OptHR-${jobId}.${fmt}`,
1342
+ ),
1343
+ size_bytes: buf.length,
1344
+ content_type: res.headers.get("content-type") || "application/octet-stream",
1345
+ base64: buf.toString("base64"),
1346
+ message: `Downloaded ${buf.length} bytes. Decode the base64 field and save it using the filename above.`,
1347
+ };
1348
+ }
1349
+
433
1350
  /* ----------------------------------------------------------------- */
434
1351
  /* MCP server bootstrap */
435
1352
  /* ----------------------------------------------------------------- */
@@ -437,27 +1354,58 @@ async function handle(name, args = {}) {
437
1354
  const server = new Server(
438
1355
  {
439
1356
  name: "opthr-mcp-server",
440
- title: "OptHR — Compensation & Skill Gap agents",
441
- version: "0.2.0",
442
- description: "Run OptHR's Compensation and Skill Gap agents directly from chat. Audit-ready reasoning, EU Pay Directive coverage, exportable reports.",
1357
+ title: "OptHR — Compensation, Pay-Equity & Skill Gap agents",
1358
+ version: PKG_VERSION,
1359
+ description:
1360
+ "Run OptHR's Compensation, Pay-Equity, Skill Gap and Rapporto Biennale agents directly from chat. " +
1361
+ "Audit-ready reasoning, EU Pay Directive coverage, exportable reports.",
443
1362
  websiteUrl: "https://github.com/Dunic15/OptHRr",
444
- ...(OPTHR_ICONS ? { icons: OPTHR_ICONS } : {}),
1363
+ ...ICON_FIELDS,
445
1364
  },
446
- { capabilities: { tools: {} } },
1365
+ { capabilities: { tools: {}, resources: {}, prompts: {} } },
447
1366
  );
448
1367
 
449
1368
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
450
1369
 
451
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
452
- const { name, arguments: args = {} } = request.params;
1370
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCES }));
1371
+
1372
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => ({
1373
+ contents: [await readResource(req.params.uri)],
1374
+ }));
1375
+
1376
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
1377
+
1378
+ server.setRequestHandler(GetPromptRequestSchema, async (req) =>
1379
+ getPrompt(req.params.name, req.params.arguments || {}),
1380
+ );
1381
+
1382
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1383
+ const { name, arguments: args = {} } = req.params;
1384
+ const started = Date.now();
453
1385
  try {
454
- return await handle(name, args);
1386
+ const result = await handle(name, args);
1387
+ logEvent({
1388
+ event: "tool_call",
1389
+ tool: name,
1390
+ outcome: "ok",
1391
+ latency_ms: Date.now() - started,
1392
+ });
1393
+ return result;
455
1394
  } catch (err) {
1395
+ const code = err && err.code ? err.code : ERROR_CODES.BACKEND_ERROR;
1396
+ logEvent({
1397
+ event: "tool_call",
1398
+ tool: name,
1399
+ outcome: "error",
1400
+ error_code: code,
1401
+ status: err && err.status,
1402
+ latency_ms: Date.now() - started,
1403
+ });
456
1404
  return fail(err);
457
1405
  }
458
1406
  });
459
1407
 
460
1408
  const transport = new StdioServerTransport();
461
1409
  server.connect(transport).then(() => {
462
- console.error(`[opthr-mcp-server] connected on stdio · API base = ${BASE}`);
1410
+ logEvent({ event: "connected", api_base: BASE });
463
1411
  });