@desplega.ai/agent-swarm 1.52.1 → 1.53.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 (43) hide show
  1. package/openapi.json +1517 -488
  2. package/package.json +5 -2
  3. package/src/be/db.ts +530 -0
  4. package/src/be/events.ts +322 -0
  5. package/src/be/migrations/021_events.sql +24 -0
  6. package/src/be/migrations/022_context_usage.sql +34 -0
  7. package/src/be/migrations/023_mcp_servers.sql +44 -0
  8. package/src/commands/runner.ts +348 -1
  9. package/src/http/context.ts +118 -0
  10. package/src/http/events.ts +188 -0
  11. package/src/http/index.ts +6 -0
  12. package/src/http/mcp-servers.ts +364 -0
  13. package/src/http/tasks.ts +33 -0
  14. package/src/linear/outbound.ts +8 -1
  15. package/src/linear/sync.ts +3 -0
  16. package/src/oauth/ensure-token.ts +50 -0
  17. package/src/prompts/base-prompt.ts +7 -0
  18. package/src/providers/claude-adapter.ts +156 -15
  19. package/src/providers/pi-mono-adapter.ts +68 -0
  20. package/src/providers/pi-mono-extension.ts +56 -2
  21. package/src/providers/pi-mono-mcp-client.ts +10 -1
  22. package/src/providers/types.ts +14 -1
  23. package/src/server.ts +19 -0
  24. package/src/tests/context-window.test.ts +66 -0
  25. package/src/tests/ensure-token.test.ts +170 -0
  26. package/src/tests/events-db.test.ts +314 -0
  27. package/src/tests/events-http.test.ts +267 -0
  28. package/src/tests/prompt-template-remaining.test.ts +5 -5
  29. package/src/tests/tool-annotations.test.ts +2 -2
  30. package/src/tests/vcs-tracking.test.ts +176 -0
  31. package/src/tests/workflow-executors.test.ts +8 -1
  32. package/src/tools/mcp-servers/index.ts +7 -0
  33. package/src/tools/mcp-servers/mcp-server-create.ts +138 -0
  34. package/src/tools/mcp-servers/mcp-server-delete.ts +72 -0
  35. package/src/tools/mcp-servers/mcp-server-get.ts +80 -0
  36. package/src/tools/mcp-servers/mcp-server-install.ts +110 -0
  37. package/src/tools/mcp-servers/mcp-server-list.ts +67 -0
  38. package/src/tools/mcp-servers/mcp-server-uninstall.ts +71 -0
  39. package/src/tools/mcp-servers/mcp-server-update.ts +120 -0
  40. package/src/tools/tool-config.ts +9 -0
  41. package/src/types.ts +153 -0
  42. package/src/utils/context-window.ts +41 -0
  43. package/src/workflows/executors/base.ts +9 -1
