shakers-cli-beta 0.8.0-beta.5 → 0.10.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 +39 -9
- package/bin/ai-usage.js +37 -19
- package/bin/login.js +1 -1
- package/bin/mcp.js +25 -21
- package/bin/mcpb.js +5 -0
- package/package.json +3 -2
- package/src/auth-session-store.js +1 -0
- package/src/authorship.js +7 -6
- package/src/backend-request.js +2 -2
- package/src/certify-dimension-client.js +0 -54
- package/src/certify-dimension-flow.js +20 -81
- package/src/config.js +29 -8
- package/src/cv-file.js +62 -0
- package/src/device-login.js +5 -3
- package/src/find-projects-client.js +16 -6
- package/src/git-activity.js +8 -7
- package/src/git-log-prefetch.js +53 -0
- package/src/i18n.js +33 -58
- package/src/mcp-ai-usage-tool.js +150 -65
- package/src/mcp-auth-form.js +213 -44
- package/src/mcp-certify-dimension-tools.js +2 -2
- 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 +3 -3
- package/src/mcp-prompts.js +73 -0
- package/src/mcp-rate-tools.js +2 -2
- package/src/mcp-register-tools.js +89 -313
- package/src/mcp-role-tools.js +177 -16
- package/src/mcp-server.js +108 -4
- 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/scanner.js +13 -1
- package/src/session-normalizers.js +174 -0
- package/src/session-scan.js +149 -10
- 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/work-streams.js +7 -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 = [], 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') {
|
|
@@ -36,8 +43,66 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
36
43
|
registry.set(t.name, t);
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
const promptRegistry = new Map();
|
|
47
|
+
for (const p of prompts) {
|
|
48
|
+
if (!p || !p.name || typeof p.buildMessages !== 'function') {
|
|
49
|
+
throw new Error('each prompt needs a name and a buildMessages function');
|
|
50
|
+
}
|
|
51
|
+
promptRegistry.set(p.name, p);
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
const advertisedVersion = protocolVersion || DEFAULT_PROTOCOL_VERSION;
|
|
40
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
|
+
|
|
41
106
|
function shapeTool(t) {
|
|
42
107
|
return {
|
|
43
108
|
name: t.name,
|
|
@@ -50,6 +115,27 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
50
115
|
return { tools: [...registry.values()].map(shapeTool) };
|
|
51
116
|
}
|
|
52
117
|
|
|
118
|
+
function shapePrompt(p) {
|
|
119
|
+
const out = { name: p.name, description: p.description || '' };
|
|
120
|
+
if (Array.isArray(p.arguments)) out.arguments = p.arguments;
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function listPrompts() {
|
|
125
|
+
return { prompts: [...promptRegistry.values()].map(shapePrompt) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getPrompt(params) {
|
|
129
|
+
const name = params && params.name;
|
|
130
|
+
const prompt = name && promptRegistry.get(name);
|
|
131
|
+
if (!prompt) {
|
|
132
|
+
const err = new Error(`unknown prompt: ${name}`);
|
|
133
|
+
err.rpcCode = ERROR.INVALID_PARAMS;
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
return { description: prompt.description || '', messages: prompt.buildMessages((params && params.arguments) || {}) };
|
|
137
|
+
}
|
|
138
|
+
|
|
53
139
|
async function listAvailableTools() {
|
|
54
140
|
const out = [];
|
|
55
141
|
for (const t of registry.values()) {
|
|
@@ -81,7 +167,7 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
81
167
|
try { await beforeCall(name); } catch { /* hook must never break a tool call */ }
|
|
82
168
|
}
|
|
83
169
|
try {
|
|
84
|
-
const value = await tool.handler(args);
|
|
170
|
+
const value = await tool.handler(args, { elicit, elicitation: !!send && canElicitForm() });
|
|
85
171
|
return {
|
|
86
172
|
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
87
173
|
isError: false,
|
|
@@ -95,6 +181,10 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
95
181
|
}
|
|
96
182
|
|
|
97
183
|
async function handleMessage(msg) {
|
|
184
|
+
if (isResponse(msg)) {
|
|
185
|
+
settle(msg);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
98
188
|
if (!msg || msg.jsonrpc !== JSONRPC || typeof msg.method !== 'string') {
|
|
99
189
|
if (isNotification(msg)) return null;
|
|
100
190
|
return errorResponse(msg && msg.id, ERROR.INVALID_REQUEST, 'invalid JSON-RPC request');
|
|
@@ -110,16 +200,21 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
110
200
|
switch (method) {
|
|
111
201
|
case 'initialize': {
|
|
112
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;
|
|
113
205
|
return resultResponse(id, {
|
|
114
206
|
protocolVersion: requested || advertisedVersion,
|
|
115
|
-
capabilities: {
|
|
207
|
+
capabilities: {
|
|
208
|
+
tools: { listChanged: false },
|
|
209
|
+
...(promptRegistry.size ? { prompts: { listChanged: false } } : {}),
|
|
210
|
+
},
|
|
116
211
|
serverInfo: {
|
|
117
212
|
name: serverInfo.name || 'shakers-mcp',
|
|
118
213
|
version: serverInfo.version || '0.0.0',
|
|
119
214
|
},
|
|
120
215
|
// Server-level guidance the client keeps in the model's context even
|
|
121
216
|
// when it loads tool schemas lazily (Claude Code defers MCP tools).
|
|
122
|
-
...(typeof
|
|
217
|
+
...(typeof text === 'string' && text ? { instructions: text } : {}),
|
|
123
218
|
});
|
|
124
219
|
}
|
|
125
220
|
case 'ping':
|
|
@@ -128,6 +223,10 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
128
223
|
return resultResponse(id, await listAvailableTools());
|
|
129
224
|
case 'tools/call':
|
|
130
225
|
return resultResponse(id, await callTool(msg.params));
|
|
226
|
+
case 'prompts/list':
|
|
227
|
+
return resultResponse(id, listPrompts());
|
|
228
|
+
case 'prompts/get':
|
|
229
|
+
return resultResponse(id, getPrompt(msg.params));
|
|
131
230
|
default:
|
|
132
231
|
if (isNotification(msg)) return null;
|
|
133
232
|
return errorResponse(id, ERROR.METHOD_NOT_FOUND, `method not found: ${method}`);
|
|
@@ -142,6 +241,11 @@ function createMcpServer({ tools = [], serverInfo = {}, protocolVersion, beforeC
|
|
|
142
241
|
function start({ input = process.stdin, output = process.stdout, onError } = {}) {
|
|
143
242
|
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
144
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
|
+
});
|
|
145
249
|
|
|
146
250
|
rl.on('line', async (line) => {
|
|
147
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 };
|