@desplega.ai/agent-swarm 1.90.0 → 1.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +803 -150
  3. package/package.json +5 -5
  4. package/src/artifact-sdk/server.ts +2 -1
  5. package/src/be/db.ts +337 -1
  6. package/src/be/memory/providers/sqlite-store.ts +6 -1
  7. package/src/be/memory/types.ts +1 -0
  8. package/src/be/migrations/083_script_workflows.sql +51 -0
  9. package/src/be/modelsdev-cache.json +42352 -38595
  10. package/src/be/scripts/typecheck.ts +181 -1
  11. package/src/be/seed-scripts/catalog/compound-insights.ts +398 -0
  12. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +911 -0
  13. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  14. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  15. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  16. package/src/be/seed-scripts/catalog/tool-usage.ts +59 -0
  17. package/src/be/seed-scripts/index.ts +54 -0
  18. package/src/be/seed-skills/index.ts +7 -0
  19. package/src/be/swarm-config-guard.ts +17 -0
  20. package/src/commands/artifact.ts +3 -2
  21. package/src/commands/profile-sync.ts +310 -0
  22. package/src/commands/runner.ts +134 -3
  23. package/src/hooks/hook.ts +32 -9
  24. package/src/http/db-query.ts +20 -5
  25. package/src/http/index.ts +57 -0
  26. package/src/http/integrations.ts +6 -1
  27. package/src/http/mcp-bridge.ts +117 -0
  28. package/src/http/mcp-oauth.ts +97 -39
  29. package/src/http/memory.ts +5 -2
  30. package/src/http/openapi.ts +2 -2
  31. package/src/http/pages-public.ts +10 -11
  32. package/src/http/pages.ts +7 -11
  33. package/src/http/script-runs.ts +555 -0
  34. package/src/http/scripts.ts +24 -1
  35. package/src/http/utils.ts +11 -4
  36. package/src/jira/app.ts +2 -3
  37. package/src/jira/webhook-lifecycle.ts +2 -1
  38. package/src/linear/app.ts +2 -3
  39. package/src/prompts/session-templates.ts +24 -4
  40. package/src/providers/claude-adapter.ts +86 -13
  41. package/src/script-workflows/executor.ts +110 -0
  42. package/src/script-workflows/harness.ts +73 -0
  43. package/src/script-workflows/label-lint.ts +51 -0
  44. package/src/script-workflows/limits.ts +22 -0
  45. package/src/script-workflows/supervisor.ts +139 -0
  46. package/src/script-workflows/workflow-ctx.ts +205 -0
  47. package/src/scripts-runtime/executors/native.ts +1 -0
  48. package/src/scripts-runtime/sdk-allowlist.ts +124 -0
  49. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  50. package/src/scripts-runtime/types/stdlib.d.ts +287 -0
  51. package/src/scripts-runtime/types/swarm-sdk.d.ts +287 -0
  52. package/src/server.ts +2 -0
  53. package/src/slack/handlers.ts +11 -4
  54. package/src/slack/message-text.ts +98 -0
  55. package/src/slack/thread-buffer.ts +5 -3
  56. package/src/tests/claude-adapter-binary.test.ts +147 -4
  57. package/src/tests/claude-adapter-otel.test.ts +85 -1
  58. package/src/tests/db-query.test.ts +28 -0
  59. package/src/tests/error-tracker.test.ts +121 -0
  60. package/src/tests/harness-provider-resolution.test.ts +33 -0
  61. package/src/tests/hook-registration-nudge.test.ts +69 -0
  62. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  63. package/src/tests/mcp-tools.test.ts +6 -0
  64. package/src/tests/pages-public-html.test.ts +41 -0
  65. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  66. package/src/tests/profile-sync.test.ts +282 -0
  67. package/src/tests/prompt-template-session.test.ts +34 -5
  68. package/src/tests/script-runs-http.test.ts +278 -0
  69. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  70. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  71. package/src/tests/scripts-mcp-e2e.test.ts +49 -2
  72. package/src/tests/scripts-runtime.test.ts +33 -0
  73. package/src/tests/seed-scripts.test.ts +347 -2
  74. package/src/tests/slack-message-text.test.ts +250 -0
  75. package/src/tests/system-default-skills.test.ts +40 -0
  76. package/src/tools/create-metric.ts +2 -3
  77. package/src/tools/create-page.ts +3 -6
  78. package/src/tools/db-query.ts +16 -6
  79. package/src/tools/memory-rate.ts +2 -1
  80. package/src/tools/memory-search.ts +1 -0
  81. package/src/tools/register-kapso-number.ts +2 -4
  82. package/src/tools/request-human-input.ts +2 -1
  83. package/src/tools/script-common.ts +2 -4
  84. package/src/tools/script-run.ts +7 -0
  85. package/src/tools/script-runs.ts +123 -0
  86. package/src/tools/slack-read.ts +12 -3
  87. package/src/tools/tool-config.ts +4 -1
  88. package/src/types.ts +52 -0
  89. package/src/utils/constants.ts +58 -8
  90. package/src/utils/error-tracker.ts +40 -1
  91. package/src/utils/internal-ai/complete-structured.ts +10 -4
  92. package/src/workflows/executors/raw-llm.ts +76 -59
  93. package/templates/skills/pages/content.md +205 -55
  94. package/templates/skills/script-workflows/config.json +14 -0
  95. package/templates/skills/script-workflows/content.md +68 -0
  96. package/templates/skills/swarm-scripts/content.md +45 -7