@@ -0,0 +1,267 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import type { Subprocess } from "bun";
4
+
5
+ const TEST_PORT = 13033;
6
+ const TEST_DB_PATH = `/tmp/test-events-http-${Date.now()}.sqlite`;
7
+ const BASE = `http://localhost:${TEST_PORT}`;
8
+
9
+ let serverProc: Subprocess;
10
+
11
+ async function api(
12
+ method: string,
13
+ path: string,
14
+ opts: { body?: unknown } = {},
15
+ ): Promise<{ status: number; body: any }> {
16
+ const res = await fetch(`${BASE}${path}`, {
17
+ method,
18
+ headers: { "Content-Type": "application/json" },
19
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
20
+ });
21
+ const text = await res.text();
22
+ let body: any;
23
+ try {
24
+ body = JSON.parse(text);
25
+ } catch {
26
+ body = text;
27
+ }
28
+ return { status: res.status, body };
29
+ }
30
+
31
+ const get = (p: string) => api("GET", p);
32
+ const post = (p: string, body?: unknown) => api("POST", p, { body });
33
+
34
+ async function waitForServer(url: string, timeoutMs = 15000) {
35
+ const start = Date.now();
36
+ while (Date.now() - start < timeoutMs) {
37
+ try {
38
+ const r = await fetch(url);
39
+ if (r.ok) return;
40
+ } catch {}
41
+ await Bun.sleep(200);
42
+ }
43
+ throw new Error(`Server did not start within ${timeoutMs}ms`);
44
+ }
45
+
46
+ beforeAll(async () => {
47
+ try {
48
+ await unlink(TEST_DB_PATH);
49
+ } catch {}
50
+
51
+ serverProc = Bun.spawn(["bun", "src/http.ts"], {
52
+ cwd: `${import.meta.dir}/../..`,
53
+ env: {
54
+ ...process.env,
55
+ PORT: String(TEST_PORT),
56
+ DATABASE_PATH: TEST_DB_PATH,
57
+ API_KEY: "",
58
+ CAPABILITIES: "core",
59
+ SLACK_BOT_TOKEN: "",
60
+ GITHUB_WEBHOOK_SECRET: "",
61
+ AGENTMAIL_API_KEY: "",
62
+ },
63
+ stdout: "ignore",
64
+ stderr: "ignore",
65
+ });
66
+
67
+ await waitForServer(`${BASE}/health`);
68
+ }, 20000);
69
+
70
+ afterAll(async () => {
71
+ if (serverProc) {
72
+ serverProc.kill();
73
+ try {
74
+ await serverProc.exited;
75
+ } catch {}
76
+ }
77
+ await Bun.sleep(300);
78
+ try {
79
+ await unlink(TEST_DB_PATH);
80
+ await unlink(`${TEST_DB_PATH}-wal`);
81
+ await unlink(`${TEST_DB_PATH}-shm`);
82
+ } catch {}
83
+ });
84
+
85
+ describe("POST /api/events — single event", () => {
86
+ test("creates event with required fields", async () => {
87
+ const { status, body } = await post("/api/events", {
88
+ category: "tool",
89
+ event: "tool.start",
90
+ source: "worker",
91
+ });
92
+ expect(status).toBe(201);
93
+ expect(body.success).toBe(true);
94
+ expect(body.event.id).toBeDefined();
95
+ expect(body.event.category).toBe("tool");
96
+ expect(body.event.event).toBe("tool.start");
97
+ expect(body.event.status).toBe("ok");
98
+ expect(body.event.source).toBe("worker");
99
+ });
100
+
101
+ test("creates event with all optional fields", async () => {
102
+ const { status, body } = await post("/api/events", {
103
+ category: "skill",
104
+ event: "skill.invoke",
105
+ source: "worker",
106
+ status: "error",
107
+ agentId: "agent-1",
108
+ taskId: "task-1",
109
+ sessionId: "session-1",
110
+ parentEventId: "parent-1",
111
+ numericValue: 3.14,
112
+ durationMs: 500,
113
+ data: { skillName: "commit" },
114
+ });
115
+ expect(status).toBe(201);
116
+ expect(body.event.status).toBe("error");
117
+ expect(body.event.agentId).toBe("agent-1");
118
+ expect(body.event.data.skillName).toBe("commit");
119
+ expect(body.event.numericValue).toBe(3.14);
120
+ expect(body.event.durationMs).toBe(500);
121
+ });
122
+
123
+ test("rejects invalid category", async () => {
124
+ const { status } = await post("/api/events", {
125
+ category: "invalid",
126
+ event: "tool.start",
127
+ source: "worker",
128
+ });
129
+ expect(status).toBe(400);
130
+ });
131
+
132
+ test("rejects invalid event name", async () => {
133
+ const { status } = await post("/api/events", {
134
+ category: "tool",
135
+ event: "invalid.event",
136
+ source: "worker",
137
+ });
138
+ expect(status).toBe(400);
139
+ });
140
+
141
+ test("rejects missing required fields", async () => {
142
+ const { status } = await post("/api/events", { category: "tool" });
143
+ expect(status).toBe(400);
144
+ });
145
+ });
146
+
147
+ describe("POST /api/events/batch", () => {
148
+ test("creates multiple events", async () => {
149
+ const { status, body } = await post("/api/events/batch", {
150
+ events: [
151
+ { category: "tool", event: "tool.start", source: "worker", data: { toolName: "Read" } },
152
+ { category: "tool", event: "tool.start", source: "worker", data: { toolName: "Bash" } },
153
+ {
154
+ category: "skill",
155
+ event: "skill.invoke",
156
+ source: "worker",
157
+ data: { skillName: "commit" },
158
+ },
159
+ ],
160
+ });
161
+ expect(status).toBe(201);
162
+ expect(body.success).toBe(true);
163
+ expect(body.count).toBe(3);
164
+ });
165
+
166
+ test("rejects empty events array", async () => {
167
+ const { status } = await post("/api/events/batch", { events: [] });
168
+ expect(status).toBe(400);
169
+ });
170
+
171
+ test("rejects batch with invalid event", async () => {
172
+ const { status } = await post("/api/events/batch", {
173
+ events: [
174
+ { category: "tool", event: "tool.start", source: "worker" },
175
+ { category: "bad", event: "tool.start", source: "worker" },
176
+ ],
177
+ });
178
+ expect(status).toBe(400);
179
+ });
180
+ });
181
+
182
+ describe("GET /api/events", () => {
183
+ test("returns events list", async () => {
184
+ const { status, body } = await get("/api/events");
185
+ expect(status).toBe(200);
186
+ expect(Array.isArray(body.events)).toBe(true);
187
+ expect(body.events.length).toBeGreaterThanOrEqual(1);
188
+ });
189
+
190
+ test("filters by category", async () => {
191
+ const { status, body } = await get("/api/events?category=skill");
192
+ expect(status).toBe(200);
193
+ for (const evt of body.events) {
194
+ expect(evt.category).toBe("skill");
195
+ }
196
+ });
197
+
198
+ test("filters by event name", async () => {
199
+ const { status, body } = await get("/api/events?event=tool.start");
200
+ expect(status).toBe(200);
201
+ for (const evt of body.events) {
202
+ expect(evt.event).toBe("tool.start");
203
+ }
204
+ });
205
+
206
+ test("filters by source", async () => {
207
+ const { status, body } = await get("/api/events?source=worker");
208
+ expect(status).toBe(200);
209
+ for (const evt of body.events) {
210
+ expect(evt.source).toBe("worker");
211
+ }
212
+ });
213
+
214
+ test("respects limit parameter", async () => {
215
+ const { status, body } = await get("/api/events?limit=2");
216
+ expect(status).toBe(200);
217
+ expect(body.events.length).toBeLessThanOrEqual(2);
218
+ });
219
+
220
+ test("filters by agentId", async () => {
221
+ const { body } = await get("/api/events?agentId=agent-1");
222
+ for (const evt of body.events) {
223
+ expect(evt.agentId).toBe("agent-1");
224
+ }
225
+ });
226
+
227
+ test("returns empty array for non-matching filters", async () => {
228
+ const { status, body } = await get("/api/events?agentId=nonexistent");
229
+ expect(status).toBe(200);
230
+ expect(body.events).toEqual([]);
231
+ });
232
+ });
233
+
234
+ describe("GET /api/events/counts", () => {
235
+ test("returns counts grouped by event name", async () => {
236
+ const { status, body } = await get("/api/events/counts");
237
+ expect(status).toBe(200);
238
+ expect(Array.isArray(body.counts)).toBe(true);
239
+ expect(body.counts.length).toBeGreaterThanOrEqual(1);
240
+ const first = body.counts[0];
241
+ expect(first.event).toBeDefined();
242
+ expect(first.count).toBeGreaterThanOrEqual(1);
243
+ });
244
+
245
+ test("counts are in descending order", async () => {
246
+ const { body } = await get("/api/events/counts");
247
+ for (let i = 1; i < body.counts.length; i++) {
248
+ expect(body.counts[i - 1].count).toBeGreaterThanOrEqual(body.counts[i].count);
249
+ }
250
+ });
251
+
252
+ test("filters counts by category", async () => {
253
+ const { body } = await get("/api/events/counts?category=tool");
254
+ for (const c of body.counts) {
255
+ expect(c.event.startsWith("tool.")).toBe(true);
256
+ }
257
+ });
258
+
259
+ test("filters counts by agentId", async () => {
260
+ const { body: all } = await get("/api/events/counts");
261
+ const { body: filtered } = await get("/api/events/counts?agentId=agent-1");
262
+ // Filtered should be a subset
263
+ const filteredTotal = filtered.counts.reduce((s: number, c: any) => s + c.count, 0);
264
+ const allTotal = all.counts.reduce((s: number, c: any) => s + c.count, 0);
265
+ expect(filteredTotal).toBeLessThanOrEqual(allTotal);
266
+ });
267
+ });
@@ -1,7 +1,7 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, initDb } from "../be/db";
4
- import { getAllTemplateDefinitions, getTemplateDefinition } from "../prompts/registry";
4
+ import { getAllTemplateDefinitions } from "../prompts/registry";
5
5
  import { resolveTemplate } from "../prompts/resolver";
