@opthr/mcp-server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/assets/opthr-logo.svg +29 -0
- package/assets/opthr-wordmark.svg +33 -0
- package/package.json +38 -0
- package/src/index.js +463 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @opthr/mcp-server
|
|
2
|
+
|
|
3
|
+
Model Context Protocol server for **OptHR** — exposes the Skill Gap and Compensation agents to MCP-aware clients (Claude Desktop, Cursor, etc.) so you can run HR analyses directly from a chat.
|
|
4
|
+
|
|
5
|
+
This is a thin Node.js wrapper around the OptHR FastAPI backend (`http://localhost:8765` by default). All tools are real — no mocks.
|
|
6
|
+
|
|
7
|
+
## Tools exposed
|
|
8
|
+
|
|
9
|
+
| Tool | What it does |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `opthr_health` | Check the backend is reachable, see which Claude model is wired |
|
|
12
|
+
| `opthr_list_jobs` | Recent analyses for the tenant |
|
|
13
|
+
| `opthr_get_job` | Full job record — state, outputs, last event |
|
|
14
|
+
| `opthr_run_skill_gap` | Start a new Skill Gap analysis (auto-attaches the sample profile) |
|
|
15
|
+
| `opthr_run_compensation` | Start a new Compensation analysis vs P25/P50/P75/P90 |
|
|
16
|
+
| `opthr_answer` | Reply to a paused job (clarification or follow-up) |
|
|
17
|
+
| `opthr_export` | Download report (PDF) or data (XLSX/JSON) as base64 |
|
|
18
|
+
|
|
19
|
+
## Install + run locally
|
|
20
|
+
|
|
21
|
+
Requires Node 18+.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cd mcp-server
|
|
25
|
+
npm install
|
|
26
|
+
npm test # smoke-tests the OptHR backend connection
|
|
27
|
+
npm start # boots the MCP server on stdio
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Wire it into Claude Desktop
|
|
31
|
+
|
|
32
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"opthr": {
|
|
38
|
+
"command": "node",
|
|
39
|
+
"args": ["/Users/YOU/Desktop/HR_startup/mcp-server/src/index.js"],
|
|
40
|
+
"env": {
|
|
41
|
+
"OPTHR_API_BASE": "http://localhost:8765",
|
|
42
|
+
"OPTHR_DEV_ROLE": "admin_hr",
|
|
43
|
+
"OPTHR_DEV_TENANT_ID": "tenant_demo"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
After publishing to npm, replace `command/args` with:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
"command": "npx",
|
|
54
|
+
"args": ["-y", "@opthr/mcp-server@latest"]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then restart Claude Desktop and try:
|
|
58
|
+
|
|
59
|
+
> "Run a skill gap analysis for a Senior Backend Engineer role using the OptHR sample data."
|
|
60
|
+
|
|
61
|
+
Claude will call `opthr_run_skill_gap`, poll `opthr_get_job` until the state is `review_required`, and summarise the matrix + recommended courses for you.
|
|
62
|
+
|
|
63
|
+
## Environment variables
|
|
64
|
+
|
|
65
|
+
| Var | Default | What |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `OPTHR_API_BASE` | `http://localhost:8765` | Where the FastAPI backend lives |
|
|
68
|
+
| `OPTHR_API_KEY` | — | Sent as `X-API-Key` if set |
|
|
69
|
+
| `OPTHR_BEARER_TOKEN` | — | Sent as `Authorization: Bearer …` if set |
|
|
70
|
+
| `OPTHR_DEV_ROLE` | `admin_hr` | Dev fallback `X-Role` header (used when no Bearer token) |
|
|
71
|
+
| `OPTHR_DEV_TENANT_ID` | `tenant_demo` | Dev fallback `X-Tenant-ID` |
|
|
72
|
+
|
|
73
|
+
## How it differs from the prototype `api.js`
|
|
74
|
+
|
|
75
|
+
The browser prototype's `api.js` and this MCP server hit the **exact same endpoints** — there is no separate "MCP backend". The prototype uses cookies/dev headers; this server uses env-supplied auth. Adding new tools is a 10-line patch in `src/index.js` (definition + handler).
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 80 80" role="img" aria-label="optHR">
|
|
2
|
+
<title>optHR</title>
|
|
3
|
+
<desc>Figure surfing through the middle of an ascending bar chart on a coral-to-teal arrow.</desc>
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="bars" x1="0" y1="1" x2="0" y2="0">
|
|
6
|
+
<stop offset="0" stop-color="#FF8A5B"/>
|
|
7
|
+
<stop offset="1" stop-color="#FF6B6B"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
<linearGradient id="wave" x1="0" y1="0" x2="1" y2="0">
|
|
10
|
+
<stop offset="0" stop-color="#FF6B6B"/>
|
|
11
|
+
<stop offset="1" stop-color="#1A535C"/>
|
|
12
|
+
</linearGradient>
|
|
13
|
+
</defs>
|
|
14
|
+
<rect x="6" y="56" width="12" height="18" rx="2.5" fill="url(#bars)" opacity="0.55"/>
|
|
15
|
+
<rect x="22" y="46" width="12" height="28" rx="2.5" fill="url(#bars)" opacity="0.7"/>
|
|
16
|
+
<rect x="38" y="32" width="12" height="42" rx="2.5" fill="url(#bars)" opacity="0.85"/>
|
|
17
|
+
<rect x="54" y="18" width="12" height="56" rx="2.5" fill="url(#bars)"/>
|
|
18
|
+
<path d="M4 64 Q 22 50, 38 38 T 76 8" stroke="url(#wave)" stroke-width="6" stroke-linecap="round" fill="none"/>
|
|
19
|
+
<path d="M76 8 L66 8 M76 8 L76 18" stroke="#1A535C" stroke-width="5" stroke-linecap="round" fill="none"/>
|
|
20
|
+
<g transform="translate(34 30) rotate(-32)">
|
|
21
|
+
<ellipse cx="0" cy="14" rx="16" ry="2.2" fill="#1A535C"/>
|
|
22
|
+
<path d="M-3 13 L -5 8" stroke="#1A535C" stroke-width="2.6" stroke-linecap="round"/>
|
|
23
|
+
<path d="M3 13 L 5 7" stroke="#1A535C" stroke-width="2.6" stroke-linecap="round"/>
|
|
24
|
+
<path d="M-3 7 C -4 4, -2 0, 1 -1 L4 1 C 5 5, 3 8, 0 8 Z" fill="#1A535C"/>
|
|
25
|
+
<path d="M-2 3 L -9 5" stroke="#1A535C" stroke-width="2.4" stroke-linecap="round"/>
|
|
26
|
+
<path d="M3 1 L 10 -2" stroke="#1A535C" stroke-width="2.4" stroke-linecap="round"/>
|
|
27
|
+
<circle cx="3" cy="-4" r="3.8" fill="#1A535C"/>
|
|
28
|
+
</g>
|
|
29
|
+
</svg>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="180" viewBox="0 0 240 90" role="img" aria-label="optHR">
|
|
2
|
+
<title>optHR</title>
|
|
3
|
+
<desc>Figure surfing through the middle of an ascending bar chart on a coral-to-teal arrow, with optHR wordmark.</desc>
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="bars" x1="0" y1="1" x2="0" y2="0">
|
|
6
|
+
<stop offset="0" stop-color="#FF8A5B"/>
|
|
7
|
+
<stop offset="1" stop-color="#FF6B6B"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
<linearGradient id="wave" x1="0" y1="0" x2="1" y2="0">
|
|
10
|
+
<stop offset="0" stop-color="#FF6B6B"/>
|
|
11
|
+
<stop offset="1" stop-color="#1A535C"/>
|
|
12
|
+
</linearGradient>
|
|
13
|
+
</defs>
|
|
14
|
+
<rect x="6" y="60" width="11" height="20" rx="2.5" fill="url(#bars)" opacity="0.55"/>
|
|
15
|
+
<rect x="20" y="50" width="11" height="30" rx="2.5" fill="url(#bars)" opacity="0.7"/>
|
|
16
|
+
<rect x="34" y="38" width="11" height="42" rx="2.5" fill="url(#bars)" opacity="0.85"/>
|
|
17
|
+
<rect x="48" y="24" width="11" height="56" rx="2.5" fill="url(#bars)"/>
|
|
18
|
+
<path d="M4 70 Q 18 56, 32 44 T 70 14" stroke="url(#wave)" stroke-width="5" stroke-linecap="round" fill="none"/>
|
|
19
|
+
<path d="M70 14 L60 14 M70 14 L70 24" stroke="#1A535C" stroke-width="4" stroke-linecap="round" fill="none"/>
|
|
20
|
+
<g transform="translate(28 32) rotate(-32)">
|
|
21
|
+
<ellipse cx="0" cy="13" rx="14" ry="2" fill="#1A535C"/>
|
|
22
|
+
<path d="M-3 12 L -5 8" stroke="#1A535C" stroke-width="2.4" stroke-linecap="round"/>
|
|
23
|
+
<path d="M3 12 L 5 7" stroke="#1A535C" stroke-width="2.4" stroke-linecap="round"/>
|
|
24
|
+
<path d="M-3 7 C -4 4, -2 0, 1 -1 L4 1 C 5 5, 3 8, 0 8 Z" fill="#1A535C"/>
|
|
25
|
+
<path d="M-2 3 L -8 5" stroke="#1A535C" stroke-width="2.2" stroke-linecap="round"/>
|
|
26
|
+
<path d="M3 1 L 9 -2" stroke="#1A535C" stroke-width="2.2" stroke-linecap="round"/>
|
|
27
|
+
<circle cx="3" cy="-4" r="3.4" fill="#1A535C"/>
|
|
28
|
+
</g>
|
|
29
|
+
<g font-family="Inter Tight, Inter, system-ui, sans-serif" font-weight="800" font-size="44" letter-spacing="-1.5">
|
|
30
|
+
<text x="86" y="60" fill="#1A535C">opt</text>
|
|
31
|
+
<text x="158" y="60" fill="#FF6B6B">HR</text>
|
|
32
|
+
</g>
|
|
33
|
+
</svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opthr/mcp-server",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Model Context Protocol server for OptHR — exposes Skill Gap and Compensation agents to Claude Desktop, Cursor, and other MCP clients.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"opthr-mcp-server": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"icon": "assets/opthr-logo.svg",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"assets",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"main": "src/index.js",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node src/index.js",
|
|
18
|
+
"dev": "node src/index.js",
|
|
19
|
+
"test": "node test/smoke.js"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"hr",
|
|
25
|
+
"opthr",
|
|
26
|
+
"compensation",
|
|
27
|
+
"skill-gap",
|
|
28
|
+
"anthropic"
|
|
29
|
+
],
|
|
30
|
+
"author": "OptHR",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @opthr/mcp-server — Model Context Protocol bridge for OptHR.
|
|
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.
|
|
9
|
+
*
|
|
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)
|
|
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
|
+
* }
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
44
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
45
|
+
import {
|
|
46
|
+
CallToolRequestSchema,
|
|
47
|
+
ListToolsRequestSchema,
|
|
48
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
49
|
+
import { readFileSync } from "node:fs";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
51
|
+
import { dirname, join } from "node:path";
|
|
52
|
+
|
|
53
|
+
const BASE = (process.env.OPTHR_API_BASE || "http://localhost:8765").replace(/\/$/, "");
|
|
54
|
+
const API_KEY = process.env.OPTHR_API_KEY || "";
|
|
55
|
+
const BEARER = process.env.OPTHR_BEARER_TOKEN || "";
|
|
56
|
+
const DEV_ROLE = process.env.OPTHR_DEV_ROLE || "admin_hr";
|
|
57
|
+
const DEV_TENANT = process.env.OPTHR_DEV_TENANT_ID || "tenant_demo";
|
|
58
|
+
|
|
59
|
+
/* ----------------------------------------------------------------- */
|
|
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. */
|
|
63
|
+
/* ----------------------------------------------------------------- */
|
|
64
|
+
|
|
65
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
66
|
+
const LOGO_PATH = join(__dirname, "..", "assets", "opthr-logo.svg");
|
|
67
|
+
|
|
68
|
+
function loadOptHRIcon() {
|
|
69
|
+
try {
|
|
70
|
+
const svg = readFileSync(LOGO_PATH, "utf8");
|
|
71
|
+
const dataUri = "data:image/svg+xml;base64," + Buffer.from(svg, "utf8").toString("base64");
|
|
72
|
+
return [{ src: dataUri, mimeType: "image/svg+xml", sizes: ["any"] }];
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const OPTHR_ICONS = loadOptHRIcon();
|
|
79
|
+
|
|
80
|
+
/* ----------------------------------------------------------------- */
|
|
81
|
+
/* HTTP helpers */
|
|
82
|
+
/* ----------------------------------------------------------------- */
|
|
83
|
+
|
|
84
|
+
async function request(path, opts = {}) {
|
|
85
|
+
const url = path.startsWith("http") ? path : BASE + path;
|
|
86
|
+
const headers = { ...(opts.headers || {}) };
|
|
87
|
+
|
|
88
|
+
// Body handling: pass FormData/Blob/Buffer through untouched (fetch sets the
|
|
89
|
+
// correct multipart boundary). Stringify plain objects as JSON. Leave string
|
|
90
|
+
// bodies alone.
|
|
91
|
+
let body = opts.body;
|
|
92
|
+
const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
|
|
93
|
+
const isBinary =
|
|
94
|
+
body instanceof ArrayBuffer ||
|
|
95
|
+
ArrayBuffer.isView(body) ||
|
|
96
|
+
(typeof Blob !== "undefined" && body instanceof Blob) ||
|
|
97
|
+
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream);
|
|
98
|
+
if (body && !isFormData && !isBinary && typeof body !== "string") {
|
|
99
|
+
body = JSON.stringify(body);
|
|
100
|
+
if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
|
|
101
|
+
} else if (typeof body === "string" && !headers["Content-Type"]) {
|
|
102
|
+
headers["Content-Type"] = "application/json";
|
|
103
|
+
}
|
|
104
|
+
// For FormData, do NOT set Content-Type — fetch must set it with the boundary.
|
|
105
|
+
|
|
106
|
+
if (BEARER) headers["Authorization"] = "Bearer " + BEARER;
|
|
107
|
+
else {
|
|
108
|
+
headers["X-Role"] = DEV_ROLE;
|
|
109
|
+
headers["X-Tenant-ID"] = DEV_TENANT;
|
|
110
|
+
}
|
|
111
|
+
if (API_KEY) headers["X-API-Key"] = API_KEY;
|
|
112
|
+
|
|
113
|
+
const res = await fetch(url, {
|
|
114
|
+
method: opts.method || "GET",
|
|
115
|
+
headers,
|
|
116
|
+
body,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const ct = res.headers.get("content-type") || "";
|
|
120
|
+
let resBody = null;
|
|
121
|
+
if (ct.includes("application/json")) resBody = await res.json().catch(() => null);
|
|
122
|
+
else if (!opts.raw) resBody = await res.text().catch(() => null);
|
|
123
|
+
|
|
124
|
+
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;
|
|
130
|
+
}
|
|
131
|
+
return opts.raw ? res : resBody;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
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 });
|
|
137
|
+
|
|
138
|
+
function filenameFromDisposition(value, fallback) {
|
|
139
|
+
const raw = value || "";
|
|
140
|
+
const utf = raw.match(/filename\*=UTF-8''([^;]+)/i);
|
|
141
|
+
if (utf) return decodeURIComponent(utf[1]);
|
|
142
|
+
const ascii = raw.match(/filename="?([^";]+)"?/i);
|
|
143
|
+
return ascii ? ascii[1] : fallback;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ----------------------------------------------------------------- */
|
|
147
|
+
/* Tool definitions */
|
|
148
|
+
/* ----------------------------------------------------------------- */
|
|
149
|
+
|
|
150
|
+
const TOOLS = [
|
|
151
|
+
{
|
|
152
|
+
name: "opthr_health",
|
|
153
|
+
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.",
|
|
155
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
156
|
+
...(OPTHR_ICONS ? { icons: OPTHR_ICONS } : {}),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
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.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
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
|
+
},
|
|
169
|
+
additionalProperties: false,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
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.",
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: "object",
|
|
179
|
+
properties: {
|
|
180
|
+
job_id: { type: "string", description: "Job UUID, e.g. from opthr_list_jobs" },
|
|
181
|
+
},
|
|
182
|
+
required: ["job_id"],
|
|
183
|
+
additionalProperties: false,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "opthr_run_skill_gap",
|
|
188
|
+
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.",
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: "object",
|
|
193
|
+
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" },
|
|
197
|
+
},
|
|
198
|
+
required: ["job_title"],
|
|
199
|
+
additionalProperties: false,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "opthr_run_compensation",
|
|
204
|
+
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.",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
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'" },
|
|
213
|
+
area: { type: "string", description: "Geo cluster, e.g. 'nord_est'" },
|
|
214
|
+
experience: { type: "string", description: "e.g. '3_5_anni'" },
|
|
215
|
+
ral_actual: { type: "number", description: "Annual gross salary EUR" },
|
|
216
|
+
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" },
|
|
218
|
+
},
|
|
219
|
+
required: ["employee_name", "job_title", "ral_actual"],
|
|
220
|
+
additionalProperties: false,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
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.",
|
|
228
|
+
inputSchema: {
|
|
229
|
+
type: "object",
|
|
230
|
+
properties: {
|
|
231
|
+
job_id: { type: "string" },
|
|
232
|
+
answer: { type: "string", description: "Plain-English answer or follow-up instruction" },
|
|
233
|
+
},
|
|
234
|
+
required: ["job_id", "answer"],
|
|
235
|
+
additionalProperties: false,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "opthr_export",
|
|
240
|
+
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.",
|
|
243
|
+
inputSchema: {
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: {
|
|
246
|
+
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)" },
|
|
249
|
+
},
|
|
250
|
+
required: ["job_id"],
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
/* ----------------------------------------------------------------- */
|
|
257
|
+
/* Tool implementations */
|
|
258
|
+
/* ----------------------------------------------------------------- */
|
|
259
|
+
|
|
260
|
+
async function handle(name, args = {}) {
|
|
261
|
+
switch (name) {
|
|
262
|
+
case "opthr_health": {
|
|
263
|
+
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
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
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 });
|
|
285
|
+
}
|
|
286
|
+
|
|
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);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
case "opthr_run_skill_gap": {
|
|
304
|
+
// 1) create the job
|
|
305
|
+
const created = await request("/jobs", {
|
|
306
|
+
method: "POST",
|
|
307
|
+
body: {
|
|
308
|
+
modules: ["skillgap"],
|
|
309
|
+
job_title: args.job_title,
|
|
310
|
+
natural_language: args.natural_language || `Skill gap for ${args.job_title}`,
|
|
311
|
+
metadata: { natural_language: args.natural_language || "" },
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
const jobId = created.job_id;
|
|
315
|
+
|
|
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,
|
|
328
|
+
});
|
|
329
|
+
} else {
|
|
330
|
+
// Use sample
|
|
331
|
+
const samples = await request("/samples");
|
|
332
|
+
const sample = (samples.items || []).find(s => s.mode === "skill_learn");
|
|
333
|
+
if (sample) {
|
|
334
|
+
const sres = await request(`/samples/${encodeURIComponent(sample.name)}`, { raw: true });
|
|
335
|
+
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
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 3) start
|
|
347
|
+
await request(`/jobs/${encodeURIComponent(jobId)}/start`, { method: "POST" });
|
|
348
|
+
|
|
349
|
+
return ok({
|
|
350
|
+
job_id: jobId,
|
|
351
|
+
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.`,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
case "opthr_run_compensation": {
|
|
357
|
+
const created = await request("/jobs", {
|
|
358
|
+
method: "POST",
|
|
359
|
+
body: {
|
|
360
|
+
modules: ["compensation", "pay_equity"],
|
|
361
|
+
employee_name: args.employee_name,
|
|
362
|
+
employee_gender: args.employee_gender || "female",
|
|
363
|
+
job_title: args.job_title,
|
|
364
|
+
area: args.area || "nord_est",
|
|
365
|
+
experience: args.experience || "3_5_anni",
|
|
366
|
+
ral_actual: Number(args.ral_actual),
|
|
367
|
+
pay_equity_threshold_pct: 5,
|
|
368
|
+
review_period: args.review_period || "current",
|
|
369
|
+
natural_language: args.natural_language || `Compensation for ${args.employee_name} (${args.job_title})`,
|
|
370
|
+
metadata: { natural_language: args.natural_language || "" },
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
const jobId = created.job_id;
|
|
374
|
+
|
|
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,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await request(`/jobs/${encodeURIComponent(jobId)}/start`, { method: "POST" });
|
|
391
|
+
|
|
392
|
+
return ok({
|
|
393
|
+
job_id: jobId,
|
|
394
|
+
state: "running",
|
|
395
|
+
message: `Compensation analysis started for ${args.employee_name}. Use opthr_get_job to read results once state=review_required or completed.`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
case "opthr_answer": {
|
|
400
|
+
const r = await request(`/jobs/${encodeURIComponent(args.job_id)}/answer`, {
|
|
401
|
+
method: "POST",
|
|
402
|
+
body: { answer: args.answer, fields: {} },
|
|
403
|
+
});
|
|
404
|
+
return ok(r);
|
|
405
|
+
}
|
|
406
|
+
|
|
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
|
+
}
|
|
427
|
+
|
|
428
|
+
default:
|
|
429
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/* ----------------------------------------------------------------- */
|
|
434
|
+
/* MCP server bootstrap */
|
|
435
|
+
/* ----------------------------------------------------------------- */
|
|
436
|
+
|
|
437
|
+
const server = new Server(
|
|
438
|
+
{
|
|
439
|
+
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.",
|
|
443
|
+
websiteUrl: "https://github.com/Dunic15/OptHRr",
|
|
444
|
+
...(OPTHR_ICONS ? { icons: OPTHR_ICONS } : {}),
|
|
445
|
+
},
|
|
446
|
+
{ capabilities: { tools: {} } },
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
450
|
+
|
|
451
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
452
|
+
const { name, arguments: args = {} } = request.params;
|
|
453
|
+
try {
|
|
454
|
+
return await handle(name, args);
|
|
455
|
+
} catch (err) {
|
|
456
|
+
return fail(err);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const transport = new StdioServerTransport();
|
|
461
|
+
server.connect(transport).then(() => {
|
|
462
|
+
console.error(`[opthr-mcp-server] connected on stdio · API base = ${BASE}`);
|
|
463
|
+
});
|