@desplega.ai/agent-swarm 1.90.0 → 1.91.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 (52) hide show
  1. package/openapi.json +74 -1
  2. package/package.json +5 -5
  3. package/src/artifact-sdk/server.ts +2 -1
  4. package/src/be/memory/providers/sqlite-store.ts +6 -1
  5. package/src/be/memory/types.ts +1 -0
  6. package/src/be/scripts/typecheck.ts +132 -1
  7. package/src/be/seed-scripts/catalog/compound-insights.ts +188 -0
  8. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  9. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  10. package/src/be/seed-scripts/catalog/tool-usage.ts +56 -0
  11. package/src/be/seed-scripts/index.ts +36 -0
  12. package/src/commands/artifact.ts +3 -2
  13. package/src/commands/profile-sync.ts +310 -0
  14. package/src/commands/runner.ts +91 -1
  15. package/src/hooks/hook.ts +32 -9
  16. package/src/http/index.ts +47 -0
  17. package/src/http/integrations.ts +6 -1
  18. package/src/http/mcp-bridge.ts +117 -0
  19. package/src/http/mcp-oauth.ts +97 -39
  20. package/src/http/memory.ts +5 -2
  21. package/src/http/openapi.ts +2 -2
  22. package/src/http/pages-public.ts +10 -11
  23. package/src/http/pages.ts +7 -11
  24. package/src/http/scripts.ts +24 -1
  25. package/src/http/utils.ts +11 -4
  26. package/src/jira/app.ts +2 -3
  27. package/src/jira/webhook-lifecycle.ts +2 -1
  28. package/src/linear/app.ts +2 -3
  29. package/src/providers/claude-adapter.ts +26 -0
  30. package/src/scripts-runtime/executors/native.ts +1 -0
  31. package/src/scripts-runtime/sdk-allowlist.ts +121 -0
  32. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  33. package/src/scripts-runtime/types/stdlib.d.ts +227 -0
  34. package/src/scripts-runtime/types/swarm-sdk.d.ts +227 -0
  35. package/src/tests/claude-adapter-otel.test.ts +85 -1
  36. package/src/tests/hook-registration-nudge.test.ts +69 -0
  37. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  38. package/src/tests/pages-public-html.test.ts +41 -0
  39. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  40. package/src/tests/profile-sync.test.ts +282 -0
  41. package/src/tests/scripts-runtime.test.ts +33 -0
  42. package/src/tests/seed-scripts.test.ts +2 -2
  43. package/src/tools/create-metric.ts +2 -3
  44. package/src/tools/create-page.ts +3 -6
  45. package/src/tools/memory-rate.ts +2 -1
  46. package/src/tools/memory-search.ts +1 -0
  47. package/src/tools/register-kapso-number.ts +2 -4
  48. package/src/tools/request-human-input.ts +2 -1
  49. package/src/tools/script-common.ts +2 -4
  50. package/src/tools/script-run.ts +7 -0
  51. package/src/utils/constants.ts +58 -8
  52. package/templates/skills/swarm-scripts/content.md +46 -7
package/src/hooks/hook.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from "../be/memory/raters/llm";
12
12
  import type { Agent } from "../types";
13
13
  import { getApiKey } from "../utils/api-key";
14
+ import { getMcpBaseUrl } from "../utils/constants";
14
15
  import { summarizeSession as runSummarize } from "../utils/internal-ai";
15
16
  import { checkToolLoop, clearToolHistory } from "./tool-loop-detection";
16
17
 
@@ -82,6 +83,27 @@ interface CancelledTasksResponse {
82
83
  cancelled: CancelledTask[];
83
84
  }
84
85
 