package/src/http/index.ts CHANGED
@@ -21,9 +21,11 @@ import {
21
21
  withRemoteContext,
22
22
  withSpanContext,
23
23
  } from "../otel";
24
+ import { startScriptRunSupervisor, stopScriptRunSupervisor } from "../script-workflows/supervisor";
24
25
  import { startSlackApp, stopSlackApp } from "../slack";
25
26
  import { initTelemetry, telemetry } from "../telemetry";
26
27
  import { getApiKey } from "../utils/api-key";
28
+ import { getMcpBaseUrl } from "../utils/constants";
27
29
  import { scrubSecrets } from "../utils/secret-scrubber";
28
30
  import { initWorkflows } from "../workflows";
29
31
  import { handleActiveSessions } from "./active-sessions";
@@ -42,6 +44,7 @@ import { handleInboxState } from "./inbox-state";
42
44
  import { handleIntegrations } from "./integrations";
43
45
  import { handleKv } from "./kv";
44
46
  import { handleMcp } from "./mcp";
47
+ import { handleMcpBridge } from "./mcp-bridge";
45
48
  import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
46
49
  import { handleMcpServers } from "./mcp-servers";
47
50
  import { handleMcpUser } from "./mcp-user";
@@ -56,6 +59,7 @@ import { handlePromptTemplates } from "./prompt-templates";
56
59
  import { handleRepos } from "./repos";
57
60
  import { describeRequestRoute } from "./route-def";
58
61
  import { handleSchedules } from "./schedules";
62
+ import { handleScriptRuns } from "./script-runs";
59
63
  import { handleScripts } from "./scripts";
60
64
  import { handleSessionData } from "./session-data";
61
65
  import { handleSessions } from "./sessions";
@@ -96,9 +100,47 @@ const globalState = globalThis as typeof globalThis & {
96
100
  __transportsUser?: Record<string, StreamableHTTPServerTransport>;
97
101
  __sessionUsers?: Record<string, string>;
98
102
  __sigintRegistered?: boolean;
103
+ __apiGcInterval?: ReturnType<typeof setInterval>;
99
104
  __runId?: string;
100
105
  };
101
106
 
107
+ const API_GC_INTERVAL_MS = 5 * 60 * 1000;
108
+
109
+ type GcCapableGlobal = typeof globalThis & { gc?: () => void };
110
+
111
+ function scheduleApiGc(reason: string): boolean {
112
+ const gc = (globalThis as GcCapableGlobal).gc;
113
+ if (typeof gc !== "function") return false;
114
+
115
+ const timer = setTimeout(() => {
116
+ const startedAt = Date.now();
117
+ try {
118
+ gc();
119
+ console.log(`[HTTP] Explicit GC completed after ${reason} in ${Date.now() - startedAt}ms`);
120
+ } catch (err) {
121
+ console.warn(`[HTTP] Explicit GC failed after ${reason}: ${err}`);
122
+ }
123
+ }, 0);
124
+ timer.unref?.();
125
+ return true;
126
+ }
127
+
128
+ function startApiGcInterval() {
129
+ if (globalState.__apiGcInterval) return;
130
+
131
+ const gc = (globalThis as GcCapableGlobal).gc;
132
+ if (typeof gc !== "function") {
133
+ console.log("[HTTP] Explicit GC unavailable; start API with --expose-gc to enable sweeps");
134
+ return;
135
+ }
136
+
137
+ const interval = setInterval(() => {
138
+ scheduleApiGc("periodic API sweep");
139
+ }, API_GC_INTERVAL_MS);
140
+ interval.unref?.();
141
+ globalState.__apiGcInterval = interval;
142
+ }
143
+
102
144
  // Clean up previous server on hot reload
