@desplega.ai/agent-swarm 1.57.4 → 1.58.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.
Files changed (36) hide show
  1. package/openapi.json +1 -1
  2. package/package.json +1 -1
  3. package/src/agentmail/handlers.ts +20 -0
  4. package/src/be/db.ts +265 -2
  5. package/src/be/migrations/031_user_registry.sql +35 -0
  6. package/src/commands/runner.ts +8 -1
  7. package/src/github/handlers.ts +18 -1
  8. package/src/gitlab/handlers.ts +12 -1
  9. package/src/hooks/hook.ts +17 -2
  10. package/src/http/poll.ts +9 -0
  11. package/src/linear/sync.ts +25 -1
  12. package/src/server.ts +6 -0
  13. package/src/slack/actions.ts +10 -1
  14. package/src/slack/assistant.ts +7 -0
  15. package/src/slack/handlers.ts +9 -2
  16. package/src/tests/events-http.test.ts +2 -2
  17. package/src/tests/http-api-integration.test.ts +3 -3
  18. package/src/tests/linear-outbound-sync.test.ts +7 -7
  19. package/src/tests/preload.ts +14 -0
  20. package/src/tests/rest-api.test.ts +1 -1
  21. package/src/tests/user-identity.test.ts +306 -0
  22. package/src/tests/workflow-async-v2.test.ts +7 -7
  23. package/src/tests/workflow-engine-v2.test.ts +3 -3
  24. package/src/tests/workflow-hitl-routing.test.ts +7 -7
  25. package/src/tests/workflow-http-v2.test.ts +1 -1
  26. package/src/tests/workflow-retry-v2.test.ts +4 -4
  27. package/src/tests/workflow-retry-validation.test.ts +3 -3
  28. package/src/tests/workflow-validation-port-routing.test.ts +221 -0
  29. package/src/tools/get-task-details.ts +14 -1
  30. package/src/tools/manage-user.ts +172 -0
  31. package/src/tools/resolve-user.ts +55 -0
  32. package/src/tools/tool-config.ts +4 -0
  33. package/src/types.ts +26 -0
  34. package/src/workflows/engine.ts +18 -4
  35. package/src/workflows/executors/script.ts +16 -1
  36. package/src/workflows/validation.ts +6 -3
@@ -215,7 +215,7 @@ describe("Workflow Async v2 (Phase 4)", () => {
215
215
  });
216
216
 
217
217
  // Give the async handler time to process
218
- await new Promise((resolve) => setTimeout(resolve, 200));
218
+ await new Promise((resolve) => setTimeout(resolve, 10));
219
219
 
220
220
  // Workflow should be completed now
221
221
  const updatedRun = getWorkflowRun(runId);
@@ -260,7 +260,7 @@ describe("Workflow Async v2 (Phase 4)", () => {
260
260
  workflowRunStepId: taskStep.id,
261
261
  });
262
262
 
263
- await new Promise((resolve) => setTimeout(resolve, 100));
263
+ await new Promise((resolve) => setTimeout(resolve, 10));
264
264
 
265
265
  const updatedRun = getWorkflowRun(runId);
266
266
  expect(updatedRun!.status).toBe("failed");
@@ -291,7 +291,7 @@ describe("Workflow Async v2 (Phase 4)", () => {
291
291
  workflowRunStepId: taskStep.id,
292
292
  });
293
293
 
294
- await new Promise((resolve) => setTimeout(resolve, 100));
294
+ await new Promise((resolve) => setTimeout(resolve, 10));
295
295
 
296
296
  const updatedRun = getWorkflowRun(runId);
297
297
  expect(updatedRun!.status).toBe("failed");
@@ -455,7 +455,7 @@ describe("Workflow Async v2 (Phase 4)", () => {
455
455
  workflowRunId: runId,
456
456
  workflowRunStepId: reviewSteps.find((s) => s.nodeId === "review-a")!.id,
457
457
  });
