@desplega.ai/agent-swarm 1.74.2 → 1.74.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.74.2",
5
+ "version": "1.74.3",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.74.2",
3
+ "version": "1.74.3",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -91,51 +91,55 @@ export function mergeMcpConfig(
91
91
 
92
92
  /**
93
93
  * Create a per-session MCP config file with X-Source-Task-Id header injected
94
- * and installed MCP servers merged in.
95
- * Each session gets its own copy to avoid race conditions when multiple concurrent
96
- * Claude sessions share the same cwd. The session-specific config is passed to
97
- * Claude CLI via --mcp-config, so the shared .mcp.json is never modified.
98
- * Returns the path to the per-session config, or null if no config exists.
94
+ * and installed MCP servers merged in. Each session gets its own copy at
95
+ * `/tmp/mcp-<taskId>.json`, passed to Claude via `--mcp-config`, so the shared
96
+ * `.mcp.json` is never modified. Returns the path, or null if there's nothing
97
+ * to write.
98
+ *
99
+ * Exported for unit testing.
99
100
  */
100
- async function createSessionMcpConfig(
101
+ export async function createSessionMcpConfig(
101
102
  cwd: string,
102
103
  taskId: string,
103
104
  installedServers?: Record<string, Record<string, unknown>> | null,
104
105
  ): Promise<string | null> {
105
- // Walk up from cwd to find .mcp.json (mirrors Claude CLI's project-level config discovery).
106
- // In Docker, .mcp.json lives at /workspace/.mcp.json but tasks often run with cwd set to
107
- // a subdirectory like /workspace/repos/<repo>, so a single-directory check misses it.
106
+ // Collect every .mcp.json from cwd up to filesystem root. Stopping at the first
107
+ // match silently drops the swarm-managed /workspace/.mcp.json when the cloned
108
+ // repo ships its own .mcp.json (e.g. Datadog) — so we merge all layers, with
109
+ // rootmost winning on key conflicts.
110
+ const mcpJsonPaths: string[] = [];
108
111
  let searchDir = cwd;
109
- let mcpJsonPath: string | null = null;
110
112
  while (true) {
111
113
  const candidate = join(searchDir, ".mcp.json");
112
114
  if (await Bun.file(candidate).exists()) {
113
- mcpJsonPath = candidate;
114
- break;
115
+ mcpJsonPaths.push(candidate);
115
116
  }
116
117
  const parent = dirname(searchDir);
117
- if (parent === searchDir) break; // reached filesystem root
118
+ if (parent === searchDir) break;
118
119
  searchDir = parent;
119
120
  }
120
121
 
121
- if (!mcpJsonPath && !installedServers) return null;
122
+ if (mcpJsonPaths.length === 0 && !installedServers) return null;
122
123
 
123
- try {
124
- let baseConfig: { mcpServers?: Record<string, unknown> } = { mcpServers: {} };
125
- if (mcpJsonPath) {
126
- const file = Bun.file(mcpJsonPath);
127
- baseConfig = await file.json();
124
+ // Merge deepest → rootmost so rootmost (swarm) overrides cwd-ward layers.
125
+ const mergedServers: Record<string, unknown> = {};
126
+ for (const path of mcpJsonPaths) {
127
+ try {
128
+ const layer = (await Bun.file(path).json()) as { mcpServers?: Record<string, unknown> };
129
+ if (layer?.mcpServers) Object.assign(mergedServers, layer.mcpServers);
130
+ } catch (err) {
131
+ console.warn(`\x1b[33m[claude]\x1b[0m Skipping malformed ${path}: ${err}`);
128
132
  }
129
- if (!baseConfig?.mcpServers && !installedServers) return null;
133
+ }
130
134
 
131
- const config = mergeMcpConfig(baseConfig, installedServers ?? null, taskId);
135
+ if (Object.keys(mergedServers).length === 0 && !installedServers) return null;
132
136
 
133
- // Write per-session config to /tmp — no race, each session has its own file
137
+ try {
138
+ const config = mergeMcpConfig({ mcpServers: mergedServers }, installedServers ?? null, taskId);
134
139
  const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
135
140
  await writeFile(sessionConfigPath, JSON.stringify(config, null, 2));
136
141
  return sessionConfigPath;
137
142
  } catch (err) {
138
- // Non-fatal — if creation fails, sourceTaskId won't be sent and Slack metadata won't auto-inherit
139
143
  console.warn(`\x1b[33m[claude]\x1b[0m Failed to create session MCP config: ${err}`);
140
144
  return null;
141
145
  }
@@ -1,5 +1,8 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { ClaudeAdapter, mergeMcpConfig } from "../providers/claude-adapter";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ClaudeAdapter, createSessionMcpConfig, mergeMcpConfig } from "../providers/claude-adapter";
3
6
  import type { ProviderSessionConfig } from "../providers/types";
4
7
 
5
8
  /** Minimal config for testing — sessions won't actually spawn in these unit tests */
@@ -245,6 +248,162 @@ describe("mergeMcpConfig (issue #369)", () => {
245
248
  });
246
249
  });
247
250
 
251
+ describe("createSessionMcpConfig", () => {
252
+ let sandbox: string;
253
+
254
+ beforeEach(async () => {
255
+ sandbox = await mkdtemp(join(tmpdir(), "mcp-cfg-test-"));
256
+ });
257
+
258
+ afterEach(async () => {
259
+ await rm(sandbox, { recursive: true, force: true });
260
+ });
261
+
262
+ async function readWritten(path: string) {
263
+ return JSON.parse(await Bun.file(path).text()) as {
264
+ mcpServers: Record<string, Record<string, unknown>>;
265
+ };
266
+ }
267
+
268
+ test("returns null when no .mcp.json found and no installed servers", async () => {
269
+ const cwd = join(sandbox, "empty");
270
+ await mkdir(cwd, { recursive: true });
271
+ const path = await createSessionMcpConfig(cwd, "task-empty");
272
+ expect(path).toBeNull();
273
+ });
274
+
275
+ test("ancestor-only .mcp.json is found via walk-up (Docker layout)", async () => {
276
+ await writeFile(
277
+ join(sandbox, ".mcp.json"),
278
+ JSON.stringify({
279
+ mcpServers: {
280
+ "agent-swarm": {
281
+ type: "http",
282
+ url: "http://swarm/mcp",
283
+ headers: { Authorization: "Bearer SWARM", "X-Agent-ID": "a1" },
284
+ },
285
+ },
286
+ }),
287
+ );
288
+ const cwd = join(sandbox, "repos", "foo");
289
+ await mkdir(cwd, { recursive: true });
290
+
291
+ const path = await createSessionMcpConfig(cwd, "task-anc");
292
+ expect(path).toBe("/tmp/mcp-task-anc.json");
293
+ const written = await readWritten(path!);
294
+ expect(written.mcpServers["agent-swarm"]).toBeDefined();
295
+ expect(
296
+ (written.mcpServers["agent-swarm"].headers as Record<string, string>)["X-Source-Task-Id"],
297
+ ).toBe("task-anc");
298
+ });
299
+
300
+ test("merges repo-local + ancestor when server names differ", async () => {
301
+ await writeFile(
302
+ join(sandbox, ".mcp.json"),
303
+ JSON.stringify({
304
+ mcpServers: {
305
+ "agent-swarm": {
306
+ type: "http",
307
+ url: "http://swarm/mcp",
308
+ headers: { Authorization: "Bearer SWARM", "X-Agent-ID": "a1" },
309
+ },
310
+ },
311
+ }),
312
+ );
313
+ const repo = join(sandbox, "repos", "client-monorepo");
314
+ await mkdir(repo, { recursive: true });
315
+ await writeFile(
316
+ join(repo, ".mcp.json"),
317
+ JSON.stringify({
318
+ mcpServers: { Datadog: { command: "npx", args: ["-y", "@winor30/mcp-server-datadog"] } },
319
+ }),
320
+ );
321
+
322
+ const path = await createSessionMcpConfig(repo, "task-merge");
323
+ const written = await readWritten(path!);
324
+ expect(written.mcpServers["agent-swarm"]).toBeDefined();
325
+ expect(written.mcpServers.Datadog).toBeDefined();
326
+ expect(Object.keys(written.mcpServers).sort()).toEqual(["Datadog", "agent-swarm"]);
327
+ });
328
+
329
+ test("ancestor wins over repo-local on agent-swarm key conflict", async () => {
330
+ await writeFile(
331
+ join(sandbox, ".mcp.json"),
332
+ JSON.stringify({
333
+ mcpServers: {
334
+ "agent-swarm": {
335
+ type: "http",
336
+ url: "http://swarm/mcp",
337
+ headers: { Authorization: "Bearer SWARM", "X-Agent-ID": "a1" },
338
+ },
339
+ },
340
+ }),
341
+ );
342
+ const repo = join(sandbox, "repos", "foo");
343
+ await mkdir(repo, { recursive: true });
344
+ await writeFile(
345
+ join(repo, ".mcp.json"),
346
+ JSON.stringify({
347
+ mcpServers: {
348
+ "agent-swarm": {
349
+ type: "http",
350
+ url: "http://stale/mcp",
351
+ headers: { Authorization: "Bearer STALE", "X-Agent-ID": "stale-agent" },
352
+ },
353
+ },
354
+ }),
355
+ );
356
+
357
+ const path = await createSessionMcpConfig(repo, "task-conflict");
358
+ const written = await readWritten(path!);
359
+ const swarm = written.mcpServers["agent-swarm"] as Record<string, unknown>;
360
+ const headers = swarm.headers as Record<string, string>;
361
+ expect(swarm.url).toBe("http://swarm/mcp");
362
+ expect(headers.Authorization).toBe("Bearer SWARM");
363
+ expect(headers["X-Agent-ID"]).toBe("a1");
364
+ expect(headers["X-Source-Task-Id"]).toBe("task-conflict");
365
+ });
366
+
367
+ test("malformed repo-local .mcp.json is skipped without poisoning ancestor entries", async () => {
368
+ await writeFile(
369
+ join(sandbox, ".mcp.json"),
370
+ JSON.stringify({
371
+ mcpServers: {
372
+ "agent-swarm": {
373
+ type: "http",
374
+ url: "http://swarm/mcp",
375
+ headers: { "X-Agent-ID": "a1" },
376
+ },
377
+ },
378
+ }),
379
+ );
380
+ const repo = join(sandbox, "repos", "foo");
381
+ await mkdir(repo, { recursive: true });
382
+ await writeFile(join(repo, ".mcp.json"), "{ this is not valid json");
383
+
384
+ const path = await createSessionMcpConfig(repo, "task-malformed");
385
+ expect(path).not.toBeNull();
386
+ const written = await readWritten(path!);
387
+ expect(written.mcpServers["agent-swarm"]).toBeDefined();
388
+ });
389
+
390
+ test("only installedServers, no .mcp.json on disk", async () => {
391
+ const cwd = join(sandbox, "no-mcp");
392
+ await mkdir(cwd, { recursive: true });
393
+
394
+ const path = await createSessionMcpConfig(cwd, "task-installed", {
395
+ "from-api": {
396
+ type: "http",
397
+ url: "http://api.test/mcp",
398
+ headers: { Authorization: "Bearer API" },
399
+ },
400
+ });
401
+ expect(path).toBe("/tmp/mcp-task-installed.json");
402
+ const written = await readWritten(path!);
403
+ expect(written.mcpServers["from-api"]).toBeDefined();
404
+ });
405
+ });
406
+
248
407
  describe("Stale session retry logic", () => {
249
408
  test("--resume args are stripped correctly", () => {
250
409
  const args = ["--max-turns", "10", "--resume", "session-abc", "--verbose"];