@starasia/task-management-mcp 1.0.2 → 1.1.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 +43 -4
- package/dist/auth-store.d.ts +16 -0
- package/dist/auth-store.js +85 -0
- package/dist/auth-store.js.map +1 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/generated-config.d.ts +1 -1
- package/dist/generated-config.js +1 -1
- package/dist/generated-config.js.map +1 -1
- package/dist/hris-api.d.ts +10 -0
- package/dist/hris-api.js +108 -0
- package/dist/hris-api.js.map +1 -0
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/sso-api.d.ts +78 -0
- package/dist/sso-api.js +140 -0
- package/dist/sso-api.js.map +1 -0
- package/dist/tools.d.ts +17 -1
- package/dist/tools.js +733 -17
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer, } from "node:http";
|
|
1
3
|
import { z } from "zod";
|
|
2
4
|
import { TaskManagementApiError } from "./api.js";
|
|
5
|
+
import { SsoApiError } from "./sso-api.js";
|
|
3
6
|
import { categorySchema, dryRunSchema, paginationSchema, prioritySchema, } from "./schemas.js";
|
|
4
7
|
const json = (data) => ({
|
|
5
8
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
@@ -45,16 +48,105 @@ function resolveUserId(bearerToken, userId) {
|
|
|
45
48
|
return jwtSubject;
|
|
46
49
|
throw new Error("Unable to derive userId from bearer token JWT subject. Provide userId explicitly or use a JWT access token with a string sub claim.");
|
|
47
50
|
}
|
|
48
|
-
const authStatus = (context) => ({
|
|
51
|
+
const authStatus = (context, pendingSsoSession) => ({
|
|
49
52
|
configured: Boolean(context),
|
|
50
53
|
environment: context?.environment,
|
|
51
54
|
userId: context?.userId,
|
|
52
55
|
organizationId: context?.organizationId,
|
|
53
56
|
hasToken: Boolean(context?.bearerToken),
|
|
54
57
|
expiresAt: context?.expiresAt,
|
|
58
|
+
...(pendingSsoSession
|
|
59
|
+
? {
|
|
60
|
+
pendingSsoOrganizationSelection: true,
|
|
61
|
+
pendingOrganizationCount: pendingSsoSession.organizations.length,
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
55
64
|
});
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
function publicOrganization(option) {
|
|
66
|
+
return {
|
|
67
|
+
id: option.id,
|
|
68
|
+
name: option.name,
|
|
69
|
+
isActive: option.isActive,
|
|
70
|
+
application: option.application,
|
|
71
|
+
role: option.role,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function normalizeOrganizationName(value) {
|
|
75
|
+
return value
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.normalize("NFKD")
|
|
78
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
79
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
80
|
+
.split(/\s+/)
|
|
81
|
+
.filter((part) => part && !["pt", "tbk", "cv", "inc", "ltd"].includes(part))
|
|
82
|
+
.join(" ")
|
|
83
|
+
.trim();
|
|
84
|
+
}
|
|
85
|
+
function organizationMatchScore(input, option) {
|
|
86
|
+
const query = normalizeOrganizationName(input);
|
|
87
|
+
const target = normalizeOrganizationName(option.name);
|
|
88
|
+
if (!query || !target)
|
|
89
|
+
return 0;
|
|
90
|
+
if (query === target)
|
|
91
|
+
return 100;
|
|
92
|
+
if (target.includes(query))
|
|
93
|
+
return 90 + Math.min(9, query.length / 4);
|
|
94
|
+
if (query.includes(target))
|
|
95
|
+
return 85;
|
|
96
|
+
const queryTokens = new Set(query.split(" ").filter(Boolean));
|
|
97
|
+
const targetTokens = new Set(target.split(" ").filter(Boolean));
|
|
98
|
+
if (!queryTokens.size || !targetTokens.size)
|
|
99
|
+
return 0;
|
|
100
|
+
let matches = 0;
|
|
101
|
+
for (const token of queryTokens) {
|
|
102
|
+
if (targetTokens.has(token) ||
|
|
103
|
+
[...targetTokens].some((targetToken) => targetToken.startsWith(token) ||
|
|
104
|
+
token.startsWith(targetToken) ||
|
|
105
|
+
levenshteinDistance(token, targetToken) <= 1))
|
|
106
|
+
matches += 1;
|
|
107
|
+
}
|
|
108
|
+
return (matches / queryTokens.size) * 80;
|
|
109
|
+
}
|
|
110
|
+
function levenshteinDistance(a, b) {
|
|
111
|
+
const dp = Array.from({ length: a.length + 1 }, (_, index) => [index]);
|
|
112
|
+
for (let j = 1; j <= b.length; j += 1)
|
|
113
|
+
dp[0][j] = j;
|
|
114
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
115
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
116
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return dp[a.length][b.length];
|
|
120
|
+
}
|
|
121
|
+
function matchOrganization(organizations, input) {
|
|
122
|
+
const organizationId = input.organizationId?.trim();
|
|
123
|
+
if (organizationId) {
|
|
124
|
+
return { selected: organizations.find((org) => org.id === organizationId) };
|
|
125
|
+
}
|
|
126
|
+
const organizationName = input.organizationName?.trim();
|
|
127
|
+
if (!organizationName)
|
|
128
|
+
return {};
|
|
129
|
+
const scored = organizations
|
|
130
|
+
.map((org) => ({
|
|
131
|
+
org,
|
|
132
|
+
score: organizationMatchScore(organizationName, org),
|
|
133
|
+
}))
|
|
134
|
+
.filter((item) => item.score >= 55)
|
|
135
|
+
.sort((a, b) => b.score - a.score);
|
|
136
|
+
if (!scored.length)
|
|
137
|
+
return {};
|
|
138
|
+
const [best, second] = scored;
|
|
139
|
+
if (second && best.score - second.score < 12) {
|
|
140
|
+
return { ambiguous: scored.slice(0, 5).map((item) => item.org) };
|
|
141
|
+
}
|
|
142
|
+
return { selected: best.org };
|
|
143
|
+
}
|
|
144
|
+
export function registerTools(server, api, ssoApi, hrisApi, options) {
|
|
145
|
+
const { maxBulkSize, authTtlMs, defaultSsoClientId, authStore, webLogin } = options;
|
|
146
|
+
let activeContext = authStore.load();
|
|
147
|
+
let pendingSsoSession;
|
|
148
|
+
const webSsoSessions = new Map();
|
|
149
|
+
const webLoginServerState = {};
|
|
58
150
|
const requireContext = () => {
|
|
59
151
|
if (!activeContext) {
|
|
60
152
|
throw new Error("Authentication context is not configured. Ask the user for bearerToken and organizationId, then call set_auth_context first. userId is derived from the token JWT sub claim unless explicitly provided.");
|
|
@@ -65,6 +157,550 @@ export function registerTools(server, api, maxBulkSize, authTtlMs) {
|
|
|
65
157
|
}
|
|
66
158
|
return activeContext;
|
|
67
159
|
};
|
|
160
|
+
const validateAndActivateContext = async (nextContext, validate) => {
|
|
161
|
+
if (validate) {
|
|
162
|
+
try {
|
|
163
|
+
await api.get("spaces", undefined, nextContext);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (error instanceof TaskManagementApiError) {
|
|
167
|
+
throw new Error(`Auth context validation failed with API status ${error.status}. Verify the SSO token, selected organization, derived userId, and environment-specific Task Management API URL.`, { cause: error });
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
activeContext = nextContext;
|
|
173
|
+
authStore.save(nextContext);
|
|
174
|
+
return activeContext;
|
|
175
|
+
};
|
|
176
|
+
const listHrisOrganizations = async (bearerToken) => {
|
|
177
|
+
const organizations = await hrisApi.listSwitchOrganizations({ bearerToken });
|
|
178
|
+
if (!organizations.length) {
|
|
179
|
+
throw new Error("No active HRIS organization was found for this user.");
|
|
180
|
+
}
|
|
181
|
+
return organizations;
|
|
182
|
+
};
|
|
183
|
+
const exchangeForTaskManagementAccessToken = async (input) => {
|
|
184
|
+
if (!input.sessionId || !input.secret) {
|
|
185
|
+
throw new Error("Task Management access-token exchange requires a web handoff session.");
|
|
186
|
+
}
|
|
187
|
+
const tokenResult = await ssoApi.exchangeTaskManagementMcpAccessToken({
|
|
188
|
+
sessionId: input.sessionId,
|
|
189
|
+
secret: input.secret,
|
|
190
|
+
clientId: input.clientId,
|
|
191
|
+
organizationId: input.organizationId,
|
|
192
|
+
});
|
|
193
|
+
if (!tokenResult.accessToken) {
|
|
194
|
+
throw new Error("SSO API did not return a Task Management access token.");
|
|
195
|
+
}
|
|
196
|
+
return tokenResult;
|
|
197
|
+
};
|
|
198
|
+
const buildWebLoginUrl = (session) => {
|
|
199
|
+
if (webLogin.publicBaseUrl) {
|
|
200
|
+
const url = new URL("/login", webLogin.publicBaseUrl.replace(/\/$/, "") + "/");
|
|
201
|
+
url.searchParams.set("clientId", session.clientId);
|
|
202
|
+
url.searchParams.set("mcpSessionId", session.id);
|
|
203
|
+
if (session.handoffSecret)
|
|
204
|
+
url.searchParams.set("mcpSecret", session.handoffSecret);
|
|
205
|
+
url.searchParams.set("mcpTarget", "task-management");
|
|
206
|
+
if (session.environment)
|
|
207
|
+
url.searchParams.set("environment", session.environment);
|
|
208
|
+
return url.toString();
|
|
209
|
+
}
|
|
210
|
+
const baseUrl = webLoginServerState.baseUrl;
|
|
211
|
+
if (!baseUrl)
|
|
212
|
+
throw new Error("Web login server is not ready.");
|
|
213
|
+
return `${baseUrl.replace(/\/$/, "")}/task-management-mcp/sso-login/${encodeURIComponent(session.id)}`;
|
|
214
|
+
};
|
|
215
|
+
const ensureWebLoginServer = async () => {
|
|
216
|
+
if (webLoginServerState.server && webLoginServerState.baseUrl)
|
|
217
|
+
return;
|
|
218
|
+
const httpServer = createServer((request, response) => {
|
|
219
|
+
handleWebLoginRequest(request, response).catch((error) => {
|
|
220
|
+
response.statusCode = 500;
|
|
221
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
222
|
+
response.end(renderPage("Login failed", `<p>${escapeHtml(errorMessage(error))}</p>`));
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
await new Promise((resolve) => httpServer.listen(webLogin.port, webLogin.host, resolve));
|
|
226
|
+
const address = httpServer.address();
|
|
227
|
+
const actualPort = typeof address === "object" && address
|
|
228
|
+
? address.port
|
|
229
|
+
: webLogin.port;
|
|
230
|
+
webLoginServerState.server = httpServer;
|
|
231
|
+
webLoginServerState.baseUrl =
|
|
232
|
+
webLogin.publicBaseUrl?.replace(/\/$/, "") ??
|
|
233
|
+
`http://${webLogin.host}:${actualPort}`;
|
|
234
|
+
};
|
|
235
|
+
const handleWebLoginRequest = async (request, response) => {
|
|
236
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
237
|
+
const match = /^\/task-management-mcp\/sso-login\/([^/]+)$/.exec(url.pathname);
|
|
238
|
+
if (!match) {
|
|
239
|
+
response.statusCode = 404;
|
|
240
|
+
response.end("Not Found");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const sessionId = decodeURIComponent(match[1]);
|
|
244
|
+
const session = webSsoSessions.get(sessionId);
|
|
245
|
+
if (!session || new Date(session.expiresAt).getTime() <= Date.now()) {
|
|
246
|
+
webSsoSessions.delete(sessionId);
|
|
247
|
+
response.statusCode = 410;
|
|
248
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
249
|
+
response.end(renderPage("Login expired", "<p>This login link is expired. Ask the agent for a new login link.</p>"));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (request.method === "GET") {
|
|
253
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
254
|
+
response.end(renderWebSession(session));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (request.method !== "POST") {
|
|
258
|
+
response.statusCode = 405;
|
|
259
|
+
response.end("Method Not Allowed");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const form = new URLSearchParams(await readRequestBody(request));
|
|
263
|
+
try {
|
|
264
|
+
if (session.status === "pending") {
|
|
265
|
+
const email = form.get("email")?.trim() ?? "";
|
|
266
|
+
const password = form.get("password") ?? "";
|
|
267
|
+
if (!email || !password)
|
|
268
|
+
throw new Error("Email and password are required.");
|
|
269
|
+
const loginResult = await ssoApi.login({
|
|
270
|
+
email,
|
|
271
|
+
password,
|
|
272
|
+
clientId: session.clientId,
|
|
273
|
+
mode: "web",
|
|
274
|
+
});
|
|
275
|
+
if (loginResult.requirePasswordChange) {
|
|
276
|
+
throw new Error("SSO requires a password change before MCP login can continue.");
|
|
277
|
+
}
|
|
278
|
+
if (!loginResult.authToken)
|
|
279
|
+
throw new Error("SSO login did not return an authToken.");
|
|
280
|
+
const userId = resolveUserId(loginResult.authToken);
|
|
281
|
+
const organizations = await listHrisOrganizations(loginResult.authToken);
|
|
282
|
+
session.bearerToken = loginResult.authToken;
|
|
283
|
+
session.userId = userId;
|
|
284
|
+
if (!webLogin.publicBaseUrl && session.handoffSecret) {
|
|
285
|
+
await ssoApi.completeTaskManagementMcpHandoff({
|
|
286
|
+
sessionId: session.id,
|
|
287
|
+
secret: session.handoffSecret,
|
|
288
|
+
authToken: loginResult.authToken,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
session.organizations = organizations;
|
|
292
|
+
if (organizations.length === 1) {
|
|
293
|
+
await completeWebSession(session, organizations[0]);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
session.status = "organization_selection";
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else if (session.status === "organization_selection") {
|
|
300
|
+
const organizationId = form.get("organizationId")?.trim();
|
|
301
|
+
const selected = session.organizations?.find((org) => org.id === organizationId);
|
|
302
|
+
if (!selected)
|
|
303
|
+
throw new Error("Selected organization is not available for this login session.");
|
|
304
|
+
await completeWebSession(session, selected);
|
|
305
|
+
}
|
|
306
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
307
|
+
response.end(renderWebSession(session));
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
session.status = "failed";
|
|
311
|
+
session.error = errorMessage(error);
|
|
312
|
+
response.statusCode = 400;
|
|
313
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
314
|
+
response.end(renderWebSession(session));
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
const completeWebSession = async (session, selectedOrganization) => {
|
|
318
|
+
if (!session.bearerToken || !session.userId) {
|
|
319
|
+
throw new Error("Web SSO session is missing token context.");
|
|
320
|
+
}
|
|
321
|
+
const tokenResult = await exchangeForTaskManagementAccessToken({
|
|
322
|
+
sessionId: session.id,
|
|
323
|
+
secret: session.handoffSecret,
|
|
324
|
+
clientId: session.clientId,
|
|
325
|
+
organizationId: selectedOrganization.id,
|
|
326
|
+
});
|
|
327
|
+
session.status = "completed";
|
|
328
|
+
session.selectedOrganization = selectedOrganization;
|
|
329
|
+
session.context = {
|
|
330
|
+
bearerToken: tokenResult.accessToken,
|
|
331
|
+
userId: tokenResult.userId ?? session.userId,
|
|
332
|
+
organizationId: selectedOrganization.id,
|
|
333
|
+
environment: session.environment,
|
|
334
|
+
expiresAt: new Date(Date.now() + authTtlMs).toISOString(),
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
server.registerTool("start_web_sso_login", {
|
|
338
|
+
title: "Start Web SSO Login",
|
|
339
|
+
description: "Create a short-lived HTTPS/web login handoff URL so chat users can enter SSO email/password in a masked browser form instead of the agent chat. The returned link contains no token.",
|
|
340
|
+
inputSchema: {
|
|
341
|
+
clientId: z.string().min(1).optional(),
|
|
342
|
+
environment: z.string().optional(),
|
|
343
|
+
validate: z.boolean().default(true).optional(),
|
|
344
|
+
},
|
|
345
|
+
annotations: {
|
|
346
|
+
readOnlyHint: false,
|
|
347
|
+
destructiveHint: false,
|
|
348
|
+
idempotentHint: false,
|
|
349
|
+
openWorldHint: true,
|
|
350
|
+
},
|
|
351
|
+
}, async (args) => {
|
|
352
|
+
const clientId = args.clientId?.trim() || defaultSsoClientId?.trim();
|
|
353
|
+
if (!clientId) {
|
|
354
|
+
throw new Error("TASK_MANAGEMENT_SSO_CLIENT_ID is not configured. Set it to the Task Management module client ID from SSO, or pass clientId explicitly.");
|
|
355
|
+
}
|
|
356
|
+
if (!webLogin.publicBaseUrl)
|
|
357
|
+
await ensureWebLoginServer();
|
|
358
|
+
const sessionId = randomBytes(18).toString("base64url");
|
|
359
|
+
const handoffSecret = randomBytes(32).toString("base64url");
|
|
360
|
+
const expiresAt = new Date(Date.now() + webLogin.ttlMs).toISOString();
|
|
361
|
+
const session = {
|
|
362
|
+
id: sessionId,
|
|
363
|
+
clientId,
|
|
364
|
+
environment: args.environment,
|
|
365
|
+
expiresAt,
|
|
366
|
+
validate: args.validate ?? true,
|
|
367
|
+
status: "pending",
|
|
368
|
+
handoffSecret,
|
|
369
|
+
};
|
|
370
|
+
webSsoSessions.set(sessionId, session);
|
|
371
|
+
return json({
|
|
372
|
+
sessionId,
|
|
373
|
+
loginUrl: buildWebLoginUrl(session),
|
|
374
|
+
expiresAt,
|
|
375
|
+
status: "pending",
|
|
376
|
+
message: "Open the loginUrl in a browser/Telegram WebView. Do not ask the user to paste their password into chat.",
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
server.registerTool("check_web_sso_login", {
|
|
380
|
+
title: "Check Web SSO Login",
|
|
381
|
+
description: "Check a pending web SSO login session. When completed, validates and activates the Task Management auth context without returning the token.",
|
|
382
|
+
inputSchema: {
|
|
383
|
+
sessionId: z.string().min(1),
|
|
384
|
+
validate: z.boolean().optional(),
|
|
385
|
+
},
|
|
386
|
+
annotations: {
|
|
387
|
+
readOnlyHint: false,
|
|
388
|
+
destructiveHint: false,
|
|
389
|
+
idempotentHint: true,
|
|
390
|
+
openWorldHint: true,
|
|
391
|
+
},
|
|
392
|
+
}, async (args) => {
|
|
393
|
+
const session = webSsoSessions.get(args.sessionId);
|
|
394
|
+
if (!session)
|
|
395
|
+
return json({ status: "not_found", configured: false });
|
|
396
|
+
if (new Date(session.expiresAt).getTime() <= Date.now()) {
|
|
397
|
+
webSsoSessions.delete(args.sessionId);
|
|
398
|
+
return json({ status: "expired", configured: false });
|
|
399
|
+
}
|
|
400
|
+
if (session.status === "completed" && session.context && session.selectedOrganization) {
|
|
401
|
+
const validate = args.validate ?? session.validate;
|
|
402
|
+
const context = await validateAndActivateContext(session.context, validate);
|
|
403
|
+
webSsoSessions.delete(args.sessionId);
|
|
404
|
+
return json({
|
|
405
|
+
status: "completed",
|
|
406
|
+
...authStatus(context),
|
|
407
|
+
selectedOrganization: publicOrganization(session.selectedOrganization),
|
|
408
|
+
validated: validate,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
if (session.handoffSecret) {
|
|
412
|
+
const handoff = await ssoApi.consumeTaskManagementMcpHandoff({
|
|
413
|
+
sessionId: session.id,
|
|
414
|
+
secret: session.handoffSecret,
|
|
415
|
+
});
|
|
416
|
+
if (handoff.status !== "completed") {
|
|
417
|
+
return json({
|
|
418
|
+
status: "pending",
|
|
419
|
+
configured: false,
|
|
420
|
+
expiresAt: session.expiresAt,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if (!handoff.authToken || !handoff.userId) {
|
|
424
|
+
throw new Error("Completed MCP handoff is missing token context.");
|
|
425
|
+
}
|
|
426
|
+
const expiresAt = handoff.expiresAt ?? session.expiresAt;
|
|
427
|
+
const validate = args.validate ?? session.validate;
|
|
428
|
+
if (handoff.organizationId && handoff.organizationName) {
|
|
429
|
+
const selectedOrganization = {
|
|
430
|
+
id: handoff.organizationId,
|
|
431
|
+
name: handoff.organizationName,
|
|
432
|
+
};
|
|
433
|
+
const tokenResult = await exchangeForTaskManagementAccessToken({
|
|
434
|
+
sessionId: session.id,
|
|
435
|
+
secret: session.handoffSecret,
|
|
436
|
+
clientId: session.clientId,
|
|
437
|
+
organizationId: handoff.organizationId,
|
|
438
|
+
});
|
|
439
|
+
const context = await validateAndActivateContext({
|
|
440
|
+
bearerToken: tokenResult.accessToken,
|
|
441
|
+
userId: tokenResult.userId ?? handoff.userId,
|
|
442
|
+
organizationId: handoff.organizationId,
|
|
443
|
+
environment: session.environment,
|
|
444
|
+
expiresAt,
|
|
445
|
+
}, validate);
|
|
446
|
+
webSsoSessions.delete(args.sessionId);
|
|
447
|
+
return json({
|
|
448
|
+
status: "completed",
|
|
449
|
+
...authStatus(context),
|
|
450
|
+
selectedOrganization: publicOrganization(selectedOrganization),
|
|
451
|
+
validated: validate,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
const organizations = await listHrisOrganizations(handoff.authToken);
|
|
455
|
+
if (organizations.length === 1) {
|
|
456
|
+
const selectedOrganization = organizations[0];
|
|
457
|
+
const tokenResult = await exchangeForTaskManagementAccessToken({
|
|
458
|
+
sessionId: session.id,
|
|
459
|
+
secret: session.handoffSecret,
|
|
460
|
+
clientId: session.clientId,
|
|
461
|
+
organizationId: selectedOrganization.id,
|
|
462
|
+
});
|
|
463
|
+
const context = await validateAndActivateContext({
|
|
464
|
+
bearerToken: tokenResult.accessToken,
|
|
465
|
+
userId: tokenResult.userId ?? handoff.userId,
|
|
466
|
+
organizationId: selectedOrganization.id,
|
|
467
|
+
environment: session.environment,
|
|
468
|
+
expiresAt,
|
|
469
|
+
}, validate);
|
|
470
|
+
webSsoSessions.delete(args.sessionId);
|
|
471
|
+
return json({
|
|
472
|
+
status: "completed",
|
|
473
|
+
...authStatus(context),
|
|
474
|
+
selectedOrganization: publicOrganization(selectedOrganization),
|
|
475
|
+
validated: validate,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
pendingSsoSession = {
|
|
479
|
+
bearerToken: handoff.authToken,
|
|
480
|
+
userId: handoff.userId,
|
|
481
|
+
clientId: session.clientId,
|
|
482
|
+
environment: session.environment,
|
|
483
|
+
organizations,
|
|
484
|
+
expiresAt,
|
|
485
|
+
validate,
|
|
486
|
+
handoffSessionId: session.id,
|
|
487
|
+
handoffSecret: session.handoffSecret,
|
|
488
|
+
};
|
|
489
|
+
webSsoSessions.delete(args.sessionId);
|
|
490
|
+
return json({
|
|
491
|
+
status: "organization_selection",
|
|
492
|
+
configured: false,
|
|
493
|
+
pendingSsoOrganizationSelection: true,
|
|
494
|
+
userId: handoff.userId,
|
|
495
|
+
organizationCount: organizations.length,
|
|
496
|
+
organizations: organizations.map(publicOrganization),
|
|
497
|
+
message: "Multiple HRIS organizations were found. Present the organization names to the user and call select_sso_organization with the typed organization name.",
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
if (session.status === "completed") {
|
|
501
|
+
if (!session.context || !session.selectedOrganization) {
|
|
502
|
+
throw new Error("Completed web SSO session is missing context.");
|
|
503
|
+
}
|
|
504
|
+
const validate = args.validate ?? session.validate;
|
|
505
|
+
const context = await validateAndActivateContext(session.context, validate);
|
|
506
|
+
webSsoSessions.delete(args.sessionId);
|
|
507
|
+
return json({
|
|
508
|
+
status: "completed",
|
|
509
|
+
...authStatus(context),
|
|
510
|
+
selectedOrganization: publicOrganization(session.selectedOrganization),
|
|
511
|
+
validated: validate,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return json({
|
|
515
|
+
status: session.status,
|
|
516
|
+
configured: false,
|
|
517
|
+
expiresAt: session.expiresAt,
|
|
518
|
+
error: session.error,
|
|
519
|
+
...(session.status === "organization_selection" && session.organizations
|
|
520
|
+
? { organizations: session.organizations.map(publicOrganization) }
|
|
521
|
+
: {}),
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
server.registerTool("cancel_web_sso_login", {
|
|
525
|
+
title: "Cancel Web SSO Login",
|
|
526
|
+
description: "Cancel and forget a pending web SSO login handoff session.",
|
|
527
|
+
inputSchema: { sessionId: z.string().min(1) },
|
|
528
|
+
annotations: {
|
|
529
|
+
readOnlyHint: false,
|
|
530
|
+
destructiveHint: false,
|
|
531
|
+
idempotentHint: true,
|
|
532
|
+
openWorldHint: false,
|
|
533
|
+
},
|
|
534
|
+
}, async (args) => {
|
|
535
|
+
const deleted = webSsoSessions.delete(args.sessionId);
|
|
536
|
+
return json({
|
|
537
|
+
status: deleted ? "cancelled" : "not_found",
|
|
538
|
+
configured: Boolean(activeContext),
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
server.registerTool("sso_login", {
|
|
542
|
+
title: "SSO Login",
|
|
543
|
+
description: "Authenticate through Starasia SSO using email and password, then list or auto-select organizations for Task Management. Prefer this over asking users for raw bearer tokens. Password and token stay in process memory and are never returned.",
|
|
544
|
+
inputSchema: {
|
|
545
|
+
email: z.string().email(),
|
|
546
|
+
password: z
|
|
547
|
+
.string()
|
|
548
|
+
.min(1)
|
|
549
|
+
.describe("SSO password supplied by the active user. Do not log or repeat it."),
|
|
550
|
+
clientId: z
|
|
551
|
+
.string()
|
|
552
|
+
.min(1)
|
|
553
|
+
.optional()
|
|
554
|
+
.describe("Optional override for the Task Management module SSO client ID. Defaults to TASK_MANAGEMENT_SSO_CLIENT_ID."),
|
|
555
|
+
environment: z.string().optional(),
|
|
556
|
+
validate: z
|
|
557
|
+
.boolean()
|
|
558
|
+
.default(true)
|
|
559
|
+
.optional()
|
|
560
|
+
.describe("When true, verify the final context with GET /spaces."),
|
|
561
|
+
},
|
|
562
|
+
annotations: {
|
|
563
|
+
readOnlyHint: false,
|
|
564
|
+
destructiveHint: false,
|
|
565
|
+
idempotentHint: false,
|
|
566
|
+
openWorldHint: true,
|
|
567
|
+
},
|
|
568
|
+
}, async (args) => {
|
|
569
|
+
const clientId = args.clientId?.trim() || defaultSsoClientId?.trim();
|
|
570
|
+
if (!clientId) {
|
|
571
|
+
throw new Error("TASK_MANAGEMENT_SSO_CLIENT_ID is not configured. Set it to the Task Management module client ID from SSO, or pass clientId explicitly.");
|
|
572
|
+
}
|
|
573
|
+
let loginResult;
|
|
574
|
+
try {
|
|
575
|
+
loginResult = await ssoApi.login({
|
|
576
|
+
email: args.email,
|
|
577
|
+
password: args.password,
|
|
578
|
+
clientId,
|
|
579
|
+
mode: "web",
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
if (error instanceof SsoApiError) {
|
|
584
|
+
throw new Error(`SSO login failed with status ${error.status}: ${error.message}`, {
|
|
585
|
+
cause: error,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
if (loginResult.requirePasswordChange) {
|
|
591
|
+
throw new Error("SSO login requires a password change. Please update the password through SSO first, then retry MCP login.");
|
|
592
|
+
}
|
|
593
|
+
if (!loginResult.authToken) {
|
|
594
|
+
throw new Error("SSO login did not return an authToken.");
|
|
595
|
+
}
|
|
596
|
+
const userId = resolveUserId(loginResult.authToken);
|
|
597
|
+
const organizations = await listHrisOrganizations(loginResult.authToken);
|
|
598
|
+
const expiresAt = new Date(Date.now() + authTtlMs).toISOString();
|
|
599
|
+
const validate = args.validate ?? true;
|
|
600
|
+
if (organizations.length === 1) {
|
|
601
|
+
const selectedOrganization = organizations[0];
|
|
602
|
+
const context = await validateAndActivateContext({
|
|
603
|
+
bearerToken: loginResult.authToken,
|
|
604
|
+
userId,
|
|
605
|
+
organizationId: selectedOrganization.id,
|
|
606
|
+
environment: args.environment,
|
|
607
|
+
expiresAt,
|
|
608
|
+
}, validate);
|
|
609
|
+
pendingSsoSession = undefined;
|
|
610
|
+
return json({
|
|
611
|
+
...authStatus(context),
|
|
612
|
+
selectedOrganization: publicOrganization(selectedOrganization),
|
|
613
|
+
validated: validate,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
activeContext = undefined;
|
|
617
|
+
pendingSsoSession = {
|
|
618
|
+
bearerToken: loginResult.authToken,
|
|
619
|
+
userId,
|
|
620
|
+
clientId,
|
|
621
|
+
environment: args.environment,
|
|
622
|
+
organizations,
|
|
623
|
+
expiresAt,
|
|
624
|
+
validate,
|
|
625
|
+
};
|
|
626
|
+
return json({
|
|
627
|
+
configured: false,
|
|
628
|
+
pendingSsoOrganizationSelection: true,
|
|
629
|
+
userId,
|
|
630
|
+
organizationCount: organizations.length,
|
|
631
|
+
organizations: organizations.map(publicOrganization),
|
|
632
|
+
message: "Multiple organizations were found. Present the organization names to the user and call select_sso_organization with the typed organization name. Do not ask the user to copy an organization ID unless names are ambiguous.",
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
server.registerTool("select_sso_organization", {
|
|
636
|
+
title: "Select SSO Organization",
|
|
637
|
+
description: "Select an organization from the pending SSO login session. Prefer organizationName from natural user input; fuzzy matching handles close-but-not-exact names. organizationId is only an internal/expert fallback.",
|
|
638
|
+
inputSchema: {
|
|
639
|
+
organizationName: z.string().min(1).optional(),
|
|
640
|
+
organizationId: z.string().min(1).optional(),
|
|
641
|
+
validate: z.boolean().optional(),
|
|
642
|
+
},
|
|
643
|
+
annotations: {
|
|
644
|
+
readOnlyHint: false,
|
|
645
|
+
destructiveHint: false,
|
|
646
|
+
idempotentHint: false,
|
|
647
|
+
openWorldHint: true,
|
|
648
|
+
},
|
|
649
|
+
}, async (args) => {
|
|
650
|
+
if (!pendingSsoSession) {
|
|
651
|
+
throw new Error("No pending SSO organization selection. Call sso_login first.");
|
|
652
|
+
}
|
|
653
|
+
if (new Date(pendingSsoSession.expiresAt).getTime() <= Date.now()) {
|
|
654
|
+
pendingSsoSession = undefined;
|
|
655
|
+
throw new Error("Pending SSO login expired. Call sso_login again.");
|
|
656
|
+
}
|
|
657
|
+
if (Boolean(args.organizationId) === Boolean(args.organizationName)) {
|
|
658
|
+
throw new Error("Provide exactly one of organizationName or organizationId. Prefer organizationName for user input.");
|
|
659
|
+
}
|
|
660
|
+
const match = matchOrganization(pendingSsoSession.organizations, {
|
|
661
|
+
organizationId: args.organizationId,
|
|
662
|
+
organizationName: args.organizationName,
|
|
663
|
+
});
|
|
664
|
+
if (match.ambiguous?.length) {
|
|
665
|
+
return json({
|
|
666
|
+
configured: false,
|
|
667
|
+
pendingSsoOrganizationSelection: true,
|
|
668
|
+
ambiguousOrganizations: match.ambiguous.map(publicOrganization),
|
|
669
|
+
message: "Several organizations look similar. Ask the user to clarify by organization name; do not ask them to copy an ID unless necessary.",
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
if (!match.selected) {
|
|
673
|
+
return json({
|
|
674
|
+
configured: false,
|
|
675
|
+
pendingSsoOrganizationSelection: true,
|
|
676
|
+
organizations: pendingSsoSession.organizations.map(publicOrganization),
|
|
677
|
+
message: "No organization matched that input. Ask the user to choose one of these organization names.",
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
const session = pendingSsoSession;
|
|
681
|
+
const validate = args.validate ?? session.validate;
|
|
682
|
+
const tokenResult = session.handoffSessionId && session.handoffSecret
|
|
683
|
+
? await exchangeForTaskManagementAccessToken({
|
|
684
|
+
sessionId: session.handoffSessionId,
|
|
685
|
+
secret: session.handoffSecret,
|
|
686
|
+
clientId: session.clientId,
|
|
687
|
+
organizationId: match.selected.id,
|
|
688
|
+
})
|
|
689
|
+
: undefined;
|
|
690
|
+
const context = await validateAndActivateContext({
|
|
691
|
+
bearerToken: tokenResult?.accessToken ?? session.bearerToken,
|
|
692
|
+
userId: tokenResult?.userId ?? session.userId,
|
|
693
|
+
organizationId: match.selected.id,
|
|
694
|
+
environment: session.environment,
|
|
695
|
+
expiresAt: session.expiresAt,
|
|
696
|
+
}, validate);
|
|
697
|
+
pendingSsoSession = undefined;
|
|
698
|
+
return json({
|
|
699
|
+
...authStatus(context),
|
|
700
|
+
selectedOrganization: publicOrganization(match.selected),
|
|
701
|
+
validated: validate,
|
|
702
|
+
});
|
|
703
|
+
});
|
|
68
704
|
server.registerTool("set_auth_context", {
|
|
69
705
|
title: "Set Auth Context",
|
|
70
706
|
description: "Set the active user authentication context for this MCP process. Ask the user for bearerToken and organizationId before calling this tool. userId is derived from the token JWT sub claim unless explicitly provided. The token is kept only in process memory and is never returned.",
|
|
@@ -108,20 +744,10 @@ export function registerTools(server, api, maxBulkSize, authTtlMs) {
|
|
|
108
744
|
environment: args.environment,
|
|
109
745
|
expiresAt: new Date(Date.now() + authTtlMs).toISOString(),
|
|
110
746
|
};
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
await api.get("spaces", undefined, nextContext);
|
|
114
|
-
}
|
|
115
|
-
catch (error) {
|
|
116
|
-
if (error instanceof TaskManagementApiError) {
|
|
117
|
-
throw new Error(`Auth context validation failed with API status ${error.status}. Verify the bearer token, organizationId, derived/explicit userId, and environment-specific API URL.`, { cause: error });
|
|
118
|
-
}
|
|
119
|
-
throw error;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
activeContext = nextContext;
|
|
747
|
+
const context = await validateAndActivateContext(nextContext, args.validate ?? true);
|
|
748
|
+
pendingSsoSession = undefined;
|
|
123
749
|
return json({
|
|
124
|
-
...authStatus(
|
|
750
|
+
...authStatus(context),
|
|
125
751
|
validated: args.validate ?? true,
|
|
126
752
|
});
|
|
127
753
|
});
|
|
@@ -130,7 +756,7 @@ export function registerTools(server, api, maxBulkSize, authTtlMs) {
|
|
|
130
756
|
description: "Return whether an auth context is configured. Never returns the bearer token.",
|
|
131
757
|
inputSchema: {},
|
|
132
758
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
133
|
-
}, async () => json(authStatus(activeContext)));
|
|
759
|
+
}, async () => json(authStatus(activeContext, pendingSsoSession)));
|
|
134
760
|
server.registerTool("clear_auth_context", {
|
|
135
761
|
title: "Clear Auth Context",
|
|
136
762
|
description: "Clear the active user authentication context from process memory.",
|
|
@@ -143,6 +769,9 @@ export function registerTools(server, api, maxBulkSize, authTtlMs) {
|
|
|
143
769
|
},
|
|
144
770
|
}, async () => {
|
|
145
771
|
activeContext = undefined;
|
|
772
|
+
pendingSsoSession = undefined;
|
|
773
|
+
webSsoSessions.clear();
|
|
774
|
+
authStore.clear();
|
|
146
775
|
return json(authStatus(activeContext));
|
|
147
776
|
});
|
|
148
777
|
server.registerTool("list_spaces", {
|
|
@@ -736,4 +1365,91 @@ function workloadByUser(tasks) {
|
|
|
736
1365
|
function isComplete(task) {
|
|
737
1366
|
return task.statusRef?.category === "Complete";
|
|
738
1367
|
}
|
|
1368
|
+
function readRequestBody(request) {
|
|
1369
|
+
return new Promise((resolve, reject) => {
|
|
1370
|
+
const chunks = [];
|
|
1371
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
1372
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
1373
|
+
request.on("error", reject);
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
function renderWebSession(session) {
|
|
1377
|
+
if (session.status === "completed") {
|
|
1378
|
+
return renderPage("Login successful", `<div class="status-card success">
|
|
1379
|
+
<p>Login berhasil untuk organisasi <strong>${escapeHtml(session.selectedOrganization?.name ?? "selected organization")}</strong>.</p>
|
|
1380
|
+
<p class="muted">Silakan kembali ke chat agent.</p>
|
|
1381
|
+
</div>`);
|
|
1382
|
+
}
|
|
1383
|
+
if (session.status === "organization_selection") {
|
|
1384
|
+
const options = (session.organizations ?? [])
|
|
1385
|
+
.map((org) => `<label class="option-card"><input type="radio" name="organizationId" value="${escapeHtml(org.id)}" required /><span>${escapeHtml(org.name)}</span></label>`)
|
|
1386
|
+
.join("");
|
|
1387
|
+
return renderPage("Choose organization", `<form method="post"><p class="muted">Pilih organisasi Task Management:</p><div class="options">${options}</div><button type="submit">Continue</button></form>`);
|
|
1388
|
+
}
|
|
1389
|
+
if (session.status === "failed") {
|
|
1390
|
+
return renderPage("Login failed", `<div class="status-card error">
|
|
1391
|
+
<p>${escapeHtml(session.error ?? "Login failed")}</p>
|
|
1392
|
+
<p class="muted">Ask the agent for a new login link.</p>
|
|
1393
|
+
</div>`);
|
|
1394
|
+
}
|
|
1395
|
+
return renderPage("Task Management SSO Login", `<form method="post" autocomplete="on">
|
|
1396
|
+
<label>Email<input type="email" name="email" autocomplete="username" inputmode="email" required /></label>
|
|
1397
|
+
<label>Password<input type="password" name="password" autocomplete="current-password" required /></label>
|
|
1398
|
+
<button type="submit">Login</button>
|
|
1399
|
+
</form>`);
|
|
1400
|
+
}
|
|
1401
|
+
function renderPage(title, body) {
|
|
1402
|
+
return `<!doctype html>
|
|
1403
|
+
<html lang="en">
|
|
1404
|
+
<head>
|
|
1405
|
+
<meta charset="utf-8" />
|
|
1406
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
1407
|
+
<title>${escapeHtml(title)}</title>
|
|
1408
|
+
<style>
|
|
1409
|
+
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
1410
|
+
* { box-sizing: border-box; }
|
|
1411
|
+
html { min-height: 100%; background: #f8fafc; }
|
|
1412
|
+
body { min-height: 100dvh; margin: 0; padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); color: #111827; display: grid; place-items: center; }
|
|
1413
|
+
main { width: min(100%, 28rem); background: #ffffff; border: 1px solid #e5e7eb; border-radius: 1.25rem; box-shadow: 0 1.25rem 3rem rgba(15, 23, 42, 0.10); padding: clamp(1.25rem, 5vw, 2rem); }
|
|
1414
|
+
h1 { margin: 0 0 1.25rem; font-size: clamp(1.5rem, 7vw, 2rem); line-height: 1.1; letter-spacing: -0.035em; }
|
|
1415
|
+
form { display: grid; gap: 1rem; }
|
|
1416
|
+
label { display: grid; gap: .45rem; font-size: .95rem; font-weight: 600; color: #374151; }
|
|
1417
|
+
input { width: 100%; min-height: 3rem; padding: .8rem .9rem; border: 1px solid #d1d5db; border-radius: .8rem; color: #111827; background: #ffffff; font: inherit; font-size: 1rem; }
|
|
1418
|
+
input:focus { outline: 3px solid rgba(17, 24, 39, .14); border-color: #111827; }
|
|
1419
|
+
input[type="radio"] { width: 1.1rem; min-height: 1.1rem; margin: .15rem .7rem 0 0; flex: 0 0 auto; }
|
|
1420
|
+
button { width: 100%; min-height: 3rem; padding: .85rem 1rem; border: 0; border-radius: .8rem; background: #111827; color: #ffffff; font: inherit; font-weight: 700; cursor: pointer; touch-action: manipulation; }
|
|
1421
|
+
button:active { transform: translateY(1px); }
|
|
1422
|
+
.muted { color: #6b7280; margin: 0 0 .75rem; line-height: 1.5; }
|
|
1423
|
+
.options { display: grid; gap: .75rem; margin-bottom: .25rem; }
|
|
1424
|
+
.option-card { display: flex; align-items: flex-start; gap: 0; padding: .9rem; border: 1px solid #e5e7eb; border-radius: .9rem; background: #f9fafb; line-height: 1.35; }
|
|
1425
|
+
.status-card { border-radius: .9rem; padding: 1rem; line-height: 1.5; }
|
|
1426
|
+
.status-card p { margin: 0 0 .5rem; }
|
|
1427
|
+
.status-card p:last-child { margin-bottom: 0; }
|
|
1428
|
+
.success { background: #ecfdf5; border: 1px solid #bbf7d0; }
|
|
1429
|
+
.error { background: #fef2f2; border: 1px solid #fecaca; }
|
|
1430
|
+
@media (max-width: 480px) {
|
|
1431
|
+
body { align-items: stretch; padding: 0; background: #ffffff; }
|
|
1432
|
+
main { min-height: 100dvh; width: 100%; border: 0; border-radius: 0; box-shadow: none; padding: max(1.25rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); display: flex; flex-direction: column; justify-content: center; }
|
|
1433
|
+
}
|
|
1434
|
+
</style>
|
|
1435
|
+
</head>
|
|
1436
|
+
<body>
|
|
1437
|
+
<main>
|
|
1438
|
+
<h1>${escapeHtml(title)}</h1>
|
|
1439
|
+
${body}
|
|
1440
|
+
</main>
|
|
1441
|
+
</body>
|
|
1442
|
+
</html>`;
|
|
1443
|
+
}
|
|
1444
|
+
function escapeHtml(value) {
|
|
1445
|
+
return value
|
|
1446
|
+
.replace(/&/g, "&")
|
|
1447
|
+
.replace(/</g, "<")
|
|
1448
|
+
.replace(/>/g, ">")
|
|
1449
|
+
.replace(/"/g, """)
|
|
1450
|
+
.replace(/'/g, "'");
|
|
1451
|
+
}
|
|
1452
|
+
function errorMessage(error) {
|
|
1453
|
+
return error instanceof Error ? error.message : String(error);
|
|
1454
|
+
}
|
|
739
1455
|
//# sourceMappingURL=tools.js.map
|