103
145
  if (globalState.__httpServer) {
104
146
  console.log("[HTTP] Hot reload detected, closing previous server...");
@@ -233,7 +275,9 @@ const httpServer = createHttpServer(async (req, res) => {
233
275
  () => handleMetrics(req, res, pathSegments, queryParams, myAgentId),
234
276
  () => handleRepos(req, res, pathSegments, queryParams),
235
277
  () => handleSkills(req, res, pathSegments, queryParams, myAgentId),
278
+ () => handleScriptRuns(req, res, pathSegments, queryParams, myAgentId),
236
279
  () => handleScripts(req, res, pathSegments, queryParams, myAgentId),
280
+ () => handleMcpBridge(req, res, pathSegments, queryParams, myAgentId),
237
281
  () => handleMcpServers(req, res, pathSegments, queryParams),
238
282
  () => handleMcpOAuth(req, res, pathSegments, queryParams),
239
283
  () => handleMemory(req, res, pathSegments, myAgentId),
@@ -303,6 +347,9 @@ async function shutdown() {
303
347
  // Stop heartbeat triage
304
348
  stopHeartbeat();
305
349
 
350
+ // Stop durable script workflow subprocesses
351
+ stopScriptRunSupervisor();
352
+
306
353
  // Stop Slack bot
307
354
  await stopSlackApp();
308
355
 
@@ -315,6 +362,11 @@ async function shutdown() {
315
362
  // Stop MCP OAuth pending-session garbage collector
316
363
  stopMcpOAuthPendingGc();
317
364
 
365
+ if (globalState.__apiGcInterval) {
366
+ clearInterval(globalState.__apiGcInterval);
367
+ delete globalState.__apiGcInterval;
368
+ }
369
+
318
370
  // Close all active transports (SSE connections, etc.)
319
371
  for (const [id, transport] of Object.entries(transports)) {
320
372
  console.log(`[HTTP] Closing transport ${id}`);
@@ -349,6 +401,8 @@ if (!globalState.__runId) {
349
401
  globalState.__runId = `run_${Date.now()}`;
350
402
  }
351
403
 
404
+ startApiGcInterval();
405
+
352
406
  // Load global swarm configs before the server starts listening so decrypt/key
353
407
  // failures fail closed instead of leaving the runtime half-initialized.
354
408
  let startupConfigsInjected: string[] = [];
@@ -440,6 +494,9 @@ httpServer
440
494
  // Initialize workflow engine (trigger subscriptions + resume listener)
441
495
  initWorkflows();
442
496
 
497
+ // Reconcile durable script workflow subprocesses
498
+ startScriptRunSupervisor(getMcpBaseUrl());
499
+
443
500
  // Start scheduler (if enabled)
444
501
  if (hasCapability("scheduling")) {
445
502
  const { startScheduler } = await import("../scheduler");
@@ -88,7 +88,12 @@ function resolveConfigValue(key: string): string | null {
88
88
  }
89
89
 
90
90
  function resolveMcpBaseUrl(): string {
91
- const configured = resolveConfigValue("MCP_BASE_URL");
91
+ // Browser-facing connect URLs: prefer the public ingress origin
92
+ // (PUBLIC_MCP_BASE_URL) over the internal MCP_BASE_URL so split deployments
93
+ // (Helm) surface a host the user's browser can actually reach. Both honor the
94
+ // swarm_config → env resolution order. Falls back to the localhost dev base.
95
+ const configured =
96
+ resolveConfigValue("PUBLIC_MCP_BASE_URL") ?? resolveConfigValue("MCP_BASE_URL");
92
97
  const fallback = `http://localhost:${process.env.PORT || "3013"}`;
93
98
  return (configured || fallback).replace(/\/+$/, "");
94
99
  }
@@ -0,0 +1,117 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { z } from "zod";
4
+ import { createServer } from "@/server";
5
+ import { isSdkToolAllowed } from "../scripts-runtime/sdk-allowlist";
6
+ import { route } from "./route-def";
7
+ import { json, jsonError } from "./utils";
8
+
9
+ // Lazy singleton — created once on first bridge call to avoid boot-time cost.
10
+ let _bridgeServer: McpServer | null = null;
11
+ function getBridgeServer(): McpServer {
12
+ if (!_bridgeServer) {
13
+ _bridgeServer = createServer();
14
+ }
15
+ return _bridgeServer;
16
+ }
17
+
18
+ type RegisteredTool = {
19
+ handler: Function;
20
+ inputSchema?: unknown;
21
+ enabled?: boolean;
22
+ };
23
+
24
+ type ToolRegistry = Record<string, RegisteredTool>;
25
+
26
+ const mcpBridgeRoute = route({
27
+ method: "post",
28
+ path: "/api/mcp-bridge",
29
+ pattern: ["api", "mcp-bridge"],
30
+ summary: "Generic MCP tool proxy for the scripts SDK bridge",
31
+ tags: ["Scripts"],
32
+ body: z.object({
33
+ tool: z.string().min(1).max(200),
34
+ args: z.record(z.string(), z.unknown()).default({}),
35
+ }),
36
+ responses: {
37
+ 200: { description: "Tool result" },
38
+ 400: { description: "Invalid tool name or args" },
39
+ 403: { description: "Tool not in SDK allowlist" },
40
+ 404: { description: "Tool not found" },
41
+ },
42
+ });
43
+
44
+ export async function handleMcpBridge(
45
+ req: IncomingMessage,
46
+ res: ServerResponse,
47
+ pathSegments: string[],
48
+ _queryParams?: URLSearchParams,
49
+ myAgentId?: string,
50
+ ): Promise<boolean> {
51
+ if (!mcpBridgeRoute.match(req.method, pathSegments)) return false;
52
+
53
+ const parsed = await mcpBridgeRoute.parse(req, res, pathSegments, new URLSearchParams());
54
+ if (!parsed) return true;
55
+
56
+ const { tool: toolName, args } = parsed.body;
57
+
58
+ if (!isSdkToolAllowed(toolName)) {
59
+ jsonError(res, `Tool '${toolName}' is not in the SDK allowlist`, 403);
60
+ return true;
61
+ }
62
+
63
+ const server = getBridgeServer();
64
+ const tools = (server as unknown as { _registeredTools: ToolRegistry })._registeredTools;
65
+
66
+ const tool = tools[toolName];
67
+ if (!tool) {
68
+ jsonError(res, `Tool '${toolName}' not found in the MCP registry`, 404);
69
+ return true;
70
+ }
71
+
72
+ if (tool.enabled === false) {
73
+ jsonError(res, `Tool '${toolName}' is disabled`, 400);
74
+ return true;
75
+ }
76
+
77
+ const sourceTaskId = Array.isArray(req.headers["x-source-task-id"])
78
+ ? req.headers["x-source-task-id"][0]
79
+ : (req.headers["x-source-task-id"] as string | undefined);
80
+
81
+ const extra = {
82
+ sessionId: "mcp-bridge",
83
+ requestInfo: {
84
+ headers: {
85
+ "x-agent-id": myAgentId ?? "",
86
+ ...(sourceTaskId ? { "x-source-task-id": sourceTaskId } : {}),
87
+ },
88
+ },
89
+ };
90
+
91
+ try {
92
+ const result = tool.inputSchema
93
+ ? await Promise.resolve(tool.handler(args, extra))
94
+ : await Promise.resolve(tool.handler(extra));
95
+
96
+ if (result && typeof result === "object" && "structuredContent" in result) {
97
+ json(res, result.structuredContent);
98
+ } else if (result && typeof result === "object" && "content" in result) {
99
+ const content = (result as { content: Array<{ type: string; text?: string }> }).content;
100
+ const text = content
101
+ .filter((c) => c.type === "text" && c.text)
102
+ .map((c) => c.text)
103
+ .join("\n");
104
+ try {
105
+ json(res, JSON.parse(text));
106
+ } catch {
107
+ json(res, { result: text });
108
+ }
109
+ } else {
110
+ json(res, result ?? {});
111
+ }
112
+ } catch (err) {
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ jsonError(res, message, 500);
115
+ }
116
+ return true;
117
+ }
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import { getMcpServerById } from "../be/db";
4
+ import type { McpOAuthToken } from "../be/db-queries/mcp-oauth";
4
5
  import {
5
6
  applyMcpOAuthRefresh,
6
7
  consumeMcpOAuthPending,
@@ -23,6 +24,7 @@ import {
23
24
  registerClient,
24
25
  revokeMcpToken,
25
26
  } from "../oauth/mcp-wrapper";
27
+ import { getAppUrl, getPublicMcpBaseUrl } from "../utils/constants";
26
28
  import { route } from "./route-def";
27
29
  import { json, jsonError } from "./utils";
28
30
 
@@ -36,12 +38,14 @@ function ssrfOptions() {
36
38
  }
37
39
 
38
40
  function callbackRedirectUri(): string {
39
- const base = process.env.APP_URL?.replace(/\/+$/, "") ?? "http://localhost:3013";
40
- return `${base}/api/mcp-oauth/callback`;
41
+ // The callback route lives on the API server, so it must use the PUBLIC MCP
42
+ // base (externally reachable), not the dashboard APP_URL.
43
+ return `${getPublicMcpBaseUrl()}/api/mcp-oauth/callback`;
41
44
  }
42
45
 
43
46
  function dashboardBase(): string {
44
- return process.env.DASHBOARD_URL?.replace(/\/+$/, "") ?? "http://localhost:5274";
47
+ // getAppUrl absorbs DASHBOARD_URL as a deprecated alias.
48
+ return getAppUrl();
45
49
  }
46
50
 
47
51
  function defaultFinalRedirect(mcpServerId: string): string {
@@ -61,6 +65,43 @@ interface DiscoveryResult {
61
65
  bearerMethodsSupported: string[] | null;
62
66
  }
63
67
 
68
+ interface OAuthClientForAuthorize {
69
+ clientId: string;
70
+ clientSecret: string | null;
71
+ resourceUrl: string;
72
+ authorizationServerIssuer: string;
73
+ authorizeUrl: string;
74
+ tokenUrl: string;
75
+ revocationUrl: string | null;
76
+ scopes: string[];
77
+ }
78
+
79
+ function splitScopes(scopes: string | null | undefined): string[] {
80
+ return scopes?.split(/\s+/).filter(Boolean) ?? [];
81
+ }
82
+
83
+ function manualClientFromToken(token: McpOAuthToken | null): OAuthClientForAuthorize | null {
84
+ if (!token || token.clientSource !== "manual" || !token.dcrClientId) return null;
85
+
86
+ // The manual-client route validates these on write. Re-check before using the
87
+ // stored endpoints because /authorize redirects the browser to authorizeUrl.
88
+ assertUrlSafe(token.resourceUrl, ssrfOptions());
89
+ assertUrlSafe(token.authorizeUrl, ssrfOptions());
90
+ assertUrlSafe(token.tokenUrl, ssrfOptions());
91
+ if (token.revocationUrl) assertUrlSafe(token.revocationUrl, ssrfOptions());
92
+
93
+ return {
94
+ clientId: token.dcrClientId,
95
+ clientSecret: token.dcrClientSecret,
96
+ resourceUrl: token.resourceUrl,
97
+ authorizationServerIssuer: token.authorizationServerIssuer,
98
+ authorizeUrl: token.authorizeUrl,
99
+ tokenUrl: token.tokenUrl,
100
+ revocationUrl: token.revocationUrl,
101
+ scopes: splitScopes(token.scope),
102
+ };
103
+ }
104
+
64
105
  async function discoverForMcp(resourceUrl: string): Promise<DiscoveryResult | null> {
65
106
  assertUrlSafe(resourceUrl, ssrfOptions());
66
107
 
@@ -266,10 +307,10 @@ interface AuthorizeFlowQuery {
266
307
  }
267
308
 
268
309
  /**
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.
310
+ * Use a stored manual client or discover metadata + DCR-register, build the
311
+ * authorize URL, and persist the pending session. Returns the provider
312
+ * `providerUrl` the caller should redirect to / respond with. On failure,
313
+ * writes a JSON error response and returns null.
273
314
  */
274
315
  async function prepareAuthorizeFlow(
275
316
  res: ServerResponse,
@@ -277,15 +318,26 @@ async function prepareAuthorizeFlow(
277
318
  server: NonNullable<ReturnType<typeof getMcpServerById>>,
278
319
  q: AuthorizeFlowQuery,
279
320
  ): 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
- }
321
+ const userId = q.userId ?? null;
322
+ let client = manualClientFromToken(getMcpOAuthToken(mcpServerId, userId));
323
+
324
+ if (!client) {
325
+ const discovery = await discoverForMcp(server.url!);
326
+ if (!discovery) {
327
+ jsonError(res, "MCP server does not require OAuth", 400);
328
+ return null;
329
+ }
285
330
 
286
- let clientId: string | null = null;
287
- let clientSecret: string | null = null;
288
- if (discovery.dcrSupported && discovery.registrationEndpoint) {
331
+ if (!discovery.dcrSupported || !discovery.registrationEndpoint) {
332
+ jsonError(
333
+ res,
334
+ "DCR not supported — paste client_id/client_secret via POST /api/mcp-oauth/:id/manual-client first.",
335
+ 400,
336
+ );
337
+ return null;
338
+ }
339
+
340
+ const scopes = q.scopes ? splitScopes(q.scopes) : discovery.scopes;
289
341
  const dcr = await registerClient(discovery.registrationEndpoint, {
290
342
  client_name: `agent-swarm (${server.name})`,
291
343
  redirect_uris: [callbackRedirectUri()],
@@ -293,43 +345,45 @@ async function prepareAuthorizeFlow(
293
345
  response_types: ["code"],
294
346
  token_endpoint_auth_method: "client_secret_basic",
295
347
  application_type: "web",
296
- scope: (q.scopes ?? discovery.scopes.join(" ")) || undefined,
348
+ scope: scopes.join(" ") || undefined,
297
349
  });
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;
350
+
351
+ client = {
352
+ clientId: dcr.client_id,
353
+ clientSecret: dcr.client_secret ?? null,
354
+ resourceUrl: discovery.resourceUrl,
355
+ authorizationServerIssuer: discovery.authorizationServerIssuer,
356
+ authorizeUrl: discovery.authorizeUrl,
357
+ tokenUrl: discovery.tokenUrl,
358
+ revocationUrl: discovery.revocationUrl,
359
+ scopes,
360
+ };
307
361
  }
308
362
 
309
- const scopes = q.scopes ? q.scopes.split(" ").filter(Boolean) : discovery.scopes;
363
+ const scopes = q.scopes ? splitScopes(q.scopes) : client.scopes;
310
364
 
311
365
  const built = await buildAuthorizeUrl({
312
- authorizeUrl: discovery.authorizeUrl,
313
- tokenUrl: discovery.tokenUrl,
314
- clientId: clientId!,
366
+ authorizeUrl: client.authorizeUrl,
367
+ tokenUrl: client.tokenUrl,
368
+ clientId: client.clientId,
315
369
  redirectUri: callbackRedirectUri(),
316
370
  scopes,
317
- resource: discovery.resourceUrl,
371
+ resource: client.resourceUrl,
318
372
  });
319
373
 
320
374
  insertMcpOAuthPending({
321
375
  state: built.state,
322
376
  mcpServerId,
323
- userId: q.userId ?? null,
377
+ userId,
324
378
  codeVerifier: built.codeVerifier,
325
- resourceUrl: discovery.resourceUrl,
326
- authorizationServerIssuer: discovery.authorizationServerIssuer,
327
- authorizeUrl: discovery.authorizeUrl,
328
- tokenUrl: discovery.tokenUrl,
329
- revocationUrl: discovery.revocationUrl,
379
+ resourceUrl: client.resourceUrl,
380
+ authorizationServerIssuer: client.authorizationServerIssuer,
381
+ authorizeUrl: client.authorizeUrl,
382
+ tokenUrl: client.tokenUrl,
383
+ revocationUrl: client.revocationUrl,
330
384
  scopes: scopes.join(" "),
331
- dcrClientId: clientId!,
332
- dcrClientSecret: clientSecret,
385
+ dcrClientId: client.clientId,
386
+ dcrClientSecret: client.clientSecret,
333
387
  redirectUri: callbackRedirectUri(),
334
388
  finalRedirect: q.redirect ?? null,
335
389
  });
@@ -390,6 +444,10 @@ export async function handleMcpOAuth(
390
444
  codeVerifier: pending.codeVerifier,
391
445
  resource: pending.resourceUrl,
392
446
  });
447
+ const existing = getMcpOAuthToken(pending.mcpServerId, pending.userId);
448
+ const clientSource =
449
+ existing?.clientSource ??
450
+ (pending.dcrClientId ? ("dcr" as const) : ("preregistered" as const));
393
451
 
394
452
  upsertMcpOAuthToken({
395
453
  mcpServerId: pending.mcpServerId,
@@ -406,7 +464,7 @@ export async function handleMcpOAuth(
406
464
  revocationUrl: pending.revocationUrl,
407
465
  dcrClientId: pending.dcrClientId,
408
466
  dcrClientSecret: pending.dcrClientSecret,
409
- clientSource: pending.dcrClientId ? "dcr" : "preregistered",
467
+ clientSource,
410
468
  lastRefreshedAt: new Date().toISOString(),
411
469
  });
412
470
 
@@ -54,6 +54,8 @@ const searchMemory = route({
54
54
  body: z.object({
55
55
  query: z.string().min(1),
56
56
  limit: z.number().int().min(1).max(20).default(5),
57
+ scope: z.enum(["agent", "swarm", "all"]).default("all"),
58
+ source: z.enum(["manual", "file_index", "session_summary", "task_completion"]).optional(),
57
59
  }),
58
60
  responses: {
59
61
  200: { description: "Search results" },
@@ -325,7 +327,7 @@ export async function handleMemory(
325
327
  const parsed = await searchMemory.parse(req, res, pathSegments, new URLSearchParams());
326
328
  if (!parsed) return true;
327
329
 
328
- const { query, limit } = parsed.body;
330
+ const { query, limit, scope, source } = parsed.body;
329
331
 
330
332
  try {
331
333
  const provider = getEmbeddingProvider();
@@ -339,8 +341,9 @@ export async function handleMemory(
339
341
 
340
342
  const candidateLimit = Math.min(limit, 20) * CANDIDATE_SET_MULTIPLIER;
341
343
  const candidates = store.search(queryEmbedding, myAgentId, {
342
- scope: "all",
344
+ scope,
343
345
  limit: candidateLimit,
346
+ source,
344
347
  isLead: false,
345
348
  });
346
349
  const ranked = rerank(candidates, { limit: Math.min(limit, 20) });
@@ -4,6 +4,7 @@ import {
4
4
  OpenApiGeneratorV31,
5
5
  } from "@asteasolutions/zod-to-openapi";
6
6
  import { z } from "zod";
7
+ import { getPublicMcpBaseUrl } from "../utils/constants";
7
8
  import { routeRegistry } from "./route-def";
8
9
 
9
10
  extendZodWithOpenApi(z);
@@ -74,8 +75,7 @@ export function generateOpenApiSpec(opts: OpenApiOptions): string {
74
75
  });
75
76
  }
76
77
 
77
- const serverUrl =
78
- opts.serverUrl || process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
78
+ const serverUrl = opts.serverUrl || getPublicMcpBaseUrl();
79
79
 
80
80
  const generator = new OpenApiGeneratorV31(registry.definitions);
81
81
  const doc = generator.generateDocument({
@@ -25,6 +25,7 @@ import { z } from "zod";
25
25
  import { BROWSER_SDK_JS, SWARM_UI_JS } from "../artifact-sdk/browser-sdk";
26
26
  import { getPage, incrementPageViewCount } from "../be/db";
27
27
  import type { Page } from "../types";
28
+ import { getAppUrl, getConfiguredAppUrls } from "../utils/constants";
28
29
  import { extractAndVerifyCookie, issuePageSessionCookie } from "../utils/page-session";
29
30
  import { scrubSecrets } from "../utils/secret-scrubber";
30
31
  import { route } from "./route-def";
@@ -159,14 +160,15 @@ function stripJsonSuffix(idSegment: string): string | null {
159
160
  }
160
161
 
161
162
  /**
162
- * Compute the SPA base URL (`APP_URL`). Mirrors `getAppBaseUrl` in pages.ts
163
- * duplicated here to keep this module standalone (no cross-import inside the
164
- * http/ layer).
163
+ * Compute the SPA base URL. Public JSON pages historically redirect to the
164
+ * local dashboard when no app URL is configured; keep that route-local
165
+ * fallback while still delegating `APP_URL`/`DASHBOARD_URL` resolution to the
166
+ * shared helper.
165
167
  */
168
+ const LOCAL_PAGE_APP_URL = "http://localhost:5274";
169
+
166
170
  function getAppBaseUrl(): string {
167
- const env = process.env.APP_URL?.trim();
168
- if (env) return env.replace(/\/+$/, "");
169
- return "http://localhost:5274";
171
+ return getAppUrl(LOCAL_PAGE_APP_URL);
170
172
  }
171
173
 
172
174
  /**
@@ -177,15 +179,12 @@ function getAppBaseUrl(): string {
177
179
  */
178
180
  function buildCsp(): string {
179
181
  // `frame-ancestors` lists every origin allowed to iframe `/p/:id`. We must
180
- // include the SPA origin(s). `APP_URL` may carry a comma-separated list so
182
+ // include the SPA origin(s). Configured app URLs may be comma-separated so
181
183
  // portless dev (`https://ui.swarm.localhost`), a Vite port (`http://localhost:5274`),
182
184
  // and a tunnel/staging origin can all coexist. Additionally, in non-production
183
185
  // we always allow `http://localhost:*` and `https://*.localhost` so swapping
184
186
  // between Vite ports / portless dev doesn't require restarting the API.
185
- const configured = (process.env.APP_URL ?? "")
186
- .split(",")
187
- .map((s) => s.trim().replace(/\/+$/, ""))
188
- .filter(Boolean);
187
+ const configured = getConfiguredAppUrls();
189
188
  const devFallbacks =
190
189
  process.env.NODE_ENV === "production"
191
190
  ? []
package/src/http/pages.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "../be/db";
15
15
  import { snapshotPage } from "../pages/version";
16
16
  import { type Page, PageAuthModeSchema, PageContentTypeSchema, type PageSummary } from "../types";
17
+ import { getAppUrl, getPublicMcpBaseUrl } from "../utils/constants";
17
18
  import { issuePageSessionCookie } from "../utils/page-session";
18
19
  import { route } from "./route-def";
19
20
  import { BODY_TOO_LARGE, enforceContentLengthCap, json, jsonError } from "./utils";
@@ -280,25 +281,20 @@ function applyLaunchCors(req: IncomingMessage, res: ServerResponse): void {
280
281
 
281
282
  /**
282
283
  * Resolve the public API base URL used to build a page's `api_url` share
283
- * pointer. Falls back to `http://localhost:<PORT>` when `MCP_BASE_URL` is
284
- * unset (same convention as src/tools/memory-rate.ts, etc.). Trailing slashes
285
- * are stripped so callers can concatenate `/p/:id` directly.
284
+ * pointer (handed to a browser). Delegates to the shared
285
+ * {@link getPublicMcpBaseUrl} helper (trailing slashes already stripped) so
286
+ * callers can concatenate `/p/:id` directly.
286
287
  */
287
288
  function getApiBaseUrl(): string {
288
- const env = process.env.MCP_BASE_URL?.trim();
289
- if (env) return env.replace(/\/+$/, "");
290
- return `http://localhost:${process.env.PORT || "3013"}`;
289
+ return getPublicMcpBaseUrl();
291
290
  }
292
291
 
293
292
  /**
294
293
  * Resolve the SPA / dashboard base URL used to build a page's `app_url` share
295
- * pointer (→ `/pages/:id`). `APP_URL` is the canonical env (matches the
296
- * request-human-input tool); falls back to the local dev port `5274`.
294
+ * pointer (→ `/pages/:id`). Delegates to the shared {@link getAppUrl} helper.
297
295
  */
298
296
  function getAppBaseUrl(): string {
299
- const env = process.env.APP_URL?.trim();
300
- if (env) return env.replace(/\/+$/, "");
301
- return "http://localhost:5274";
297
+ return getAppUrl();
302
298
  }
303
299
 
304
300
  /** Decorate a page row with share-URL pointers. */