86
+ /**
87
+ * Decide whether to show the "not registered — use join-swarm" nudge.
88
+ *
89
+ * Rules:
90
+ * 1. Only nudge on SessionStart — other events should not prompt re-registration.
91
+ * 2. If X-Agent-ID header is present the agent is pre-assigned; a null lookup
92
+ * is transient, not a real "unregistered" state → suppress the nudge.
93
+ * 3. Only genuinely-unregistered agents (no X-Agent-ID, null lookup, SessionStart)
94
+ * see the nudge.
95
+ */
96
+ export function shouldShowRegistrationNudge(opts: {
97
+ agentInfoPresent: boolean;
98
+ eventType: string;
99
+ hasAgentIdHeader: boolean;
100
+ }): boolean {
101
+ if (opts.agentInfoPresent) return false;
102
+ if (opts.eventType !== "SessionStart") return false;
103
+ if (opts.hasAgentIdHeader) return false;
104
+ return true;
105
+ }
106
+
85
107
  /**
86
108
  * Check if a path is under the agent's own subdirectory on the shared disk.
87
109
  * Shared disk categories: thoughts, memory, downloads, misc.
@@ -150,7 +172,7 @@ async function readTaskFile(): Promise<TaskFileData | null> {
150
172
  async function fetchTaskDetails(
151
173
  taskId: string,
152
174
  ): Promise<{ id: string; task: string; progress?: string } | null> {
153
- const apiUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
175
+ const apiUrl = getMcpBaseUrl();
154
176
  const apiKey = getApiKey();
155
177
  const headers: Record<string, string> = {};
156
178
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
@@ -889,13 +911,15 @@ export async function handleHook(): Promise<void> {
889
911
  console.log(tray);
890
912
  }
891
913
  }
892
- } else {
914
+ } else if (
915
+ shouldShowRegistrationNudge({
916
+ agentInfoPresent: false,
917
+ eventType: msg.hook_event_name,
918
+ hasAgentIdHeader: hasAgentIdHeader(),
919
+ })
920
+ ) {
893
921
  console.log(
894
- `You are not registered in the agent swarm yet. Use the join-swarm tool to register yourself, then check your status with my-agent-info.
895
-
896
- If the ${SERVER_NAME} server is not running or disabled, disregard this message.
897
-
898
- ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?.headers["X-Agent-ID"]}, it will be used automatically on join-swarm.` : "You do not have a pre-defined agent ID, you will receive one when you join the swarm, or optionally you can request one when calling join-swarm."}`,
922
+ `You are not registered in the agent swarm yet. Use the join-swarm tool to register yourself, then check your status with my-agent-info.\n\nIf the ${SERVER_NAME} server is not running or disabled, disregard this message.\n\nYou do not have a pre-defined agent ID, you will receive one when you join the swarm, or optionally you can request one when calling join-swarm.`,
899
923
  );
900
924
  }
901
925
 
@@ -1151,8 +1175,7 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
1151
1175
  editedPath.startsWith("/workspace/shared/memory/"))
1152
1176
  ) {
1153
1177
  try {
1154
- const apiUrl =
1155
- process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
1178
+ const apiUrl = getMcpBaseUrl();
1156
1179
  const apiKey = getApiKey();
1157
1180
  const fileContent = await Bun.file(editedPath).text();
1158
1181
  const isShared = editedPath.startsWith("/workspace/shared/");
package/src/http/index.ts CHANGED
@@ -42,6 +42,7 @@ import { handleInboxState } from "./inbox-state";
42
42
  import { handleIntegrations } from "./integrations";
43
43
  import { handleKv } from "./kv";
44
44
  import { handleMcp } from "./mcp";
45
+ import { handleMcpBridge } from "./mcp-bridge";
45
46
  import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
46
47
  import { handleMcpServers } from "./mcp-servers";
47
48
  import { handleMcpUser } from "./mcp-user";
@@ -96,9 +97,47 @@ const globalState = globalThis as typeof globalThis & {
96
97
  __transportsUser?: Record<string, StreamableHTTPServerTransport>;
97
98
  __sessionUsers?: Record<string, string>;
98
99
  __sigintRegistered?: boolean;
100
+ __apiGcInterval?: ReturnType<typeof setInterval>;
99
101
  __runId?: string;
100
102
  };
101
103
 
104
+ const API_GC_INTERVAL_MS = 5 * 60 * 1000;
105
+
106
+ type GcCapableGlobal = typeof globalThis & { gc?: () => void };
107
+
108
+ function scheduleApiGc(reason: string): boolean {
109
+ const gc = (globalThis as GcCapableGlobal).gc;
110
+ if (typeof gc !== "function") return false;
111
+
112
+ const timer = setTimeout(() => {
113
+ const startedAt = Date.now();
114
+ try {
115
+ gc();
116
+ console.log(`[HTTP] Explicit GC completed after ${reason} in ${Date.now() - startedAt}ms`);
117
+ } catch (err) {
118
+ console.warn(`[HTTP] Explicit GC failed after ${reason}: ${err}`);
119
+ }
120
+ }, 0);
121
+ timer.unref?.();
122
+ return true;
123
+ }
124
+
125
+ function startApiGcInterval() {
126
+ if (globalState.__apiGcInterval) return;
127
+
128
+ const gc = (globalThis as GcCapableGlobal).gc;
129
+ if (typeof gc !== "function") {
130
+ console.log("[HTTP] Explicit GC unavailable; start API with --expose-gc to enable sweeps");
131
+ return;
132
+ }
133
+
134
+ const interval = setInterval(() => {
135
+ scheduleApiGc("periodic API sweep");
136
+ }, API_GC_INTERVAL_MS);
137
+ interval.unref?.();
138
+ globalState.__apiGcInterval = interval;
139
+ }
140
+
102
141
  // Clean up previous server on hot reload
103
142
  if (globalState.__httpServer) {
104
143
  console.log("[HTTP] Hot reload detected, closing previous server...");
@@ -234,6 +273,7 @@ const httpServer = createHttpServer(async (req, res) => {
234
273
  () => handleRepos(req, res, pathSegments, queryParams),
235
274
  () => handleSkills(req, res, pathSegments, queryParams, myAgentId),
236
275
  () => handleScripts(req, res, pathSegments, queryParams, myAgentId),
276
+ () => handleMcpBridge(req, res, pathSegments, queryParams, myAgentId),
237
277
  () => handleMcpServers(req, res, pathSegments, queryParams),
238
278
  () => handleMcpOAuth(req, res, pathSegments, queryParams),
239
279
  () => handleMemory(req, res, pathSegments, myAgentId),
@@ -315,6 +355,11 @@ async function shutdown() {
315
355
  // Stop MCP OAuth pending-session garbage collector
316
356
  stopMcpOAuthPendingGc();
317
357
 
358
+ if (globalState.__apiGcInterval) {
359
+ clearInterval(globalState.__apiGcInterval);
360
+ delete globalState.__apiGcInterval;
361
+ }
362
+
318
363
  // Close all active transports (SSE connections, etc.)
319
364
  for (const [id, transport] of Object.entries(transports)) {
320
365
  console.log(`[HTTP] Closing transport ${id}`);
@@ -349,6 +394,8 @@ if (!globalState.__runId) {
349
394
  globalState.__runId = `run_${Date.now()}`;
350
395
  }
351
396
 
397
+ startApiGcInterval();
398
+
352
399
  // Load global swarm configs before the server starts listening so decrypt/key
353
400
  // failures fail closed instead of leaving the runtime half-initialized.
354
401
  let startupConfigsInjected: string[] = [];
@@ -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. */