@recruiter-tools/mcp 1.0.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/dist/client.js +46 -0
- package/dist/index.js +170 -0
- package/dist/registry.js +189 -0
- package/dist/token-store.js +16 -0
- package/dist/tools/candidates.js +99 -0
- package/dist/tools/hh.js +49 -0
- package/dist/tools/jobs.js +59 -0
- package/dist/tools/linkedin.js +34 -0
- package/dist/tools/neon.js +385 -0
- package/package.json +34 -0
- package/services.json +14 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for candidate-routing API.
|
|
3
|
+
* Auth: Bearer token — reads dynamically from token-store so login() takes effect
|
|
4
|
+
* immediately without restarting the server.
|
|
5
|
+
*/
|
|
6
|
+
import { getToken } from './token-store.js';
|
|
7
|
+
export class CRClient {
|
|
8
|
+
baseUrl;
|
|
9
|
+
constructor(baseUrl, _token) {
|
|
10
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
11
|
+
}
|
|
12
|
+
async get(path) {
|
|
13
|
+
return this.request('GET', path);
|
|
14
|
+
}
|
|
15
|
+
async post(path, body) {
|
|
16
|
+
return this.request('POST', path, body);
|
|
17
|
+
}
|
|
18
|
+
async delete(path) {
|
|
19
|
+
return this.request('DELETE', path);
|
|
20
|
+
}
|
|
21
|
+
async request(method, path, body) {
|
|
22
|
+
const url = this.baseUrl + path;
|
|
23
|
+
const token = getToken();
|
|
24
|
+
if (!token) {
|
|
25
|
+
throw new Error('Not authenticated. Call login(email, password) to authenticate.');
|
|
26
|
+
}
|
|
27
|
+
const headers = {
|
|
28
|
+
Authorization: `Bearer ${token}`,
|
|
29
|
+
};
|
|
30
|
+
const init = { method, headers, signal: AbortSignal.timeout(30_000) };
|
|
31
|
+
if (body !== undefined) {
|
|
32
|
+
headers['Content-Type'] = 'application/json';
|
|
33
|
+
init.body = JSON.stringify(body);
|
|
34
|
+
}
|
|
35
|
+
const res = await fetch(url, init);
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const text = await res.text().catch(() => '');
|
|
38
|
+
throw new Error(`${method} ${path} → ${res.status}: ${text.slice(0, 500)}`);
|
|
39
|
+
}
|
|
40
|
+
const ct = res.headers.get('content-type') ?? '';
|
|
41
|
+
if (ct.includes('application/json')) {
|
|
42
|
+
return res.json();
|
|
43
|
+
}
|
|
44
|
+
return res.text();
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
#!/usr/bin/env node
|
|
3
|
+
/**
|
|
4
|
+
* recruiter-mcp — MCP server entry point.
|
|
5
|
+
*
|
|
6
|
+
* Startup sequence:
|
|
7
|
+
* 1. Register login/whoami tools (work without a token)
|
|
8
|
+
* 2. Read services.json — list of backend services
|
|
9
|
+
* 3. For each service: try to fetch /api/mcp/manifest and register tools dynamically
|
|
10
|
+
* 4. If manifest unavailable and fallback="static" — register hardcoded tools
|
|
11
|
+
* 5. Connect MCP transport
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
15
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { CRClient } from './client.js';
|
|
18
|
+
import { registerFromManifest } from './registry.js';
|
|
19
|
+
import { getToken, setToken } from './token-store.js';
|
|
20
|
+
// Static tool registrations (fallback)
|
|
21
|
+
import { registerCandidateTools } from './tools/candidates.js';
|
|
22
|
+
import { registerJobTools } from './tools/jobs.js';
|
|
23
|
+
import { registerHHTools } from './tools/hh.js';
|
|
24
|
+
import { registerLinkedInTools } from './tools/linkedin.js';
|
|
25
|
+
// Neon-backed tools (always registered if RECRUITER_NEON_URL is set)
|
|
26
|
+
import { registerNeonTools } from './tools/neon.js';
|
|
27
|
+
const servicesPath = new URL('../services.json', import.meta.url);
|
|
28
|
+
const servicesConfig = JSON.parse(readFileSync(servicesPath, 'utf-8'));
|
|
29
|
+
// ── Env ───────────────────────────────────────────────────────
|
|
30
|
+
const crBaseUrl = process.env.CR_BASE_URL ?? 'https://recruiter-assistant.com';
|
|
31
|
+
const agentUrl = (process.env.RECRUITER_AGENT_URL ?? 'https://agent.recruiter-assistant.com').replace(/\/$/, '');
|
|
32
|
+
// ── Create MCP server ─────────────────────────────────────────
|
|
33
|
+
const server = new McpServer({
|
|
34
|
+
name: 'recruiter-mcp',
|
|
35
|
+
version: '1.0.0',
|
|
36
|
+
});
|
|
37
|
+
let totalTools = 0;
|
|
38
|
+
// ── login tool ────────────────────────────────────────────────
|
|
39
|
+
// Works without a pre-configured token. Call this first to authenticate.
|
|
40
|
+
server.tool('login', 'Authenticate with your recruiter account. Call this at the start of a session if no token is configured. Returns your MCP token and the exact config snippet to paste into Claude Code or Codex settings.', {
|
|
41
|
+
email: z.string().describe('Your recruiter account email'),
|
|
42
|
+
password: z.string().describe('Your recruiter account password'),
|
|
43
|
+
label: z.string().optional().describe('Label for this token, e.g. "Claude Code", "Codex"'),
|
|
44
|
+
}, async ({ email, password, label }) => {
|
|
45
|
+
const text = (msg) => ({ content: [{ type: 'text', text: msg }] });
|
|
46
|
+
// Step 1 — get JWT from recruiting-agent
|
|
47
|
+
let jwt;
|
|
48
|
+
let userName;
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`${agentUrl}/api/auth/login`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ email, password }),
|
|
54
|
+
signal: AbortSignal.timeout(10_000),
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const err = await res.text().catch(() => '');
|
|
58
|
+
return text(`Login failed (${res.status}): ${err || 'invalid credentials'}`);
|
|
59
|
+
}
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
jwt = data.token;
|
|
62
|
+
userName = data.user.name;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return text(`Login error: ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
}
|
|
67
|
+
// Step 2 — bootstrap an MCP token via recruiting-agent
|
|
68
|
+
const tokenLabel = label ?? `Claude Code ${new Date().toISOString().slice(0, 10)}`;
|
|
69
|
+
let bootstrap;
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(`${agentUrl}/api/mcp/bootstrap`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
Authorization: `Bearer ${jwt}`,
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify({ client_type: 'claude-code', label: tokenLabel }),
|
|
78
|
+
signal: AbortSignal.timeout(15_000),
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
const err = await res.text().catch(() => '');
|
|
82
|
+
return text(`Bootstrap failed (${res.status}): ${err}`);
|
|
83
|
+
}
|
|
84
|
+
bootstrap = await res.json();
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return text(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
|
|
88
|
+
}
|
|
89
|
+
// Step 3 — store token in memory for this session
|
|
90
|
+
setToken(bootstrap.token);
|
|
91
|
+
const jobLines = bootstrap.accessible_jobs === '*'
|
|
92
|
+
? ' (all jobs — admin)'
|
|
93
|
+
: bootstrap.accessible_jobs
|
|
94
|
+
.map(j => ` • ${j.slug} — ${j.title}`)
|
|
95
|
+
.join('\n');
|
|
96
|
+
return text([
|
|
97
|
+
`Logged in as ${userName} (${bootstrap.user.role})`,
|
|
98
|
+
`Token ID: ${bootstrap.token_id} | Expires: ${bootstrap.expires_at ?? 'never'}`,
|
|
99
|
+
'',
|
|
100
|
+
'Accessible jobs:',
|
|
101
|
+
jobLines,
|
|
102
|
+
'',
|
|
103
|
+
'Token is active for this session.',
|
|
104
|
+
'To persist across restarts, add to your config:',
|
|
105
|
+
'',
|
|
106
|
+
'── Claude Code (~/.claude/settings.json) ──',
|
|
107
|
+
`"CR_SESSION_TOKEN": "${bootstrap.token}"`,
|
|
108
|
+
'',
|
|
109
|
+
'── Codex (~/.codex/config.toml) ──',
|
|
110
|
+
`[mcp_servers.recruiter-mcp.env]`,
|
|
111
|
+
`CR_SESSION_TOKEN = "${bootstrap.token}"`,
|
|
112
|
+
].join('\n'));
|
|
113
|
+
});
|
|
114
|
+
totalTools += 1;
|
|
115
|
+
// ── whoami tool ───────────────────────────────────────────────
|
|
116
|
+
server.tool('whoami', 'Show current user identity and accessible jobs. Call this at the start of a session to confirm which recruiter account is active. If not authenticated, call login(email, password) first.', {}, async () => {
|
|
117
|
+
const token = getToken();
|
|
118
|
+
if (!token) {
|
|
119
|
+
return { content: [{ type: 'text', text: 'Not authenticated. Call login(email, password) to authenticate.' }] };
|
|
120
|
+
}
|
|
121
|
+
const res = await fetch(`${crBaseUrl.replace(/\/$/, '')}/api/tokens/whoami`, {
|
|
122
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
123
|
+
signal: AbortSignal.timeout(10_000),
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
const t = await res.text().catch(() => '');
|
|
127
|
+
return { content: [{ type: 'text', text: `Error ${res.status}: ${t}` }] };
|
|
128
|
+
}
|
|
129
|
+
const data = await res.json();
|
|
130
|
+
const jobLines = data.accessible_jobs === '*'
|
|
131
|
+
? ' (all jobs — admin)'
|
|
132
|
+
: data.accessible_jobs.map(j => ` • ${j.slug} — ${j.title}`).join('\n');
|
|
133
|
+
return { content: [{ type: 'text', text: `Logged in as: ${data.user_id} (${data.role})\n\nAccessible jobs:\n${jobLines}` }] };
|
|
134
|
+
});
|
|
135
|
+
totalTools += 1;
|
|
136
|
+
process.stderr.write('[recruiter-mcp] login + whoami tools registered\n');
|
|
137
|
+
// ── Register tools from all services ──────────────────────────
|
|
138
|
+
for (const svc of servicesConfig.services) {
|
|
139
|
+
const result = await registerFromManifest(server, svc);
|
|
140
|
+
if (result) {
|
|
141
|
+
process.stderr.write(`[recruiter-mcp] ${result.service} v${result.version}: ${result.toolCount} tools` +
|
|
142
|
+
(result.playbookCount ? `, ${result.playbookCount} playbooks` : '') +
|
|
143
|
+
` (dynamic)\n`);
|
|
144
|
+
totalTools += result.toolCount;
|
|
145
|
+
}
|
|
146
|
+
else if (svc.fallback === 'static') {
|
|
147
|
+
const baseUrl = process.env[svc.env.base_url];
|
|
148
|
+
if (!baseUrl) {
|
|
149
|
+
process.stderr.write(`[recruiter-mcp] ${svc.name}: skipped — missing ${svc.env.base_url}\n`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const client = new CRClient(baseUrl);
|
|
153
|
+
registerCandidateTools(server, client);
|
|
154
|
+
registerJobTools(server, client);
|
|
155
|
+
registerHHTools(server, client);
|
|
156
|
+
registerLinkedInTools(server, client);
|
|
157
|
+
process.stderr.write(`[recruiter-mcp] ${svc.name}: 27 tools (static fallback)\n`);
|
|
158
|
+
totalTools += 27;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
process.stderr.write(`[recruiter-mcp] ${svc.name}: skipped — no manifest, no fallback\n`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ── Register Neon tools (independent of CR services) ─────────
|
|
165
|
+
const neonToolCount = registerNeonTools(server);
|
|
166
|
+
totalTools += neonToolCount;
|
|
167
|
+
// ── Connect transport ─────────────────────────────────────────
|
|
168
|
+
const transport = new StdioServerTransport();
|
|
169
|
+
await server.connect(transport);
|
|
170
|
+
process.stderr.write(`[recruiter-mcp] Server started (${totalTools} tools)\n`);
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getToken } from './token-store.js';
|
|
3
|
+
// ── JSON Schema → Zod conversion ─────────────────────────────
|
|
4
|
+
function paramToZod(param) {
|
|
5
|
+
let schema;
|
|
6
|
+
switch (param.type) {
|
|
7
|
+
case 'string':
|
|
8
|
+
schema = param.enum
|
|
9
|
+
? z.enum(param.enum)
|
|
10
|
+
: z.string();
|
|
11
|
+
break;
|
|
12
|
+
case 'number':
|
|
13
|
+
schema = z.number();
|
|
14
|
+
break;
|
|
15
|
+
case 'boolean':
|
|
16
|
+
schema = z.boolean();
|
|
17
|
+
break;
|
|
18
|
+
case 'object':
|
|
19
|
+
schema = z.record(z.unknown());
|
|
20
|
+
break;
|
|
21
|
+
case 'array':
|
|
22
|
+
schema = z.array(param.items?.type === 'number' ? z.number() : z.string());
|
|
23
|
+
break;
|
|
24
|
+
default:
|
|
25
|
+
schema = z.unknown();
|
|
26
|
+
}
|
|
27
|
+
if (param.description) {
|
|
28
|
+
schema = schema.describe(param.description);
|
|
29
|
+
}
|
|
30
|
+
if (!param.required) {
|
|
31
|
+
schema = schema.optional();
|
|
32
|
+
}
|
|
33
|
+
return schema;
|
|
34
|
+
}
|
|
35
|
+
function buildZodSchema(parameters) {
|
|
36
|
+
const shape = {};
|
|
37
|
+
for (const [key, param] of Object.entries(parameters)) {
|
|
38
|
+
shape[key] = paramToZod(param);
|
|
39
|
+
}
|
|
40
|
+
return shape;
|
|
41
|
+
}
|
|
42
|
+
function buildAuthHeaders(auth) {
|
|
43
|
+
switch (auth.type) {
|
|
44
|
+
case 'cookie':
|
|
45
|
+
return { Cookie: `${auth.cookieName ?? 'session'}=${auth.token}` };
|
|
46
|
+
case 'bearer':
|
|
47
|
+
return { Authorization: `Bearer ${auth.token}` };
|
|
48
|
+
case 'header':
|
|
49
|
+
return { [auth.headerName ?? 'X-API-Key']: auth.token };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function proxyRequest(baseUrl, auth, endpoint, params) {
|
|
53
|
+
// Token store takes precedence over the static auth config captured at registration
|
|
54
|
+
const liveToken = getToken();
|
|
55
|
+
if (!liveToken) {
|
|
56
|
+
throw new Error('Not authenticated. Call login(email, password) to authenticate.');
|
|
57
|
+
}
|
|
58
|
+
const resolvedAuth = { ...auth, token: liveToken };
|
|
59
|
+
// 1. Substitute path params
|
|
60
|
+
let path = endpoint.path;
|
|
61
|
+
for (const p of endpoint.path_params ?? []) {
|
|
62
|
+
path = path.replace(`{${p}}`, encodeURIComponent(String(params[p])));
|
|
63
|
+
}
|
|
64
|
+
// 2. Build query string
|
|
65
|
+
const qp = new URLSearchParams();
|
|
66
|
+
for (const p of endpoint.query_params ?? []) {
|
|
67
|
+
if (params[p] !== undefined) {
|
|
68
|
+
qp.set(p, String(params[p]));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const qs = qp.toString();
|
|
72
|
+
const url = `${baseUrl}${path}${qs ? '?' + qs : ''}`;
|
|
73
|
+
// 3. Build body
|
|
74
|
+
const bodyKeys = endpoint.body_params;
|
|
75
|
+
let body;
|
|
76
|
+
if (bodyKeys && (endpoint.method === 'POST' || endpoint.method === 'PUT')) {
|
|
77
|
+
body = {};
|
|
78
|
+
for (const p of bodyKeys) {
|
|
79
|
+
if (params[p] !== undefined) {
|
|
80
|
+
body[p] = params[p];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (!bodyKeys &&
|
|
85
|
+
(endpoint.method === 'POST' || endpoint.method === 'PUT')) {
|
|
86
|
+
// No explicit body_params — send all non-path/non-query params as body
|
|
87
|
+
const pathSet = new Set(endpoint.path_params ?? []);
|
|
88
|
+
const querySet = new Set(endpoint.query_params ?? []);
|
|
89
|
+
body = {};
|
|
90
|
+
for (const [k, v] of Object.entries(params)) {
|
|
91
|
+
if (!pathSet.has(k) && !querySet.has(k) && v !== undefined) {
|
|
92
|
+
body[k] = v;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (Object.keys(body).length === 0)
|
|
96
|
+
body = undefined;
|
|
97
|
+
}
|
|
98
|
+
// 4. Make request
|
|
99
|
+
const headers = {
|
|
100
|
+
...buildAuthHeaders(resolvedAuth),
|
|
101
|
+
};
|
|
102
|
+
const init = {
|
|
103
|
+
method: endpoint.method,
|
|
104
|
+
headers,
|
|
105
|
+
signal: AbortSignal.timeout(30_000),
|
|
106
|
+
};
|
|
107
|
+
if (body !== undefined) {
|
|
108
|
+
headers['Content-Type'] = 'application/json';
|
|
109
|
+
init.body = JSON.stringify(body);
|
|
110
|
+
}
|
|
111
|
+
const res = await fetch(url, init);
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
const text = await res.text().catch(() => '');
|
|
114
|
+
throw new Error(`${endpoint.method} ${path} → ${res.status}: ${text.slice(0, 500)}`);
|
|
115
|
+
}
|
|
116
|
+
const ct = res.headers.get('content-type') ?? '';
|
|
117
|
+
if (ct.includes('application/json')) {
|
|
118
|
+
return res.json();
|
|
119
|
+
}
|
|
120
|
+
return res.text();
|
|
121
|
+
}
|
|
122
|
+
// ── Main: fetch manifest & register tools ─────────────────────
|
|
123
|
+
function json(data) {
|
|
124
|
+
return {
|
|
125
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export async function registerFromManifest(server, svcConfig) {
|
|
129
|
+
const baseUrl = process.env[svcConfig.env.base_url];
|
|
130
|
+
const token = process.env[svcConfig.env.token];
|
|
131
|
+
if (!baseUrl || !token) {
|
|
132
|
+
process.stderr.write(`[registry] Skipping ${svcConfig.name}: missing env ${svcConfig.env.base_url} or ${svcConfig.env.token}\n`);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const manifestUrl = `${baseUrl}${svcConfig.manifest_path}`;
|
|
136
|
+
let manifest;
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(manifestUrl, {
|
|
139
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
140
|
+
signal: AbortSignal.timeout(10_000),
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
throw new Error(`${res.status} ${res.statusText}`);
|
|
144
|
+
}
|
|
145
|
+
manifest = (await res.json());
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
process.stderr.write(`[registry] Failed to fetch manifest from ${svcConfig.name} (${manifestUrl}): ${err}\n`);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
// Build auth config
|
|
152
|
+
const authToken = process.env[manifest.auth.env_var] ?? token;
|
|
153
|
+
const authCfg = {
|
|
154
|
+
type: manifest.auth.type,
|
|
155
|
+
cookieName: manifest.auth.cookie_name,
|
|
156
|
+
headerName: manifest.auth.header_name,
|
|
157
|
+
token: authToken,
|
|
158
|
+
};
|
|
159
|
+
const svcBaseUrl = process.env[manifest.base_url_env] ?? baseUrl;
|
|
160
|
+
// Register tools
|
|
161
|
+
for (const tool of manifest.tools) {
|
|
162
|
+
let description = tool.description;
|
|
163
|
+
if (tool.deprecated) {
|
|
164
|
+
description = `[DEPRECATED: ${tool.deprecated_message ?? 'use newer alternative'}] ${description}`;
|
|
165
|
+
}
|
|
166
|
+
const zodShape = buildZodSchema(tool.parameters);
|
|
167
|
+
server.tool(tool.name, description, zodShape, async (params) => {
|
|
168
|
+
return json(await proxyRequest(svcBaseUrl, authCfg, tool.endpoint, params));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// Register playbooks as a resource (list_playbooks meta-tool)
|
|
172
|
+
if (manifest.playbooks && manifest.playbooks.length > 0) {
|
|
173
|
+
const playbooks = manifest.playbooks;
|
|
174
|
+
server.tool(`${manifest.service}_playbooks`, `List available playbooks for ${manifest.service}. Playbooks describe multi-step workflows.`, {}, async () => {
|
|
175
|
+
return json(playbooks.map((p) => ({
|
|
176
|
+
name: p.name,
|
|
177
|
+
description: p.description,
|
|
178
|
+
steps: p.steps,
|
|
179
|
+
context: p.context,
|
|
180
|
+
})));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
service: manifest.service,
|
|
185
|
+
toolCount: manifest.tools.length,
|
|
186
|
+
playbookCount: manifest.playbooks?.length ?? 0,
|
|
187
|
+
version: manifest.version,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutable token store — holds the active Bearer token for the current session.
|
|
3
|
+
*
|
|
4
|
+
* Initialized from env at startup; can be overwritten by the `login` tool
|
|
5
|
+
* so that auth works without restarting the MCP server.
|
|
6
|
+
*/
|
|
7
|
+
let currentToken = process.env.CR_SESSION_TOKEN ?? null;
|
|
8
|
+
export function getToken() {
|
|
9
|
+
return currentToken;
|
|
10
|
+
}
|
|
11
|
+
export function setToken(token) {
|
|
12
|
+
currentToken = token;
|
|
13
|
+
}
|
|
14
|
+
export function hasToken() {
|
|
15
|
+
return Boolean(currentToken);
|
|
16
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
function json(data) {
|
|
3
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
4
|
+
}
|
|
5
|
+
export function registerCandidateTools(server, client) {
|
|
6
|
+
// ── list_candidates ──────────────────────────────────────
|
|
7
|
+
server.tool('list_candidates', 'List candidates with optional filters by job, status, name/email search, and fact values. Returns candidate list with AI scores.', {
|
|
8
|
+
job_slug: z.string().optional().describe('Filter by job slug'),
|
|
9
|
+
status: z.string().optional().describe('Filter by status (e.g. NEW, SCREENING, INTERVIEW_PASSED)'),
|
|
10
|
+
search: z.string().optional().describe('Search by name or email (supports cyrillic)'),
|
|
11
|
+
limit: z.number().optional().describe('Max results (default 500, max 2000)'),
|
|
12
|
+
}, async ({ job_slug, status, search, limit }) => {
|
|
13
|
+
const params = new URLSearchParams();
|
|
14
|
+
if (job_slug)
|
|
15
|
+
params.set('job_slug', job_slug);
|
|
16
|
+
if (status)
|
|
17
|
+
params.set('status', status);
|
|
18
|
+
if (search)
|
|
19
|
+
params.set('search', search);
|
|
20
|
+
if (limit)
|
|
21
|
+
params.set('limit', String(limit));
|
|
22
|
+
const qs = params.toString();
|
|
23
|
+
return json(await client.get(`/api/candidates${qs ? '?' + qs : ''}`));
|
|
24
|
+
});
|
|
25
|
+
// ── get_candidate ────────────────────────────────────────
|
|
26
|
+
server.tool('get_candidate', 'Get full candidate details including messages. Returns candidate object and message history.', {
|
|
27
|
+
candidate_id: z.number().describe('Candidate ID'),
|
|
28
|
+
}, async ({ candidate_id }) => {
|
|
29
|
+
return json(await client.get(`/api/candidates/${candidate_id}`));
|
|
30
|
+
});
|
|
31
|
+
// ── get_candidate_dialog ─────────────────────────────────
|
|
32
|
+
server.tool('get_candidate_dialog', 'Get candidate conversation log formatted for agent consumption. Separates conversation, interview transcript, resume, and parsed facts.', {
|
|
33
|
+
candidate_id: z.number().describe('Candidate ID'),
|
|
34
|
+
}, async ({ candidate_id }) => {
|
|
35
|
+
return json(await client.get(`/api/candidates/${candidate_id}/dialog`));
|
|
36
|
+
});
|
|
37
|
+
// ── get_candidate_status ─────────────────────────────────
|
|
38
|
+
server.tool('get_candidate_status', 'Get candidate status info: current status, available actions (reject, send-interview, send-message, etc.), screening progress, AI score, timeline.', {
|
|
39
|
+
candidate_id: z.number().describe('Candidate ID'),
|
|
40
|
+
}, async ({ candidate_id }) => {
|
|
41
|
+
return json(await client.get(`/api/candidates/${candidate_id}/status-info`));
|
|
42
|
+
});
|
|
43
|
+
// ── send_message ─────────────────────────────────────────
|
|
44
|
+
server.tool('send_message', 'Send a message to a candidate through their channel (email, hh.ru, or LinkedIn). Supports delayed sending.', {
|
|
45
|
+
candidate_id: z.number().describe('Candidate ID'),
|
|
46
|
+
message: z.string().describe('Message text to send'),
|
|
47
|
+
delay_minutes: z.number().optional().describe('Delay in minutes before sending (default: job setting or 15)'),
|
|
48
|
+
}, async ({ candidate_id, message, delay_minutes }) => {
|
|
49
|
+
const body = { message };
|
|
50
|
+
if (delay_minutes !== undefined)
|
|
51
|
+
body.delay_minutes = delay_minutes;
|
|
52
|
+
return json(await client.post(`/api/candidates/${candidate_id}/send-message`, body));
|
|
53
|
+
});
|
|
54
|
+
// ── advance_candidate ────────────────────────────────────
|
|
55
|
+
server.tool('advance_candidate', 'Advance or reject a candidate. Actions: reject, send-interview, resend-interview, skip-to-homework, send-homework. Check get_candidate_status for available actions first.', {
|
|
56
|
+
candidate_id: z.number().describe('Candidate ID'),
|
|
57
|
+
action: z.enum(['reject', 'send-interview', 'resend-interview', 'skip-to-homework', 'send-homework'])
|
|
58
|
+
.describe('Action to perform'),
|
|
59
|
+
}, async ({ candidate_id, action }) => {
|
|
60
|
+
return json(await client.post(`/api/candidates/${candidate_id}/advance`, { action }));
|
|
61
|
+
});
|
|
62
|
+
// ── add_candidate ────────────────────────────────────────
|
|
63
|
+
server.tool('add_candidate', 'Manually add a new candidate to a job. Returns created candidate.', {
|
|
64
|
+
email: z.string().describe('Candidate email'),
|
|
65
|
+
name: z.string().optional().describe('Candidate name'),
|
|
66
|
+
job_slug: z.string().describe('Job slug to assign'),
|
|
67
|
+
status: z.string().optional().describe('Initial status (default: NEW)'),
|
|
68
|
+
}, async ({ email, name, job_slug, status }) => {
|
|
69
|
+
const body = { email, job_slug };
|
|
70
|
+
if (name)
|
|
71
|
+
body.name = name;
|
|
72
|
+
if (status)
|
|
73
|
+
body.status = status;
|
|
74
|
+
return json(await client.post('/api/candidates', body));
|
|
75
|
+
});
|
|
76
|
+
// ── change_status ────────────────────────────────────────
|
|
77
|
+
server.tool('change_status', 'Change status for one or more candidates (bulk operation). Use advance_candidate for pipeline actions instead.', {
|
|
78
|
+
candidate_ids: z.array(z.number()).describe('Array of candidate IDs'),
|
|
79
|
+
new_status: z.string().describe('New status to set'),
|
|
80
|
+
}, async ({ candidate_ids, new_status }) => {
|
|
81
|
+
return json(await client.post('/api/execute', {
|
|
82
|
+
action_type: 'STATUS_CHANGE',
|
|
83
|
+
candidate_ids,
|
|
84
|
+
new_status,
|
|
85
|
+
}));
|
|
86
|
+
});
|
|
87
|
+
// ── search_candidates ────────────────────────────────────
|
|
88
|
+
server.tool('search_candidates', 'AI-powered semantic search across all candidate profiles using Gemini. Returns matching candidate IDs. Use for complex queries like "candidates with 5+ years of React experience".', {
|
|
89
|
+
query: z.string().describe('Natural language search query'),
|
|
90
|
+
}, async ({ query }) => {
|
|
91
|
+
return json(await client.post('/api/search-ai', { query }));
|
|
92
|
+
});
|
|
93
|
+
// ── generate_profile ─────────────────────────────────────
|
|
94
|
+
server.tool('generate_profile', 'Trigger generation of a final candidate profile (summary document). Only works for candidates with enough data.', {
|
|
95
|
+
candidate_id: z.number().describe('Candidate ID'),
|
|
96
|
+
}, async ({ candidate_id }) => {
|
|
97
|
+
return json(await client.post(`/api/candidates/${candidate_id}/generate-profile`));
|
|
98
|
+
});
|
|
99
|
+
}
|
package/dist/tools/hh.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
function json(data) {
|
|
3
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
4
|
+
}
|
|
5
|
+
export function registerHHTools(server, client) {
|
|
6
|
+
// ── hh_status ────────────────────────────────────────────
|
|
7
|
+
server.tool('hh_status', 'Check hh.ru connection status: connected/disconnected, employer info, token expiration.', {}, async () => {
|
|
8
|
+
return json(await client.get('/api/hh/status'));
|
|
9
|
+
});
|
|
10
|
+
// ── hh_list_vacancies ────────────────────────────────────
|
|
11
|
+
server.tool('hh_list_vacancies', 'List active vacancies on hh.ru for the connected employer account.', {}, async () => {
|
|
12
|
+
return json(await client.get('/api/hh/vacancies'));
|
|
13
|
+
});
|
|
14
|
+
// ── hh_sync ──────────────────────────────────────────────
|
|
15
|
+
server.tool('hh_sync', 'Trigger full hh.ru sync: fetch new responses, process must-have replies, interview follow-ups, channel replies, QA replies, homework replies, resume mismatch replies, screening replies.', {}, async () => {
|
|
16
|
+
return json(await client.post('/api/hh/sync'));
|
|
17
|
+
});
|
|
18
|
+
// ── hh_reprocess_new ─────────────────────────────────────
|
|
19
|
+
server.tool('hh_reprocess_new', 'Re-process NEW hh.ru candidates: auto-advance those above score threshold, send mismatch to the rest. Useful after changing thresholds.', {
|
|
20
|
+
job_slug: z.string().describe('Job slug to reprocess'),
|
|
21
|
+
limit: z.number().optional().describe('Max candidates to process (default: 20)'),
|
|
22
|
+
}, async ({ job_slug, limit }) => {
|
|
23
|
+
const body = { job_slug };
|
|
24
|
+
if (limit !== undefined)
|
|
25
|
+
body.limit = limit;
|
|
26
|
+
return json(await client.post('/api/hh/reprocess-new', body));
|
|
27
|
+
});
|
|
28
|
+
// ── hh_vacancy_maps ──────────────────────────────────────
|
|
29
|
+
server.tool('hh_vacancy_maps', 'Get all hh.ru vacancy → job_slug mappings. Shows which hh.ru vacancies are linked to which jobs.', {}, async () => {
|
|
30
|
+
return json(await client.get('/api/hh/vacancy-maps'));
|
|
31
|
+
});
|
|
32
|
+
// ── hh_create_vacancy_map ────────────────────────────────
|
|
33
|
+
server.tool('hh_create_vacancy_map', 'Create or update a mapping between an hh.ru vacancy and a job_slug. Required to route hh.ru responses to the correct job.', {
|
|
34
|
+
hh_vacancy_id: z.string().describe('HH.ru vacancy ID'),
|
|
35
|
+
job_slug: z.string().describe('Job slug to map to'),
|
|
36
|
+
hh_vacancy_name: z.string().optional().describe('HH vacancy name (for display)'),
|
|
37
|
+
}, async ({ hh_vacancy_id, job_slug, hh_vacancy_name }) => {
|
|
38
|
+
const body = { hh_vacancy_id, job_slug };
|
|
39
|
+
if (hh_vacancy_name)
|
|
40
|
+
body.hh_vacancy_name = hh_vacancy_name;
|
|
41
|
+
return json(await client.post('/api/hh/vacancy-map', body));
|
|
42
|
+
});
|
|
43
|
+
// ── hh_create_draft ──────────────────────────────────────
|
|
44
|
+
server.tool('hh_create_draft', 'Create a vacancy draft on hh.ru. Pass the draft data as a JSON object following hh.ru API format.', {
|
|
45
|
+
draft_data: z.record(z.unknown()).describe('Draft vacancy data in hh.ru API format'),
|
|
46
|
+
}, async ({ draft_data }) => {
|
|
47
|
+
return json(await client.post('/api/hh/create-draft', draft_data));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
function json(data) {
|
|
3
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
4
|
+
}
|
|
5
|
+
export function registerJobTools(server, client) {
|
|
6
|
+
// ── list_jobs ────────────────────────────────────────────
|
|
7
|
+
server.tool('list_jobs', 'List all jobs with their settings, sender/contact emails, and candidate counts.', {}, async () => {
|
|
8
|
+
return json(await client.get('/api/jobs'));
|
|
9
|
+
});
|
|
10
|
+
// ── get_job_settings ─────────────────────────────────────
|
|
11
|
+
server.tool('get_job_settings', 'Get full settings for a job: pipeline_template, FAQ reference, template_instruction, must-haves, interview config, homework, etc.', {
|
|
12
|
+
job_slug: z.string().describe('Job slug'),
|
|
13
|
+
}, async ({ job_slug }) => {
|
|
14
|
+
return json(await client.get(`/api/jobs/${encodeURIComponent(job_slug)}/settings`));
|
|
15
|
+
});
|
|
16
|
+
// ── update_job_settings ──────────────────────────────────
|
|
17
|
+
server.tool('update_job_settings', 'Update job settings (partial update). Can update pipeline_template, template_instruction, must_haves, summary_fields, interview_language, homework_text, sender_email, and other fields.', {
|
|
18
|
+
job_slug: z.string().describe('Job slug'),
|
|
19
|
+
settings: z.record(z.unknown()).describe('Settings object (partial — only provided fields are updated)'),
|
|
20
|
+
}, async ({ job_slug, settings }) => {
|
|
21
|
+
return json(await client.post(`/api/jobs/${encodeURIComponent(job_slug)}/settings`, settings));
|
|
22
|
+
});
|
|
23
|
+
// ── deploy_job ───────────────────────────────────────────
|
|
24
|
+
server.tool('deploy_job', 'Deploy a job in one call: create/update job + settings + inbox mapping. Use this to set up a new job or reconfigure an existing one.', {
|
|
25
|
+
job_slug: z.string().describe('Job slug (URL-safe, lowercase, e.g. "frontend-engineer-1")'),
|
|
26
|
+
title: z.string().describe('Job title'),
|
|
27
|
+
sender_email: z.string().describe('Email to send from'),
|
|
28
|
+
contact_email: z.string().optional().describe('Contact email (default: vladimir+{slug}@skillset.ae)'),
|
|
29
|
+
inbox_email: z.string().optional().describe('Inbox email for receiving candidate replies'),
|
|
30
|
+
settings: z.record(z.unknown()).optional().describe('Job settings object'),
|
|
31
|
+
}, async ({ job_slug, title, sender_email, contact_email, inbox_email, settings }) => {
|
|
32
|
+
const body = { title, sender_email };
|
|
33
|
+
if (contact_email)
|
|
34
|
+
body.contact_email = contact_email;
|
|
35
|
+
if (inbox_email)
|
|
36
|
+
body.inbox_email = inbox_email;
|
|
37
|
+
if (settings)
|
|
38
|
+
body.settings = settings;
|
|
39
|
+
return json(await client.post(`/api/jobs/${encodeURIComponent(job_slug)}/deploy`, body));
|
|
40
|
+
});
|
|
41
|
+
// ── get_job_status ───────────────────────────────────────
|
|
42
|
+
server.tool('get_job_status', 'Get job pipeline statistics: candidate counts by status, last activity timestamps, pending outbox count.', {
|
|
43
|
+
job_slug: z.string().describe('Job slug'),
|
|
44
|
+
}, async ({ job_slug }) => {
|
|
45
|
+
return json(await client.get(`/api/jobs/${encodeURIComponent(job_slug)}/status`));
|
|
46
|
+
});
|
|
47
|
+
// ── get_job_funnel ───────────────────────────────────────
|
|
48
|
+
server.tool('get_job_funnel', 'Get candidate status funnel for a job — counts and percentages at each pipeline stage.', {
|
|
49
|
+
job_slug: z.string().describe('Job slug'),
|
|
50
|
+
}, async ({ job_slug }) => {
|
|
51
|
+
return json(await client.get(`/api/jobs/${encodeURIComponent(job_slug)}/funnel`));
|
|
52
|
+
});
|
|
53
|
+
// ── get_facts_schema ─────────────────────────────────────
|
|
54
|
+
server.tool('get_facts_schema', 'Get the summary_fields schema for a job with fill rates — shows which candidate facts are configured and how many candidates have each field filled.', {
|
|
55
|
+
job_slug: z.string().describe('Job slug'),
|
|
56
|
+
}, async ({ job_slug }) => {
|
|
57
|
+
return json(await client.get(`/api/jobs/${encodeURIComponent(job_slug)}/facts-schema`));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
function json(data) {
|
|
3
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
4
|
+
}
|
|
5
|
+
export function registerLinkedInTools(server, client) {
|
|
6
|
+
// ── linkedin_create_candidate ────────────────────────────
|
|
7
|
+
server.tool('linkedin_create_candidate', 'Create a candidate from a LinkedIn messaging thread. Automatically sends channel preference message via LinkedIn. Requires thread_url (from browser), job_slug, and name.', {
|
|
8
|
+
thread_url: z.string().describe('LinkedIn messaging thread URL'),
|
|
9
|
+
job_slug: z.string().describe('Job slug to assign'),
|
|
10
|
+
name: z.string().describe('Candidate full name'),
|
|
11
|
+
email: z.string().optional().describe('Candidate email (synthetic generated if not provided)'),
|
|
12
|
+
profile_url: z.string().optional().describe('LinkedIn profile URL'),
|
|
13
|
+
}, async ({ thread_url, job_slug, name, email, profile_url }) => {
|
|
14
|
+
const body = { thread_url, job_slug, name };
|
|
15
|
+
if (email)
|
|
16
|
+
body.email = email;
|
|
17
|
+
if (profile_url)
|
|
18
|
+
body.profile_url = profile_url;
|
|
19
|
+
return json(await client.post('/api/linkedin/create-candidate', body));
|
|
20
|
+
});
|
|
21
|
+
// ── linkedin_send_interview ──────────────────────────────
|
|
22
|
+
server.tool('linkedin_send_interview', 'Send an interview link to a LinkedIn candidate via their messaging thread. Only works for candidates with source=linkedin.', {
|
|
23
|
+
candidate_id: z.number().describe('Candidate ID (must be a LinkedIn candidate)'),
|
|
24
|
+
}, async ({ candidate_id }) => {
|
|
25
|
+
return json(await client.post('/api/linkedin/send-interview-link', { candidate_id }));
|
|
26
|
+
});
|
|
27
|
+
// ── linkedin_setup_hiring ────────────────────────────────
|
|
28
|
+
server.tool('linkedin_setup_hiring', 'Map a LinkedIn Job posting to a job_slug for automatic applicant sync from LinkedIn Hiring.', {
|
|
29
|
+
linkedin_job_id: z.string().describe('LinkedIn Job ID'),
|
|
30
|
+
job_slug: z.string().describe('Job slug to map to'),
|
|
31
|
+
}, async ({ linkedin_job_id, job_slug }) => {
|
|
32
|
+
return json(await client.post('/api/linkedin/hiring/setup', { linkedin_job_id, job_slug }));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neon-backed tools for recruiter-mcp.
|
|
3
|
+
*
|
|
4
|
+
* Tools registered here:
|
|
5
|
+
* get_mart_catalog — list all output_data_marts views with columns
|
|
6
|
+
* query_mart — query a mart view with optional filters
|
|
7
|
+
* log_playbook_run_start — open a playbook run record, returns run id
|
|
8
|
+
* log_playbook_run_complete — close a run with final status + summary
|
|
9
|
+
* log_playbook_run_input — attach an input artifact to a run
|
|
10
|
+
* log_playbook_run_output — attach an output artifact to a run
|
|
11
|
+
* get_playbook_steps — fetch steps for a playbook scenario
|
|
12
|
+
*
|
|
13
|
+
* MCP Prompt registered here:
|
|
14
|
+
* recruiter_context — returns AI rules + mart catalog summary
|
|
15
|
+
*
|
|
16
|
+
* Requires env: RECRUITER_NEON_URL
|
|
17
|
+
*/
|
|
18
|
+
import { neon } from '@neondatabase/serverless';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
// Known mart views with human descriptions (mirrors output_data_marts schema)
|
|
21
|
+
const MART_VIEWS = [
|
|
22
|
+
{
|
|
23
|
+
name: 'mart_candidate_current_state',
|
|
24
|
+
description: 'Current state of each candidate across all jobs (status, source, contact info)',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'mart_candidate_pipeline_state',
|
|
28
|
+
description: 'Candidate pipeline stage history and transitions',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'mart_job_current_profile',
|
|
32
|
+
description: 'Current job profile: requirements, compensation, company context',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'mart_job_current_publication_artifacts',
|
|
36
|
+
description: 'Published job texts (hh.ru, LinkedIn) and their status',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'mart_job_pipeline_generation_context',
|
|
40
|
+
description: 'Context snapshot used to generate pipeline templates',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'mart_job_pipeline_template_current',
|
|
44
|
+
description: 'Current pipeline template (goals list) for each job',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'mart_job_profile_inputs',
|
|
48
|
+
description: 'Raw inputs used for job profile generation',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'mart_playbook_run_inputs',
|
|
52
|
+
description: 'Input artifacts attached to each playbook run',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'mart_playbook_run_outputs',
|
|
56
|
+
description: 'Output artifacts produced by each playbook run',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'mart_playbook_runs',
|
|
60
|
+
description: 'History of all playbook runs: status, timing, result summary',
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
const MART_NAMES = MART_VIEWS.map((v) => v.name);
|
|
64
|
+
export function registerNeonTools(server) {
|
|
65
|
+
const neonUrl = process.env.RECRUITER_NEON_URL;
|
|
66
|
+
if (!neonUrl) {
|
|
67
|
+
process.stderr.write('[recruiter-mcp] Neon tools skipped — RECRUITER_NEON_URL not set\n');
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
const sql = neon(neonUrl);
|
|
71
|
+
// ── get_mart_catalog ────────────────────────────────────────────
|
|
72
|
+
server.tool('get_mart_catalog', 'List all available data mart views in output_data_marts with their descriptions and column names. Use this before query_mart to understand what fields are available.', {}, async () => {
|
|
73
|
+
const rows = await sql `
|
|
74
|
+
SELECT table_name, column_name, data_type
|
|
75
|
+
FROM information_schema.columns
|
|
76
|
+
WHERE table_schema = 'output_data_marts'
|
|
77
|
+
AND table_name LIKE 'mart_%'
|
|
78
|
+
ORDER BY table_name, ordinal_position
|
|
79
|
+
`;
|
|
80
|
+
const descMap = Object.fromEntries(MART_VIEWS.map((v) => [v.name, v.description]));
|
|
81
|
+
const catalog = {};
|
|
82
|
+
for (const row of rows) {
|
|
83
|
+
const name = row.table_name;
|
|
84
|
+
if (!catalog[name]) {
|
|
85
|
+
catalog[name] = {
|
|
86
|
+
description: descMap[name] ?? '',
|
|
87
|
+
columns: [],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catalog[name].columns.push({
|
|
91
|
+
name: row.column_name,
|
|
92
|
+
type: row.data_type,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: 'text', text: JSON.stringify(catalog, null, 2) }],
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
// ── query_mart ─────────────────────────────────────────────────
|
|
100
|
+
server.tool('query_mart', 'Query a data mart view (read-only). Returns up to 100 rows. Always call get_mart_catalog first to know which columns exist.', {
|
|
101
|
+
mart: z.enum(MART_NAMES).describe('The mart view to query'),
|
|
102
|
+
filters: z
|
|
103
|
+
.record(z.string())
|
|
104
|
+
.optional()
|
|
105
|
+
.describe('Optional equality filters: { column: value }. Only simple = comparisons.'),
|
|
106
|
+
limit: z
|
|
107
|
+
.number()
|
|
108
|
+
.int()
|
|
109
|
+
.min(1)
|
|
110
|
+
.max(100)
|
|
111
|
+
.optional()
|
|
112
|
+
.default(50)
|
|
113
|
+
.describe('Max rows to return (default 50, max 100)'),
|
|
114
|
+
}, async ({ mart, filters, limit }) => {
|
|
115
|
+
// mart is already validated by z.enum — safe to interpolate
|
|
116
|
+
let query = `SELECT * FROM output_data_marts.${mart}`;
|
|
117
|
+
const params = [];
|
|
118
|
+
if (filters && Object.keys(filters).length > 0) {
|
|
119
|
+
const conditions = [];
|
|
120
|
+
for (const [col, val] of Object.entries(filters)) {
|
|
121
|
+
// Validate column name: lowercase letters, digits, underscores only
|
|
122
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(col)) {
|
|
123
|
+
throw new Error(`Invalid column name: ${col}`);
|
|
124
|
+
}
|
|
125
|
+
params.push(val);
|
|
126
|
+
conditions.push(`${col} = $${params.length}`);
|
|
127
|
+
}
|
|
128
|
+
query += ` WHERE ${conditions.join(' AND ')}`;
|
|
129
|
+
}
|
|
130
|
+
query += ` LIMIT ${limit ?? 50}`;
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
132
|
+
const rows = await sql(query, params);
|
|
133
|
+
return {
|
|
134
|
+
content: [
|
|
135
|
+
{
|
|
136
|
+
type: 'text',
|
|
137
|
+
text: JSON.stringify({ mart, row_count: rows.length, rows }, null, 2),
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
// ── log_playbook_run_start ────────────────────────────────────
|
|
143
|
+
server.tool('log_playbook_run_start', 'Open a new playbook run record. Call at the start of any playbook execution. Returns playbook_run_id — pass it to all subsequent log_playbook_run_* calls.', {
|
|
144
|
+
playbook_template_id: z
|
|
145
|
+
.string()
|
|
146
|
+
.describe('Playbook scenario ID (matches management.playbook_scenarios.id)'),
|
|
147
|
+
tenant_id: z.string().optional().describe('Tenant identifier'),
|
|
148
|
+
data_layer_job_id: z.number().int().optional().describe('Canonical job ID'),
|
|
149
|
+
candidate_routing_candidate_id: z
|
|
150
|
+
.number()
|
|
151
|
+
.int()
|
|
152
|
+
.optional()
|
|
153
|
+
.describe('Candidate ID in candidate-routing (if applicable)'),
|
|
154
|
+
session_id: z.string().optional().describe('Current Claude Code session ID'),
|
|
155
|
+
triggered_by_user_id: z.string().optional().describe('User who triggered this run'),
|
|
156
|
+
run_payload_json: z
|
|
157
|
+
.record(z.unknown())
|
|
158
|
+
.optional()
|
|
159
|
+
.describe('Extra context to attach to the run record'),
|
|
160
|
+
}, async (params) => {
|
|
161
|
+
const rows = await sql `
|
|
162
|
+
INSERT INTO repository_recruiter_agent.recruiter_agent_playbook_runs
|
|
163
|
+
(tenant_id, playbook_template_id, data_layer_job_id,
|
|
164
|
+
candidate_routing_candidate_id, session_id, playbook_run_status,
|
|
165
|
+
triggered_by_user_id, playbook_run_started_at, run_payload_json, created_at)
|
|
166
|
+
VALUES (
|
|
167
|
+
${params.tenant_id ?? null},
|
|
168
|
+
${params.playbook_template_id},
|
|
169
|
+
${params.data_layer_job_id ?? null},
|
|
170
|
+
${params.candidate_routing_candidate_id ?? null},
|
|
171
|
+
${params.session_id ?? null},
|
|
172
|
+
'running',
|
|
173
|
+
${params.triggered_by_user_id ?? null},
|
|
174
|
+
NOW(),
|
|
175
|
+
${params.run_payload_json ? JSON.stringify(params.run_payload_json) : null},
|
|
176
|
+
NOW()
|
|
177
|
+
)
|
|
178
|
+
RETURNING id
|
|
179
|
+
`;
|
|
180
|
+
return {
|
|
181
|
+
content: [
|
|
182
|
+
{
|
|
183
|
+
type: 'text',
|
|
184
|
+
text: JSON.stringify({ playbook_run_id: rows[0].id }),
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
// ── log_playbook_run_complete ─────────────────────────────────
|
|
190
|
+
server.tool('log_playbook_run_complete', 'Close a playbook run with final status and result summary. Call after all actions are done.', {
|
|
191
|
+
playbook_run_id: z
|
|
192
|
+
.number()
|
|
193
|
+
.int()
|
|
194
|
+
.describe('Run ID returned by log_playbook_run_start'),
|
|
195
|
+
status: z
|
|
196
|
+
.enum(['completed', 'failed', 'cancelled'])
|
|
197
|
+
.describe('Final run status'),
|
|
198
|
+
run_result_summary: z
|
|
199
|
+
.string()
|
|
200
|
+
.optional()
|
|
201
|
+
.describe('Human-readable summary of what was done and the outcome'),
|
|
202
|
+
}, async ({ playbook_run_id, status, run_result_summary }) => {
|
|
203
|
+
await sql `
|
|
204
|
+
UPDATE repository_recruiter_agent.recruiter_agent_playbook_runs
|
|
205
|
+
SET playbook_run_status = ${status},
|
|
206
|
+
playbook_run_completed_at = NOW(),
|
|
207
|
+
run_result_summary = ${run_result_summary ?? null}
|
|
208
|
+
WHERE id = ${playbook_run_id}
|
|
209
|
+
`;
|
|
210
|
+
return {
|
|
211
|
+
content: [
|
|
212
|
+
{
|
|
213
|
+
type: 'text',
|
|
214
|
+
text: JSON.stringify({ ok: true, playbook_run_id, status }),
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
// ── log_playbook_run_input ────────────────────────────────────
|
|
220
|
+
server.tool('log_playbook_run_input', 'Attach an input artifact to a playbook run (candidate profile, vacancy text, FAQ, etc.).', {
|
|
221
|
+
playbook_run_id: z.number().int().describe('Run ID from log_playbook_run_start'),
|
|
222
|
+
input_type: z
|
|
223
|
+
.string()
|
|
224
|
+
.describe('Type of input: "candidate_profile", "vacancy_text", "faq", "search_query", etc.'),
|
|
225
|
+
source_system: z
|
|
226
|
+
.string()
|
|
227
|
+
.optional()
|
|
228
|
+
.describe('Source system: "candidate-routing", "neon", "hh.ru", etc.'),
|
|
229
|
+
source_ref: z
|
|
230
|
+
.string()
|
|
231
|
+
.optional()
|
|
232
|
+
.describe('Reference ID or URL in the source system'),
|
|
233
|
+
input_payload_json: z
|
|
234
|
+
.record(z.unknown())
|
|
235
|
+
.optional()
|
|
236
|
+
.describe('Input content as JSON'),
|
|
237
|
+
}, async ({ playbook_run_id, input_type, source_system, source_ref, input_payload_json }) => {
|
|
238
|
+
const rows = await sql `
|
|
239
|
+
INSERT INTO repository_recruiter_agent.recruiter_agent_playbook_run_inputs
|
|
240
|
+
(playbook_run_id, input_type, source_system, source_ref, input_payload_json, created_at)
|
|
241
|
+
VALUES (
|
|
242
|
+
${playbook_run_id},
|
|
243
|
+
${input_type},
|
|
244
|
+
${source_system ?? null},
|
|
245
|
+
${source_ref ?? null},
|
|
246
|
+
${input_payload_json ? JSON.stringify(input_payload_json) : null},
|
|
247
|
+
NOW()
|
|
248
|
+
)
|
|
249
|
+
RETURNING id
|
|
250
|
+
`;
|
|
251
|
+
return {
|
|
252
|
+
content: [
|
|
253
|
+
{
|
|
254
|
+
type: 'text',
|
|
255
|
+
text: JSON.stringify({ ok: true, id: rows[0].id }),
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
// ── log_playbook_run_output ───────────────────────────────────
|
|
261
|
+
server.tool('log_playbook_run_output', 'Attach an output artifact to a playbook run (message sent, file created, status changed, etc.).', {
|
|
262
|
+
playbook_run_id: z.number().int().describe('Run ID from log_playbook_run_start'),
|
|
263
|
+
output_type: z
|
|
264
|
+
.string()
|
|
265
|
+
.describe('Type of output: "message_sent", "status_changed", "report_generated", "file_created", etc.'),
|
|
266
|
+
target_system: z
|
|
267
|
+
.string()
|
|
268
|
+
.optional()
|
|
269
|
+
.describe('Target system: "candidate-routing", "email", "hh.ru", etc.'),
|
|
270
|
+
target_ref: z
|
|
271
|
+
.string()
|
|
272
|
+
.optional()
|
|
273
|
+
.describe('Reference ID or URL in the target system'),
|
|
274
|
+
output_payload_json: z
|
|
275
|
+
.record(z.unknown())
|
|
276
|
+
.optional()
|
|
277
|
+
.describe('Output content or metadata as JSON'),
|
|
278
|
+
}, async ({ playbook_run_id, output_type, target_system, target_ref, output_payload_json }) => {
|
|
279
|
+
const rows = await sql `
|
|
280
|
+
INSERT INTO repository_recruiter_agent.recruiter_agent_playbook_run_outputs
|
|
281
|
+
(playbook_run_id, output_type, target_system, target_ref, output_payload_json, created_at)
|
|
282
|
+
VALUES (
|
|
283
|
+
${playbook_run_id},
|
|
284
|
+
${output_type},
|
|
285
|
+
${target_system ?? null},
|
|
286
|
+
${target_ref ?? null},
|
|
287
|
+
${output_payload_json ? JSON.stringify(output_payload_json) : null},
|
|
288
|
+
NOW()
|
|
289
|
+
)
|
|
290
|
+
RETURNING id
|
|
291
|
+
`;
|
|
292
|
+
return {
|
|
293
|
+
content: [
|
|
294
|
+
{
|
|
295
|
+
type: 'text',
|
|
296
|
+
text: JSON.stringify({ ok: true, id: rows[0].id }),
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
// ── get_playbook_steps ────────────────────────────────────────
|
|
302
|
+
server.tool('get_playbook_steps', 'Get the step-by-step execution plan for a playbook scenario from the management schema. Steps form a linked list via previous_step_id.', {
|
|
303
|
+
scenario_id: z
|
|
304
|
+
.string()
|
|
305
|
+
.describe('Playbook scenario ID (e.g. "candidate-statuses", "funnel")'),
|
|
306
|
+
}, async ({ scenario_id }) => {
|
|
307
|
+
const [scenario] = await sql `
|
|
308
|
+
SELECT id, name, trigger_description, keywords
|
|
309
|
+
FROM management.playbook_scenarios
|
|
310
|
+
WHERE id = ${scenario_id}
|
|
311
|
+
`;
|
|
312
|
+
if (!scenario) {
|
|
313
|
+
return {
|
|
314
|
+
content: [
|
|
315
|
+
{
|
|
316
|
+
type: 'text',
|
|
317
|
+
text: JSON.stringify({ error: `Scenario '${scenario_id}' not found` }),
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const steps = await sql `
|
|
323
|
+
SELECT id, name, previous_step_id, step_type, instruction_prompt
|
|
324
|
+
FROM management.playbook_steps
|
|
325
|
+
WHERE scenario_id = ${scenario_id}
|
|
326
|
+
ORDER BY created_at
|
|
327
|
+
`;
|
|
328
|
+
return {
|
|
329
|
+
content: [
|
|
330
|
+
{
|
|
331
|
+
type: 'text',
|
|
332
|
+
text: JSON.stringify({ scenario, steps }, null, 2),
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
// ── MCP Prompt: recruiter_context ─────────────────────────────
|
|
338
|
+
server.prompt('recruiter_context', 'Load recruiter agent system context: AI rules and mart catalog. Use at the start of a session to orient the agent.', {}, async () => {
|
|
339
|
+
const [rules, martCols] = await Promise.all([
|
|
340
|
+
sql `
|
|
341
|
+
SELECT id, scope, rule
|
|
342
|
+
FROM repository_candidate_routing._ai_rules
|
|
343
|
+
ORDER BY scope DESC, id
|
|
344
|
+
`,
|
|
345
|
+
sql `
|
|
346
|
+
SELECT table_name, column_name
|
|
347
|
+
FROM information_schema.columns
|
|
348
|
+
WHERE table_schema = 'output_data_marts' AND table_name LIKE 'mart_%'
|
|
349
|
+
ORDER BY table_name, ordinal_position
|
|
350
|
+
`,
|
|
351
|
+
]);
|
|
352
|
+
// Build mart summary (just table + column names, compact)
|
|
353
|
+
const martSummary = {};
|
|
354
|
+
for (const row of martCols) {
|
|
355
|
+
const t = row.table_name;
|
|
356
|
+
if (!martSummary[t])
|
|
357
|
+
martSummary[t] = [];
|
|
358
|
+
martSummary[t].push(row.column_name);
|
|
359
|
+
}
|
|
360
|
+
const rulesText = rules
|
|
361
|
+
.map((r) => `[${r.id}] (${r.scope}) ${r.rule}`)
|
|
362
|
+
.join('\n');
|
|
363
|
+
const martsText = Object.entries(martSummary)
|
|
364
|
+
.map(([name, cols]) => `${name}: ${cols.join(', ')}`)
|
|
365
|
+
.join('\n');
|
|
366
|
+
const text = `# Recruiter Agent Context
|
|
367
|
+
|
|
368
|
+
## AI Rules
|
|
369
|
+
${rulesText}
|
|
370
|
+
|
|
371
|
+
## Available Mart Views (output_data_marts)
|
|
372
|
+
${martsText}
|
|
373
|
+
`;
|
|
374
|
+
return {
|
|
375
|
+
messages: [
|
|
376
|
+
{
|
|
377
|
+
role: 'user',
|
|
378
|
+
content: { type: 'text', text },
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
process.stderr.write('[recruiter-mcp] Neon tools registered (7 tools + recruiter_context prompt)\n');
|
|
384
|
+
return 7;
|
|
385
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@recruiter-tools/mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server connecting AI assistants (Claude Code, Codex, Cursor…) to recruiter-assistant.com recruiting tools",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"recruiter-mcp": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "node --experimental-strip-types src/index.ts",
|
|
11
|
+
"build": "tsc && npm run postbuild",
|
|
12
|
+
"postbuild": "node -e \"const fs=require('fs');const f='dist/index.js';fs.writeFileSync(f,'#!/usr/bin/env node\\n'+fs.readFileSync(f,'utf8'));fs.chmodSync(f,0o755)\"",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"services.json"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
21
|
+
"@neondatabase/serverless": "^1.0.2",
|
|
22
|
+
"zod": "^3.24.4"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22",
|
|
26
|
+
"typescript": "^5.4"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/services.json
ADDED