458
- await new Promise((r) => setTimeout(r, 50));
458
+ await new Promise((r) => setTimeout(r, 10));
459
459
 
460
460
  // After A completes — merge should NOT have been created yet (B, C still pending)
461
461
  steps = getWorkflowRunStepsByRunId(runId);
@@ -472,7 +472,7 @@ describe("Workflow Async v2 (Phase 4)", () => {
472
472
  workflowRunId: runId,
473
473
  workflowRunStepId: reviewSteps.find((s) => s.nodeId === "review-b")!.id,
474
474
  });
475
- await new Promise((r) => setTimeout(r, 50));
475
+ await new Promise((r) => setTimeout(r, 10));
476
476
 
477
477
  // After B completes — merge STILL should not exist (C still pending)
478
478
  steps = getWorkflowRunStepsByRunId(runId);
@@ -489,11 +489,11 @@ describe("Workflow Async v2 (Phase 4)", () => {
489
489
  workflowRunId: runId,
490
490
  workflowRunStepId: reviewSteps.find((s) => s.nodeId === "review-c")!.id,
491
491
  });
492
- await new Promise((r) => setTimeout(r, 50));
492
+ await new Promise((r) => setTimeout(r, 10));
493
493
 
494
494
  // Now ALL 3 are done — merge should execute exactly ONCE
495
495
  // Allow extra time for serialized queue processing
496
- await new Promise((r) => setTimeout(r, 100));
496
+ await new Promise((r) => setTimeout(r, 10));
497
497
 
498
498
  steps = getWorkflowRunStepsByRunId(runId);
499
499
  const mergeSteps = steps.filter((s) => s.nodeId === "merge");
@@ -386,8 +386,8 @@ describe("Workflow Engine v2 (Phase 3)", () => {
386
386
  const registry = createTestRegistry();
387
387
  const def: WorkflowDefinition = {
388
388
  nodes: [
389
- // 60s delay should exceed the 30s default timeout
390
- { id: "slow", type: "slow", config: { delayMs: 60_000 } },
389
+ // 2s delay should exceed the 500ms timeout
390
+ { id: "slow", type: "slow", config: { delayMs: 2_000, timeoutMs: 500 } },
391
391
  ],
392
392
  };
393
393
 
@@ -397,7 +397,7 @@ describe("Workflow Engine v2 (Phase 3)", () => {
397
397
  const run = getWorkflowRun(runId);
398
398
  expect(run!.status).toBe("failed");
399
399
  expect(run!.error).toContain("timed out");
400
- }, 35_000); // Allow test up to 35s
400
+ }, 5_000); // Allow test up to 5s
401
401
  });
402
402
 
403
403
  // ─── Validation ───────────────────────────────────────────
