@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.
- package/openapi.json +74 -1
- package/package.json +5 -5
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/memory/providers/sqlite-store.ts +6 -1
- package/src/be/memory/types.ts +1 -0
- package/src/be/scripts/typecheck.ts +132 -1
- package/src/be/seed-scripts/catalog/compound-insights.ts +188 -0
- package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
- package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
- package/src/be/seed-scripts/catalog/tool-usage.ts +56 -0
- package/src/be/seed-scripts/index.ts +36 -0
- package/src/commands/artifact.ts +3 -2
- package/src/commands/profile-sync.ts +310 -0
- package/src/commands/runner.ts +91 -1
- package/src/hooks/hook.ts +32 -9
- package/src/http/index.ts +47 -0
- package/src/http/integrations.ts +6 -1
- package/src/http/mcp-bridge.ts +117 -0
- package/src/http/mcp-oauth.ts +97 -39
- package/src/http/memory.ts +5 -2
- package/src/http/openapi.ts +2 -2
- package/src/http/pages-public.ts +10 -11
- package/src/http/pages.ts +7 -11
- package/src/http/scripts.ts +24 -1
- package/src/http/utils.ts +11 -4
- package/src/jira/app.ts +2 -3
- package/src/jira/webhook-lifecycle.ts +2 -1
- package/src/linear/app.ts +2 -3
- package/src/providers/claude-adapter.ts +26 -0
- package/src/scripts-runtime/executors/native.ts +1 -0
- package/src/scripts-runtime/sdk-allowlist.ts +121 -0
- package/src/scripts-runtime/swarm-sdk.ts +198 -3
- package/src/scripts-runtime/types/stdlib.d.ts +227 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +227 -0
- package/src/tests/claude-adapter-otel.test.ts +85 -1
- package/src/tests/hook-registration-nudge.test.ts +69 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
- package/src/tests/pages-public-html.test.ts +41 -0
- package/src/tests/pages-public-json-redirect.test.ts +37 -2
- package/src/tests/profile-sync.test.ts +282 -0
- package/src/tests/scripts-runtime.test.ts +33 -0
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tools/create-metric.ts +2 -3
- package/src/tools/create-page.ts +3 -6
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/register-kapso-number.ts +2 -4
- package/src/tools/request-human-input.ts +2 -1
- package/src/tools/script-common.ts +2 -4
- package/src/tools/script-run.ts +7 -0
- package/src/utils/constants.ts +58 -8
- 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 =
|
|
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[] = [];
|
package/src/http/integrations.ts
CHANGED
|
@@ -88,7 +88,12 @@ function resolveConfigValue(key: string): string | null {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
function resolveMcpBaseUrl(): string {
|
|
91
|
-
|
|
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
|
+
}
|
package/src/http/mcp-oauth.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
270
|
-
* persist the pending session. Returns the provider
|
|
271
|
-
* should redirect to / respond with. On failure,
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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:
|
|
348
|
+
scope: scopes.join(" ") || undefined,
|
|
297
349
|
});
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
363
|
+
const scopes = q.scopes ? splitScopes(q.scopes) : client.scopes;
|
|
310
364
|
|
|
311
365
|
const built = await buildAuthorizeUrl({
|
|
312
|
-
authorizeUrl:
|
|
313
|
-
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:
|
|
371
|
+
resource: client.resourceUrl,
|
|
318
372
|
});
|
|
319
373
|
|
|
320
374
|
insertMcpOAuthPending({
|
|
321
375
|
state: built.state,
|
|
322
376
|
mcpServerId,
|
|
323
|
-
userId
|
|
377
|
+
userId,
|
|
324
378
|
codeVerifier: built.codeVerifier,
|
|
325
|
-
resourceUrl:
|
|
326
|
-
authorizationServerIssuer:
|
|
327
|
-
authorizeUrl:
|
|
328
|
-
tokenUrl:
|
|
329
|
-
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
|
|
467
|
+
clientSource,
|
|
410
468
|
lastRefreshedAt: new Date().toISOString(),
|
|
411
469
|
});
|
|
412
470
|
|
package/src/http/memory.ts
CHANGED
|
@@ -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
|
|
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) });
|
package/src/http/openapi.ts
CHANGED
|
@@ -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({
|
package/src/http/pages-public.ts
CHANGED
|
@@ -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
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
-
|
|
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).
|
|
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 = (
|
|
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
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
-
|
|
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`).
|
|
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
|
-
|
|
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. */
|