@desplega.ai/agent-swarm 1.113.0 → 1.115.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 (237) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-q4n7cz6e.js → actions-17ya5wde.js} +8 -9
  3. package/dist/{app-es4nzc71.js → app-4f718yk0.js} +6 -6
  4. package/dist/{assistant-0x8ey0vs.js → assistant-ah7csnjz.js} +19 -18
  5. package/dist/{boot-reembed-69agkhv6.js → boot-reembed-4jpxhb25.js} +6 -6
  6. package/dist/{boot-reembed-p0ny05t8.js → boot-reembed-qata34xa.js} +5 -5
  7. package/dist/{boot-scrub-logs-yybrkmvr.js → boot-scrub-logs-tv48nn3z.js} +4 -4
  8. package/dist/{claude-adapter-mma9a1yr.js → claude-adapter-3csn5bks.js} +3 -3
  9. package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-kysmkz89.js} +2 -2
  10. package/dist/cli-0pygq9r1.js +429 -0
  11. package/dist/{cli-hsetkkd3.js → cli-12vb0xg6.js} +33 -9
  12. package/dist/{cli-ncfgsqbd.js → cli-15y7qhzp.js} +262 -33
  13. package/dist/{cli-7ga43spr.js → cli-1yjrbg1f.js} +1 -1
  14. package/dist/{cli-36ymmpmp.js → cli-3samkyap.js} +2 -2
  15. package/dist/cli-52fg7s7x.js +269 -0
  16. package/dist/{cli-hjhvekf4.js → cli-65ekje01.js} +11 -8
  17. package/dist/{cli-h3k622d0.js → cli-6aq8xhvm.js} +90 -3
  18. package/dist/{cli-f3qa069v.js → cli-7xje5amz.js} +1 -1
  19. package/dist/cli-86fdnerz.js +405 -0
  20. package/dist/{cli-9cy6nc5m.js → cli-8fjwym53.js} +1 -1
  21. package/dist/{cli-c3frqf65.js → cli-b23axgz4.js} +3 -3
  22. package/dist/{cli-a0t7vapf.js → cli-bc89my0z.js} +1 -1
  23. package/dist/{cli-bnzbn8j8.js → cli-beea4a3y.js} +73 -24
  24. package/dist/{cli-q21d49ac.js → cli-bw80ck94.js} +1 -1
  25. package/dist/{cli-pwc5e83c.js → cli-cchksf5q.js} +12 -7
  26. package/dist/{cli-30bbaveh.js → cli-cmw9rzy5.js} +2 -2
  27. package/dist/{cli-y6mrptcn.js → cli-cnwssmsr.js} +1 -1
  28. package/dist/{cli-r5g6ngtn.js → cli-dcca6j8m.js} +46 -5
  29. package/dist/{cli-tkmm5nf9.js → cli-e61fs2hp.js} +59 -2
  30. package/dist/{cli-6wscj9z0.js → cli-e7306swv.js} +14 -14
  31. package/dist/{cli-dcfyyh3t.js → cli-ftyf58xj.js} +2 -2
  32. package/dist/{cli-4xyj9jtq.js → cli-hrhye2rm.js} +1062 -478
  33. package/dist/cli-jkhwmmgg.js +136 -0
  34. package/dist/{cli-y4ycrnjc.js → cli-k3y4z3h5.js} +1 -1
  35. package/dist/{cli-6xd0bvf8.js → cli-ma04faw5.js} +1 -1
  36. package/dist/{cli-2fc97xm5.js → cli-mnbgv3w7.js} +4 -4
  37. package/dist/{cli-trmapn9k.js → cli-pet3aev6.js} +95 -10
  38. package/dist/{cli-z46pnksz.js → cli-q1rx7g0d.js} +175 -28
  39. package/dist/{cli-qwgtacd6.js → cli-r9f6ngrf.js} +1244 -336
  40. package/dist/{cli-gtkqc144.js → cli-shvaxw27.js} +13 -7
  41. package/dist/{cli-ywez6yps.js → cli-sq7f72d6.js} +1 -1
  42. package/dist/cli-t0vqevnm.js +393 -0
  43. package/dist/{cli-tbw13sx8.js → cli-tece6zgm.js} +1 -1
  44. package/dist/{cli-bgvskydy.js → cli-tffvrh6j.js} +1 -1
  45. package/dist/cli-wh91zby6.js +187222 -0
  46. package/dist/{cli-anrj584m.js → cli-xz9aq0rf.js} +1 -1
  47. package/dist/{cli-mq580e06.js → cli-zsrdty74.js} +18 -18
  48. package/dist/cli.js +32 -15
  49. package/dist/{codex-adapter-5cmy858w.js → codex-adapter-de3w0nch.js} +10 -10
  50. package/dist/{codex-session-runner-46q8mrba.js → codex-session-runner-bnw8cysk.js} +10 -10
  51. package/dist/{commands-z5dbwta3.js → commands-hch4e2k1.js} +4 -4
  52. package/dist/{db-bbgahh4z.js → db-p3h2j06x.js} +14 -4
  53. package/dist/{handlers-6b44317z.js → handlers-9er4zx9q.js} +17 -31
  54. package/dist/{hook-sbp5fmps.js → hook-4axs756z.js} +7 -7
  55. package/dist/{http-2xz43gz3.js → http-809fvtz2.js} +1934 -886
  56. package/dist/{index-wz1ea15p.js → index-2dgfj9av.js} +3 -3
  57. package/dist/{index-nhat35em.js → index-47em3d2n.js} +14 -11
  58. package/dist/{index-5ghxn8s6.js → index-9fqaktjr.js} +12 -9
  59. package/dist/{index-0gzmqj4k.js → index-9ht8gr56.js} +13 -10
  60. package/dist/{index-vfedgdw6.js → index-rm85cfdx.js} +368 -40
  61. package/dist/{index-ykvs43z4.js → index-t2snazez.js} +4 -4
  62. package/dist/{keepalive-62gfj91d.js → keepalive-3cps6e3e.js} +7 -6
  63. package/dist/{lead-ag0akn0v.js → lead-fkk95wgq.js} +36 -32
  64. package/dist/{maintenance-xxrrzk5x.js → maintenance-fweqh0yj.js} +6 -6
  65. package/dist/{mistral-conversations-26yrnbf1.js → mistral-conversations-7gqnz2wt.js} +7 -7
  66. package/dist/oauth-refresh-sweep-sycqtbph.js +114 -0
  67. package/dist/{onboard-64zyfcbs.js → onboard-cy0rkgvx.js} +2 -2
  68. package/dist/{opencode-adapter-mqsxr170.js → opencode-adapter-p6bxnbm4.js} +2 -2
  69. package/dist/{otel-zjd9zaxs.js → otel-g299k41b.js} +2 -2
  70. package/dist/{otel-impl-b86qseaa.js → otel-impl-wbns8hf7.js} +1 -1
  71. package/dist/{pi-mono-adapter-kxjxpfgy.js → pi-mono-adapter-05sck1ds.js} +17 -109
  72. package/dist/{pricing-refresh-cgtkffpa.js → pricing-refresh-0hvazmkf.js} +6 -6
  73. package/dist/rbac-roles-kfesjy9y.js +28 -0
  74. package/dist/rbac-roles-mwcwhbxc.js +283 -0
  75. package/dist/{seed-pricing-1r1y1xkq.js → seed-pricing-h2kdq4cq.js} +5 -5
  76. package/dist/{setup-mkcgkjf7.js → setup-8506q97w.js} +2 -2
  77. package/dist/{worker-351sze63.js → worker-4r5c6zyb.js} +36 -32
  78. package/openapi.json +1327 -20
  79. package/package.json +3 -3
  80. package/src/agentmail/handlers.ts +16 -6
  81. package/src/agentmail/templates.ts +25 -5
  82. package/src/be/audit-user.ts +28 -4
  83. package/src/be/db-queries/oauth.ts +42 -0
  84. package/src/be/db.ts +314 -28
  85. package/src/be/identity.ts +79 -0
  86. package/src/be/mcp-proxy.ts +132 -0
  87. package/src/be/migrations/109_rbac_roles.sql +79 -0
  88. package/src/be/migrations/111_oauth_credential_bindings.sql +6 -0
  89. package/src/be/migrations/112_script_connections_graphql.sql +96 -0
  90. package/src/be/migrations/113_task_routing_affinity.sql +9 -0
  91. package/src/be/migrations/114_backfill_gpt_5_6_pricing.sql +15 -0
  92. package/src/be/modelsdev-cache.json +135 -0
  93. package/src/be/oauth-credential-bindings.ts +35 -0
  94. package/src/be/oauth-refresh-sweep.ts +159 -0
  95. package/src/be/rbac-audit.ts +52 -13
  96. package/src/be/rbac-roles.ts +424 -0
  97. package/src/be/script-connections.ts +459 -40
  98. package/src/be/script-credential-broker.ts +29 -3
  99. package/src/be/scripts/typecheck.ts +51 -7
  100. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +325 -24
  101. package/src/be/seed-scripts/catalog/catalog-report.ts +325 -24
  102. package/src/be/users.ts +24 -0
  103. package/src/cli.tsx +17 -0
  104. package/src/github/handlers.ts +10 -9
  105. package/src/github/templates.ts +45 -9
  106. package/src/gitlab/handlers.ts +20 -8
  107. package/src/gitlab/templates.ts +18 -6
  108. package/src/heartbeat/heartbeat.ts +143 -21
  109. package/src/http/all-routes.ts +3 -0
  110. package/src/http/approval-requests.ts +12 -0
  111. package/src/http/config.ts +44 -10
  112. package/src/http/core.ts +30 -3
  113. package/src/http/favorites.ts +1 -5
  114. package/src/http/index.ts +25 -1
  115. package/src/http/mcp-oauth.ts +2 -0
  116. package/src/http/mcp-servers.ts +44 -3
  117. package/src/http/oauth-generic.ts +105 -0
  118. package/src/http/poll.ts +20 -3
  119. package/src/http/script-connection-proxy.ts +116 -0
  120. package/src/http/script-connections.ts +1657 -0
  121. package/src/http/scripts.ts +27 -7
  122. package/src/http/skills.ts +11 -0
  123. package/src/http/tasks.ts +27 -13
  124. package/src/http/x.ts +6 -2
  125. package/src/integrations/kapso/inbound.ts +23 -6
  126. package/src/jira/sync.ts +111 -17
  127. package/src/jira/templates.ts +10 -2
  128. package/src/mcp-client/http-client.ts +194 -0
  129. package/src/oauth/app-validation.ts +16 -0
  130. package/src/oauth/ensure-token.ts +69 -14
  131. package/src/oauth/mcp-wrapper.ts +15 -0
  132. package/src/oauth/wrapper.ts +48 -20
  133. package/src/prompts/session-templates.ts +3 -3
  134. package/src/providers/codex-models.ts +100 -30
  135. package/src/providers/modelsdev-reasoning.json +51 -0
  136. package/src/providers/pi-mono-mcp-client.ts +2 -133
  137. package/src/providers/reasoning-effort.ts +11 -5
  138. package/src/rbac/admission.ts +68 -0
  139. package/src/rbac/index.ts +2 -0
  140. package/src/rbac/legacy-policy.ts +12 -0
  141. package/src/rbac/permissions.ts +48 -0
  142. package/src/scheduler/scheduler.ts +9 -1
  143. package/src/scripts-runtime/api-client.ts +151 -19
  144. package/src/scripts-runtime/api-types.ts +38 -6
  145. package/src/scripts-runtime/credential-broker/default-bindings.ts +1 -0
  146. package/src/scripts-runtime/credential-broker/types.ts +8 -0
  147. package/src/scripts-runtime/ctx.ts +11 -1
  148. package/src/scripts-runtime/eval-harness.ts +5 -1
  149. package/src/scripts-runtime/executors/types.ts +2 -1
  150. package/src/scripts-runtime/loader.ts +3 -1
  151. package/src/scripts-runtime/mcp-client.ts +87 -0
  152. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  153. package/src/scripts-runtime/types/stdlib.d.ts +6 -0
  154. package/src/scripts-runtime/types/swarm-sdk.d.ts +19 -0
  155. package/src/server-user.ts +114 -59
  156. package/src/server.ts +12 -0
  157. package/src/slack/assistant.ts +14 -6
  158. package/src/slack/enrich.ts +17 -0
  159. package/src/slack/handlers.ts +8 -22
  160. package/src/slack/responses.ts +91 -1
  161. package/src/slack/thread-buffer.ts +6 -3
  162. package/src/slack/watcher.ts +9 -6
  163. package/src/tasks/worker-follow-up.ts +100 -3
  164. package/src/tests/agentmail-handlers.test.ts +32 -3
  165. package/src/tests/approval-requests.test.ts +61 -0
  166. package/src/tests/audit-user.test.ts +47 -1
  167. package/src/tests/codex-adapter.test.ts +46 -23
  168. package/src/tests/credential-broker.test.ts +5 -2
  169. package/src/tests/delete-page-tool.test.ts +197 -0
  170. package/src/tests/github-handlers.test.ts +28 -2
  171. package/src/tests/gitlab-handlers.test.ts +125 -0
  172. package/src/tests/heartbeat.test.ts +48 -3
  173. package/src/tests/identity.test.ts +117 -0
  174. package/src/tests/jira-sync.test.ts +99 -1
  175. package/src/tests/kapso-inbound.test.ts +7 -0
  176. package/src/tests/mcp-tools-user.test.ts +60 -6
  177. package/src/tests/model-control.test.ts +8 -2
  178. package/src/tests/oauth-credential-bindings.test.ts +490 -0
  179. package/src/tests/oauth-refresh-sweep.test.ts +160 -0
  180. package/src/tests/pool-affinity.test.ts +401 -0
  181. package/src/tests/prompt-template-remaining.test.ts +2 -2
  182. package/src/tests/rbac-admission-e2e.test.ts +386 -0
  183. package/src/tests/rbac-admission.test.ts +492 -0
  184. package/src/tests/rbac-e2e-helpers.ts +3 -0
  185. package/src/tests/rbac-engine.test.ts +50 -0
  186. package/src/tests/rbac-mcp-admission-e2e.test.ts +362 -0
  187. package/src/tests/rbac-roles.test.ts +328 -0
  188. package/src/tests/reasoning-effort.test.ts +15 -2
  189. package/src/tests/schedule-target-type.test.ts +50 -0
  190. package/src/tests/scheduled-tasks.test.ts +40 -0
  191. package/src/tests/script-apis-mcp.test.ts +37 -13
  192. package/src/tests/script-connections-http.test.ts +1158 -0
  193. package/src/tests/script-connections-mcp.test.ts +894 -0
  194. package/src/tests/script-connections.test.ts +754 -6
  195. package/src/tests/scripts-external-api.test.ts +49 -1
  196. package/src/tests/scripts-runtime-secret-egress.test.ts +1 -0
  197. package/src/tests/scripts-runtime.test.ts +291 -0
  198. package/src/tests/seed-scripts.test.ts +36 -0
  199. package/src/tests/slack-identity-resolution.test.ts +37 -3
  200. package/src/tests/slack-inline-output.test.ts +65 -0
  201. package/src/tests/slack-thread-buffer.test.ts +28 -0
  202. package/src/tests/tool-annotations.test.ts +1 -0
  203. package/src/tests/user-token-rest-auth.test.ts +140 -1
  204. package/src/tests/workflow-swarm-script.test.ts +44 -0
  205. package/src/tests/workflow-triggers-v2.test.ts +300 -1
  206. package/src/tools/credential-bindings/tool.ts +277 -18
  207. package/src/tools/delete-page.ts +145 -0
  208. package/src/tools/mcp-servers/mcp-server-delete.ts +7 -4
  209. package/src/tools/request-human-input.ts +2 -0
  210. package/src/tools/resolve-user.ts +77 -27
  211. package/src/tools/script-apis.ts +62 -3
  212. package/src/tools/script-connections/tool.ts +275 -31
  213. package/src/tools/send-task.ts +15 -1
  214. package/src/tools/task-action.ts +34 -2
  215. package/src/tools/templates.ts +53 -1
  216. package/src/tools/tool-config.ts +2 -1
  217. package/src/tools/utils.ts +2 -0
  218. package/src/tools/workflows/create-workflow.ts +8 -1
  219. package/src/tools/workflows/update-workflow.ts +11 -2
  220. package/src/types.ts +90 -11
  221. package/src/utils/scoped-resource.ts +25 -0
  222. package/src/workflows/executors/human-in-the-loop.ts +1 -0
  223. package/src/workflows/executors/swarm-script.ts +7 -1
  224. package/src/workflows/triggers.ts +146 -18
  225. package/templates/skills/swarm-scripts/content.md +33 -8
  226. package/dist/cli-bgef578c.js +0 -771
  227. package/dist/cli-c9mtm09b.js +0 -43
  228. package/dist/cli-ceewqajw.js +0 -1698
  229. package/dist/{anthropic-messages-8nc6r2dw.js → anthropic-messages-kq3pjhbp.js} +6 -6
  230. package/dist/{azure-openai-responses-6yfzw3qw.js → azure-openai-responses-gm1ejtmn.js} +7 -7
  231. package/dist/{cli-vbraamxr.js → cli-6b4wwmm8.js} +9 -9
  232. package/dist/{cli-2r7s8jhp.js → cli-sznberje.js} +3 -3
  233. package/dist/{google-generative-ai-vfq6ymz9.js → google-generative-ai-hyrra2jm.js} +3 -3
  234. package/dist/{google-vertex-mj5y5rfj.js → google-vertex-8bs2pzgn.js} +3 -3
  235. package/dist/{openai-codex-responses-8h6kvfv8.js → openai-codex-responses-3nkvje79.js} +4 -4
  236. package/dist/{openai-completions-x6kjnqjd.js → openai-completions-0asftkwg.js} +11 -11
  237. package/dist/{openai-responses-7jwyz3ds.js → openai-responses-pmatek45.js} +10 -10
