shakers-cli-beta 0.9.0 → 0.10.1
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 +30 -5
- package/bin/ai-usage.js +32 -19
- package/bin/mcp.js +11 -20
- package/bin/mcpb.js +5 -0
- package/package.json +2 -2
- package/src/auth-session-store.js +1 -0
- package/src/backend-request.js +2 -2
- package/src/config.js +29 -0
- package/src/cv-file.js +62 -0
- package/src/device-login.js +5 -3
- package/src/find-projects-client.js +16 -6
- package/src/i18n.js +23 -22
- package/src/mcp-ai-usage-tool.js +150 -65
- package/src/mcp-auth-form.js +186 -31
- package/src/mcp-choice.js +47 -0
- package/src/mcp-install.js +32 -3
- package/src/mcp-instructions.js +27 -0
- package/src/mcp-onboarding-tools.js +2 -2
- package/src/mcp-prompts.js +8 -6
- package/src/mcp-rate-tools.js +2 -2
- package/src/mcp-register-tools.js +89 -315
- package/src/mcp-role-tools.js +177 -16
- package/src/mcp-server.js +71 -3
- package/src/mcp-signup-client.js +118 -0
- package/src/mcp-signup-tools.js +818 -0
- package/src/onboarding-client.js +28 -52
- package/src/onboarding-flow.js +3 -147
- package/src/onboarding-status-client.js +16 -1
- package/src/register-context.js +1 -17
- package/src/roles-client.js +3 -1
- package/src/signup-client.js +33 -21
- package/src/signup-copy.js +338 -0
- package/src/signup-language.js +21 -0
- package/src/usage-run-steps.js +3 -2
package/src/mcp-role-tools.js
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// MCP
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
// MCP role tools: list, add and recommend roles, and set the main role (the sign-up's last step before open_web).
|
|
4
|
+
|
|
5
|
+
const { askChoice, askAfterTexts } = require('./mcp-choice');
|
|
6
|
+
const { signupCopy, renderRole } = require('./signup-copy');
|
|
7
|
+
const { signupLanguage } = require('./signup-language');
|
|
6
8
|
|
|
7
9
|
const EMPTY_SCHEMA = { type: 'object', properties: {} };
|
|
8
10
|
|
|
11
|
+
// Same bounds as the web's growth-direction step: certs finalizes an interview within ~90 s, polled every 2 s.
|
|
12
|
+
const FINALIZATION_WAIT_LIMIT_MS = 90000;
|
|
13
|
+
const POLL_MS = 2000;
|
|
14
|
+
const MAX_WAIT_SECONDS = 25;
|
|
15
|
+
const PENDING_FINALIZATION = new Set(['COLLECTING', 'PROCESSING']);
|
|
16
|
+
// Alma's signal from the interview first, then the CV import, as the web stacks them.
|
|
17
|
+
const SOURCE_ORDER = { ONBOARDING_INTERVIEW: 0, IMPORT: 1 };
|
|
18
|
+
|
|
19
|
+
const LIST_MY_ROLES_SCHEMA = {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
waitSeconds: { type: 'number', description: `In the sign-up, wait up to this many seconds (max ${MAX_WAIT_SECONDS}) for the interview evaluation or the import to recommend roles.` },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
9
26
|
const CLUSTER_SCHEMA = {
|
|
10
27
|
type: 'object',
|
|
11
28
|
properties: {
|
|
@@ -14,7 +31,31 @@ const CLUSTER_SCHEMA = {
|
|
|
14
31
|
required: ['clusterId'],
|
|
15
32
|
};
|
|
16
33
|
|
|
34
|
+
const SET_MAIN_ROLE_SCHEMA = {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
evidence: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
additionalProperties: { type: 'string' },
|
|
40
|
+
description: 'In the sign-up, for each recommended role (by clusterId from list_my_roles): ONE short concrete fact from the CV, the interview or the AI-usage analysis that shows why it fits (e.g. "6 years with Spark and Airflow"), in the talent\'s language. It is shown under the role.',
|
|
41
|
+
},
|
|
42
|
+
shown: { type: 'boolean', description: 'true only after you showed relayVerbatim from the previous call: it then asks the question in a dialog.' },
|
|
43
|
+
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
44
|
+
clusterId: { type: 'string', description: 'The role the talent already picked, by clusterId from list_my_roles or from the roles this tool returned. Omit it in the sign-up: the tool asks the talent which recommended role is their main one (a dialog, or options it returns), with the catalogue as the last option.' },
|
|
45
|
+
catalogue: { type: 'boolean', description: 'true when the role comes from the catalogue: with clusterId it adds the role before setting it; without clusterId it asks from the catalogue.' },
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// One option per role name: the talent picks by name, so two rows with the same name would be one choice.
|
|
50
|
+
function byName(roles) {
|
|
51
|
+
const seen = new Map();
|
|
52
|
+
for (const r of roles) if (r && r.name && !seen.has(r.name)) seen.set(r.name, r);
|
|
53
|
+
return [...seen.values()];
|
|
54
|
+
}
|
|
55
|
+
|
|
17
56
|
function makeRoleTools(deps = {}) {
|
|
57
|
+
const lang = () => signupLanguage(deps.lang || require('./i18n').detectFlowLang());
|
|
58
|
+
const copy = () => signupCopy(lang());
|
|
18
59
|
const {
|
|
19
60
|
getAvailableRolesEndpoint = require('./config').getAvailableRolesEndpoint,
|
|
20
61
|
getSetMainRoleEndpoint = require('./config').getSetMainRoleEndpoint,
|
|
@@ -28,6 +69,12 @@ function makeRoleTools(deps = {}) {
|
|
|
28
69
|
loadAuthSession = require('./auth-session-store').loadAuthSession,
|
|
29
70
|
sessionStatus = require('./auth-session-store').sessionStatus,
|
|
30
71
|
dedupeByCluster = require('./roles-flow').dedupeByCluster,
|
|
72
|
+
getOnboardingInterviewsEndpoint = require('./config').getOnboardingInterviewsEndpoint,
|
|
73
|
+
requestInterviewFinalization = require('./onboarding-status-client').requestInterviewFinalization,
|
|
74
|
+
completedOnboardingInterview = () => require('./mcp-register-tools').completedOnboardingInterview(),
|
|
75
|
+
countOpenPositions = (opts) => require('./find-projects-client').countOpenPositions({}, opts),
|
|
76
|
+
sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
77
|
+
now = () => Date.now(),
|
|
31
78
|
} = deps;
|
|
32
79
|
|
|
33
80
|
const requireSession = () => {
|
|
@@ -38,7 +85,14 @@ function makeRoleTools(deps = {}) {
|
|
|
38
85
|
return session;
|
|
39
86
|
};
|
|
40
87
|
|
|
41
|
-
const shape = (r) => ({
|
|
88
|
+
const shape = (r) => ({
|
|
89
|
+
clusterId: r.clusterId,
|
|
90
|
+
name: r.name,
|
|
91
|
+
category: r.category,
|
|
92
|
+
...(r.proposalKind ? { proposalKind: r.proposalKind } : {}),
|
|
93
|
+
...(r.type ? { type: r.type } : {}),
|
|
94
|
+
...(r.source ? { source: r.source } : {}),
|
|
95
|
+
});
|
|
42
96
|
|
|
43
97
|
async function listAvailableRoles() {
|
|
44
98
|
const session = requireSession();
|
|
@@ -58,9 +112,15 @@ function makeRoleTools(deps = {}) {
|
|
|
58
112
|
return { ok: true, clusterId: args.clusterId };
|
|
59
113
|
}
|
|
60
114
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
115
|
+
// Whether certs is still evaluating the interview this process completed, within the web's wait.
|
|
116
|
+
async function evaluationPending(session) {
|
|
117
|
+
const interview = completedOnboardingInterview();
|
|
118
|
+
if (!interview || now() - interview.completedAt >= FINALIZATION_WAIT_LIMIT_MS) return false;
|
|
119
|
+
const res = await requestInterviewFinalization({ interviewId: interview.interviewId, accessToken: session.accessToken }, { base: getOnboardingInterviewsEndpoint() });
|
|
120
|
+
return res.ok && PENDING_FINALIZATION.has(res.status);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function readRoles(hubAccessToken) {
|
|
64
124
|
const held = [];
|
|
65
125
|
let mainClusterId = null;
|
|
66
126
|
const cert = await requestMeCertifications({ hubAccessToken }, { endpoint: getMyCertificationsEndpoint() });
|
|
@@ -69,14 +129,115 @@ function makeRoleTools(deps = {}) {
|
|
|
69
129
|
for (const g of cert.growingInto || []) held.push(g);
|
|
70
130
|
}
|
|
71
131
|
const assigned = await requestAssignedClusters({ hubAccessToken }, { endpoint: getAssignedClustersEndpoint() });
|
|
72
|
-
|
|
73
|
-
|
|
132
|
+
const clusters = assigned.ok ? assigned.clusters || [] : [];
|
|
133
|
+
const recommended = clusters
|
|
134
|
+
.filter((c) => c.type === 'RECOMMENDED')
|
|
135
|
+
.sort((a, b) => (SOURCE_ORDER[a.source] ?? 2) - (SOURCE_ORDER[b.source] ?? 2));
|
|
136
|
+
return { mainClusterId, roles: dedupeByCluster([...held, ...clusters]).map(shape), recommended: recommended.map(shape) };
|
|
74
137
|
}
|
|
75
138
|
|
|
76
|
-
async function
|
|
139
|
+
async function listMyRoles(args = {}) {
|
|
77
140
|
const session = requireSession();
|
|
78
|
-
|
|
141
|
+
const deadline = now() + Math.min(Math.max(Number(args.waitSeconds) || 0, 0), MAX_WAIT_SECONDS) * 1000;
|
|
142
|
+
let pending = await evaluationPending(session);
|
|
143
|
+
while (pending && now() < deadline) {
|
|
144
|
+
await sleep(POLL_MS);
|
|
145
|
+
pending = await evaluationPending(session);
|
|
146
|
+
}
|
|
147
|
+
let read = await readRoles(session.hubAccessToken);
|
|
148
|
+
while (!pending && read.recommended.length === 0 && now() < deadline) {
|
|
149
|
+
await sleep(POLL_MS);
|
|
150
|
+
read = await readRoles(session.hubAccessToken);
|
|
151
|
+
}
|
|
152
|
+
let message;
|
|
153
|
+
let next = null;
|
|
154
|
+
if (pending) message = 'The interview evaluation is still recommending roles: tell the talent you are preparing their roles and call list_my_roles again with waitSeconds.';
|
|
155
|
+
else if (read.recommended.length) {
|
|
156
|
+
next = 'set_main_role';
|
|
157
|
+
message = 'Call set_main_role without clusterId: it asks the talent which recommended role is their main one, in a dialog or with options it returns for you to show.';
|
|
158
|
+
} else {
|
|
159
|
+
next = 'set_main_role';
|
|
160
|
+
message = 'Nothing was recommended: call set_main_role without clusterId, it offers the catalogue (list_available_roles), then adds (add_role) and sets the role the talent picks.';
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, ...read, pending, ...(next ? { next } : {}), message };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function roleChoice(asked, roles, extra) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
reason: 'main-role-choice-required',
|
|
169
|
+
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
170
|
+
...asked.chat,
|
|
171
|
+
roles: roles.map((r) => ({ clusterId: r.clusterId, name: r.name })),
|
|
172
|
+
message: `${asked.chat.choiceMessage} Then call set_main_role with the clusterId (from roles) of the one they pick. ${extra}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// How many open projects look for each role; a failed count leaves its line out.
|
|
177
|
+
async function openCounts(hubAccessToken, roles) {
|
|
178
|
+
return Promise.all(roles.map(async (r) => {
|
|
179
|
+
try {
|
|
180
|
+
const res = await countOpenPositions({ hubAccessToken, clusterId: r.clusterId });
|
|
181
|
+
return res && res.ok ? res.total : null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The main-role step: why each recommended role fits and how many projects want it, then the question; the catalogue is the last option.
|
|
189
|
+
async function chooseMainRole(session, args, ctx) {
|
|
190
|
+
const recommended = byName((await readRoles(session.hubAccessToken)).recommended);
|
|
191
|
+
if (!recommended.length) return chooseFromCatalogue(args, ctx);
|
|
192
|
+
const c = copy();
|
|
193
|
+
const counts = await openCounts(session.hubAccessToken, recommended);
|
|
194
|
+
const evidence = args.evidence && typeof args.evidence === 'object' ? args.evidence : {};
|
|
195
|
+
const blocks = recommended.map((r, i) => renderRole(lang(), { name: r.name, source: r.source, evidence: evidence[r.clusterId], openProjects: counts[i], recommended: i === 0 }));
|
|
196
|
+
const asked = await askAfterTexts(ctx, {
|
|
197
|
+
tool: 'set_main_role',
|
|
198
|
+
texts: [[c.rolesIntro, ...blocks, c.rolesOutro].join('\n\n')],
|
|
199
|
+
question: c.mainRoleQuestion,
|
|
200
|
+
options: [...recommended.map((r) => r.name), c.mainRoleOther],
|
|
201
|
+
shown: args.shown,
|
|
202
|
+
answer: args.answer,
|
|
203
|
+
});
|
|
204
|
+
if (asked.reply) {
|
|
205
|
+
return { reply: { ...asked.reply, roles: recommended.map((r) => ({ clusterId: r.clusterId, name: r.name })), message: `${asked.reply.message} Pass the same evidence again. If they pick the last option, call set_main_role with catalogue:true and no clusterId.` } };
|
|
206
|
+
}
|
|
207
|
+
if (asked.answer === c.mainRoleOther) return chooseFromCatalogue({}, ctx);
|
|
208
|
+
return { role: recommended.find((r) => r.name === asked.answer) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function chooseFromCatalogue(args, ctx) {
|
|
212
|
+
const available = await listAvailableRoles();
|
|
213
|
+
if (!available.ok) return { reply: { ok: false, reason: available.reason } };
|
|
214
|
+
const roles = byName(available.roles);
|
|
215
|
+
if (!roles.length) return { reply: { ok: false, reason: 'no-roles' } };
|
|
216
|
+
const typed = roles.find((r) => typeof args.answer === 'string' && r.name === args.answer.trim());
|
|
217
|
+
if (typed) return { role: typed, add: true };
|
|
218
|
+
const asked = await askChoice(ctx, { question: copy().mainRoleQuestion, options: roles.map((r) => r.name) });
|
|
219
|
+
if (!asked.answer) return { reply: roleChoice(asked, roles, 'Pass catalogue:true too: it adds the role first.') };
|
|
220
|
+
return { role: roles.find((r) => r.name === asked.answer), add: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function setMainRole(args = {}, ctx = {}) {
|
|
224
|
+
const session = requireSession();
|
|
225
|
+
if (args.clusterId && args.catalogue === true) {
|
|
226
|
+
const added = await addRole({ clusterId: args.clusterId });
|
|
227
|
+
if (!added.ok) return added;
|
|
228
|
+
}
|
|
229
|
+
if (!args.clusterId) {
|
|
230
|
+
const picked = args.catalogue === true ? await chooseFromCatalogue(args, ctx) : await chooseMainRole(session, args, ctx);
|
|
231
|
+
if (picked.reply) return picked.reply;
|
|
232
|
+
if (picked.add) {
|
|
233
|
+
const added = await addRole({ clusterId: picked.role.clusterId });
|
|
234
|
+
if (!added.ok) return added;
|
|
235
|
+
}
|
|
236
|
+
const set = await setMainRole({ clusterId: picked.role.clusterId });
|
|
237
|
+
return set.ok ? { ...set, name: picked.role.name } : set;
|
|
238
|
+
}
|
|
79
239
|
const res = await requestSetMainRole({ clusterId: args.clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getSetMainRoleEndpoint() });
|
|
240
|
+
if (res.ok === false && res.reason === 'http-400') return { ok: false, reason: res.reason, message: 'The talent does not hold that role yet: call add_role with it, then set_main_role again.' };
|
|
80
241
|
if (!res.ok) return { ok: false, reason: res.reason };
|
|
81
242
|
return { ok: true, clusterId: args.clusterId };
|
|
82
243
|
}
|
|
@@ -96,17 +257,17 @@ function makeRoleTools(deps = {}) {
|
|
|
96
257
|
},
|
|
97
258
|
{
|
|
98
259
|
name: 'list_my_roles',
|
|
99
|
-
description: "List the roles the talent already holds, plus mainClusterId (their current main role). Use it before set_main_role. clusterId is an internal handle — refer to roles by name to the talent. Requires an active session.",
|
|
100
|
-
inputSchema:
|
|
260
|
+
description: "List the roles the talent already holds, plus mainClusterId (their current main role) and `recommended`: the roles the onboarding interview and the CV import suggest, interview first. Use it before set_main_role. In the sign-up pass waitSeconds (20): it waits for the interview evaluation to recommend roles and says when to call again (`pending`) or to fall back to list_available_roles. Follow `message`. clusterId is an internal handle — refer to roles by name to the talent. Requires an active session.",
|
|
261
|
+
inputSchema: LIST_MY_ROLES_SCHEMA,
|
|
101
262
|
handler: listMyRoles,
|
|
102
263
|
},
|
|
103
264
|
{
|
|
104
265
|
name: 'set_main_role',
|
|
105
|
-
description: "Set the talent's MAIN role
|
|
106
|
-
inputSchema:
|
|
266
|
+
description: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the recommended roles is their main one, with the catalogue as the last option (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns the question, its options and the roles to call it again with the clusterId they pick. Then it sets that role (adding a catalogue role first). Also usable standalone with clusterId to change the main role later; catalogue:true adds a catalogue role first. Requires an active session.",
|
|
267
|
+
inputSchema: SET_MAIN_ROLE_SCHEMA,
|
|
107
268
|
handler: setMainRole,
|
|
108
269
|
},
|
|
109
270
|
];
|
|
110
271
|
}
|
|
111
272
|
|
|
112
|
-
module.exports = { makeRoleTools, EMPTY_SCHEMA, CLUSTER_SCHEMA };
|
|
273
|
+
module.exports = { makeRoleTools, EMPTY_SCHEMA, CLUSTER_SCHEMA, LIST_MY_ROLES_SCHEMA, SET_MAIN_ROLE_SCHEMA };
|
package/src/mcp-server.js
CHANGED
|
@@ -4,6 +4,9 @@ const readline = require('readline');
|
|
|
4
4
|
|
|
5
5
|
const JSONRPC = '2.0';
|
|
6
6
|
const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
|
|
7
|
+
// A talent answering a dialog takes human time; past this the tool falls back to asking in the chat.
|
|
8
|
+
const ELICITATION_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
const ELICITATION_ACTIONS = new Set(['accept', 'decline', 'cancel']);
|
|
7
10
|
|
|
8
11
|
const ERROR = {
|
|
9
12
|
PARSE: -32700,
|
|
@@ -13,6 +16,10 @@ const ERROR = {
|
|
|
13
16
|
INTERNAL: -32603,
|
|
14
17
|
};
|
|
15
18
|
|
|
19
|
+
function isResponse(msg) {
|
|
20
|
+
return !!msg && msg.jsonrpc === JSONRPC && msg.method === undefined && msg.id !== undefined && msg.id !== null && ('result' in msg || 'error' in msg);
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
function isNotification(msg) {
|
|
17
24
|
return !msg || msg.id === undefined || msg.id === null;
|
|
18
25
|
}
|
|
@@ -27,7 +34,7 @@ function errorResponse(id, code, message, data) {
|
|
|
27
34
|
return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null } = {}) {
|
|
37
|
+
function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
|
|
31
38
|
const registry = new Map();
|
|
32
39
|
for (const t of tools) {
|
|
33
40
|
if (!t || !t.name || typeof t.handler !== 'function') {
|
|
@@ -46,6 +53,56 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
46
53
|
|
|
47
54
|
const advertisedVersion = protocolVersion || DEFAULT_PROTOCOL_VERSION;
|
|
48
55
|
|
|
56
|
+
let clientCapabilities = {};
|
|
57
|
+
let send = null;
|
|
58
|
+
let lastRequestId = 0;
|
|
59
|
+
const pending = new Map();
|
|
60
|
+
|
|
61
|
+
function request(method, params, timeoutMs) {
|
|
62
|
+
if (!send) return Promise.reject(new Error('no transport to the client'));
|
|
63
|
+
lastRequestId += 1;
|
|
64
|
+
const id = `shakers-${lastRequestId}`;
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${method} timed out`)); }, timeoutMs);
|
|
67
|
+
pending.set(id, { resolve, reject, timer });
|
|
68
|
+
send({ jsonrpc: JSONRPC, id, method, params });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function settle(msg) {
|
|
73
|
+
const waiter = pending.get(msg.id);
|
|
74
|
+
if (!waiter) return;
|
|
75
|
+
pending.delete(msg.id);
|
|
76
|
+
clearTimeout(waiter.timer);
|
|
77
|
+
if (msg.error) waiter.reject(new Error((msg.error && msg.error.message) || 'client error'));
|
|
78
|
+
else waiter.resolve(msg.result);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rejectPending(reason) {
|
|
82
|
+
for (const [id, waiter] of pending) {
|
|
83
|
+
pending.delete(id);
|
|
84
|
+
clearTimeout(waiter.timer);
|
|
85
|
+
waiter.reject(new Error(reason));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// An `elicitation` capability without `form` but with `url` only opens links, so it cannot ask a closed question.
|
|
90
|
+
function canElicitForm() {
|
|
91
|
+
const e = clientCapabilities.elicitation;
|
|
92
|
+
return !!e && typeof e === 'object' && (e.form !== undefined || e.url === undefined);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// MCP elicitation/create: the client's { action, content }, or null when it has no dialog or the exchange failed.
|
|
96
|
+
async function elicit({ message, requestedSchema }) {
|
|
97
|
+
if (!send || !canElicitForm()) return null;
|
|
98
|
+
try {
|
|
99
|
+
const result = await request('elicitation/create', { message, requestedSchema }, elicitationTimeoutMs);
|
|
100
|
+
return result && ELICITATION_ACTIONS.has(result.action) ? result : null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
49
106
|
function shapeTool(t) {
|
|
50
107
|
return {
|
|
51
108
|
name: t.name,
|
|
@@ -110,7 +167,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
110
167
|
try { await beforeCall(name); } catch { /* hook must never break a tool call */ }
|
|
111
168
|
}
|
|
112
169
|
try {
|
|
113
|
-
const value = await tool.handler(args);
|
|
170
|
+
const value = await tool.handler(args, { elicit, elicitation: !!send && canElicitForm() });
|
|
114
171
|
return {
|
|
115
172
|
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
116
173
|
isError: false,
|
|
@@ -124,6 +181,10 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
124
181
|
}
|
|
125
182
|
|
|
126
183
|
async function handleMessage(msg) {
|
|
184
|
+
if (isResponse(msg)) {
|
|
185
|
+
settle(msg);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
127
188
|
if (!msg || msg.jsonrpc !== JSONRPC || typeof msg.method !== 'string') {
|
|
128
189
|
if (isNotification(msg)) return null;
|
|
129
190
|
return errorResponse(msg && msg.id, ERROR.INVALID_REQUEST, 'invalid JSON-RPC request');
|
|
@@ -139,6 +200,8 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
139
200
|
switch (method) {
|
|
140
201
|
case 'initialize': {
|
|
141
202
|
const requested = msg.params && msg.params.protocolVersion;
|
|
203
|
+
clientCapabilities = (msg.params && msg.params.capabilities) || {};
|
|
204
|
+
const text = typeof instructions === 'function' ? instructions({ elicitation: canElicitForm() }) : instructions;
|
|
142
205
|
return resultResponse(id, {
|
|
143
206
|
protocolVersion: requested || advertisedVersion,
|
|
144
207
|
capabilities: {
|
|
@@ -151,7 +214,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
151
214
|
},
|
|
152
215
|
// Server-level guidance the client keeps in the model's context even
|
|
153
216
|
// when it loads tool schemas lazily (Claude Code defers MCP tools).
|
|
154
|
-
...(typeof
|
|
217
|
+
...(typeof text === 'string' && text ? { instructions: text } : {}),
|
|
155
218
|
});
|
|
156
219
|
}
|
|
157
220
|
case 'ping':
|
|
@@ -178,6 +241,11 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
178
241
|
function start({ input = process.stdin, output = process.stdout, onError } = {}) {
|
|
179
242
|
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
180
243
|
const write = (obj) => output.write(`${JSON.stringify(obj)}\n`);
|
|
244
|
+
send = write;
|
|
245
|
+
rl.on('close', () => {
|
|
246
|
+
send = null;
|
|
247
|
+
rejectPending('the client closed the connection');
|
|
248
|
+
});
|
|
181
249
|
|
|
182
250
|
rl.on('line', async (line) => {
|
|
183
251
|
const trimmed = line.trim();
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { postJsonWithTimeout, postMultipartWithTimeout } = require('./backend-request');
|
|
4
|
+
|
|
5
|
+
// Hub calls of the MCP sign-up (ticket 39540): public sign-up + claim code, the signed-in talent's import status, and the one-time web login token.
|
|
6
|
+
|
|
7
|
+
const SIGNUP_TIMEOUT_MS = 60000;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
9
|
+
|
|
10
|
+
// Hub's coded errors on the sign-up, verified against the works-ai exception filter (hub MR !2198); none says whether an account exists.
|
|
11
|
+
const CODE_TO_REASON = {
|
|
12
|
+
MISSING_SOURCE: 'missing-source',
|
|
13
|
+
INVALID_CV: 'invalid-cv',
|
|
14
|
+
INVALID_LINKEDIN_URL: 'invalid-linkedin-url',
|
|
15
|
+
RATE_LIMITED: 'rate-limited',
|
|
16
|
+
};
|
|
17
|
+
const IMPORT_SOURCES = ['linkedin', 'cv'];
|
|
18
|
+
|
|
19
|
+
function parseJson(raw) {
|
|
20
|
+
try {
|
|
21
|
+
return raw ? JSON.parse(raw) : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function failure(res) {
|
|
28
|
+
const body = parseJson(res.raw);
|
|
29
|
+
const code = body && typeof body.code === 'string' ? body.code : null;
|
|
30
|
+
return { ok: false, reason: (code && CODE_TO_REASON[code]) || `http-${res.status}`, status: res.status };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function envelopeData(raw) {
|
|
34
|
+
const json = parseJson(raw);
|
|
35
|
+
return json && json.status === 'OK' && json.data && typeof json.data === 'object' ? json.data : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nonEmpty(value) {
|
|
39
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function requestMcpSignup(
|
|
43
|
+
{ linkedinUrl, cv, email, firstName, lastName, userQuery, language } = {},
|
|
44
|
+
{ endpoint, timeoutMs = SIGNUP_TIMEOUT_MS } = {},
|
|
45
|
+
) {
|
|
46
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
47
|
+
const fields = {
|
|
48
|
+
linkedinUrl: nonEmpty(linkedinUrl),
|
|
49
|
+
email: nonEmpty(email),
|
|
50
|
+
firstName: nonEmpty(firstName),
|
|
51
|
+
lastName: nonEmpty(lastName),
|
|
52
|
+
userQuery: nonEmpty(userQuery),
|
|
53
|
+
language: nonEmpty(language),
|
|
54
|
+
file: cv && Buffer.isBuffer(cv.data) ? cv : undefined,
|
|
55
|
+
};
|
|
56
|
+
let res;
|
|
57
|
+
try {
|
|
58
|
+
res = await postMultipartWithTimeout(endpoint, fields, timeoutMs, 'POST');
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
61
|
+
}
|
|
62
|
+
if (res.status < 200 || res.status >= 300) return failure(res);
|
|
63
|
+
const data = envelopeData(res.raw);
|
|
64
|
+
if (!data || typeof data.claimCode !== 'string' || !data.claimCode) return { ok: false, reason: 'bad-response' };
|
|
65
|
+
return { ok: true, claimCode: data.claimCode, claimCodeExpiresAt: data.claimCodeExpiresAt || null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// hub works/me/import-profile/status → { state: running | done | failed, sources: { linkedin?, cv? } }, the shape signup_status reports.
|
|
69
|
+
function importStateOf(data) {
|
|
70
|
+
const status = data.profileImportStatus;
|
|
71
|
+
const reported = Array.isArray(data.sources) ? data.sources : [];
|
|
72
|
+
const sources = {};
|
|
73
|
+
for (const entry of reported) {
|
|
74
|
+
if (!entry || !IMPORT_SOURCES.includes(entry.source) || !['imported', 'failed'].includes(entry.status)) continue;
|
|
75
|
+
const failed = entry.status === 'failed';
|
|
76
|
+
sources[entry.source] = { state: failed ? 'failed' : 'done', ...(failed && typeof entry.code === 'string' ? { code: entry.code } : {}) };
|
|
77
|
+
}
|
|
78
|
+
if (status === 'PENDING' || status === 'RUNNING') return { state: 'running', sources: {} };
|
|
79
|
+
if (status === 'FAILED' || status === 'SKIPPED') return { state: 'failed', code: data.errorCode || null, sources };
|
|
80
|
+
if (status === 'DONE') return { state: 'done', sources };
|
|
81
|
+
return { state: 'none', sources: {} };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function requestMyImportStatus({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
85
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
86
|
+
if (!hubAccessToken) return { ok: false, reason: 'no-session' };
|
|
87
|
+
let res;
|
|
88
|
+
try {
|
|
89
|
+
res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, { Authorization: `Bearer ${hubAccessToken}` });
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
92
|
+
}
|
|
93
|
+
if (res.status < 200 || res.status >= 300) return { ok: false, reason: `http-${res.status}`, status: res.status };
|
|
94
|
+
const data = envelopeData(res.raw);
|
|
95
|
+
if (!data) return { ok: false, reason: 'bad-response' };
|
|
96
|
+
return { ok: true, ...importStateOf(data) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// better-auth reads the session from the cookie (email sign-in) or the Bearer session token (device flow).
|
|
100
|
+
async function requestOneTimeToken({ cookie = null, sessionToken = null } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
101
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
102
|
+
const headers = {};
|
|
103
|
+
if (cookie) headers.Cookie = cookie;
|
|
104
|
+
else if (sessionToken) headers.Authorization = `Bearer ${sessionToken}`;
|
|
105
|
+
else return { ok: false, reason: 'no-session' };
|
|
106
|
+
let res;
|
|
107
|
+
try {
|
|
108
|
+
res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, headers);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
111
|
+
}
|
|
112
|
+
if (res.status < 200 || res.status >= 300) return { ok: false, reason: `http-${res.status}`, status: res.status };
|
|
113
|
+
const body = parseJson(res.raw);
|
|
114
|
+
const token = body && (typeof body.token === 'string' ? body.token : (body.data && body.data.token));
|
|
115
|
+
return typeof token === 'string' && token ? { ok: true, token } : { ok: false, reason: 'bad-response' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { requestMcpSignup, requestMyImportStatus, requestOneTimeToken, CODE_TO_REASON };
|