@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 +59 -1
- package/package.json +1 -1
- package/src/commands/runner.ts +1 -0
- package/src/hooks/hook.ts +4 -2
- package/src/http/core.ts +36 -26
- package/src/http/mcp-oauth.ts +132 -60
- package/src/http/mcp-servers.ts +5 -1
- package/src/providers/claude-adapter.ts +50 -29
- package/src/server.ts +2 -0
- package/src/tests/claude-adapter.test.ts +143 -1
- package/src/tests/core-auth.test.ts +142 -0
- package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
- package/src/tests/tool-annotations.test.ts +1 -0
- package/src/tools/slack-post.ts +10 -3
- package/src/tools/slack-start-thread.ts +123 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/update-profile.ts +5 -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.
|
|
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
package/src/commands/runner.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
req.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
package/src/http/mcp-oauth.ts
CHANGED
|
@@ -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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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);
|
package/src/http/mcp-servers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
187
|
+
let baseConfig: { mcpServers?: Record<string, unknown> } = { mcpServers: {} };
|
|
142
188
|
if (mcpJsonPath) {
|
|
143
189
|
const file = Bun.file(mcpJsonPath);
|
|
144
|
-
|
|
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
|
-
|
|
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(),
|
package/src/tools/slack-post.ts
CHANGED
|
@@ -9,14 +9,20 @@ export const registerSlackPostTool = (server: McpServer) => {
|
|
|
9
9
|
createToolRegistrar(server)(
|
|
10
10
|
"slack-post",
|
|
11
11
|
{
|
|
12
|
-
title: "Post
|
|
12
|
+
title: "Post message to Slack channel",
|
|
13
13
|
description:
|
|
14
|
-
"Post a
|
|
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
|
+
};
|
package/src/tools/tool-config.ts
CHANGED
|
@@ -80,13 +80,14 @@ export const DEFERRED_TOOLS = new Set([
|
|
|
80
80
|
"context-history",
|
|
81
81
|
"context-diff",
|
|
82
82
|
|
|
83
|
-
// Slack (
|
|
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);
|