@@ -0,0 +1,894 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { Readable } from "node:stream";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import {
7
+ closeDb,
8
+ createAgent,
9
+ createMcpServer,
10
+ deleteMcpServer,
11
+ getDb,
12
+ getMcpServerById,
13
+ initDb,
14
+ upsertSwarmConfig,
15
+ } from "../be/db";
16
+ import {
17
+ getScriptConnectionById,
18
+ getScriptMcpConnectionDescriptors,
19
+ refreshScriptConnection,
20
+ setScriptConnectionEnabled,
21
+ upsertScriptConnection,
22
+ } from "../be/script-connections";
23
+ import { typecheckScript } from "../be/scripts/typecheck";
24
+ import { handleCore } from "../http/core";
25
+ import { handleScriptConnectionProxy } from "../http/script-connection-proxy";
26
+ import { getPathSegments, parseQueryParams } from "../http/utils";
27
+ import { createMcpRegistryClient } from "../scripts-runtime/mcp-client";
28
+ import { SwarmConfig } from "../scripts-runtime/swarm-config";
29
+ import { registerScriptConnectionsTool } from "../tools/script-connections";
30
+ import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
31
+
32
+ const TEST_DB_PATH = "./test-script-connections-mcp.sqlite";
33
+ const API_KEY = "test-script-connections-mcp-key-1234567890";
34
+ const SECRET_VALUE = "mcp-secret-value-1234567890";
35
+
36
+ type RegisteredTool = {
37
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
38
+ };
39
+
40
+ type ToolResult = {
41
+ structuredContent: {
42
+ success: boolean;
43
+ message: string;
44
+ connections: Array<{ id: string; slug: string; generationError?: string | null }>;
45
+ };
46
+ };
47
+
48
+ type CapturedMcpRequest = {
49
+ method: string | undefined;
50
+ tool: string | undefined;
51
+ secretHeader: string | null;
52
+ staticHeader: string | null;
53
+ };
54
+
55
+ async function removeDbFiles(path: string): Promise<void> {
56
+ for (const suffix of ["", "-wal", "-shm"]) {
57
+ await unlink(path + suffix).catch((error) => {
58
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
59
+ });
60
+ }
61
+ }
62
+
63
+ function scriptConnectionsTool() {
64
+ const server = new McpServer({ name: "script-connections-mcp-test", version: "1.0.0" });
65
+ registerScriptConnectionsTool(server);
66
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
67
+ ._registeredTools;
68
+ const tool = registered["script-connections"];
69
+ if (!tool) throw new Error("script-connections tool not registered");
70
+ return tool;
71
+ }
72
+
73
+ function meta(agentId: string) {
74
+ return {
75
+ sessionId: "script-connections-mcp-test-session",
76
+ requestInfo: { headers: { "x-agent-id": agentId } },
77
+ };
78
+ }
79
+
80
+ function startFakeMcpServer(opts: { failList?: boolean; jsonRpcListError?: boolean } = {}) {
81
+ const requests: CapturedMcpRequest[] = [];
82
+ const extraTools: Array<{ name: string; description: string; inputSchema: unknown }> = [];
83
+ const server = Bun.serve({
84
+ port: 0,
85
+ async fetch(req) {
86
+ const body = (await req.json()) as {
87
+ id?: number;
88
+ method?: string;
89
+ params?: { name?: string; arguments?: Record<string, unknown> };
90
+ };
91
+ requests.push({
92
+ method: body.method,
93
+ tool: body.params?.name,
94
+ secretHeader: req.headers.get("x-test-secret"),
95
+ staticHeader: req.headers.get("x-static-header"),
96
+ });
97
+
98
+ if (body.method === "initialize") {
99
+ return Response.json(
100
+ {
101
+ jsonrpc: "2.0",
102
+ id: body.id,
103
+ result: {
104
+ protocolVersion: "2025-03-26",
105
+ capabilities: {},
106
+ serverInfo: { name: "fake-external-mcp", version: "1.0.0" },
107
+ },
108
+ },
109
+ { headers: { "mcp-session-id": "fake-session" } },
110
+ );
111
+ }
112
+ if (body.method === "notifications/initialized") {
113
+ return Response.json({});
114
+ }
115
+ if (body.method === "tools/list") {
116
+ if (opts.failList) {
117
+ return Response.json({ error: "list failed" }, { status: 500 });
118
+ }
119
+ if (opts.jsonRpcListError) {
120
+ return Response.json({
121
+ jsonrpc: "2.0",
122
+ id: body.id,
123
+ error: { code: -32000, message: "list failed inside envelope" },
124
+ });
125
+ }
126
+ return Response.json({
127
+ jsonrpc: "2.0",
128
+ id: body.id,
129
+ result: {
130
+ tools: [
131
+ {
132
+ name: "echo-tool",
133
+ description: "Echo a message",
134
+ inputSchema: {
135
+ type: "object",
136
+ required: ["message"],
137
+ properties: { message: { type: "string" } },
138
+ },
139
+ },
140
+ ...extraTools,
141
+ ],
142
+ },
143
+ });
144
+ }
145
+ if (body.method === "tools/call") {
146
+ return Response.json({
147
+ jsonrpc: "2.0",
148
+ id: body.id,
149
+ result: {
150
+ content: [
151
+ {
152
+ type: "text",
153
+ text: JSON.stringify({
154
+ echo: body.params?.arguments?.message,
155
+ secret: req.headers.get("x-test-secret"),
156
+ }),
157
+ },
158
+ ],
159
+ },
160
+ });
161
+ }
162
+ return Response.json({ error: "not found" }, { status: 404 });
163
+ },
164
+ });
165
+ return {
166
+ requests,
167
+ url: `http://127.0.0.1:${server.port}/mcp`,
168
+ addExtraTool(name: string, description: string) {
169
+ extraTools.push({
170
+ name,
171
+ description,
172
+ inputSchema: { type: "object", properties: {} },
173
+ });
174
+ },
175
+ stop() {
176
+ server.stop(true);
177
+ },
178
+ };
179
+ }
180
+
181
+ function headersRecord(headers: HeadersInit | undefined): Record<string, string> {
182
+ if (!headers) return {};
183
+ if (headers instanceof Headers) return Object.fromEntries(headers.entries());
184
+ if (Array.isArray(headers)) return Object.fromEntries(headers);
185
+ return headers as Record<string, string>;
186
+ }
187
+
188
+ async function dispatchProxyApi(url: string, init: RequestInit = {}): Promise<Response> {
189
+ const parsedUrl = new URL(url, "http://script-connections-mcp.test");
190
+ const headers = Object.fromEntries(
191
+ Object.entries(headersRecord(init.headers)).map(([key, value]) => [
192
+ key.toLowerCase(),
193
+ String(value),
194
+ ]),
195
+ );
196
+ const body = init.body === undefined ? undefined : String(init.body);
197
+ const req = Readable.from(body ? [Buffer.from(body)] : []) as IncomingMessage;
198
+ req.method = init.method ?? "GET";
199
+ req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
200
+ req.headers = headers;
201
+
202
+ let status = 200;
203
+ let text = "";
204
+ const res = {
205
+ headersSent: false,
206
+ writableEnded: false,
207
+ setHeader() {},
208
+ writeHead(code: number) {
209
+ status = code;
210
+ this.headersSent = true;
211
+ return this;
212
+ },
213
+ end(chunk?: unknown) {
214
+ if (chunk !== undefined) text += String(chunk);
215
+ this.writableEnded = true;
216
+ return this;
217
+ },
218
+ } as unknown as ServerResponse;
219
+
220
+ const agentId = headers["x-agent-id"];
221
+ if (!(await handleCore(req, res, agentId, API_KEY))) {
222
+ const pathSegments = getPathSegments(req.url || "");
223
+ const queryParams = parseQueryParams(req.url || "");
224
+ if (!(await handleScriptConnectionProxy(req, res, pathSegments, queryParams, agentId))) {
225
+ res.writeHead(404);
226
+ res.end("Not Found");
227
+ }
228
+ }
229
+
230
+ return new Response(text, {
231
+ status,
232
+ headers: { "Content-Type": "application/json" },
233
+ });
234
+ }
235
+
236
+ function seedExternalMcpServer(url: string) {
237
+ upsertSwarmConfig({
238
+ scope: "global",
239
+ key: "MCP_TEST_SECRET",
240
+ value: SECRET_VALUE,
241
+ isSecret: true,
242
+ });
243
+ return createMcpServer({
244
+ name: `external-${crypto.randomUUID()}`,
245
+ transport: "http",
246
+ scope: "global",
247
+ url,
248
+ headers: JSON.stringify({ "X-Static-Header": "static-ok" }),
249
+ headerConfigKeys: JSON.stringify({ "X-Test-Secret": "MCP_TEST_SECRET" }),
250
+ });
251
+ }
252
+
253
+ let savedEnv: NodeJS.ProcessEnv;
254
+
255
+ beforeAll(async () => {
256
+ savedEnv = { ...process.env };
257
+ await removeDbFiles(TEST_DB_PATH);
258
+ closeDb();
259
+ initDb(TEST_DB_PATH);
260
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
261
+ delete process.env.API_KEY;
262
+ refreshSecretScrubberCache();
263
+ });
264
+
265
+ afterAll(async () => {
266
+ closeDb();
267
+ await removeDbFiles(TEST_DB_PATH);
268
+ for (const key of Object.keys(process.env)) {
269
+ if (!(key in savedEnv)) delete process.env[key];
270
+ }
271
+ for (const [key, value] of Object.entries(savedEnv)) {
272
+ if (value === undefined) delete process.env[key];
273
+ else process.env[key] = value;
274
+ }
275
+ refreshSecretScrubberCache();
276
+ });
277
+
278
+ beforeEach(() => {
279
+ const db = getDb();
280
+ db.run("DELETE FROM script_connections");
281
+ db.run("DELETE FROM agent_mcp_servers");
282
+ db.run("DELETE FROM mcp_servers");
283
+ db.run("DELETE FROM swarm_config");
284
+ db.run("DELETE FROM agents");
285
+ });
286
+
287
+ describe("script MCP connections", () => {
288
+ test("upsert-mcp generates descriptors/types and resolves header config while listing tools", async () => {
289
+ const fake = startFakeMcpServer();
290
+ try {
291
+ const lead = createAgent({ name: "mcp-lead", isLead: true, status: "idle" });
292
+ const mcpServer = seedExternalMcpServer(fake.url);
293
+ const tool = scriptConnectionsTool();
294
+
295
+ const result = (await tool.handler(
296
+ {
297
+ action: "upsert-mcp",
298
+ slug: "external",
299
+ displayName: "External MCP",
300
+ mcpServerId: mcpServer.id,
301
+ },
302
+ meta(lead.id),
303
+ )) as ToolResult;
304
+
305
+ expect(result.structuredContent.success).toBe(true);
306
+ const connection = result.structuredContent.connections.find(
307
+ (candidate) => candidate.slug === "external",
308
+ );
309
+ expect(connection).toBeDefined();
310
+ const descriptor = getScriptMcpConnectionDescriptors({ agentId: lead.id }).find(
311
+ (candidate) => candidate.slug === "external",
312
+ );
313
+ expect(descriptor).toEqual(
314
+ expect.objectContaining({
315
+ kind: "mcp",
316
+ connectionId: connection?.id,
317
+ tools: [
318
+ expect.objectContaining({
319
+ name: "echo-tool",
320
+ description: "Echo a message",
321
+ }),
322
+ ],
323
+ }),
324
+ );
325
+
326
+ const row = getDb()
327
+ .prepare<{ generated_types: string | null }, [string]>(
328
+ "SELECT generated_types FROM script_connections WHERE id = ?",
329
+ )
330
+ .get(connection!.id);
331
+ expect(row?.generated_types).toContain("export interface ExternalMcp");
332
+ expect(row?.generated_types).toContain("echoTool(args:");
333
+ expect(fake.requests.some((request) => request.secretHeader === SECRET_VALUE)).toBe(true);
334
+ expect(fake.requests.some((request) => request.staticHeader === "static-ok")).toBe(true);
335
+
336
+ const typecheck = typecheckScript(
337
+ `
338
+ import type { ScriptMain } from "swarm-sdk";
339
+ const main: ScriptMain = async (_args, ctx) => {
340
+ const result = await ctx.mcp.external.echoTool({ message: "hi" });
341
+ return result;
342
+ };
343
+ export default main;
344
+ `,
345
+ { agentId: lead.id },
346
+ );
347
+ expect(typecheck).toEqual({ ok: true });
348
+ } finally {
349
+ fake.stop();
350
+ }
351
+ });
352
+
353
+ test("MCP generated types suffix normalized tool name collisions deterministically", async () => {
354
+ const fake = startFakeMcpServer();
355
+ try {
356
+ fake.addExtraTool("foo-bar", "Hyphenated");
357
+ fake.addExtraTool("foo_bar", "Underscored");
358
+ const lead = createAgent({ name: "mcp-collision-lead", isLead: true, status: "idle" });
359
+ const mcpServer = seedExternalMcpServer(fake.url);
360
+
361
+ const connection = await upsertScriptConnection({
362
+ slug: "collisionExternal",
363
+ kind: "mcp",
364
+ mcpServerId: mcpServer.id,
365
+ agentId: lead.id,
366
+ });
367
+
368
+ expect(connection.generationError).toBeNull();
369
+ expect(connection.generatedTypes).toContain("fooBar(args:");
370
+ expect(connection.generatedTypes).toContain("fooBar2(args:");
371
+ expect(connection.generatedTypes?.match(/fooBar\(args:/g) ?? []).toHaveLength(1);
372
+ } finally {
373
+ fake.stop();
374
+ }
375
+ });
376
+
377
+ test("agent-scoped registration resolves discovery auth with the target agent's scoped config", async () => {
378
+ const fake = startFakeMcpServer();
379
+ try {
380
+ const lead = createAgent({ name: "mcp-scope-lead", isLead: true, status: "idle" });
381
+ const owner = createAgent({ name: "mcp-scope-owner", isLead: false, status: "idle" });
382
+ // Secret visible ONLY in the owner agent's scope — not to the lead caller.
383
+ upsertSwarmConfig({
384
+ scope: "agent",
385
+ scopeId: owner.id,
386
+ key: "MCP_OWNER_ONLY_SECRET",
387
+ value: "owner-only-secret",
388
+ isSecret: true,
389
+ });
390
+ const mcpServer = createMcpServer({
391
+ name: `external-owner-${crypto.randomUUID()}`,
392
+ transport: "http",
393
+ scope: "global",
394
+ url: fake.url,
395
+ headerConfigKeys: JSON.stringify({ "X-Test-Secret": "MCP_OWNER_ONLY_SECRET" }),
396
+ });
397
+
398
+ const connection = await upsertScriptConnection({
399
+ slug: "ownerScopedDiscovery",
400
+ kind: "mcp",
401
+ scope: "agent",
402
+ scopeId: owner.id,
403
+ mcpServerId: mcpServer.id,
404
+ agentId: lead.id,
405
+ });
406
+
407
+ expect(connection.generationError).toBeNull();
408
+ const listRequest = fake.requests.find((request) => request.method === "tools/list");
409
+ expect(listRequest?.secretHeader).toBe("owner-only-secret");
410
+ } finally {
411
+ fake.stop();
412
+ }
413
+ });
414
+
415
+ test("refresh uses caller agent context for global MCP discovery auth", async () => {
416
+ const fake = startFakeMcpServer();
417
+ try {
418
+ const lead = createAgent({ name: "mcp-refresh-context-lead", isLead: true, status: "idle" });
419
+ upsertSwarmConfig({
420
+ scope: "agent",
421
+ scopeId: lead.id,
422
+ key: "MCP_CALLER_ONLY_SECRET",
423
+ value: "caller-only-secret",
424
+ isSecret: true,
425
+ });
426
+ const mcpServer = createMcpServer({
427
+ name: `external-caller-${crypto.randomUUID()}`,
428
+ transport: "http",
429
+ scope: "global",
430
+ url: fake.url,
431
+ headerConfigKeys: JSON.stringify({ "X-Test-Secret": "MCP_CALLER_ONLY_SECRET" }),
432
+ });
433
+ const connection = await upsertScriptConnection({
434
+ slug: "callerScopedRefresh",
435
+ kind: "mcp",
436
+ scope: "global",
437
+ mcpServerId: mcpServer.id,
438
+ agentId: lead.id,
439
+ });
440
+ expect(connection.generationError).toBeNull();
441
+
442
+ fake.requests.length = 0;
443
+ fake.addExtraTool("after-refresh", "Added during refresh");
444
+ const result = (await scriptConnectionsTool().handler(
445
+ { action: "refresh", id: connection.id },
446
+ meta(lead.id),
447
+ )) as ToolResult;
448
+
449
+ expect(result.structuredContent.success).toBe(true);
450
+ const listRequest = fake.requests.find((request) => request.method === "tools/list");
451
+ expect(listRequest?.secretHeader).toBe("caller-only-secret");
452
+ } finally {
453
+ fake.stop();
454
+ }
455
+ });
456
+
457
+ test("proxy route lets a worker invoke a global MCP connection and forwards resolved secret headers", async () => {
458
+ const fake = startFakeMcpServer();
459
+ try {
460
+ const lead = createAgent({ name: "mcp-lead", isLead: true, status: "idle" });
461
+ const worker = createAgent({ name: "mcp-worker", isLead: false, status: "idle" });
462
+ const mcpServer = seedExternalMcpServer(fake.url);
463
+ const connection = await upsertScriptConnection({
464
+ slug: "proxyExternal",
465
+ kind: "mcp",
466
+ scope: "global",
467
+ mcpServerId: mcpServer.id,
468
+ agentId: lead.id,
469
+ });
470
+
471
+ const response = await dispatchProxyApi(`/api/script-connections/${connection.id}/mcp-call`, {
472
+ method: "POST",
473
+ headers: {
474
+ Authorization: `Bearer ${API_KEY}`,
475
+ "X-Agent-ID": worker.id,
476
+ "Content-Type": "application/json",
477
+ },
478
+ body: JSON.stringify({ tool: "echo-tool", arguments: { message: "hello" } }),
479
+ });
480
+
481
+ expect(response.status).toBe(200);
482
+ const body = (await response.json()) as {
483
+ ok: boolean;
484
+ result?: { content: Array<{ type: string; text?: string }> };
485
+ };
486
+ expect(body.ok).toBe(true);
487
+ expect(body.result?.content[0]?.text).toBe(
488
+ JSON.stringify({ echo: "hello", secret: SECRET_VALUE }),
489
+ );
490
+ expect(fake.requests.some((request) => request.method === "tools/call")).toBe(true);
491
+
492
+ const unauthenticated = await dispatchProxyApi(
493
+ `/api/script-connections/${connection.id}/mcp-call`,
494
+ {
495
+ method: "POST",
496
+ headers: { "X-Agent-ID": worker.id, "Content-Type": "application/json" },
497
+ body: JSON.stringify({ tool: "echo-tool", arguments: {} }),
498
+ },
499
+ );
500
+ expect(unauthenticated.status).toBe(401);
501
+ } finally {
502
+ fake.stop();
503
+ }
504
+ });
505
+
506
+ test("proxy route lets synthetic schedule/workflow principals invoke global MCP connections only", async () => {
507
+ const fake = startFakeMcpServer();
508
+ try {
509
+ const lead = createAgent({ name: "mcp-synthetic-lead", isLead: true, status: "idle" });
510
+ const owner = createAgent({ name: "mcp-synthetic-owner", isLead: false, status: "idle" });
511
+ const mcpServer = seedExternalMcpServer(fake.url);
512
+ const globalConnection = await upsertScriptConnection({
513
+ slug: "syntheticGlobal",
514
+ kind: "mcp",
515
+ scope: "global",
516
+ mcpServerId: mcpServer.id,
517
+ agentId: lead.id,
518
+ });
519
+
520
+ const scheduleCall = await dispatchProxyApi(
521
+ `/api/script-connections/${globalConnection.id}/mcp-call`,
522
+ {
523
+ method: "POST",
524
+ headers: {
525
+ Authorization: `Bearer ${API_KEY}`,
526
+ "X-Agent-ID": "schedule",
527
+ "Content-Type": "application/json",
528
+ },
529
+ body: JSON.stringify({ tool: "echo-tool", arguments: { message: "scheduled" } }),
530
+ },
531
+ );
532
+ expect(scheduleCall.status).toBe(200);
533
+ expect(((await scheduleCall.json()) as { ok: boolean }).ok).toBe(true);
534
+
535
+ const scopedConnection = await upsertScriptConnection({
536
+ slug: "syntheticScoped",
537
+ kind: "mcp",
538
+ scope: "agent",
539
+ scopeId: owner.id,
540
+ mcpServerId: mcpServer.id,
541
+ agentId: lead.id,
542
+ });
543
+ const scopedCall = await dispatchProxyApi(
544
+ `/api/script-connections/${scopedConnection.id}/mcp-call`,
545
+ {
546
+ method: "POST",
547
+ headers: {
548
+ Authorization: `Bearer ${API_KEY}`,
549
+ "X-Agent-ID": "workflow",
550
+ "Content-Type": "application/json",
551
+ },
552
+ body: JSON.stringify({ tool: "echo-tool", arguments: {} }),
553
+ },
554
+ );
555
+ expect(scopedCall.status).toBe(403);
556
+ } finally {
557
+ fake.stop();
558
+ }
559
+ });
560
+
561
+ test("proxy route allows repo-scoped MCP connections after descriptor-time gating", async () => {
562
+ const fake = startFakeMcpServer();
563
+ try {
564
+ const lead = createAgent({ name: "mcp-repo-lead", isLead: true, status: "idle" });
565
+ const worker = createAgent({ name: "mcp-repo-worker", isLead: false, status: "idle" });
566
+ const mcpServer = seedExternalMcpServer(fake.url);
567
+ const connection = await upsertScriptConnection({
568
+ slug: "repoExternal",
569
+ kind: "mcp",
570
+ scope: "repo",
571
+ scopeId: "desplega-ai/agent-swarm",
572
+ mcpServerId: mcpServer.id,
573
+ agentId: lead.id,
574
+ });
575
+
576
+ const response = await dispatchProxyApi(`/api/script-connections/${connection.id}/mcp-call`, {
577
+ method: "POST",
578
+ headers: {
579
+ Authorization: `Bearer ${API_KEY}`,
580
+ "X-Agent-ID": worker.id,
581
+ "Content-Type": "application/json",
582
+ },
583
+ body: JSON.stringify({ tool: "echo-tool", arguments: { message: "repo" } }),
584
+ });
585
+
586
+ expect(response.status).toBe(200);
587
+ expect(((await response.json()) as { ok: boolean }).ok).toBe(true);
588
+ } finally {
589
+ fake.stop();
590
+ }
591
+ });
592
+
593
+ test("proxy route rejects disabled connections and agent scope mismatches", async () => {
594
+ const fake = startFakeMcpServer();
595
+ try {
596
+ const lead = createAgent({ name: "mcp-lead", isLead: true, status: "idle" });
597
+ const worker = createAgent({ name: "mcp-worker", isLead: false, status: "idle" });
598
+ const owner = createAgent({ name: "mcp-owner", isLead: false, status: "idle" });
599
+ const mcpServer = seedExternalMcpServer(fake.url);
600
+ const globalConnection = await upsertScriptConnection({
601
+ slug: "disabledExternal",
602
+ kind: "mcp",
603
+ scope: "global",
604
+ mcpServerId: mcpServer.id,
605
+ agentId: lead.id,
606
+ });
607
+ setScriptConnectionEnabled(globalConnection.id, false);
608
+
609
+ const disabled = await dispatchProxyApi(
610
+ `/api/script-connections/${globalConnection.id}/mcp-call`,
611
+ {
612
+ method: "POST",
613
+ headers: {
614
+ Authorization: `Bearer ${API_KEY}`,
615
+ "X-Agent-ID": worker.id,
616
+ "Content-Type": "application/json",
617
+ },
618
+ body: JSON.stringify({ tool: "echo-tool", arguments: {} }),
619
+ },
620
+ );
621
+ expect(disabled.status).toBe(403);
622
+
623
+ const scopedConnection = await upsertScriptConnection({
624
+ slug: "ownerExternal",
625
+ kind: "mcp",
626
+ scope: "agent",
627
+ scopeId: owner.id,
628
+ mcpServerId: mcpServer.id,
629
+ agentId: lead.id,
630
+ });
631
+
632
+ const mismatch = await dispatchProxyApi(
633
+ `/api/script-connections/${scopedConnection.id}/mcp-call`,
634
+ {
635
+ method: "POST",
636
+ headers: {
637
+ Authorization: `Bearer ${API_KEY}`,
638
+ "X-Agent-ID": worker.id,
639
+ "Content-Type": "application/json",
640
+ },
641
+ body: JSON.stringify({ tool: "echo-tool", arguments: {} }),
642
+ },
643
+ );
644
+ expect(mismatch.status).toBe(403);
645
+
646
+ const allowed = await dispatchProxyApi(
647
+ `/api/script-connections/${scopedConnection.id}/mcp-call`,
648
+ {
649
+ method: "POST",
650
+ headers: {
651
+ Authorization: `Bearer ${API_KEY}`,
652
+ "X-Agent-ID": owner.id,
653
+ "Content-Type": "application/json",
654
+ },
655
+ body: JSON.stringify({ tool: "echo-tool", arguments: { message: "owner" } }),
656
+ },
657
+ );
658
+ expect(allowed.status).toBe(200);
659
+ expect(((await allowed.json()) as { ok: boolean }).ok).toBe(true);
660
+ } finally {
661
+ fake.stop();
662
+ }
663
+ });
664
+
665
+ test("refresh re-runs MCP tool discovery and picks up new tools", async () => {
666
+ const fake = startFakeMcpServer();
667
+ try {
668
+ const lead = createAgent({ name: "mcp-refresh-lead", isLead: true, status: "idle" });
669
+ const mcpServer = seedExternalMcpServer(fake.url);
670
+ const connection = await upsertScriptConnection({
671
+ slug: "refreshable",
672
+ displayName: "Refreshable MCP",
673
+ kind: "mcp",
674
+ mcpServerId: mcpServer.id,
675
+ agentId: lead.id,
676
+ });
677
+ const before = getScriptMcpConnectionDescriptors({ agentId: lead.id }).find(
678
+ (candidate) => candidate.slug === "refreshable",
679
+ );
680
+ expect(before?.tools.map((tool) => tool.name)).toEqual(["echo-tool"]);
681
+
682
+ fake.addExtraTool("new-tool", "Added after registration");
683
+ const refreshed = await refreshScriptConnection(connection.id);
684
+
685
+ expect(refreshed?.generationError).toBeNull();
686
+ const after = getScriptMcpConnectionDescriptors({ agentId: lead.id }).find(
687
+ (candidate) => candidate.slug === "refreshable",
688
+ );
689
+ expect(after?.tools.map((tool) => tool.name)).toEqual(["echo-tool", "new-tool"]);
690
+ } finally {
691
+ fake.stop();
692
+ }
693
+ });
694
+
695
+ test("tool-list fetch failures are recorded as generation_error on the connection row", async () => {
696
+ const fake = startFakeMcpServer({ failList: true });
697
+ try {
698
+ const lead = createAgent({ name: "mcp-lead", isLead: true, status: "idle" });
699
+ const mcpServer = seedExternalMcpServer(fake.url);
700
+
701
+ const connection = await upsertScriptConnection({
702
+ slug: "brokenExternal",
703
+ kind: "mcp",
704
+ scope: "global",
705
+ mcpServerId: mcpServer.id,
706
+ agentId: lead.id,
707
+ });
708
+
709
+ expect(connection.generationError).toContain("MCP request failed: 500");
710
+ expect(connection.generatedRuntimeJson).toBeNull();
711
+ expect(connection.generatedTypes).toBeNull();
712
+ } finally {
713
+ fake.stop();
714
+ }
715
+ });
716
+
717
+ test("tools/list JSON-RPC error envelopes are recorded as generation_error", async () => {
718
+ const fake = startFakeMcpServer({ jsonRpcListError: true });
719
+ try {
720
+ const lead = createAgent({ name: "mcp-jsonrpc-error-lead", isLead: true, status: "idle" });
721
+ const mcpServer = seedExternalMcpServer(fake.url);
722
+
723
+ const connection = await upsertScriptConnection({
724
+ slug: "jsonRpcBrokenExternal",
725
+ kind: "mcp",
726
+ scope: "global",
727
+ mcpServerId: mcpServer.id,
728
+ agentId: lead.id,
729
+ });
730
+
731
+ expect(connection.generationError).toContain("list failed inside envelope");
732
+ expect(connection.generatedRuntimeJson).toBeNull();
733
+ expect(connection.generatedTypes).toBeNull();
734
+ } finally {
735
+ fake.stop();
736
+ }
737
+ });
738
+
739
+ test("SSE MCP servers fail early with a clear unsupported transport error", async () => {
740
+ const lead = createAgent({ name: "mcp-sse-lead", isLead: true, status: "idle" });
741
+ const mcpServer = createMcpServer({
742
+ name: `sse-${crypto.randomUUID()}`,
743
+ transport: "sse",
744
+ scope: "global",
745
+ url: "https://mcp.vendor.test/sse",
746
+ });
747
+
748
+ const connection = await upsertScriptConnection({
749
+ slug: "sseExternal",
750
+ kind: "mcp",
751
+ scope: "global",
752
+ mcpServerId: mcpServer.id,
753
+ agentId: lead.id,
754
+ });
755
+
756
+ expect(connection.generationError).toContain("SSE MCP servers are not supported yet");
757
+
758
+ const response = await dispatchProxyApi(`/api/script-connections/${connection.id}/mcp-call`, {
759
+ method: "POST",
760
+ headers: {
761
+ Authorization: `Bearer ${API_KEY}`,
762
+ "X-Agent-ID": lead.id,
763
+ "Content-Type": "application/json",
764
+ },
765
+ body: JSON.stringify({ tool: "echo-tool", arguments: {} }),
766
+ });
767
+ expect(response.status).toBe(200);
768
+ expect(((await response.json()) as { ok: boolean; error: string }).error).toContain(
769
+ "SSE MCP servers are not supported yet",
770
+ );
771
+ });
772
+
773
+ test("deleteMcpServer deletes dependent MCP script connections before deleting the server", async () => {
774
+ const fake = startFakeMcpServer();
775
+ try {
776
+ const lead = createAgent({ name: "mcp-delete-cascade-lead", isLead: true, status: "idle" });
777
+ const mcpServer = seedExternalMcpServer(fake.url);
778
+ const connection = await upsertScriptConnection({
779
+ slug: "deleteCascadeExternal",
780
+ kind: "mcp",
781
+ scope: "global",
782
+ mcpServerId: mcpServer.id,
783
+ agentId: lead.id,
784
+ });
785
+
786
+ const result = deleteMcpServer(mcpServer.id);
787
+
788
+ expect(result).toEqual({ deleted: true, deletedScriptConnectionCount: 1 });
789
+ expect(getMcpServerById(mcpServer.id)).toBeNull();
790
+ expect(getScriptConnectionById(connection.id)).toBeNull();
791
+ } finally {
792
+ fake.stop();
793
+ }
794
+ });
795
+
796
+ test("runtime ctx.mcp registry posts the expected proxy request shape", async () => {
797
+ const calls: Array<{ url: string; headers: Record<string, string>; body: unknown }> = [];
798
+ const savedFetch = globalThis.fetch;
799
+ globalThis.fetch = (async (input, init) => {
800
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
801
+ calls.push({
802
+ url,
803
+ headers: Object.fromEntries(new Headers(init?.headers).entries()),
804
+ body: init?.body ? JSON.parse(String(init.body)) : null,
805
+ });
806
+ return Response.json({ ok: true, result: { content: [{ type: "text", text: "ok" }] } });
807
+ }) as typeof globalThis.fetch;
808
+
809
+ try {
810
+ const config = new SwarmConfig({
811
+ system: {
812
+ apiKey: { value: API_KEY, isSecret: true },
813
+ agentId: { value: "agent-runtime", isSecret: false },
814
+ mcpBaseUrl: { value: "http://swarm.test/", isSecret: false },
815
+ },
816
+ user: {},
817
+ });
818
+ const registry = createMcpRegistryClient(
819
+ [
820
+ {
821
+ slug: "external",
822
+ kind: "mcp",
823
+ connectionId: "conn-1",
824
+ tools: [{ name: "echo-tool", inputSchema: {} }],
825
+ },
826
+ ],
827
+ config,
828
+ );
829
+
830
+ const result = await registry.external!.echoTool({ message: "hi" });
831
+
832
+ expect(result).toEqual({ content: [{ type: "text", text: "ok" }] });
833
+ expect(calls).toEqual([
834
+ {
835
+ url: "http://swarm.test/api/script-connections/conn-1/mcp-call",
836
+ headers: {
837
+ authorization: `Bearer ${API_KEY}`,
838
+ "content-type": "application/json",
839
+ "x-agent-id": "agent-runtime",
840
+ },
841
+ body: { tool: "echo-tool", arguments: { message: "hi" } },
842
+ },
843
+ ]);
844
+ } finally {
845
+ globalThis.fetch = savedFetch;
846
+ }
847
+ });
848
+
849
+ test("runtime ctx.mcp registry suffixes normalized tool name collisions", async () => {
850
+ const calls: Array<{ body: unknown }> = [];
851
+ const savedFetch = globalThis.fetch;
852
+ globalThis.fetch = (async (_input, init) => {
853
+ calls.push({
854
+ body: init?.body ? JSON.parse(String(init.body)) : null,
855
+ });
856
+ return Response.json({ ok: true, result: { content: [{ type: "text", text: "ok" }] } });
857
+ }) as typeof globalThis.fetch;
858
+
859
+ try {
860
+ const config = new SwarmConfig({
861
+ system: {
862
+ apiKey: { value: API_KEY, isSecret: true },
863
+ agentId: { value: "agent-runtime", isSecret: false },
864
+ mcpBaseUrl: { value: "http://swarm.test/", isSecret: false },
865
+ },
866
+ user: {},
867
+ });
868
+ const registry = createMcpRegistryClient(
869
+ [
870
+ {
871
+ slug: "external",
872
+ kind: "mcp",
873
+ connectionId: "conn-1",
874
+ tools: [
875
+ { name: "foo-bar", inputSchema: {} },
876
+ { name: "foo_bar", inputSchema: {} },
877
+ ],
878
+ },
879
+ ],
880
+ config,
881
+ );
882
+
883
+ await registry.external!.fooBar({});
884
+ await registry.external!.fooBar2({});
885
+
886
+ expect(calls.map((call) => call.body)).toEqual([
887
+ { tool: "foo-bar", arguments: {} },
888
+ { tool: "foo_bar", arguments: {} },
889
+ ]);
890
+ } finally {
891
+ globalThis.fetch = savedFetch;
892
+ }
893
+ });
894
+ });