@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 +1 -1
- package/package.json +1 -1
- package/src/providers/claude-adapter.ts +27 -23
- package/src/tests/claude-adapter.test.ts +161 -2
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.
|
|
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
|
@@ -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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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
|
-
|
|
114
|
-
break;
|
|
115
|
+
mcpJsonPaths.push(candidate);
|
|
115
116
|
}
|
|
116
117
|
const parent = dirname(searchDir);
|
|
117
|
-
if (parent === searchDir) break;
|
|
118
|
+
if (parent === searchDir) break;
|
|
118
119
|
searchDir = parent;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
if (
|
|
122
|
+
if (mcpJsonPaths.length === 0 && !installedServers) return null;
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
133
|
+
}
|
|
130
134
|
|
|
131
|
-
|
|
135
|
+
if (Object.keys(mergedServers).length === 0 && !installedServers) return null;
|
|
132
136
|
|
|
133
|
-
|
|
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 {
|
|
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"];
|