6
6
 
7
7
  // Side-effect imports: register all templates from each source
@@ -21,10 +21,10 @@ const TEST_DB_PATH = "./test-prompt-remaining.sqlite";
21
21
  * Needed because other test files may call clearTemplateDefinitions() in parallel.
22
22
  */
23
23
  async function ensureTemplatesRegistered(): Promise<void> {
24
- // Check if templates are still registered (another test may have cleared them)
25
- if (getTemplateDefinition("gitlab.merge_request.opened")) return;
26
-
27
- // Force re-evaluation by importing with cache-busting query param
24
+ // Always re-import all template modules unconditionally.
25
+ // Other test files (prompt-template-resolver, prompt-template-session) call
26
+ // clearTemplateDefinitions() in beforeEach/beforeAll, and since Bun shares
27
+ // module state across parallel test files, a single-template check is racy.
28
28
  const ts = Date.now();
29
29
  await import(`../gitlab/templates?t=${ts}`);
30
30
  await import(`../agentmail/templates?t=${ts}`);
@@ -338,9 +338,9 @@ describe("Tool Annotations & Classification", () => {
338
338
  test("registered tool count matches expected total", () => {
339
339
  const count = Object.keys(tools).length;
340
340
  // We expect all tools to be registered when all capabilities are enabled (default)
341
- // Includes 11 skill tools added in the skill system feature
341
+ // Includes 11 skill tools and 7 MCP server tools
342
342
  expect(count).toBeGreaterThanOrEqual(45);
343
- expect(count).toBeLessThanOrEqual(85);
343
+ expect(count).toBeLessThanOrEqual(95);
344
344
  });
345
345
 
346
346
  test("core tools are fewer than deferred tools", () => {
@@ -0,0 +1,176 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ closeDb,
5
+ createAgent,
6
+ createTaskExtended,
7
+ findTaskByVcs,
8
+ initDb,
9
+ updateTaskVcs,
10
+ } from "../be/db";
11
+
12
+ const TEST_DB_PATH = "./test-vcs-tracking.sqlite";
13
+
14
+ beforeAll(async () => {
15
+ try {
16
+ await unlink(TEST_DB_PATH);
17
+ } catch {}
18
+ initDb(TEST_DB_PATH);
19
+
20
+ createAgent({
21
+ id: "vcs-track-agent-001",
22
+ name: "VcsTrackingTestAgent",
23
+ status: "idle",
24
+ isLead: false,
25
+ });
26
+ });
27
+
28
+ afterAll(async () => {
29
+ closeDb();
30
+ for (const suffix of ["", "-wal", "-shm"]) {
31
+ try {
32
+ await unlink(`${TEST_DB_PATH}${suffix}`);
33
+ } catch {}
34
+ }
35
+ });
36
+
37
+ describe("updateTaskVcs", () => {
38
+ test("sets all VCS fields correctly", () => {
39
+ const task = createTaskExtended("Test task for VCS update", {
40
+ agentId: "vcs-track-agent-001",
41
+ source: "api",
42
+ });
43
+
44
+ const updated = updateTaskVcs(task.id, {
45
+ vcsProvider: "github",
46
+ vcsRepo: "desplega-ai/agent-swarm",
47
+ vcsNumber: 42,
48
+ vcsUrl: "https://github.com/desplega-ai/agent-swarm/pull/42",
49
+ });
50
+
51
+ expect(updated).not.toBeNull();
52
+ expect(updated!.vcsProvider).toBe("github");
53
+ expect(updated!.vcsRepo).toBe("desplega-ai/agent-swarm");
54
+ expect(updated!.vcsNumber).toBe(42);
55
+ expect(updated!.vcsUrl).toBe("https://github.com/desplega-ai/agent-swarm/pull/42");
56
+ });
57
+
58
+ test("returns null for non-existent task", () => {
59
+ const result = updateTaskVcs("non-existent-id", {
60
+ vcsProvider: "github",
61
+ vcsRepo: "owner/repo",
62
+ vcsNumber: 1,
63
+ vcsUrl: "https://github.com/owner/repo/pull/1",
64
+ });
65
+ expect(result).toBeNull();
66
+ });
67
+
68
+ test("updates lastUpdatedAt", () => {
69
+ const task = createTaskExtended("Test lastUpdatedAt", {
70
+ agentId: "vcs-track-agent-001",
71
+ source: "api",
72
+ });
73
+
74
+ const before = new Date(task.lastUpdatedAt).getTime();
75
+
76
+ // Small delay to ensure timestamp differs
77
+ const updated = updateTaskVcs(task.id, {
78
+ vcsProvider: "github",
79
+ vcsRepo: "owner/repo",
80
+ vcsNumber: 10,
81
+ vcsUrl: "https://github.com/owner/repo/pull/10",
82
+ });
83
+
84
+ expect(updated).not.toBeNull();
85
+ const after = new Date(updated!.lastUpdatedAt).getTime();
86
+ expect(after).toBeGreaterThanOrEqual(before);
87
+ });
88
+
89
+ test("overwrites existing VCS fields (last PR wins)", () => {
90
+ const task = createTaskExtended("Test overwrite VCS", {
91
+ agentId: "vcs-track-agent-001",
92
+ source: "github",
93
+ vcsProvider: "github",
94
+ vcsRepo: "owner/repo",
95
+ vcsNumber: 1,
96
+ vcsUrl: "https://github.com/owner/repo/pull/1",
97
+ });
98
+
99
+ expect(task.vcsNumber).toBe(1);
100
+
101
+ const updated = updateTaskVcs(task.id, {
102
+ vcsProvider: "github",
103
+ vcsRepo: "owner/repo",
104
+ vcsNumber: 2,
105
+ vcsUrl: "https://github.com/owner/repo/pull/2",
106
+ });
107
+
108
+ expect(updated).not.toBeNull();
109
+ expect(updated!.vcsNumber).toBe(2);
110
+ expect(updated!.vcsUrl).toBe("https://github.com/owner/repo/pull/2");
111
+ });
112
+
113
+ test("findTaskByVcs finds task after updateTaskVcs", () => {
114
+ const task = createTaskExtended("Test findTaskByVcs linkage", {
115
+ agentId: "vcs-track-agent-001",
116
+ source: "api",
117
+ });
118
+
119
+ // Before update — no VCS fields, shouldn't be found
120
+ const notFound = findTaskByVcs("owner/findme", 99);
121
+ expect(notFound).toBeNull();
122
+
123
+ updateTaskVcs(task.id, {
124
+ vcsProvider: "github",
125
+ vcsRepo: "owner/findme",
126
+ vcsNumber: 99,
127
+ vcsUrl: "https://github.com/owner/findme/pull/99",
128
+ });
129
+
130
+ // After update — should be found (task is pending, not completed/failed)
131
+ const found = findTaskByVcs("owner/findme", 99);
132
+ expect(found).not.toBeNull();
133
+ expect(found!.id).toBe(task.id);
134
+ });
135
+
136
+ test("idempotent: calling twice with same data both succeed", () => {
137
+ const task = createTaskExtended("Test idempotency", {
138
+ agentId: "vcs-track-agent-001",
139
+ source: "api",
140
+ });
141
+
142
+ const vcs = {
143
+ vcsProvider: "github" as const,
144
+ vcsRepo: "owner/idem",
145
+ vcsNumber: 50,
146
+ vcsUrl: "https://github.com/owner/idem/pull/50",
147
+ };
148
+
149
+ const first = updateTaskVcs(task.id, vcs);
150
+ const second = updateTaskVcs(task.id, vcs);
151
+
152
+ expect(first).not.toBeNull();
153
+ expect(second).not.toBeNull();
154
+ expect(first!.vcsNumber).toBe(50);
155
+ expect(second!.vcsNumber).toBe(50);
156
+ });
157
+
158
+ test("supports gitlab provider", () => {
159
+ const task = createTaskExtended("Test gitlab VCS", {
160
+ agentId: "vcs-track-agent-001",
161
+ source: "api",
162
+ });
163
+
164
+ const updated = updateTaskVcs(task.id, {
165
+ vcsProvider: "gitlab",
166
+ vcsRepo: "group/project",
167
+ vcsNumber: 7,
168
+ vcsUrl: "https://gitlab.com/group/project/-/merge_requests/7",
169
+ });
170
+
171
+ expect(updated).not.toBeNull();
172
+ expect(updated!.vcsProvider).toBe("gitlab");
173
+ expect(updated!.vcsRepo).toBe("group/project");
174
+ expect(updated!.vcsNumber).toBe(7);
175
+ });
176
+ });
@@ -1,6 +1,12 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, initDb } from "../be/db";
4
+
5
+ // Mock slack/app to avoid dynamic import issues in parallel test execution
6
+ mock.module("../slack/app", () => ({
7
+ getSlackApp: () => null,
8
+ }));
9
+
4
10
  import type { ExecutorMeta } from "../types";
5
11
  import type { ExecutorDependencies, ExecutorInput } from "../workflows/executors/base";
6
12
  import { CodeMatchExecutor, CodeMatchOutputSchema } from "../workflows/executors/code-match";
@@ -363,6 +369,7 @@ describe("NotifyExecutor", () => {
363
369
  const result = await executor.run(
364
370
  input({ channel: "slack", target: "#general", template: "hi" }, {}),
365
371
  );
372
+ expect(result.status).toBe("success");
366
373
  const out = result.output as { sent: boolean };
367
374
  expect(out.sent).toBe(false);
368
375
  });
@@ -0,0 +1,7 @@
1
+ export { registerMcpServerCreateTool } from "./mcp-server-create";
2
+ export { registerMcpServerDeleteTool } from "./mcp-server-delete";
3
+ export { registerMcpServerGetTool } from "./mcp-server-get";
4
+ export { registerMcpServerInstallTool } from "./mcp-server-install";
5
+ export { registerMcpServerListTool } from "./mcp-server-list";
6
+ export { registerMcpServerUninstallTool } from "./mcp-server-uninstall";
7
+ export { registerMcpServerUpdateTool } from "./mcp-server-update";
@@ -0,0 +1,138 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createMcpServer, getAgentById, installMcpServer } from "@/be/db";
4
+ import { createToolRegistrar } from "@/tools/utils";
5
+
6
+ export const registerMcpServerCreateTool = (server: McpServer) => {
7
+ createToolRegistrar(server)(
8
+ "mcp-server-create",
9
+ {
10
+ title: "Create MCP Server",
11
+ annotations: { destructiveHint: false },
12
+ description:
13
+ "Create a new MCP server definition. Agent-scope servers are auto-installed for the creating agent. Swarm/global scope requires lead.",
14
+ inputSchema: z.object({
15
+ name: z.string().describe("Server name"),
16
+ description: z.string().optional().describe("Server description"),
17
+ transport: z.enum(["stdio", "http", "sse"]).describe("Transport type"),
18
+ scope: z
19
+ .enum(["global", "swarm", "agent"])
20
+ .default("agent")
21
+ .optional()
22
+ .describe("Scope: agent (personal), swarm (shared), or global. Default: agent"),
23
+ command: z.string().optional().describe("Command to run (required for stdio transport)"),
24
+ args: z.string().optional().describe("JSON array of command arguments (stdio only)"),
25
+ url: z.string().optional().describe("Server URL (required for http/sse transport)"),
26
+ headers: z
27
+ .string()
28
+ .optional()
29
+ .describe("JSON object of non-secret headers (http/sse only)"),
30
+ envConfigKeys: z
31
+ .string()
32
+ .optional()
33
+ .describe("JSON object mapping env var names to config key paths"),
34
+ headerConfigKeys: z
35
+ .string()
36
+ .optional()
37
+ .describe("JSON object mapping header names to config key paths for secret headers"),
38
+ }),
39
+ outputSchema: z.object({
40
+ yourAgentId: z.string().uuid().optional(),
41
+ success: z.boolean(),
42
+ message: z.string(),
43
+ server: z.any().optional(),
44
+ }),
45
+ },
46
+ async (args, requestInfo, _meta) => {
47
+ if (!requestInfo.agentId) {
48
+ return {
49
+ content: [{ type: "text", text: "Agent ID not found." }],
50
+ structuredContent: { success: false, message: "Agent ID not found." },
51
+ };
52
+ }
53
+
54
+ try {
55
+ // Validate transport-specific fields
56
+ if (args.transport === "stdio" && !args.command) {
57
+ return {
58
+ content: [{ type: "text", text: "stdio transport requires a command." }],
59
+ structuredContent: {
60
+ yourAgentId: requestInfo.agentId,
61
+ success: false,
62
+ message: "stdio transport requires a command.",
63
+ },
64
+ };
65
+ }
66
+
67
+ if ((args.transport === "http" || args.transport === "sse") && !args.url) {
68
+ return {
69
+ content: [{ type: "text", text: `${args.transport} transport requires a url.` }],
70
+ structuredContent: {
71
+ yourAgentId: requestInfo.agentId,
72
+ success: false,
73
+ message: `${args.transport} transport requires a url.`,
74
+ },
75
+ };
76
+ }
77
+
78
+ // Swarm/global scope requires lead
79
+ const scope = args.scope ?? "agent";
80
+ if (scope === "swarm" || scope === "global") {
81
+ const agent = getAgentById(requestInfo.agentId);
82
+ if (!agent?.isLead) {
83
+ return {
84
+ content: [
85
+ {
86
+ type: "text",
87
+ text: `Only lead agents can create ${scope}-scope MCP servers.`,
88
+ },
89
+ ],
90
+ structuredContent: {
91
+ yourAgentId: requestInfo.agentId,
92
+ success: false,
93
+ message: `Only lead agents can create ${scope}-scope MCP servers.`,
94
+ },
95
+ };
96
+ }
97
+ }
98
+
99
+ const created = createMcpServer({
100
+ name: args.name,
101
+ description: args.description,
102
+ transport: args.transport,
103
+ scope,
104
+ ownerAgentId: requestInfo.agentId,
105
+ command: args.command,
106
+ args: args.args,
107
+ url: args.url,
108
+ headers: args.headers,
109
+ envConfigKeys: args.envConfigKeys,
110
+ headerConfigKeys: args.headerConfigKeys,
111
+ });
112
+
113
+ // Auto-install for the creating agent
114
+ installMcpServer(requestInfo.agentId, created.id);
115
+
116
+ return {
117
+ content: [{ type: "text", text: `Created MCP server "${created.name}" (${created.id})` }],
118
+ structuredContent: {
119
+ yourAgentId: requestInfo.agentId,
120
+ success: true,
121
+ message: `Created and installed MCP server "${created.name}".`,
122
+ server: created,
123
+ },
124
+ };
125
+ } catch (error) {
126
+ const message = error instanceof Error ? error.message : "Unknown error";
127
+ return {
128
+ content: [{ type: "text", text: `Failed to create MCP server: ${message}` }],
129
+ structuredContent: {
130
+ yourAgentId: requestInfo.agentId,
131
+ success: false,
132
+ message: `Failed: ${message}`,
133
+ },
134
+ };
135
+ }
136
+ },
137
+ );
138
+ };