@@ -191,7 +191,7 @@ async function runHITLWorkflow(
191
191
  // Use a small delay to let the event handler finish
192
192
  const resumePromise = new Promise<void>((resolve) => {
193
193
  // Give the event handler time to run walkGraph
194
- setTimeout(resolve, 200);
194
+ setTimeout(resolve, 10);
195
195
  });
196
196
 
197
197
  eventBus.emit("approval.resolved", {
@@ -360,7 +360,7 @@ describe("HITL port-based routing", () => {
360
360
  const requestId1 = hitlExecutor.lastRequestId!;
361
361
 
362
362
  // REJECT — should loop back to generate-question
363
- const reject1Promise = new Promise<void>((resolve) => setTimeout(resolve, 200));
363
+ const reject1Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
364
364
  eventBus.emit("approval.resolved", {
365
365
  requestId: requestId1,
366
366
  status: "rejected",
@@ -392,7 +392,7 @@ describe("HITL port-based routing", () => {
392
392
  expect(requestId2).not.toBe(requestId1); // Different request
393
393
 
394
394
  // APPROVE — should go to success
395
- const approve2Promise = new Promise<void>((resolve) => setTimeout(resolve, 200));
395
+ const approve2Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
396
396
  eventBus.emit("approval.resolved", {
397
397
  requestId: requestId2,
398
398
  status: "approved",
@@ -458,7 +458,7 @@ describe("HITL port-based routing", () => {
458
458
 
459
459
  // Simulate async task completion for generate-question (1st iteration)
460
460
  const genMeta1 = asyncTaskExecutor.lastMeta!;
461
- const taskComplete1Promise = new Promise<void>((resolve) => setTimeout(resolve, 200));
461
+ const taskComplete1Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
462
462
  eventBus.emit("task.completed", {
463
463
  taskId: "fake-task-1",
464
464
  output: JSON.stringify({ question: "What is 2+2?" }),
@@ -478,7 +478,7 @@ describe("HITL port-based routing", () => {
478
478
  const requestId1 = hitlExecutor.lastRequestId!;
479
479
 
480
480
  // REJECT — should loop back to generate-question (async)
481
- const reject1Promise = new Promise<void>((resolve) => setTimeout(resolve, 200));
481
+ const reject1Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
482
482
  eventBus.emit("approval.resolved", {
483
483
  requestId: requestId1,
484
484
  status: "rejected",
@@ -496,7 +496,7 @@ describe("HITL port-based routing", () => {
496
496
  expect(genMeta2.stepId).not.toBe(genMeta1.stepId); // Different step
497
497
 
498
498
  // Simulate async task completion for generate-question (2nd iteration)
499
- const taskComplete2Promise = new Promise<void>((resolve) => setTimeout(resolve, 200));
499
+ const taskComplete2Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
500
500
  eventBus.emit("task.completed", {
501
501
  taskId: "fake-task-2",
502
502
  output: JSON.stringify({ question: "What is 3+3?" }),
@@ -523,7 +523,7 @@ describe("HITL port-based routing", () => {
523
523
  expect(requestId2).not.toBe(requestId1);
524
524
 
525
525
  // APPROVE — should go to success
526
- const approve2Promise = new Promise<void>((resolve) => setTimeout(resolve, 200));
526
+ const approve2Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
527
527
  eventBus.emit("approval.resolved", {
528
528
  requestId: requestId2,
529
529
  status: "approved",
@@ -652,7 +652,7 @@ describe("Workflow HTTP API v2", () => {
652
652
  const { runId } = (await triggerRes.json()) as { runId: string };
653
653
 
654
654
  // Wait for run to complete
655
- await new Promise((r) => setTimeout(r, 100));
655
+ await new Promise((r) => setTimeout(r, 10));
656
656
 
657
657
  // Try to cancel — should fail
658
658
  const cancelRes = await fetch(`${baseUrl}/api/workflow-runs/${runId}/cancel`, {
@@ -251,7 +251,7 @@ describe("Workflow Retry v2 (Phase 4)", () => {
251
251
  expect(step.retryCount).toBe(1);
252
252
 
253
253
  // Wait for nextRetryAt to pass
254
- await new Promise((resolve) => setTimeout(resolve, 50));
254
+ await new Promise((resolve) => setTimeout(resolve, 10));
255
255
 
256
256
  // Verify getRetryableSteps finds it
257
257
  const retryable = getRetryableSteps();
@@ -294,11 +294,11 @@ describe("Workflow Retry v2 (Phase 4)", () => {
294
294
 
295
295
  if (step.status === "failed" && step.nextRetryAt) {
296
296
  // Was given one retry chance — let it expire and start poller
297
- await new Promise((resolve) => setTimeout(resolve, 50));
297
+ await new Promise((resolve) => setTimeout(resolve, 10));
298
298
 
299
299
  // Start poller with very short interval
300
- startRetryPoller(registry, 50);
301
- await new Promise((resolve) => setTimeout(resolve, 200));
300
+ startRetryPoller(registry, 10);
301
+ await new Promise((resolve) => setTimeout(resolve, 50));
302
302
  stopRetryPoller();
303
303
 
304
304
  // After retry, the second failure should be terminal (retryCount >= maxRetries)
@@ -192,10 +192,10 @@ describe("Retry Poller — Validation on Retry", () => {
192
192
  const validationCountAfterInitial = validateCallCount;
193
193
 
194
194
  // Wait for nextRetryAt to pass, then run the poller
195
- await new Promise((resolve) => setTimeout(resolve, 50));
195
+ await new Promise((resolve) => setTimeout(resolve, 10));
196
196
 
197
- startRetryPoller(registry, 50);
198
- await new Promise((resolve) => setTimeout(resolve, 300));
197
+ startRetryPoller(registry, 10);
198
+ await new Promise((resolve) => setTimeout(resolve, 150));
199
199
  stopRetryPoller();
200
200
 
201
201
  // Validation should have been called again during the retry
@@ -0,0 +1,221 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { z } from "zod";
4
+ import {
5
+ closeDb,
6
+ createWorkflow,
7
+ getWorkflowRun,
8
+ getWorkflowRunStepsByRunId,
9
+ initDb,
10
+ } from "../be/db";
11
+ import type { Workflow, WorkflowDefinition } from "../types";
12
+ import { startWorkflowExecution } from "../workflows/engine";
13
+ import { workflowEventBus } from "../workflows/event-bus";
14
+ import {
15
+ BaseExecutor,
16
+ type ExecutorDependencies,
17
+ type ExecutorResult,
18
+ } from "../workflows/executors/base";
19
+ import { PropertyMatchExecutor } from "../workflows/executors/property-match";
20
+ import { ExecutorRegistry } from "../workflows/executors/registry";
21
+ import { interpolate } from "../workflows/template";
22
+
23
+ const TEST_DB_PATH = "./test-workflow-validation-port-routing.sqlite";
24
+
25
+ // ─── Test Executors ──────────────────────────────────────────
26
+
27
+ /**
28
+ * Executor that returns a configurable object as output.
29
+ * Used to produce output that property-match can validate.
30
+ */
31
+ class ObjectOutputExecutor extends BaseExecutor<
32
+ typeof ObjectOutputExecutor.schema,
33
+ typeof ObjectOutputExecutor.outSchema
34
+ > {
35
+ static readonly schema = z.object({ approved: z.boolean() });
36
+ static readonly outSchema = z.object({ approved: z.boolean() });
37
+
38
+ readonly type = "object-output";
39
+ readonly mode = "instant" as const;
40
+ readonly configSchema = ObjectOutputExecutor.schema;
41
+ readonly outputSchema = ObjectOutputExecutor.outSchema;
42
+
43
+ protected async execute(
44
+ config: z.infer<typeof ObjectOutputExecutor.schema>,
45
+ ): Promise<ExecutorResult<z.infer<typeof ObjectOutputExecutor.outSchema>>> {
46
+ return { status: "success", output: { approved: config.approved } };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Terminal executor that just succeeds. Used for leaf nodes.
52
+ */
53
+ class NoopExecutor extends BaseExecutor<typeof NoopExecutor.schema, typeof NoopExecutor.outSchema> {
54
+ static readonly schema = z.object({});
55
+ static readonly outSchema = z.object({ done: z.boolean() });
56
+
57
+ readonly type = "noop";
58
+ readonly mode = "instant" as const;
59
+ readonly configSchema = NoopExecutor.schema;
60
+ readonly outputSchema = NoopExecutor.outSchema;
61
+
62
+ protected async execute(): Promise<ExecutorResult<z.infer<typeof NoopExecutor.outSchema>>> {
63
+ return { status: "success", output: { done: true } };
64
+ }
65
+ }
66
+
67
+ // ─── Mock Dependencies ───────────────────────────────────────
68
+
69
+ import * as db from "../be/db";
70
+
71
+ const mockDeps: ExecutorDependencies = {
72
+ db: db as typeof import("../be/db"),
73
+ eventBus: workflowEventBus,
74
+ interpolate: (template, ctx) => interpolate(template, ctx).result,
75
+ };
76
+
77
+ function createTestRegistry(): ExecutorRegistry {
78
+ const registry = new ExecutorRegistry();
79
+ registry.register(new ObjectOutputExecutor(mockDeps));
80
+ registry.register(new NoopExecutor(mockDeps));
81
+ registry.register(new PropertyMatchExecutor(mockDeps));
82
+ return registry;
83
+ }
84
+
85
+ let workflowCounter = 0;
86
+
87
+ function makeWorkflow(def: WorkflowDefinition): Workflow {
88
+ workflowCounter++;
89
+ return createWorkflow({
90
+ name: `test-val-port-routing-${workflowCounter}-${Date.now()}`,
91
+ definition: def,
92
+ });
93
+ }
94
+
95
+ // ─── Setup / Teardown ────────────────────────────────────────
96
+
97
+ let registry: ExecutorRegistry;
98
+
99
+ beforeAll(async () => {
100
+ try {
101
+ await unlink(TEST_DB_PATH);
102
+ } catch {}
103
+ initDb(TEST_DB_PATH);
104
+ registry = createTestRegistry();
105
+ });
106
+
107
+ afterAll(async () => {
108
+ closeDb();
109
+ for (const suffix of ["", "-wal", "-shm"]) {
110
+ try {
111
+ await unlink(`${TEST_DB_PATH}${suffix}`);
112
+ } catch {}
113
+ }
114
+ });
115
+
116
+ // ─── Tests ───────────────────────────────────────────────────
117
+
118
+ describe("Validation Port Routing", () => {
119
+ /**
120
+ * Regression test for: when a node has record-based `next` (pass/fail ports)
121
+ * and validation with `mustPass: false`, only the matching port should be activated.
122
+ * Previously, all ports were activated because `result.nextPort` was never set.
123
+ */
124
+
125
+ function makePortRoutingWorkflow(approved: boolean): WorkflowDefinition {
126
+ return {
127
+ nodes: [
128
+ {
129
+ id: "check",
130
+ type: "object-output",
131
+ config: { approved },
132
+ next: { pass: "on-pass", fail: "on-fail" },
133
+ validation: {
134
+ executor: "property-match",
135
+ config: {
136
+ conditions: [{ field: "check.approved", op: "eq", value: true }],
137
+ },
138
+ mustPass: false,
139
+ },
140
+ },
141
+ {
142
+ id: "on-pass",
143
+ type: "noop",
144
+ config: {},
145
+ },
146
+ {
147
+ id: "on-fail",
148
+ type: "noop",
149
+ config: {},
150
+ },
151
+ ],
152
+ };
153
+ }
154
+
155
+ test("validation passes → only 'pass' port successor is created", async () => {
156
+ const workflow = makeWorkflow(makePortRoutingWorkflow(true));
157
+ const runId = await startWorkflowExecution(workflow, {}, registry);
158
+
159
+ const steps = getWorkflowRunStepsByRunId(runId);
160
+ const nodeIds = steps.map((s) => s.nodeId);
161
+
162
+ // "check" should be completed, "on-pass" should exist, "on-fail" should NOT
163
+ expect(nodeIds).toContain("check");
164
+ expect(nodeIds).toContain("on-pass");
165
+ expect(nodeIds).not.toContain("on-fail");
166
+
167
+ const run = getWorkflowRun(runId);
168
+ expect(run!.status).toBe("completed");
169
+ });
170
+
171
+ test("validation fails (mustPass: false) → only 'fail' port successor is created", async () => {
172
+ const workflow = makeWorkflow(makePortRoutingWorkflow(false));
173
+ const runId = await startWorkflowExecution(workflow, {}, registry);
174
+
175
+ const steps = getWorkflowRunStepsByRunId(runId);
176
+ const nodeIds = steps.map((s) => s.nodeId);
177
+
178
+ // "check" should be completed, "on-fail" should exist, "on-pass" should NOT
179
+ expect(nodeIds).toContain("check");
180
+ expect(nodeIds).toContain("on-fail");
181
+ expect(nodeIds).not.toContain("on-pass");
182
+
183
+ const run = getWorkflowRun(runId);
184
+ expect(run!.status).toBe("completed");
185
+ });
186
+
187
+ test("string-based next is unaffected by validation port routing", async () => {
188
+ const workflow = makeWorkflow({
189
+ nodes: [
190
+ {
191
+ id: "check",
192
+ type: "object-output",
193
+ config: { approved: false },
194
+ next: "successor",
195
+ validation: {
196
+ executor: "property-match",
197
+ config: {
198
+ conditions: [{ field: "check.approved", op: "eq", value: true }],
199
+ },
200
+ mustPass: false,
201
+ },
202
+ },
203
+ {
204
+ id: "successor",
205
+ type: "noop",
206
+ config: {},
207
+ },
208
+ ],
209
+ });
210
+
211
+ const runId = await startWorkflowExecution(workflow, {}, registry);
212
+ const steps = getWorkflowRunStepsByRunId(runId);
213
+ const nodeIds = steps.map((s) => s.nodeId);
214
+
215
+ expect(nodeIds).toContain("check");
216
+ expect(nodeIds).toContain("successor");
217
+
218
+ const run = getWorkflowRun(runId);
219
+ expect(run!.status).toBe("completed");
220
+ });
221
+ });
@@ -1,6 +1,6 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
- import { getLogsByTaskIdChronological, getTaskById } from "@/be/db";
3
+ import { getLogsByTaskIdChronological, getTaskById, getUserById } from "@/be/db";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
5
  import { AgentLogSchema, AgentTaskSchema } from "@/types";
6
6
 
@@ -21,6 +21,10 @@ export const registerGetTaskDetailsTool = (server: McpServer) => {
21
21
  success: z.boolean(),
22
22
  message: z.string(),
23
23
  task: AgentTaskSchema.optional(),
24
+ requestedBy: z
25
+ .object({ name: z.string(), email: z.string().optional() })
26
+ .optional()
27
+ .describe("Resolved user who requested this task"),
24
28
  logs: z.array(AgentLogSchema).optional(),
25
29
  }),
26
30
  },
@@ -40,6 +44,14 @@ export const registerGetTaskDetailsTool = (server: McpServer) => {
40
44
 
41
45
  const logs = getLogsByTaskIdChronological(taskId);
42
46
 
47
+ // Resolve requesting user details if available
48
+ const requestedByUser = task.requestedByUserId
49
+ ? getUserById(task.requestedByUserId)
50
+ : undefined;
51
+ const requestedBy = requestedByUser
52
+ ? { name: requestedByUser.name, email: requestedByUser.email }
53
+ : undefined;
54
+
43
55
  return {
44
56
  content: [{ type: "text", text: `Task "${taskId}" details retrieved.` }],
45
57
  structuredContent: {
@@ -47,6 +59,7 @@ export const registerGetTaskDetailsTool = (server: McpServer) => {
47
59
  success: true,
48
60
  message: `Task "${taskId}" details retrieved.`,
49
61
  task,
62
+ requestedBy,
50
63
  logs,
51
64
  },
52
65
  };
@@ -0,0 +1,172 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import {
4
+ createUser,
5
+ deleteUser,
6
+ getAgentById,
7
+ getAllUsers,
8
+ getUserById,
9
+ updateUser,
10
+ } from "@/be/db";
11
+ import { createToolRegistrar } from "@/tools/utils";
12
+
13
+ export const registerManageUserTool = (server: McpServer) => {
14
+ createToolRegistrar(server)(
15
+ "manage-user",
16
+ {
17
+ title: "Manage user profiles",
18
+ description: "Create, update, delete, or list user profiles in the user registry. Lead-only.",
19
+ annotations: { readOnlyHint: false },
20
+ inputSchema: z.object({
21
+ action: z.enum(["create", "update", "delete", "list", "get"]).describe("Action to perform"),
22
+ userId: z.string().optional().describe("User ID (required for update/delete/get)"),
23
+ name: z.string().optional().describe("Display name (required for create)"),
24
+ email: z.string().optional().describe("Primary email address"),
25
+ role: z.string().optional().describe('Role (e.g., "founder", "engineer")'),
26
+ notes: z.string().optional().describe("Free-form notes"),
27
+ slackUserId: z.string().optional().describe("Slack user ID"),
28
+ linearUserId: z.string().optional().describe("Linear user UUID"),
29
+ githubUsername: z.string().optional().describe("GitHub username"),
30
+ gitlabUsername: z.string().optional().describe("GitLab username"),
31
+ emailAliases: z.array(z.string()).optional().describe("Additional email addresses"),
32
+ preferredChannel: z.string().optional().describe("Preferred contact channel"),
33
+ timezone: z.string().optional().describe("Timezone (e.g., America/New_York)"),
34
+ }),
35
+ },
36
+ async (input, requestInfo) => {
37
+ const callerAgent = requestInfo.agentId ? getAgentById(requestInfo.agentId) : null;
38
+ if (!callerAgent?.isLead) {
39
+ return {
40
+ content: [
41
+ { type: "text" as const, text: "Only the lead agent can manage user profiles." },
42
+ ],
43
+ };
44
+ }
45
+
46
+ switch (input.action) {
47
+ case "list": {
48
+ const users = getAllUsers();
49
+ return {
50
+ content: [{ type: "text" as const, text: JSON.stringify(users, null, 2) }],
51
+ };
52
+ }
53
+
54
+ case "get": {
55
+ if (!input.userId) {
56
+ return {
57
+ content: [{ type: "text" as const, text: "userId is required for get action." }],
58
+ };
59
+ }
60
+ const user = getUserById(input.userId);
61
+ if (!user) {
62
+ return {
63
+ content: [{ type: "text" as const, text: `User ${input.userId} not found.` }],
64
+ };
65
+ }
66
+ return {
67
+ content: [{ type: "text" as const, text: JSON.stringify(user, null, 2) }],
68
+ };
69
+ }
70
+
71
+ case "create": {
72
+ if (!input.name) {
73
+ return {
74
+ content: [{ type: "text" as const, text: "name is required for create action." }],
75
+ };
76
+ }
77
+ try {
78
+ const user = createUser({
79
+ name: input.name,
80
+ email: input.email,
81
+ role: input.role,
82
+ notes: input.notes,
83
+ slackUserId: input.slackUserId,
84
+ linearUserId: input.linearUserId,
85
+ githubUsername: input.githubUsername,
86
+ gitlabUsername: input.gitlabUsername,
87
+ emailAliases: input.emailAliases,
88
+ preferredChannel: input.preferredChannel,
89
+ timezone: input.timezone,
90
+ });
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text" as const,
95
+ text: `User created: ${JSON.stringify(user, null, 2)}`,
96
+ },
97
+ ],
98
+ };
99
+ } catch (err: unknown) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ return {
102
+ content: [{ type: "text" as const, text: `Failed to create user: ${message}` }],
103
+ };
104
+ }
105
+ }
106
+
107
+ case "update": {
108
+ if (!input.userId) {
109
+ return {
110
+ content: [{ type: "text" as const, text: "userId is required for update action." }],
111
+ };
112
+ }
113
+ try {
114
+ const user = updateUser(input.userId, {
115
+ name: input.name,
116
+ email: input.email,
117
+ role: input.role,
118
+ notes: input.notes,
119
+ slackUserId: input.slackUserId,
120
+ linearUserId: input.linearUserId,
121
+ githubUsername: input.githubUsername,
122
+ gitlabUsername: input.gitlabUsername,
123
+ emailAliases: input.emailAliases,
124
+ preferredChannel: input.preferredChannel,
125
+ timezone: input.timezone,
126
+ });
127
+ if (!user) {
128
+ return {
129
+ content: [{ type: "text" as const, text: `User ${input.userId} not found.` }],
130
+ };
131
+ }
132
+ return {
133
+ content: [
134
+ {
135
+ type: "text" as const,
136
+ text: `User updated: ${JSON.stringify(user, null, 2)}`,
137
+ },
138
+ ],
139
+ };
140
+ } catch (err: unknown) {
141
+ const message = err instanceof Error ? err.message : String(err);
142
+ return {
143
+ content: [{ type: "text" as const, text: `Failed to update user: ${message}` }],
144
+ };
145
+ }
146
+ }
147
+
148
+ case "delete": {
149
+ if (!input.userId) {
150
+ return {
151
+ content: [{ type: "text" as const, text: "userId is required for delete action." }],
152
+ };
153
+ }
154
+ const deleted = deleteUser(input.userId);
155
+ return {
156
+ content: [
157
+ {
158
+ type: "text" as const,
159
+ text: deleted ? `User ${input.userId} deleted.` : `User ${input.userId} not found.`,
160
+ },
161
+ ],
162
+ };
163
+ }
164
+
165
+ default:
166
+ return {
167
+ content: [{ type: "text" as const, text: `Unknown action: ${input.action}` }],
168
+ };
169
+ }
170
+ },
171
+ );
172
+ };
@@ -0,0 +1,55 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { resolveUser } from "@/be/db";
4
+ import { createToolRegistrar } from "@/tools/utils";
5
+
6
+ export const registerResolveUserTool = (server: McpServer) => {
7
+ createToolRegistrar(server)(
8
+ "resolve-user",
9
+ {
10
+ title: "Resolve user identity",
11
+ description:
12
+ "Look up a canonical user profile by any platform-specific identifier (Slack ID, Linear ID, GitHub username, email, or name). Returns the full user profile or null.",
13
+ annotations: { readOnlyHint: true },
14
+ inputSchema: z.object({
15
+ slackUserId: z.string().optional().describe("Slack user ID (e.g., U08NR6QD6CS)"),
16
+ linearUserId: z.string().optional().describe("Linear user UUID"),
17
+ githubUsername: z.string().optional().describe("GitHub username"),
18
+ gitlabUsername: z.string().optional().describe("GitLab username"),
19
+ email: z.string().optional().describe("Email address"),
20
+ name: z.string().optional().describe("Name (fuzzy substring match, lowest priority)"),
21
+ }),
22
+ },
23
+ async ({ slackUserId, linearUserId, githubUsername, gitlabUsername, email, name }) => {
24
+ if (!slackUserId && !linearUserId && !githubUsername && !gitlabUsername && !email && !name) {
25
+ return {
26
+ content: [
27
+ {
28
+ type: "text" as const,
29
+ text: "At least one search parameter is required.",
30
+ },
31
+ ],
32
+ };
33
+ }
34
+
35
+ const user = resolveUser({
36
+ slackUserId,
37
+ linearUserId,
38
+ githubUsername,
39
+ gitlabUsername,
40
+ email,
41
+ name,
42
+ });
43
+
44
+ if (!user) {
45
+ return {
46
+ content: [{ type: "text" as const, text: "No user found matching the given criteria." }],
47
+ };
48
+ }
49
+
50
+ return {
51
+ content: [{ type: "text" as const, text: JSON.stringify(user, null, 2) }],
52
+ };
53
+ },
54
+ );
55
+ };