@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.
- package/README.md +166 -32
- package/package.json +4 -2
- 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
|
-
*
|
|
6
|
-
* web prototype
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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 —
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
136
|
-
|
|
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:
|
|
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
|
-
|
|
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: "
|
|
160
|
-
title: "OptHR ·
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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: "
|
|
174
|
-
title: "OptHR ·
|
|
175
|
-
|
|
176
|
-
|
|
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"
|
|
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
|
-
|
|
190
|
-
|
|
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"
|
|
195
|
-
natural_language: { type: "string"
|
|
196
|
-
document_url: { type: "string", description: "
|
|
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
|
-
|
|
206
|
-
|
|
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: {
|
|
212
|
-
|
|
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"
|
|
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 ·
|
|
226
|
-
|
|
227
|
-
|
|
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"
|
|
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
|
-
|
|
242
|
-
|
|
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"]
|
|
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 "
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 "
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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}".
|
|
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 || "
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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:
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
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:
|
|
442
|
-
description:
|
|
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
|
-
...
|
|
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(
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1410
|
+
logEvent({ event: "connected", api_base: BASE });
|
|
463
1411
|
});
|