@desplega.ai/agent-swarm 1.69.1 → 1.70.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.
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.69.1",
5
+ "version": "1.70.0",
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": [
@@ -4855,6 +4855,64 @@
4855
4855
  }
4856
4856
  }
4857
4857
  },
4858
+ "/api/mcp-oauth/{mcpServerId}/authorize-url": {
4859
+ "get": {
4860
+ "summary": "Build an OAuth authorize URL. Returns JSON so the browser can navigate without losing the Bearer auth header.",
4861
+ "tags": [
4862
+ "MCP OAuth"
4863
+ ],
4864
+ "security": [
4865
+ {
4866
+ "bearerAuth": []
4867
+ }
4868
+ ],
4869
+ "parameters": [
4870
+ {
4871
+ "schema": {
4872
+ "type": "string"
4873
+ },
4874
+ "required": true,
4875
+ "name": "mcpServerId",
4876
+ "in": "path"
4877
+ },
4878
+ {
4879
+ "schema": {
4880
+ "type": "string"
4881
+ },
4882
+ "required": false,
4883
+ "name": "redirect",
4884
+ "in": "query"
4885
+ },
4886
+ {
4887
+ "schema": {
4888
+ "type": "string"
4889
+ },
4890
+ "required": false,
4891
+ "name": "userId",
4892
+ "in": "query"
4893
+ },
4894
+ {
4895
+ "schema": {
4896
+ "type": "string"
4897
+ },
4898
+ "required": false,
4899
+ "name": "scopes",
4900
+ "in": "query"
4901
+ }
4902
+ ],
4903
+ "responses": {
4904
+ "200": {
4905
+ "description": "{ providerUrl: string }"
4906
+ },
4907
+ "400": {
4908
+ "description": "MCP has no URL / does not require OAuth"
4909
+ },
4910
+ "404": {
4911
+ "description": "MCP server not found"
4912
+ }
4913
+ }
4914
+ }
4915
+ },
4858
4916
  "/api/mcp-oauth/callback": {
4859
4917
  "get": {
4860
4918
  "summary": "OAuth redirect target. Exchanges code -> tokens and redirects back to dashboard.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.69.1",
3
+ "version": "1.70.0",
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>",
@@ -272,6 +272,7 @@ const SWARM_TOOL_LABELS: Record<string, string | null> = {
272
272
  "update-profile": "🪪 Updating profile",
273
273
  // Slack
274
274
  "slack-post": "💬 Posting to Slack",
275
+ "slack-start-thread": "💬 Starting Slack thread",
275
276
  "slack-reply": "💬 Replying in Slack",
276
277
  "slack-read": "💬 Reading Slack",
277
278
  "slack-list-channels": "💬 Listing Slack channels",
package/src/hooks/hook.ts CHANGED
@@ -355,8 +355,10 @@ export async function handleHook(): Promise<void> {
355
355
  }
356
356
  };
357
357
 
358
- // Minimum length for SOUL.md and IDENTITY.md to prevent accidental corruption
359
- const IDENTITY_FILE_MIN_LENGTH = 100;
358
+ // Minimum length for SOUL.md and IDENTITY.md to prevent accidental corruption.
359
+ // Raised from 100 to 500 after Picateclas profile corruption recurrences where
360
+ // a 234-char test sentinel payload was syncing into the real agent's DB row.
361
+ const IDENTITY_FILE_MIN_LENGTH = 500;
360
362
 
361
363
  /**
362
364
  * Sync SOUL.md and IDENTITY.md content back to the server
package/src/http/core.ts CHANGED
@@ -16,7 +16,27 @@ import { startSlackApp, stopSlackApp } from "../slack";
16
16
  import type { AgentStatus } from "../types";
17
17
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
18
18
  import { generateOpenApiSpec, SCALAR_HTML } from "./openapi";
19
- import { agentWithCapacity, parseQueryParams } from "./utils";
19
+ import { routeRegistry } from "./route-def";
20
+ import { agentWithCapacity, getPathSegments, matchRoute, parseQueryParams } from "./utils";
21
+
22
+ /**
23
+ * Check whether a request targets a route declared (via the `route()` factory)
24
+ * with `auth: { apiKey: false }` — i.e. one that opts out of the API-key
25
+ * bearer check. Handler files must use the `route()` factory for this to take
26
+ * effect; unknown paths fail closed (auth required).
27
+ */
28
+ function isPublicRoute(method: string | undefined, pathSegments: string[]): boolean {
29
+ for (const def of routeRegistry) {
30
+ if (def.auth?.apiKey === false) {
31
+ if (
32
+ matchRoute(method, pathSegments, def.method.toUpperCase(), def.pattern, def.exact ?? true)
33
+ ) {
34
+ return true;
35
+ }
36
+ }
37
+ }
38
+ return false;
39
+ }
20
40
 
21
41
  /**
22
42
  * Load global swarm_config entries into process.env.
@@ -121,31 +141,21 @@ export async function handleCore(
121
141
  return true;
122
142
  }
123
143
 
124
- // API key authentication (if API_KEY is configured)
125
- // Skip auth for webhooks (they have their own signature verification)
126
- const isGitHubWebhook = req.url?.startsWith("/api/github/webhook");
127
- const isGitLabWebhook = req.url?.startsWith("/api/gitlab/webhook");
128
- const isAgentMailWebhook = req.url?.startsWith("/api/agentmail/webhook");
129
- const isTrackerAuth =
130
- req.url?.startsWith("/api/trackers/linear/authorize") ||
131
- req.url?.startsWith("/api/trackers/linear/callback") ||
132
- req.url?.startsWith("/api/trackers/linear/webhook");
133
- const isWorkflowWebhook = req.url?.startsWith("/api/webhooks/");
134
- if (
135
- apiKey &&
136
- !isGitHubWebhook &&
137
- !isGitLabWebhook &&
138
- !isAgentMailWebhook &&
139
- !isTrackerAuth &&
140
- !isWorkflowWebhook
141
- ) {
142
- const authHeader = req.headers.authorization;
143
- const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
144
-
145
- if (providedKey !== apiKey) {
146
- res.writeHead(401, { "Content-Type": "application/json" });
147
- res.end(JSON.stringify({ error: "Unauthorized" }));
148
- return true;
144
+ // API-key authentication (if API_KEY is configured). Routes that opt out via
145
+ // `route({ auth: { apiKey: false } })` webhooks, OAuth provider callbacks,
146
+ // etc. are skipped based on the central `routeRegistry`. Unknown paths
147
+ // fall through to the bearer check (fail-closed).
148
+ if (apiKey) {
149
+ const pathSegments = getPathSegments(req.url || "");
150
+ if (!isPublicRoute(req.method, pathSegments)) {
151
+ const authHeader = req.headers.authorization;
152
+ const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
153
+
154
+ if (providedKey !== apiKey) {
155
+ res.writeHead(401, { "Content-Type": "application/json" });
156
+ res.end(JSON.stringify({ error: "Unauthorized" }));
157
+ return true;
158
+ }
149
159
  }
150
160
  }
151
161
 
@@ -158,6 +158,27 @@ const authorizeRoute = route({
158
158
  },
159
159
  });
160
160
 
161
+ const authorizeUrlRoute = route({
162
+ method: "get",
163
+ path: "/api/mcp-oauth/{mcpServerId}/authorize-url",
164
+ pattern: ["api", "mcp-oauth", null, "authorize-url"],
165
+ summary:
166
+ "Build an OAuth authorize URL. Returns JSON so the browser can navigate without losing the Bearer auth header.",
167
+ tags: ["MCP OAuth"],
168
+ auth: { apiKey: true },
169
+ params: z.object({ mcpServerId: z.string() }),
170
+ query: z.object({
171
+ redirect: z.string().optional(),
172
+ userId: z.string().optional(),
173
+ scopes: z.string().optional(),
174
+ }),
175
+ responses: {
176
+ 200: { description: "{ providerUrl: string }" },
177
+ 400: { description: "MCP has no URL / does not require OAuth" },
178
+ 404: { description: "MCP server not found" },
179
+ },
180
+ });
181
+
161
182
  const callbackRoute = route({
162
183
  method: "get",
163
184
  path: "/api/mcp-oauth/callback",
@@ -236,6 +257,86 @@ const manualClientRoute = route({
236
257
  },
237
258
  });
238
259
 
260
+ // ─── Shared authorize flow ───────────────────────────────────────────────────
261
+
262
+ interface AuthorizeFlowQuery {
263
+ redirect?: string;
264
+ userId?: string;
265
+ scopes?: string;
266
+ }
267
+
268
+ /**
269
+ * Discover metadata, DCR-register (or fail), build the authorize URL, and
270
+ * persist the pending session. Returns the provider `providerUrl` the caller
271
+ * should redirect to / respond with. On failure, writes a JSON error response
272
+ * and returns null.
273
+ */
274
+ async function prepareAuthorizeFlow(
275
+ res: ServerResponse,
276
+ mcpServerId: string,
277
+ server: NonNullable<ReturnType<typeof getMcpServerById>>,
278
+ q: AuthorizeFlowQuery,
279
+ ): Promise<string | null> {
280
+ const discovery = await discoverForMcp(server.url!);
281
+ if (!discovery) {
282
+ jsonError(res, "MCP server does not require OAuth", 400);
283
+ return null;
284
+ }
285
+
286
+ let clientId: string | null = null;
287
+ let clientSecret: string | null = null;
288
+ if (discovery.dcrSupported && discovery.registrationEndpoint) {
289
+ const dcr = await registerClient(discovery.registrationEndpoint, {
290
+ client_name: `agent-swarm (${server.name})`,
291
+ redirect_uris: [callbackRedirectUri()],
292
+ grant_types: ["authorization_code", "refresh_token"],
293
+ response_types: ["code"],
294
+ token_endpoint_auth_method: "client_secret_basic",
295
+ application_type: "web",
296
+ scope: (q.scopes ?? discovery.scopes.join(" ")) || undefined,
297
+ });
298
+ clientId = dcr.client_id;
299
+ clientSecret = dcr.client_secret ?? null;
300
+ } else {
301
+ jsonError(
302
+ res,
303
+ "DCR not supported — paste client_id/client_secret via POST /api/mcp-oauth/:id/manual-client first.",
304
+ 400,
305
+ );
306
+ return null;
307
+ }
308
+
309
+ const scopes = q.scopes ? q.scopes.split(" ").filter(Boolean) : discovery.scopes;
310
+
311
+ const built = await buildAuthorizeUrl({
312
+ authorizeUrl: discovery.authorizeUrl,
313
+ tokenUrl: discovery.tokenUrl,
314
+ clientId: clientId!,
315
+ redirectUri: callbackRedirectUri(),
316
+ scopes,
317
+ resource: discovery.resourceUrl,
318
+ });
319
+
320
+ insertMcpOAuthPending({
321
+ state: built.state,
322
+ mcpServerId,
323
+ userId: q.userId ?? null,
324
+ codeVerifier: built.codeVerifier,
325
+ resourceUrl: discovery.resourceUrl,
326
+ authorizationServerIssuer: discovery.authorizationServerIssuer,
327
+ authorizeUrl: discovery.authorizeUrl,
328
+ tokenUrl: discovery.tokenUrl,
329
+ revocationUrl: discovery.revocationUrl,
330
+ scopes: scopes.join(" "),
331
+ dcrClientId: clientId!,
332
+ dcrClientSecret: clientSecret,
333
+ redirectUri: callbackRedirectUri(),
334
+ finalRedirect: q.redirect ?? null,
335
+ });
336
+
337
+ return built.url;
338
+ }
339
+
239
340
  // ─── Handler ─────────────────────────────────────────────────────────────────
240
341
 
241
342
  export async function handleMcpOAuth(
@@ -398,69 +499,40 @@ export async function handleMcpOAuth(
398
499
  if (!server) return true;
399
500
 
400
501
  try {
401
- const discovery = await discoverForMcp(server.url!);
402
- if (!discovery) {
403
- jsonError(res, "MCP server does not require OAuth", 400);
404
- return true;
405
- }
406
-
407
- // Dynamic Client Registration if supported, otherwise expect the user to
408
- // have already called /manual-client.
409
- let clientId: string | null = null;
410
- let clientSecret: string | null = null;
411
- if (discovery.dcrSupported && discovery.registrationEndpoint) {
412
- const dcr = await registerClient(discovery.registrationEndpoint, {
413
- client_name: `agent-swarm (${server.name})`,
414
- redirect_uris: [callbackRedirectUri()],
415
- grant_types: ["authorization_code", "refresh_token"],
416
- response_types: ["code"],
417
- token_endpoint_auth_method: "client_secret_basic",
418
- application_type: "web",
419
- scope: (parsed.query.scopes ?? discovery.scopes.join(" ")) || undefined,
420
- });
421
- clientId = dcr.client_id;
422
- clientSecret = dcr.client_secret ?? null;
423
- } else {
424
- jsonError(
425
- res,
426
- "DCR not supported — paste client_id/client_secret via POST /api/mcp-oauth/:id/manual-client first.",
427
- 400,
428
- );
429
- return true;
430
- }
502
+ const providerUrl = await prepareAuthorizeFlow(
503
+ res,
504
+ parsed.params.mcpServerId,
505
+ server,
506
+ parsed.query,
507
+ );
508
+ if (!providerUrl) return true;
509
+ res.writeHead(302, { Location: providerUrl });
510
+ res.end();
511
+ } catch (err) {
512
+ const message = err instanceof Error ? err.message : String(err);
513
+ jsonError(res, `Authorize failed: ${message}`, 502);
514
+ }
515
+ return true;
516
+ }
431
517
 
432
- const scopes = parsed.query.scopes
433
- ? parsed.query.scopes.split(" ").filter(Boolean)
434
- : discovery.scopes;
435
-
436
- const built = await buildAuthorizeUrl({
437
- authorizeUrl: discovery.authorizeUrl,
438
- tokenUrl: discovery.tokenUrl,
439
- clientId: clientId!,
440
- redirectUri: callbackRedirectUri(),
441
- scopes,
442
- resource: discovery.resourceUrl,
443
- });
518
+ // GET /api/mcp-oauth/:id/authorize-url — JSON variant of /authorize so the
519
+ // dashboard can fetch the provider URL with Bearer auth and then navigate.
520
+ if (authorizeUrlRoute.match(req.method, pathSegments)) {
521
+ const parsed = await authorizeUrlRoute.parse(req, res, pathSegments, queryParams);
522
+ if (!parsed) return true;
444
523
 
445
- insertMcpOAuthPending({
446
- state: built.state,
447
- mcpServerId: parsed.params.mcpServerId,
448
- userId: parsed.query.userId ?? null,
449
- codeVerifier: built.codeVerifier,
450
- resourceUrl: discovery.resourceUrl,
451
- authorizationServerIssuer: discovery.authorizationServerIssuer,
452
- authorizeUrl: discovery.authorizeUrl,
453
- tokenUrl: discovery.tokenUrl,
454
- revocationUrl: discovery.revocationUrl,
455
- scopes: scopes.join(" "),
456
- dcrClientId: clientId!,
457
- dcrClientSecret: clientSecret,
458
- redirectUri: callbackRedirectUri(),
459
- finalRedirect: parsed.query.redirect ?? null,
460
- });
524
+ const server = getMcpOrError(res, parsed.params.mcpServerId);
525
+ if (!server) return true;
461
526
 
462
- res.writeHead(302, { Location: built.url });
463
- res.end();
527
+ try {
528
+ const providerUrl = await prepareAuthorizeFlow(
529
+ res,
530
+ parsed.params.mcpServerId,
531
+ server,
532
+ parsed.query,
533
+ );
534
+ if (!providerUrl) return true;
535
+ json(res, { providerUrl });
464
536
  } catch (err) {
465
537
  const message = err instanceof Error ? err.message : String(err);
466
538
  jsonError(res, `Authorize failed: ${message}`, 502);
@@ -243,7 +243,11 @@ export async function handleMcpServers(
243
243
  try {
244
244
  const token = await ensureMcpToken(server.id);
245
245
  if (token && token.status === "connected") {
246
- const prefix = token.tokenType || "Bearer";
246
+ // Normalize the bearer scheme to capital "Bearer": some resource
247
+ // servers reject the lowercase "bearer" RFC 6749 returns (issue #368).
248
+ // Non-bearer schemes (e.g. "MAC") are preserved verbatim.
249
+ const rawType = token.tokenType || "Bearer";
250
+ const prefix = rawType.toLowerCase() === "bearer" ? "Bearer" : rawType;
247
251
  resolvedHeaders.Authorization = `${prefix} ${token.accessToken}`;
248
252
  } else if (!token) {
249
253
  authError = "No OAuth token for this MCP server";
@@ -106,6 +106,52 @@ async function fetchInstalledMcpServers(
106
106
  }
107
107
  }
108
108
 
109
+ /**
110
+ * Merge a base MCP config (typically read from `.mcp.json`) with freshly-resolved
111
+ * installed servers from the API, and inject the per-task `X-Source-Task-Id` header
112
+ * into the `agent-swarm` entry.
113
+ *
114
+ * Precedence: installed servers from the API WIN over entries already in `.mcp.json`.
115
+ * This guards against stale credentials from a `.mcp.json` that was written once at
116
+ * container startup and never refreshed (see issue #369). The per-session fetch
117
+ * carries current OAuth tokens / rotated secrets / up-to-date installs.
118
+ *
119
+ * Exported for unit testing.
120
+ */
121
+ export function mergeMcpConfig(
122
+ baseConfig: { mcpServers?: Record<string, unknown> } | null,
123
+ installedServers: Record<string, Record<string, unknown>> | null,
124
+ taskId: string,
125
+ ): { mcpServers: Record<string, unknown> } {
126
+ const config: { mcpServers: Record<string, unknown> } = {
127
+ mcpServers: { ...(baseConfig?.mcpServers ?? {}) },
128
+ };
129
+
130
+ // Installed servers from the API always win — fresh credentials replace stale ones.
131
+ if (installedServers) {
132
+ for (const [name, serverConfig] of Object.entries(installedServers)) {
133
+ config.mcpServers[name] = serverConfig;
134
+ }
135
+ }
136
+
137
+ // Find the agent-swarm server entry (could be named "agent-swarm" or similar)
138
+ const serverKey = Object.keys(config.mcpServers).find(
139
+ (k) =>
140
+ k === "agent-swarm" ||
141
+ ((config.mcpServers[k] as Record<string, unknown>)?.headers &&
142
+ ((config.mcpServers[k] as Record<string, Record<string, unknown>>).headers?.[
143
+ "X-Agent-ID"
144
+ ] as unknown)),
145
+ );
146
+ if (serverKey) {
147
+ const server = config.mcpServers[serverKey] as Record<string, unknown>;
148
+ if (!server.headers) server.headers = {};
149
+ (server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
150
+ }
151
+
152
+ return config;
153
+ }
154
+
109
155
  /**
110
156
  * Create a per-session MCP config file with X-Source-Task-Id header injected
111
157
  * and installed MCP servers merged in.
@@ -138,39 +184,14 @@ async function createSessionMcpConfig(
138
184
  if (!mcpJsonPath && !installedServers) return null;
139
185
 
140
186
  try {
141
- let config: { mcpServers?: Record<string, unknown> } = { mcpServers: {} };
187
+ let baseConfig: { mcpServers?: Record<string, unknown> } = { mcpServers: {} };
142
188
  if (mcpJsonPath) {
143
189
  const file = Bun.file(mcpJsonPath);
144
- config = await file.json();
145
- }
146
- const servers = config?.mcpServers;
147
- if (!servers && !installedServers) return null;
148
-
149
- if (!config.mcpServers) config.mcpServers = {};
150
-
151
- // Find the agent-swarm server entry (could be named "agent-swarm" or similar)
152
- const serverKey = Object.keys(config.mcpServers).find(
153
- (k) =>
154
- k === "agent-swarm" ||
155
- ((config.mcpServers![k] as Record<string, unknown>)?.headers &&
156
- ((config.mcpServers![k] as Record<string, Record<string, unknown>>).headers?.[
157
- "X-Agent-ID"
158
- ] as unknown)),
159
- );
160
- if (serverKey) {
161
- const server = config.mcpServers[serverKey] as Record<string, unknown>;
162
- if (!server.headers) server.headers = {};
163
- (server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
190
+ baseConfig = await file.json();
164
191
  }
192
+ if (!baseConfig?.mcpServers && !installedServers) return null;
165
193
 
166
- // Merge installed MCP servers (don't overwrite existing entries)
167
- if (installedServers) {
168
- for (const [name, serverConfig] of Object.entries(installedServers)) {
169
- if (!config.mcpServers[name]) {
170
- config.mcpServers[name] = serverConfig;
171
- }
172
- }
173
- }
194
+ const config = mergeMcpConfig(baseConfig, installedServers ?? null, taskId);
174
195
 
175
196
  // Write per-session config to /tmp — no race, each session has its own file
176
197
  const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
package/src/server.ts CHANGED
@@ -77,6 +77,7 @@ import { registerSlackListChannelsTool } from "./tools/slack-list-channels";
77
77
  import { registerSlackPostTool } from "./tools/slack-post";
78
78
  import { registerSlackReadTool } from "./tools/slack-read";
79
79
  import { registerSlackReplyTool } from "./tools/slack-reply";
80
+ import { registerSlackStartThreadTool } from "./tools/slack-start-thread";
80
81
  import { registerSlackUploadFileTool } from "./tools/slack-upload-file";
81
82
  import { registerStoreProgressTool } from "./tools/store-progress";
82
83
  // Swarm config tools
@@ -189,6 +190,7 @@ export function createServer() {
189
190
  registerSlackReplyTool(server);
190
191
  registerSlackReadTool(server);
191
192
  registerSlackPostTool(server);
193
+ registerSlackStartThreadTool(server);
192
194
  registerSlackListChannelsTool(server);
193
195
  registerSlackUploadFileTool(server);
194
196
  registerSlackDownloadFileTool(server);
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { ClaudeAdapter } from "../providers/claude-adapter";
2
+ import { ClaudeAdapter, mergeMcpConfig } from "../providers/claude-adapter";
3
3
  import type { ProviderSessionConfig } from "../providers/types";
4
4
 
5
5
  /** Minimal config for testing — sessions won't actually spawn in these unit tests */
@@ -103,6 +103,148 @@ describe("Claude stream-json event parsing", () => {
103
103
  });
104
104
  });
105
105
 
106
+ describe("mergeMcpConfig (issue #369)", () => {
107
+ const TASK_ID = "task-abc-123";
108
+
109
+ test("returns only installed servers when base config is null", () => {
110
+ const installed = {
111
+ "my-mcp": {
112
+ type: "http",
113
+ url: "https://example.com",
114
+ headers: { Authorization: "Bearer x" },
115
+ },
116
+ };
117
+ const merged = mergeMcpConfig(null, installed, TASK_ID);
118
+ expect(merged.mcpServers["my-mcp"]).toEqual(installed["my-mcp"]);
119
+ });
120
+
121
+ test("returns only base servers when installedServers is null", () => {
122
+ const base = {
123
+ mcpServers: {
124
+ "agent-swarm": {
125
+ type: "http",
126
+ url: "http://localhost:3013/mcp",
127
+ headers: { Authorization: "Bearer KEY", "X-Agent-ID": "a1" },
128
+ },
129
+ },
130
+ };
131
+ const merged = mergeMcpConfig(base, null, TASK_ID);
132
+ const agentSwarm = merged.mcpServers["agent-swarm"] as Record<string, unknown>;
133
+ expect(agentSwarm).toBeDefined();
134
+ // Agent-swarm entry is augmented with X-Source-Task-Id
135
+ expect((agentSwarm.headers as Record<string, string>)["X-Source-Task-Id"]).toBe(TASK_ID);
136
+ });
137
+
138
+ test("installed servers OVERRIDE stale .mcp.json entries (precedence fix)", () => {
139
+ // Simulates: /workspace/.mcp.json has an entry baked at container startup with
140
+ // a stale OAuth Bearer; the per-session fetch returns a freshly-resolved Bearer.
141
+ // The merged config MUST carry the fresh token — this is the core of issue #369.
142
+ const base = {
143
+ mcpServers: {
144
+ stripe: {
145
+ type: "http",
146
+ url: "https://mcp.stripe.com",
147
+ headers: { Authorization: "Bearer STALE_TOKEN_FROM_STARTUP" },
148
+ },
149
+ },
150
+ };
151
+ const installed = {
152
+ stripe: {
153
+ type: "http",
154
+ url: "https://mcp.stripe.com",
155
+ headers: { Authorization: "Bearer FRESH_TOKEN_FROM_API" },
156
+ },
157
+ };
158
+ const merged = mergeMcpConfig(base, installed, TASK_ID);
159
+ const stripe = merged.mcpServers.stripe as Record<string, unknown>;
160
+ expect((stripe.headers as Record<string, string>).Authorization).toBe(
161
+ "Bearer FRESH_TOKEN_FROM_API",
162
+ );
163
+ });
164
+
165
+ test("installed-server removal is honored (uninstall propagates)", () => {
166
+ // Previously, if .mcp.json had `stripe` baked in but the server was uninstalled
167
+ // from the API, the stale entry persisted. With the precedence fix + skeleton
168
+ // .mcp.json, a server absent from installedServers stays in the merged config
169
+ // ONLY if it's also in base (e.g., manually-added) — no API-layer override is
170
+ // issued. This test confirms we don't spontaneously delete base entries; the
171
+ // docker-entrypoint change (don't bake installed servers) is what prevents
172
+ // stale uninstalls from persisting.
173
+ const base = {
174
+ mcpServers: {
175
+ "manually-configured": { type: "http", url: "https://x.test" },
176
+ },
177
+ };
178
+ const installed = {}; // Empty — nothing installed via API
179
+ const merged = mergeMcpConfig(base, installed, TASK_ID);
180
+ expect(merged.mcpServers["manually-configured"]).toBeDefined();
181
+ });
182
+
183
+ test("agent-swarm server gets X-Source-Task-Id injected", () => {
184
+ const base = {
185
+ mcpServers: {
186
+ "agent-swarm": {
187
+ type: "http",
188
+ url: "http://localhost:3013/mcp",
189
+ headers: { Authorization: "Bearer KEY", "X-Agent-ID": "a1" },
190
+ },
191
+ },
192
+ };
193
+ const merged = mergeMcpConfig(base, null, TASK_ID);
194
+ const agentSwarm = merged.mcpServers["agent-swarm"] as Record<string, unknown>;
195
+ const headers = agentSwarm.headers as Record<string, string>;
196
+ expect(headers["X-Source-Task-Id"]).toBe(TASK_ID);
197
+ // Existing headers preserved
198
+ expect(headers.Authorization).toBe("Bearer KEY");
199
+ expect(headers["X-Agent-ID"]).toBe("a1");
200
+ });
201
+
202
+ test("X-Source-Task-Id injection works on entry discovered by X-Agent-ID header", () => {
203
+ // Discovery path for non-standard server names.
204
+ const base = {
205
+ mcpServers: {
206
+ "custom-name-swarm": {
207
+ type: "http",
208
+ url: "http://localhost:3013/mcp",
209
+ headers: { Authorization: "Bearer KEY", "X-Agent-ID": "a1" },
210
+ },
211
+ },
212
+ };
213
+ const merged = mergeMcpConfig(base, null, TASK_ID);
214
+ const entry = merged.mcpServers["custom-name-swarm"] as Record<string, unknown>;
215
+ expect((entry.headers as Record<string, string>)["X-Source-Task-Id"]).toBe(TASK_ID);
216
+ });
217
+
218
+ test("does not mutate the input baseConfig", () => {
219
+ const base = {
220
+ mcpServers: {
221
+ stripe: {
222
+ type: "http",
223
+ url: "https://mcp.stripe.com",
224
+ headers: { Authorization: "Bearer STALE" },
225
+ },
226
+ },
227
+ };
228
+ const installed = {
229
+ stripe: {
230
+ type: "http",
231
+ url: "https://mcp.stripe.com",
232
+ headers: { Authorization: "Bearer FRESH" },
233
+ },
234
+ };
235
+ mergeMcpConfig(base, installed, TASK_ID);
236
+ // Original object should be untouched
237
+ expect((base.mcpServers.stripe.headers as Record<string, string>).Authorization).toBe(
238
+ "Bearer STALE",
239
+ );
240
+ });
241
+
242
+ test("empty base + empty installed yields empty mcpServers", () => {
243
+ const merged = mergeMcpConfig({ mcpServers: {} }, {}, TASK_ID);
244
+ expect(Object.keys(merged.mcpServers)).toHaveLength(0);
245
+ });
246
+ });
247
+
106
248
  describe("Stale session retry logic", () => {
107
249
  test("--resume args are stripped correctly", () => {
108
250
  const args = ["--max-turns", "10", "--resume", "session-abc", "--verbose"];
@@ -0,0 +1,142 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import {
3
+ createServer as createHttpServer,
4
+ type IncomingMessage,
5
+ type Server,
6
+ type ServerResponse,
7
+ } from "node:http";
8
+ import { handleCore } from "../http/core";
9
+ // Importing the handlers here is load-bearing: each import populates
10
+ // `routeRegistry` as a side effect via the `route()` factory, which is what
11
+ // the auth middleware consults.
12
+ import "../http/webhooks";
13
+ import "../http/mcp-oauth";
14
+ import "../http/trackers/linear";
15
+ import "../http/workflows";
16
+
17
+ const API_KEY = "test-secret-key";
18
+
19
+ function createTestServer(apiKey: string): Server {
20
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
21
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
22
+ const handled = await handleCore(req, res, myAgentId, apiKey);
23
+ if (!handled) {
24
+ res.writeHead(200, { "Content-Type": "application/json" });
25
+ res.end(JSON.stringify({ passed: true }));
26
+ }
27
+ });
28
+ }
29
+
30
+ async function listen(server: Server): Promise<number> {
31
+ await new Promise<void>((resolve) => server.listen(0, resolve));
32
+ const addr = server.address();
33
+ if (!addr || typeof addr === "string") throw new Error("no port");
34
+ return addr.port;
35
+ }
36
+
37
+ describe("handleCore auth middleware (route() auth.apiKey=false is honored)", () => {
38
+ let server: Server;
39
+ let port: number;
40
+
41
+ beforeAll(async () => {
42
+ server = createTestServer(API_KEY);
43
+ port = await listen(server);
44
+ });
45
+
46
+ afterAll(() => {
47
+ server.close();
48
+ });
49
+
50
+ test("public route (auth:{apiKey:false}) passes without a Bearer", async () => {
51
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/callback`);
52
+ // The callback route is public — without a state param it returns 400
53
+ // from the handler, but it must NOT be 401 from the auth middleware.
54
+ expect(res.status).not.toBe(401);
55
+ });
56
+
57
+ test("authed route (no auth flag → default authed) returns 401 without Bearer", async () => {
58
+ // /api/mcp-oauth/<id>/status is declared with auth:{apiKey:true}
59
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`);
60
+ expect(res.status).toBe(401);
61
+ const body = await res.json();
62
+ expect(body.error).toBe("Unauthorized");
63
+ });
64
+
65
+ test("authed route passes with correct Bearer", async () => {
66
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`, {
67
+ headers: { Authorization: `Bearer ${API_KEY}` },
68
+ });
69
+ // Middleware passes (no 401). Downstream handler decides the final status.
70
+ expect(res.status).not.toBe(401);
71
+ });
72
+
73
+ test("authed route returns 401 with wrong Bearer", async () => {
74
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`, {
75
+ headers: { Authorization: "Bearer WRONG" },
76
+ });
77
+ expect(res.status).toBe(401);
78
+ });
79
+
80
+ test("GitHub webhook is still public (auth:{apiKey:false})", async () => {
81
+ const res = await fetch(`http://localhost:${port}/api/github/webhook`, { method: "POST" });
82
+ expect(res.status).not.toBe(401);
83
+ });
84
+
85
+ test("GitLab webhook is still public", async () => {
86
+ const res = await fetch(`http://localhost:${port}/api/gitlab/webhook`, { method: "POST" });
87
+ expect(res.status).not.toBe(401);
88
+ });
89
+
90
+ test("AgentMail webhook is still public", async () => {
91
+ const res = await fetch(`http://localhost:${port}/api/agentmail/webhook`, { method: "POST" });
92
+ expect(res.status).not.toBe(401);
93
+ });
94
+
95
+ test("Linear webhook/authorize/callback are still public", async () => {
96
+ for (const path of [
97
+ "/api/trackers/linear/authorize",
98
+ "/api/trackers/linear/callback",
99
+ "/api/trackers/linear/webhook",
100
+ ]) {
101
+ const method = path.endsWith("/webhook") ? "POST" : "GET";
102
+ const res = await fetch(`http://localhost:${port}${path}`, { method });
103
+ expect(res.status).not.toBe(401);
104
+ }
105
+ });
106
+
107
+ test("Workflow webhook trigger is still public", async () => {
108
+ const res = await fetch(`http://localhost:${port}/api/webhooks/some-workflow-id`, {
109
+ method: "POST",
110
+ });
111
+ expect(res.status).not.toBe(401);
112
+ });
113
+
114
+ test("unknown /api/* path fails closed (401 without Bearer)", async () => {
115
+ const res = await fetch(`http://localhost:${port}/api/does-not-exist/xyz`);
116
+ expect(res.status).toBe(401);
117
+ });
118
+
119
+ test("/health is always public", async () => {
120
+ const res = await fetch(`http://localhost:${port}/health`);
121
+ expect(res.status).toBe(200);
122
+ });
123
+ });
124
+
125
+ describe("handleCore auth middleware (no API_KEY configured)", () => {
126
+ let server: Server;
127
+ let port: number;
128
+
129
+ beforeAll(async () => {
130
+ server = createTestServer(""); // empty == auth disabled
131
+ port = await listen(server);
132
+ });
133
+
134
+ afterAll(() => {
135
+ server.close();
136
+ });
137
+
138
+ test("authed routes pass without Bearer when API_KEY is empty", async () => {
139
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`);
140
+ expect(res.status).not.toBe(401);
141
+ });
142
+ });
@@ -101,6 +101,85 @@ describe("resolveSecrets integration — OAuth Authorization injection", () => {
101
101
  expect(match!.authError).toBeNull();
102
102
  });
103
103
 
104
+ test("lowercase 'bearer' tokenType is normalized to capital 'Bearer' in Authorization header", async () => {
105
+ // Providers that follow RFC 6749 strictly (e.g. Amplitude's MCP) return
106
+ // `token_type: "bearer"`. Some resource servers then reject the lowercase
107
+ // prefix with 401. The fix in src/http/mcp-servers.ts normalizes the
108
+ // scheme to capital "Bearer". See GitHub issue #368.
109
+ const agent = createAgent({
110
+ id: crypto.randomUUID(),
111
+ name: "oauth-agent-lowercase",
112
+ status: "idle",
113
+ isLead: false,
114
+ });
115
+ const mcp = createMcpServer({
116
+ name: "mcp-oauth-lowercase-bearer",
117
+ transport: "http",
118
+ url: "https://mcp.example.com",
119
+ scope: "agent",
120
+ ownerAgentId: agent.id,
121
+ });
122
+ installMcpServer(agent.id, mcp.id);
123
+ setMcpServerAuthMethod(mcp.id, "oauth");
124
+ upsertMcpOAuthToken({
125
+ mcpServerId: mcp.id,
126
+ accessToken: "lowercase-token-xyz",
127
+ refreshToken: null,
128
+ tokenType: "bearer", // lowercase, as RFC 6749 prescribes
129
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
130
+ resourceUrl: "https://mcp.example.com/",
131
+ authorizationServerIssuer: "https://as.example.com",
132
+ authorizeUrl: "https://as.example.com/authorize",
133
+ tokenUrl: "https://as.example.com/token",
134
+ clientSource: "dcr",
135
+ status: "connected",
136
+ });
137
+
138
+ const result = await agentMcpServers(agent.id);
139
+ const match = result.servers.find((s) => s.id === mcp.id);
140
+ expect(match).toBeTruthy();
141
+ expect(match!.resolvedHeaders?.Authorization).toBe("Bearer lowercase-token-xyz");
142
+ expect(match!.resolvedHeaders?.Authorization?.startsWith("Bearer ")).toBe(true);
143
+ });
144
+
145
+ test("non-bearer tokenType (e.g. 'MAC') is preserved verbatim in Authorization header", async () => {
146
+ // RFC 6749 allows non-bearer token types. The normalization must only
147
+ // touch the bearer scheme and leave others alone.
148
+ const agent = createAgent({
149
+ id: crypto.randomUUID(),
150
+ name: "oauth-agent-mac",
151
+ status: "idle",
152
+ isLead: false,
153
+ });
154
+ const mcp = createMcpServer({
155
+ name: "mcp-oauth-mac",
156
+ transport: "http",
157
+ url: "https://mcp.example.com",
158
+ scope: "agent",
159
+ ownerAgentId: agent.id,
160
+ });
161
+ installMcpServer(agent.id, mcp.id);
162
+ setMcpServerAuthMethod(mcp.id, "oauth");
163
+ upsertMcpOAuthToken({
164
+ mcpServerId: mcp.id,
165
+ accessToken: "mac-token-xyz",
166
+ refreshToken: null,
167
+ tokenType: "MAC",
168
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
169
+ resourceUrl: "https://mcp.example.com/",
170
+ authorizationServerIssuer: "https://as.example.com",
171
+ authorizeUrl: "https://as.example.com/authorize",
172
+ tokenUrl: "https://as.example.com/token",
173
+ clientSource: "dcr",
174
+ status: "connected",
175
+ });
176
+
177
+ const result = await agentMcpServers(agent.id);
178
+ const match = result.servers.find((s) => s.id === mcp.id);
179
+ expect(match).toBeTruthy();
180
+ expect(match!.resolvedHeaders?.Authorization).toBe("MAC mac-token-xyz");
181
+ });
182
+
104
183
  test("OAuth server without token row surfaces authError", async () => {
105
184
  const agent = createAgent({
106
185
  id: crypto.randomUUID(),
@@ -159,6 +159,7 @@ describe("Tool Annotations & Classification", () => {
159
159
  "slack-reply",
160
160
  "slack-read",
161
161
  "slack-post",
162
+ "slack-start-thread",
162
163
  "slack-upload-file",
163
164
  "slack-download-file",
164
165
  "slack-list-channels",
@@ -9,14 +9,20 @@ export const registerSlackPostTool = (server: McpServer) => {
9
9
  createToolRegistrar(server)(
10
10
  "slack-post",
11
11
  {
12
- title: "Post new message to Slack channel",
12
+ title: "Post message to Slack channel",
13
13
  description:
14
- "Post a new message to a Slack channel. This creates a new message (not a thread reply). Requires lead privileges.",
14
+ "Post a message to a Slack channel. By default creates a new top-level message; pass `threadTs` to post as a threaded reply under an existing message (obtain the ts from `slack-start-thread`). Requires lead privileges.",
15
15
  annotations: { openWorldHint: true },
16
16
 
17
17
  inputSchema: z.object({
18
18
  channelId: z.string().min(1).describe("The Slack channel ID to post to."),
19
19
  message: z.string().min(1).max(4000).describe("The message content to post."),
20
+ threadTs: z
21
+ .string()
22
+ .optional()
23
+ .describe(
24
+ "Optional parent message ts to thread under. Obtain via `slack-start-thread`. When omitted, posts as a new top-level message.",
25
+ ),
20
26
  }),
21
27
  outputSchema: z.object({
22
28
  success: z.boolean(),
@@ -24,7 +30,7 @@ export const registerSlackPostTool = (server: McpServer) => {
24
30
  messageTs: z.string().optional(),
25
31
  }),
26
32
  },
27
- async ({ channelId, message }, requestInfo, _meta) => {
33
+ async ({ channelId, message, threadTs }, requestInfo, _meta) => {
28
34
  if (!requestInfo.agentId) {
29
35
  return {
30
36
  content: [{ type: "text", text: "Agent ID not found." }],
@@ -67,6 +73,7 @@ export const registerSlackPostTool = (server: McpServer) => {
67
73
  text: slackMessage, // Fallback for notifications
68
74
  username: agent.name,
69
75
  icon_emoji: ":crown:",
76
+ ...(threadTs ? { thread_ts: threadTs } : {}),
70
77
  blocks: [
71
78
  {
72
79
  type: "section",
@@ -0,0 +1,123 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { getAgentById } from "@/be/db";
4
+ import { getSlackApp } from "@/slack/app";
5
+ import { markdownToSlack } from "@/slack/responses";
6
+ import { createToolRegistrar } from "@/tools/utils";
7
+
8
+ export const registerSlackStartThreadTool = (server: McpServer) => {
9
+ createToolRegistrar(server)(
10
+ "slack-start-thread",
11
+ {
12
+ title: "Start a new Slack thread",
13
+ description:
14
+ "Post a new top-level message to a Slack channel and return its ts so the caller can thread replies under it. Pass the returned `ts` as `threadTs` on subsequent `slack-post` calls to keep replies in the same thread. Requires lead privileges.",
15
+ annotations: { openWorldHint: true },
16
+
17
+ inputSchema: z.object({
18
+ channelId: z.string().min(1).describe("The Slack channel ID to post to."),
19
+ message: z.string().min(1).max(4000).describe("The message content to post."),
20
+ }),
21
+ outputSchema: z.object({
22
+ success: z.boolean(),
23
+ message: z.string(),
24
+ channelId: z.string().optional(),
25
+ ts: z.string().optional(),
26
+ }),
27
+ },
28
+ async ({ channelId, message }, requestInfo, _meta) => {
29
+ if (!requestInfo.agentId) {
30
+ return {
31
+ content: [{ type: "text", text: "Agent ID not found." }],
32
+ structuredContent: { success: false, message: "Agent ID not found." },
33
+ };
34
+ }
35
+
36
+ const agent = getAgentById(requestInfo.agentId);
37
+ if (!agent) {
38
+ return {
39
+ content: [{ type: "text", text: "Agent not found." }],
40
+ structuredContent: { success: false, message: "Agent not found." },
41
+ };
42
+ }
43
+
44
+ if (!agent.isLead) {
45
+ return {
46
+ content: [{ type: "text", text: "Posting to Slack channels requires lead privileges." }],
47
+ structuredContent: {
48
+ success: false,
49
+ message: "Posting to Slack channels requires lead privileges.",
50
+ },
51
+ };
52
+ }
53
+
54
+ const app = getSlackApp();
55
+ if (!app) {
56
+ return {
57
+ content: [{ type: "text", text: "Slack not configured." }],
58
+ structuredContent: { success: false, message: "Slack not configured." },
59
+ };
60
+ }
61
+
62
+ try {
63
+ const slackMessage = markdownToSlack(message);
64
+
65
+ const result = await app.client.chat.postMessage({
66
+ channel: channelId,
67
+ text: slackMessage, // Fallback for notifications
68
+ username: agent.name,
69
+ icon_emoji: ":crown:",
70
+ blocks: [
71
+ {
72
+ type: "section",
73
+ text: {
74
+ type: "mrkdwn",
75
+ text: slackMessage,
76
+ },
77
+ },
78
+ ],
79
+ });
80
+
81
+ const ts = result.ts;
82
+ const resolvedChannelId = result.channel ?? channelId;
83
+
84
+ if (!ts) {
85
+ return {
86
+ content: [
87
+ {
88
+ type: "text",
89
+ text: "Message posted but Slack did not return a ts — cannot thread replies.",
90
+ },
91
+ ],
92
+ structuredContent: {
93
+ success: false,
94
+ message: "Message posted but Slack did not return a ts — cannot thread replies.",
95
+ channelId: resolvedChannelId,
96
+ },
97
+ };
98
+ }
99
+
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text",
104
+ text: `Thread started. channelId=${resolvedChannelId}, ts=${ts}. Pass ts as threadTs on slack-post to reply in-thread.`,
105
+ },
106
+ ],
107
+ structuredContent: {
108
+ success: true,
109
+ message: "Thread started successfully.",
110
+ channelId: resolvedChannelId,
111
+ ts,
112
+ },
113
+ };
114
+ } catch (error) {
115
+ const errorMsg = error instanceof Error ? error.message : String(error);
116
+ return {
117
+ content: [{ type: "text", text: `Failed to start thread: ${errorMsg}` }],
118
+ structuredContent: { success: false, message: `Failed to start thread: ${errorMsg}` },
119
+ };
120
+ }
121
+ },
122
+ );
123
+ };
@@ -80,13 +80,14 @@ export const DEFERRED_TOOLS = new Set([
80
80
  "context-history",
81
81
  "context-diff",
82
82
 
83
- // Slack (6)
83
+ // Slack (7)
84
84
  "slack-reply",
85
85
  "slack-read",
86
86
  "slack-upload-file",
87
87
  "slack-download-file",
88
88
  "slack-list-channels",
89
89
  "slack-post",
90
+ "slack-start-thread",
90
91
 
91
92
  // Channel management (2)
92
93
  "create-channel",
@@ -226,9 +226,12 @@ export const registerUpdateProfileTool = (server: McpServer) => {
226
226
  },
227
227
  );
228
228
 
229
- // Write updated files to workspace only when updating self
229
+ // Write updated files to workspace only when updating self AND the caller
230
+ // matches the real running agent (process.env.AGENT_ID). This guards against
231
+ // unit tests (with fake WORKER_IDs) accidentally overwriting the container's
232
+ // SOUL.md/IDENTITY.md when the test suite runs inside a real agent container.
230
233
  // (remote agent files live on their own container)
231
- if (isUpdatingSelf) {
234
+ if (isUpdatingSelf && requestInfo.agentId === process.env.AGENT_ID) {
232
235
  if (soulMd !== undefined) {
233
236
  try {
234
237
  await Bun.write("/workspace/SOUL.md", soulMd);