@duckmind/dm-windows-x64 0.63.4 → 0.63.9
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/dm.exe +0 -0
- package/extensions/.dm-extensions.json +236 -13
- package/extensions/dm-context/package.json +1 -1
- package/extensions/dm-context/skills/context-management/SKILL.md +173 -223
- package/extensions/dm-context/skills/context-management/references/development-and-troubleshooting.md +75 -0
- package/extensions/dm-context/skills/context-management/references/interleaved-async-work.md +143 -0
- package/extensions/dm-context/skills/context-management/references/planning-and-execution.md +81 -0
- package/extensions/dm-context/skills/context-management/references/repeated-items-and-batch-work.md +69 -0
- package/extensions/dm-context/skills/context-management/references/retry-branch-and-pivot.md +80 -0
- package/extensions/dm-context/skills/context-management/references/search-research-and-reading.md +103 -0
- package/extensions/dm-context/skills/context-management/references/task-switching-and-cleanup.md +73 -0
- package/extensions/dm-context/src/context.js +3 -2
- package/extensions/dm-context/src/index.js +168 -84
- package/extensions/dm-mcp-adapter/OAUTH.md +3 -0
- package/extensions/dm-mcp-adapter/THIRD_PARTY_NOTICES.md +4 -0
- package/extensions/dm-mcp-adapter/abort.js +35 -0
- package/extensions/dm-mcp-adapter/agent-dir.js +45 -0
- package/extensions/dm-mcp-adapter/agent-plugin-loader.js +349 -0
- package/extensions/dm-mcp-adapter/app-bridge.bundle.js +5843 -0
- package/extensions/dm-mcp-adapter/cli.js +354 -0
- package/extensions/dm-mcp-adapter/commands.js +619 -0
- package/extensions/dm-mcp-adapter/config.js +1060 -0
- package/extensions/dm-mcp-adapter/consent-manager.js +60 -0
- package/extensions/dm-mcp-adapter/direct-tools.js +544 -0
- package/extensions/dm-mcp-adapter/dist/agent-dir.js +45 -0
- package/extensions/dm-mcp-adapter/dist/agent-plugin-loader.js +349 -0
- package/extensions/dm-mcp-adapter/dist/config.js +1060 -0
- package/extensions/dm-mcp-adapter/dist/mcp-bearer-store.js +311 -0
- package/extensions/dm-mcp-adapter/dist/metadata-cache.js +285 -0
- package/extensions/dm-mcp-adapter/dist/package-mcp-loader.js +144 -0
- package/extensions/dm-mcp-adapter/dist/resource-tools.js +7 -0
- package/extensions/dm-mcp-adapter/dist/types.js +247 -0
- package/extensions/dm-mcp-adapter/dist/ui-app-bridge-helpers.js +36 -0
- package/extensions/dm-mcp-adapter/dist/ui-stream-types.js +58 -0
- package/extensions/dm-mcp-adapter/dist/ui-tool-visibility.js +26 -0
- package/extensions/dm-mcp-adapter/dist/utils.js +307 -0
- package/extensions/dm-mcp-adapter/elicitation-handler.js +306 -0
- package/extensions/dm-mcp-adapter/error-signal.js +16 -0
- package/extensions/dm-mcp-adapter/errors.js +225 -0
- package/extensions/dm-mcp-adapter/failure-backoff.js +19 -0
- package/extensions/dm-mcp-adapter/glimpse-ui.js +61 -0
- package/extensions/dm-mcp-adapter/host-html-template.js +464 -0
- package/extensions/dm-mcp-adapter/index.js +1079 -0
- package/extensions/dm-mcp-adapter/init.js +624 -0
- package/extensions/dm-mcp-adapter/json-schema-validator.js +53 -0
- package/extensions/dm-mcp-adapter/lifecycle.js +385 -0
- package/extensions/dm-mcp-adapter/logger.js +115 -0
- package/extensions/dm-mcp-adapter/mcp-auth-flow.js +824 -0
- package/extensions/dm-mcp-adapter/mcp-auth.js +732 -0
- package/extensions/dm-mcp-adapter/mcp-bearer-store.js +311 -0
- package/extensions/dm-mcp-adapter/mcp-callback-server.js +388 -0
- package/extensions/dm-mcp-adapter/mcp-code.js +291 -0
- package/extensions/dm-mcp-adapter/mcp-keyring-helper.cjs +88 -0
- package/extensions/dm-mcp-adapter/mcp-oauth-provider.js +490 -0
- package/extensions/dm-mcp-adapter/mcp-output-guard.js +386 -0
- package/extensions/dm-mcp-adapter/mcp-panel.js +951 -0
- package/extensions/dm-mcp-adapter/mcp-probe.js +151 -0
- package/extensions/dm-mcp-adapter/mcp-references.js +263 -0
- package/extensions/dm-mcp-adapter/mcp-script-worker.mjs +126 -0
- package/extensions/dm-mcp-adapter/mcp-setup-panel.js +578 -0
- package/extensions/dm-mcp-adapter/mcp-status.js +80 -0
- package/extensions/dm-mcp-adapter/mcp-trace.js +215 -0
- package/extensions/dm-mcp-adapter/metadata-cache.js +292 -0
- package/extensions/dm-mcp-adapter/namespace-tools.js +155 -0
- package/extensions/dm-mcp-adapter/npx-resolver.js +465 -0
- package/extensions/dm-mcp-adapter/oauth-handler.js +17 -0
- package/extensions/dm-mcp-adapter/oauth.js +17 -0
- package/extensions/dm-mcp-adapter/onboarding-state.js +62 -0
- package/extensions/dm-mcp-adapter/package-mcp-loader.js +144 -0
- package/extensions/dm-mcp-adapter/package.json +68 -0
- package/extensions/dm-mcp-adapter/panel-keys.js +26 -0
- package/extensions/dm-mcp-adapter/prompts.js +258 -0
- package/extensions/dm-mcp-adapter/proxy-modes.js +1336 -0
- package/extensions/dm-mcp-adapter/request-headers-command.js +293 -0
- package/extensions/dm-mcp-adapter/resource-tools.js +7 -0
- package/extensions/dm-mcp-adapter/runtime-owner.js +79 -0
- package/extensions/dm-mcp-adapter/sampling-handler.js +222 -0
- package/extensions/dm-mcp-adapter/sandbox-proxy-template.js +190 -0
- package/extensions/dm-mcp-adapter/search-ranking.js +198 -0
- package/extensions/dm-mcp-adapter/server-manager.js +1258 -0
- package/extensions/dm-mcp-adapter/session-recovery.js +82 -0
- package/extensions/dm-mcp-adapter/skills/mcp-scripting/SKILL.md +39 -0
- package/extensions/dm-mcp-adapter/state.js +0 -0
- package/extensions/dm-mcp-adapter/test-runner.mjs +18 -0
- package/extensions/dm-mcp-adapter/tool-approval.js +151 -0
- package/extensions/dm-mcp-adapter/tool-metadata.js +247 -0
- package/extensions/dm-mcp-adapter/tool-registrar.js +212 -0
- package/extensions/dm-mcp-adapter/tool-result-renderer.js +355 -0
- package/extensions/dm-mcp-adapter/ts-shape.js +157 -0
- package/extensions/dm-mcp-adapter/types.js +264 -0
- package/extensions/dm-mcp-adapter/ui-app-bridge-helpers.js +36 -0
- package/extensions/dm-mcp-adapter/ui-resource-handler.js +209 -0
- package/extensions/dm-mcp-adapter/ui-server.js +815 -0
- package/extensions/dm-mcp-adapter/ui-session.js +455 -0
- package/extensions/dm-mcp-adapter/ui-stream-types.js +58 -0
- package/extensions/dm-mcp-adapter/ui-tool-visibility.js +26 -0
- package/extensions/dm-mcp-adapter/unix-socket-transport.js +83 -0
- package/extensions/dm-mcp-adapter/utils.js +307 -0
- package/extensions/dm-skills-manager/THIRD_PARTY_NOTICES.md +27 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/components.js +265 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/constants.js +31 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/creation-fallback.js +20 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/creation.js +145 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/dialog.js +738 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/dm-ai-compat.js +15 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/format.js +125 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/glyphs.js +142 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/layout.js +93 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/paths.js +76 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/registry.js +95 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/settings.js +93 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/startup.js +32 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/toggle.js +54 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/types.js +14 -0
- package/extensions/dm-skills-manager/extensions/skills-manager/ui.js +121 -0
- package/extensions/dm-skills-manager/extensions/skills-manager.js +111 -0
- package/extensions/dm-skills-manager/package.json +121 -0
- package/package.json +13 -1
- package/extensions/dm-localllm-provider/detect.test.js +0 -1051
- package/extensions/dm-localllm-provider/index.test.js +0 -149
- package/extensions/dm-localllm-provider/keychain.test.js +0 -61
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { buildAllowAttribute } from "./ui-app-bridge-helpers.js";
|
|
6
|
+
import { ContentBlockSchema } from "@modelcontextprotocol/core";
|
|
7
|
+
import { ServerError, wrapError } from "./errors.js";
|
|
8
|
+
import { formatAuthRequiredMessage, normalizeToolArguments } from "./utils.js";
|
|
9
|
+
import { buildHostHtmlTemplate, buildCspMetaContent } from "./host-html-template.js";
|
|
10
|
+
import {
|
|
11
|
+
buildSandboxProxyCsp,
|
|
12
|
+
buildSandboxProxyHtml,
|
|
13
|
+
SANDBOX_PROXY_PATH
|
|
14
|
+
} from "./sandbox-proxy-template.js";
|
|
15
|
+
import { logger } from "./logger.js";
|
|
16
|
+
import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session-recovery.js";
|
|
17
|
+
import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.js";
|
|
18
|
+
import { extractUiToolVisibility, isUiToolCallableByApp, isUiToolVisibleToModel } from "./ui-tool-visibility.js";
|
|
19
|
+
import { resourceNameToToolName } from "./resource-tools.js";
|
|
20
|
+
import {
|
|
21
|
+
createUiModelContextUpdate,
|
|
22
|
+
extractUiPromptText,
|
|
23
|
+
getVisualizationStreamEnvelope,
|
|
24
|
+
isServerDisabled
|
|
25
|
+
} from "./types.js";
|
|
26
|
+
const MAX_BODY_SIZE = 2 * 1024 * 1024;
|
|
27
|
+
const ABANDONED_GRACE_MS = 60000;
|
|
28
|
+
const WATCHDOG_INTERVAL_MS = 5000;
|
|
29
|
+
const MAX_EVENT_LOG = 128;
|
|
30
|
+
const MAX_CONTEXT_UPDATES = 20;
|
|
31
|
+
const MOSHI_DISCOVERY_PORT_START = 8377;
|
|
32
|
+
const MOSHI_DISCOVERY_PORT_END = 8396;
|
|
33
|
+
let nextMoshiDiscoveryPort = MOSHI_DISCOVERY_PORT_START;
|
|
34
|
+
export async function startUiServer(options) {
|
|
35
|
+
const sessionToken = options.sessionToken ?? randomUUID();
|
|
36
|
+
const uiResourceToken = randomUUID();
|
|
37
|
+
const log = logger.child({
|
|
38
|
+
component: "UiServer",
|
|
39
|
+
server: options.serverName,
|
|
40
|
+
tool: options.toolName,
|
|
41
|
+
session: sessionToken.slice(0, 8)
|
|
42
|
+
});
|
|
43
|
+
log.debug("Starting UI server");
|
|
44
|
+
const sseClients = new Set;
|
|
45
|
+
let completed = false;
|
|
46
|
+
let lastHeartbeatAt = Date.now();
|
|
47
|
+
let watchdog = null;
|
|
48
|
+
let currentDisplayMode = options.hostContext?.displayMode ?? "inline";
|
|
49
|
+
let nextEventId = 1;
|
|
50
|
+
const eventLog = [];
|
|
51
|
+
let latestCheckpointEventId;
|
|
52
|
+
let streamSummary;
|
|
53
|
+
let server = null;
|
|
54
|
+
let sandboxProxyServer = null;
|
|
55
|
+
let sandboxProxyUrl = null;
|
|
56
|
+
let closeTimer = null;
|
|
57
|
+
let listenersClosed = false;
|
|
58
|
+
const sessionMessages = {
|
|
59
|
+
prompts: [],
|
|
60
|
+
notifications: [],
|
|
61
|
+
intents: [],
|
|
62
|
+
contexts: []
|
|
63
|
+
};
|
|
64
|
+
const hostContext = {
|
|
65
|
+
displayMode: currentDisplayMode,
|
|
66
|
+
availableDisplayModes: ["inline", "fullscreen", "pip"],
|
|
67
|
+
platform: "desktop",
|
|
68
|
+
...options.hostContext
|
|
69
|
+
};
|
|
70
|
+
const initialStreamContext = hostContext["dm-mcp-adapter/stream"];
|
|
71
|
+
if (initialStreamContext && typeof initialStreamContext === "object") {
|
|
72
|
+
const streamId = initialStreamContext.streamId;
|
|
73
|
+
const mode = initialStreamContext.mode;
|
|
74
|
+
if (typeof streamId === "string" && (mode === "eager" || mode === "stream-first")) {
|
|
75
|
+
streamSummary = {
|
|
76
|
+
streamId,
|
|
77
|
+
mode,
|
|
78
|
+
frames: 0,
|
|
79
|
+
phases: []
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const isAppOnlyTool = (toolName) => {
|
|
84
|
+
const toolDefinition = options.manager.getConnection(options.serverName)?.tools?.find((tool) => tool.name === toolName);
|
|
85
|
+
if (!toolDefinition)
|
|
86
|
+
return false;
|
|
87
|
+
const visibility = extractUiToolVisibility(toolDefinition._meta);
|
|
88
|
+
return isUiToolCallableByApp(visibility) && !isUiToolVisibleToModel(visibility);
|
|
89
|
+
};
|
|
90
|
+
const recordUiMessage = async (msgParams) => {
|
|
91
|
+
const promptText = extractUiPromptText(msgParams);
|
|
92
|
+
if (promptText) {
|
|
93
|
+
sessionMessages.prompts.push(promptText);
|
|
94
|
+
log.debug("UI prompt received", { prompt: promptText.slice(0, 100) });
|
|
95
|
+
} else if (msgParams.type === "intent" || msgParams.intent) {
|
|
96
|
+
const intentName = msgParams.intent ?? "";
|
|
97
|
+
if (intentName) {
|
|
98
|
+
sessionMessages.intents.push({
|
|
99
|
+
intent: intentName,
|
|
100
|
+
...msgParams.params !== undefined ? { params: msgParams.params } : {}
|
|
101
|
+
});
|
|
102
|
+
log.debug("UI intent received", { intent: intentName });
|
|
103
|
+
}
|
|
104
|
+
} else if (msgParams.type === "notify" || msgParams.message) {
|
|
105
|
+
const notifyText = msgParams.message ?? "";
|
|
106
|
+
if (notifyText) {
|
|
107
|
+
sessionMessages.notifications.push(notifyText);
|
|
108
|
+
log.debug("UI notification", { message: notifyText.slice(0, 100) });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
await options.onMessage?.(msgParams);
|
|
112
|
+
};
|
|
113
|
+
const touchHeartbeat = () => {
|
|
114
|
+
lastHeartbeatAt = Date.now();
|
|
115
|
+
};
|
|
116
|
+
const updateStreamSummary = (eventId, payload) => {
|
|
117
|
+
const envelope = getVisualizationStreamEnvelope(payload?.structuredContent);
|
|
118
|
+
if (!envelope)
|
|
119
|
+
return;
|
|
120
|
+
if (envelope.frameType === "checkpoint" || envelope.frameType === "final") {
|
|
121
|
+
latestCheckpointEventId = eventId;
|
|
122
|
+
}
|
|
123
|
+
if (!streamSummary) {
|
|
124
|
+
streamSummary = {
|
|
125
|
+
streamId: envelope.streamId,
|
|
126
|
+
mode: "eager",
|
|
127
|
+
frames: 0,
|
|
128
|
+
phases: []
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
streamSummary.frames += 1;
|
|
132
|
+
if (!streamSummary.phases.includes(envelope.phase)) {
|
|
133
|
+
streamSummary.phases.push(envelope.phase);
|
|
134
|
+
}
|
|
135
|
+
streamSummary.finalStatus = envelope.status;
|
|
136
|
+
if (envelope.message !== undefined)
|
|
137
|
+
streamSummary.lastMessage = envelope.message;
|
|
138
|
+
else
|
|
139
|
+
delete streamSummary.lastMessage;
|
|
140
|
+
};
|
|
141
|
+
const serializeEvent = (eventId, name, payload) => {
|
|
142
|
+
return `id: ${eventId}
|
|
143
|
+
event: ${name}
|
|
144
|
+
data: ${JSON.stringify(payload)}
|
|
145
|
+
|
|
146
|
+
`;
|
|
147
|
+
};
|
|
148
|
+
const getLatestCheckpointIndex = () => {
|
|
149
|
+
const firstEventId = eventLog[0]?.id;
|
|
150
|
+
if (latestCheckpointEventId === undefined || firstEventId === undefined || latestCheckpointEventId < firstEventId)
|
|
151
|
+
return -1;
|
|
152
|
+
return latestCheckpointEventId - firstEventId;
|
|
153
|
+
};
|
|
154
|
+
const pruneEventLog = () => {
|
|
155
|
+
if (eventLog.length <= MAX_EVENT_LOG)
|
|
156
|
+
return;
|
|
157
|
+
const latestCheckpointIndex = getLatestCheckpointIndex();
|
|
158
|
+
if (latestCheckpointIndex > 0) {
|
|
159
|
+
eventLog.splice(0, latestCheckpointIndex);
|
|
160
|
+
}
|
|
161
|
+
if (eventLog.length > MAX_EVENT_LOG) {
|
|
162
|
+
eventLog.splice(0, eventLog.length - MAX_EVENT_LOG);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const pushEvent = (name, payload) => {
|
|
166
|
+
if (completed)
|
|
167
|
+
return;
|
|
168
|
+
const eventId = nextEventId++;
|
|
169
|
+
eventLog.push({ id: eventId, name, payload });
|
|
170
|
+
updateStreamSummary(eventId, payload);
|
|
171
|
+
pruneEventLog();
|
|
172
|
+
const chunk = serializeEvent(eventId, name, payload);
|
|
173
|
+
for (const client of sseClients) {
|
|
174
|
+
try {
|
|
175
|
+
client.write(chunk);
|
|
176
|
+
} catch {
|
|
177
|
+
sseClients.delete(client);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const replayEvents = (res, lastEventIdHeader) => {
|
|
182
|
+
const parsedLastId = lastEventIdHeader ? Number(lastEventIdHeader) : Number.NaN;
|
|
183
|
+
const eventsToReplay = Number.isFinite(parsedLastId) ? eventLog.filter((entry) => entry.id > parsedLastId) : (() => {
|
|
184
|
+
const latestCheckpointIndex = getLatestCheckpointIndex();
|
|
185
|
+
return latestCheckpointIndex >= 0 ? eventLog.slice(latestCheckpointIndex) : eventLog;
|
|
186
|
+
})();
|
|
187
|
+
for (const entry of eventsToReplay) {
|
|
188
|
+
try {
|
|
189
|
+
res.write(serializeEvent(entry.id, entry.name, entry.payload));
|
|
190
|
+
} catch {
|
|
191
|
+
sseClients.delete(res);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const closeSse = () => {
|
|
197
|
+
for (const client of sseClients) {
|
|
198
|
+
try {
|
|
199
|
+
client.end();
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
sseClients.clear();
|
|
203
|
+
};
|
|
204
|
+
const stopWatchdog = () => {
|
|
205
|
+
if (!watchdog)
|
|
206
|
+
return;
|
|
207
|
+
clearInterval(watchdog);
|
|
208
|
+
watchdog = null;
|
|
209
|
+
};
|
|
210
|
+
const closeListeners = () => {
|
|
211
|
+
if (listenersClosed)
|
|
212
|
+
return;
|
|
213
|
+
listenersClosed = true;
|
|
214
|
+
stopWatchdog();
|
|
215
|
+
if (closeTimer) {
|
|
216
|
+
clearTimeout(closeTimer);
|
|
217
|
+
closeTimer = null;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
server?.close();
|
|
221
|
+
} catch {}
|
|
222
|
+
try {
|
|
223
|
+
sandboxProxyServer?.close();
|
|
224
|
+
} catch {}
|
|
225
|
+
closeSse();
|
|
226
|
+
};
|
|
227
|
+
const scheduleListenerClose = () => {
|
|
228
|
+
if (closeTimer || listenersClosed)
|
|
229
|
+
return;
|
|
230
|
+
closeTimer = setTimeout(() => {
|
|
231
|
+
closeTimer = null;
|
|
232
|
+
closeListeners();
|
|
233
|
+
}, 20);
|
|
234
|
+
closeTimer.unref();
|
|
235
|
+
};
|
|
236
|
+
const markCompleted = (reason) => {
|
|
237
|
+
if (completed)
|
|
238
|
+
return;
|
|
239
|
+
log.debug("Session completed", { reason });
|
|
240
|
+
pushEvent("session-complete", { reason });
|
|
241
|
+
completed = true;
|
|
242
|
+
stopWatchdog();
|
|
243
|
+
options.onComplete?.(reason);
|
|
244
|
+
scheduleListenerClose();
|
|
245
|
+
};
|
|
246
|
+
const hostServer = http.createServer(async (req, res) => {
|
|
247
|
+
try {
|
|
248
|
+
const method = req.method || "GET";
|
|
249
|
+
const hostHeader = req.headers.host;
|
|
250
|
+
const url = new URL(req.url || "/", `http://${hostHeader || "127.0.0.1"}`);
|
|
251
|
+
if (hostHeader !== undefined && !isAllowedHost(url.hostname)) {
|
|
252
|
+
sendText(res, 403, "Invalid host");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (method === "HEAD" && url.pathname === "/") {
|
|
256
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
257
|
+
res.end();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (method === "GET" && url.pathname === "/") {
|
|
261
|
+
if (!url.searchParams.has("session")) {
|
|
262
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
263
|
+
res.end('<!doctype html><html><head><meta charset="utf-8"><title>MCP UI</title></head>' + "<body><p>Open the authenticated MCP UI URL shown by DM.</p></body></html>");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!validateTokenQuery(url, sessionToken, res))
|
|
267
|
+
return;
|
|
268
|
+
touchHeartbeat();
|
|
269
|
+
if (!sandboxProxyUrl) {
|
|
270
|
+
sendText(res, 503, "Sandbox proxy is not ready");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const html = buildHostHtmlTemplate({
|
|
274
|
+
sessionToken,
|
|
275
|
+
uiResourceToken,
|
|
276
|
+
serverName: options.serverName,
|
|
277
|
+
toolName: options.toolName,
|
|
278
|
+
toolArgs: options.toolArgs,
|
|
279
|
+
resource: options.resource,
|
|
280
|
+
allowAttribute: buildAllowAttribute(options.resource.meta.permissions),
|
|
281
|
+
requireToolConsent: options.consentManager.requiresPrompt(options.serverName),
|
|
282
|
+
cacheToolConsent: options.consentManager.shouldCacheConsent(),
|
|
283
|
+
hostContext,
|
|
284
|
+
sandboxProxyUrl
|
|
285
|
+
});
|
|
286
|
+
res.writeHead(200, {
|
|
287
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
288
|
+
"Cache-Control": "no-store"
|
|
289
|
+
});
|
|
290
|
+
res.end(html);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (method === "GET" && url.pathname === "/events") {
|
|
294
|
+
if (!validateTokenQuery(url, sessionToken, res))
|
|
295
|
+
return;
|
|
296
|
+
touchHeartbeat();
|
|
297
|
+
log.debug("SSE client connected", { clientCount: sseClients.size + 1 });
|
|
298
|
+
res.writeHead(200, {
|
|
299
|
+
"Content-Type": "text/event-stream",
|
|
300
|
+
"Cache-Control": "no-cache, no-transform",
|
|
301
|
+
Connection: "keep-alive",
|
|
302
|
+
"X-Accel-Buffering": "no"
|
|
303
|
+
});
|
|
304
|
+
res.write(`: connected
|
|
305
|
+
|
|
306
|
+
`);
|
|
307
|
+
sseClients.add(res);
|
|
308
|
+
replayEvents(res, req.headers["last-event-id"] ? String(req.headers["last-event-id"]) : null);
|
|
309
|
+
req.on("close", () => {
|
|
310
|
+
sseClients.delete(res);
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (method === "GET" && url.pathname === "/health") {
|
|
315
|
+
if (!validateTokenQuery(url, sessionToken, res))
|
|
316
|
+
return;
|
|
317
|
+
sendJson(res, 200, { ok: true, result: { healthy: true } });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (method === "GET" && url.pathname === "/ui-app") {
|
|
321
|
+
if (!validateTokenQuery(url, uiResourceToken, res, "resource"))
|
|
322
|
+
return;
|
|
323
|
+
touchHeartbeat();
|
|
324
|
+
const cspContent = buildCspMetaContent(options.resource.meta.csp);
|
|
325
|
+
res.writeHead(200, {
|
|
326
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
327
|
+
"Cache-Control": "no-store",
|
|
328
|
+
"Content-Security-Policy": cspContent
|
|
329
|
+
});
|
|
330
|
+
res.end(options.resource.html);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (method === "GET" && url.pathname === "/app-bridge.bundle.js") {
|
|
334
|
+
const bundlePath = path.join(import.meta.dirname, "app-bridge.bundle.js");
|
|
335
|
+
try {
|
|
336
|
+
const content = await fs.readFile(bundlePath, "utf-8");
|
|
337
|
+
res.writeHead(200, {
|
|
338
|
+
"Content-Type": "application/javascript",
|
|
339
|
+
"Cache-Control": "public, max-age=31536000"
|
|
340
|
+
});
|
|
341
|
+
res.end(content);
|
|
342
|
+
} catch {
|
|
343
|
+
sendJson(res, 500, { ok: false, error: "Bundle not found" });
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (method !== "POST") {
|
|
348
|
+
sendJson(res, 404, { ok: false, error: "Not found" });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const body = await parseBody(req, res);
|
|
352
|
+
if (!body)
|
|
353
|
+
return;
|
|
354
|
+
if (!validateTokenBody(body, sessionToken, res))
|
|
355
|
+
return;
|
|
356
|
+
const params = body.params ?? {};
|
|
357
|
+
touchHeartbeat();
|
|
358
|
+
if (url.pathname === "/proxy/tools/call") {
|
|
359
|
+
options.consentManager.ensureApproved(options.serverName);
|
|
360
|
+
const callParams = params;
|
|
361
|
+
if (!callParams || typeof callParams.name !== "string" || !callParams.name.trim()) {
|
|
362
|
+
sendJson(res, 400, { ok: false, error: "Invalid tools/call params" });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const connection = options.manager.getConnection(options.serverName);
|
|
366
|
+
if (!connection || connection.status !== "connected") {
|
|
367
|
+
sendJson(res, 503, { ok: false, error: `Server "${options.serverName}" is not connected` });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (isServerDisabled(options.config?.mcpServers[options.serverName]) || isServerDisabled(connection.definition)) {
|
|
371
|
+
sendJson(res, 503, { ok: false, error: `Server "${options.serverName}" is disabled` });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const toolDefinitions = Array.isArray(connection.tools) ? connection.tools : [];
|
|
375
|
+
const toolDefinition = toolDefinitions.find((tool) => tool.name === callParams.name);
|
|
376
|
+
if (!toolDefinition) {
|
|
377
|
+
sendJson(res, 403, { ok: false, error: `MCP tool "${callParams.name}" is not callable by apps` });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const uiVisibility = extractUiToolVisibility(toolDefinition._meta);
|
|
381
|
+
if (!isUiToolCallableByApp(uiVisibility)) {
|
|
382
|
+
sendJson(res, 403, { ok: false, error: `MCP tool "${callParams.name}" is not callable by apps` });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
let normalizedArguments;
|
|
386
|
+
try {
|
|
387
|
+
normalizedArguments = normalizeToolArguments(callParams.arguments, `tool "${callParams.name}" arguments`);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const callArgs = {
|
|
393
|
+
name: callParams.name,
|
|
394
|
+
arguments: normalizedArguments
|
|
395
|
+
};
|
|
396
|
+
const toolMeta = {
|
|
397
|
+
name: callParams.name,
|
|
398
|
+
originalName: callParams.name,
|
|
399
|
+
description: toolDefinition?.description ?? "",
|
|
400
|
+
...toolDefinition?.inputSchema !== undefined ? { inputSchema: toolDefinition.inputSchema } : {},
|
|
401
|
+
...uiVisibility !== undefined ? { uiVisibility } : {}
|
|
402
|
+
};
|
|
403
|
+
const approvalMetadata = new Map(options.state?.toolMetadata);
|
|
404
|
+
const definition = options.config?.mcpServers[options.serverName] ?? options.state?.config.mcpServers[options.serverName];
|
|
405
|
+
approvalMetadata.set(options.serverName, [
|
|
406
|
+
...connection.tools.map((tool) => ({
|
|
407
|
+
name: tool.name,
|
|
408
|
+
originalName: tool.name,
|
|
409
|
+
description: tool.description ?? ""
|
|
410
|
+
})),
|
|
411
|
+
...definition?.exposeResources !== false ? (connection.resources ?? []).map((resource) => {
|
|
412
|
+
const originalName = `read_${resourceNameToToolName(resource.name)}`;
|
|
413
|
+
return {
|
|
414
|
+
name: originalName,
|
|
415
|
+
originalName,
|
|
416
|
+
description: resource.description ?? `Read resource: ${resource.uri}`
|
|
417
|
+
};
|
|
418
|
+
}) : []
|
|
419
|
+
]);
|
|
420
|
+
const approval = options.state ? await ensureToolCallApproved(options.state, options.serverName, toolMeta, callArgs.arguments, options.state.owner?.signal, "iframe", approvalMetadata) : options.config && isToolCallApprovalRequired(options.config, options.serverName, toolMeta, approvalMetadata) ? { ok: false, reason: "approval_required_headless" } : { ok: true };
|
|
421
|
+
if (approval.ok === false) {
|
|
422
|
+
const denied = approval.reason === "denied";
|
|
423
|
+
const message = denied ? `The user declined approval to run MCP tool "${callParams.name}" on server "${options.serverName}".` : `MCP tool "${callParams.name}" on server "${options.serverName}" is approval-gated and requires an interactive session.`;
|
|
424
|
+
sendJson(res, 200, {
|
|
425
|
+
ok: true,
|
|
426
|
+
result: {
|
|
427
|
+
content: [{ type: "text", text: message }],
|
|
428
|
+
details: {
|
|
429
|
+
error: denied ? "approval_denied" : "approval_required",
|
|
430
|
+
server: options.serverName,
|
|
431
|
+
tool: callParams.name
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
options.manager.touch(options.serverName);
|
|
439
|
+
options.manager.incrementInFlight(options.serverName);
|
|
440
|
+
const result = options.config ? await withSessionRecovery({
|
|
441
|
+
manager: options.manager,
|
|
442
|
+
config: options.config,
|
|
443
|
+
...options.onNeedsAuth ? { onNeedsAuth: options.onNeedsAuth } : {}
|
|
444
|
+
}, options.serverName, async (conn) => {
|
|
445
|
+
await options.manager.ensureListen?.(options.serverName, conn);
|
|
446
|
+
return conn.client.callTool(callArgs, options.manager.getRequestOptions?.(options.serverName));
|
|
447
|
+
}) : await (async () => {
|
|
448
|
+
await options.manager.ensureListen?.(options.serverName, connection);
|
|
449
|
+
return connection.client.callTool(callArgs, options.manager.getRequestOptions?.(options.serverName));
|
|
450
|
+
})();
|
|
451
|
+
sendJson(res, 200, { ok: true, result });
|
|
452
|
+
} finally {
|
|
453
|
+
options.manager.decrementInFlight(options.serverName);
|
|
454
|
+
options.manager.touch(options.serverName);
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (url.pathname === "/proxy/ui/consent") {
|
|
459
|
+
const approved = !!params.approved;
|
|
460
|
+
options.consentManager.registerDecision(options.serverName, approved);
|
|
461
|
+
sendJson(res, 200, { ok: true, result: { approved } });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (url.pathname === "/proxy/ui/generated-tool-call-intent") {
|
|
465
|
+
const tool = typeof params.tool === "string" ? params.tool : undefined;
|
|
466
|
+
if (tool && isAppOnlyTool(tool)) {
|
|
467
|
+
log.debug("Ignored generated app-only tool call intent", { tool });
|
|
468
|
+
} else {
|
|
469
|
+
await recordUiMessage({
|
|
470
|
+
type: "intent",
|
|
471
|
+
intent: "call_tool",
|
|
472
|
+
params: {
|
|
473
|
+
...tool !== undefined ? { tool } : {},
|
|
474
|
+
...params.arguments !== undefined ? { arguments: params.arguments } : {},
|
|
475
|
+
...params.isError !== undefined ? { isError: params.isError } : {}
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (url.pathname === "/proxy/ui/message") {
|
|
483
|
+
await recordUiMessage(params);
|
|
484
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (url.pathname === "/proxy/ui/context") {
|
|
488
|
+
const content = params.content;
|
|
489
|
+
const structuredContent = params.structuredContent;
|
|
490
|
+
if (content !== undefined && (!Array.isArray(content) || content.some((block) => !ContentBlockSchema.safeParse(block).success)) || structuredContent !== undefined && (!structuredContent || typeof structuredContent !== "object" || Array.isArray(structuredContent))) {
|
|
491
|
+
sendJson(res, 400, { ok: false, error: "Invalid update-model-context params" });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const ctxParams = {
|
|
495
|
+
...content !== undefined ? { content } : {},
|
|
496
|
+
...structuredContent !== undefined ? { structuredContent } : {}
|
|
497
|
+
};
|
|
498
|
+
const update = createUiModelContextUpdate(ctxParams);
|
|
499
|
+
if (update) {
|
|
500
|
+
sessionMessages.contexts.push(update);
|
|
501
|
+
while (sessionMessages.contexts.length > MAX_CONTEXT_UPDATES) {
|
|
502
|
+
sessionMessages.contexts.shift();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
log.debug("UI context update", { hasContent: !!ctxParams.content, hasUpdate: !!update });
|
|
506
|
+
await options.onContextUpdate?.(ctxParams);
|
|
507
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (url.pathname === "/proxy/ui/open-link") {
|
|
511
|
+
const openParams = params;
|
|
512
|
+
if (!openParams?.url || typeof openParams.url !== "string") {
|
|
513
|
+
sendJson(res, 400, { ok: false, error: "Invalid open-link params" });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
let result = {};
|
|
517
|
+
try {
|
|
518
|
+
new URL(openParams.url);
|
|
519
|
+
} catch {
|
|
520
|
+
result = { isError: true };
|
|
521
|
+
}
|
|
522
|
+
sendJson(res, 200, { ok: true, result });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (url.pathname === "/proxy/ui/download-file") {
|
|
526
|
+
sendJson(res, 200, { ok: true, result: { isError: true } });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (url.pathname === "/proxy/ui/request-display-mode") {
|
|
530
|
+
const displayParams = params;
|
|
531
|
+
const requested = displayParams?.mode;
|
|
532
|
+
const available = hostContext.availableDisplayModes ?? ["inline"];
|
|
533
|
+
if (requested && available.includes(requested)) {
|
|
534
|
+
currentDisplayMode = requested;
|
|
535
|
+
}
|
|
536
|
+
hostContext.displayMode = currentDisplayMode;
|
|
537
|
+
pushEvent("host-context", { displayMode: currentDisplayMode });
|
|
538
|
+
const result = { mode: currentDisplayMode };
|
|
539
|
+
sendJson(res, 200, { ok: true, result });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (url.pathname === "/proxy/ui/heartbeat") {
|
|
543
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (url.pathname === "/proxy/ui/complete") {
|
|
547
|
+
const reason = typeof params.reason === "string" ? params.reason : "done";
|
|
548
|
+
markCompleted(reason);
|
|
549
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
sendJson(res, 404, { ok: false, error: "Not found" });
|
|
553
|
+
} catch (error) {
|
|
554
|
+
if (error instanceof SessionRecoveryAuthRequiredError) {
|
|
555
|
+
const fallback = `Server "${options.serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${options.serverName}" }) to get a browser URL, or /mcp-auth ${options.serverName} in an interactive local session.`;
|
|
556
|
+
const message = error.authMessage ?? (options.config ? formatAuthRequiredMessage(options.config, options.serverName, fallback) : fallback);
|
|
557
|
+
sendJson(res, 401, { ok: false, error: message });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const wrapped = wrapError(error, { server: options.serverName, tool: options.toolName });
|
|
561
|
+
const status = /approval required|denied/i.test(wrapped.message) ? 403 : 500;
|
|
562
|
+
if (status === 500) {
|
|
563
|
+
log.error("Request handler error", error instanceof Error ? error : undefined);
|
|
564
|
+
}
|
|
565
|
+
sendJson(res, status, { ok: false, error: wrapped.message });
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
server = hostServer;
|
|
569
|
+
if (options.initialResultPromise) {
|
|
570
|
+
options.initialResultPromise.then((result) => pushEvent("tool-result", result), (error) => {
|
|
571
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
572
|
+
pushEvent("tool-cancelled", { reason });
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
watchdog = setInterval(() => {
|
|
576
|
+
if (completed)
|
|
577
|
+
return;
|
|
578
|
+
if (Date.now() - lastHeartbeatAt <= ABANDONED_GRACE_MS)
|
|
579
|
+
return;
|
|
580
|
+
markCompleted("stale");
|
|
581
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
582
|
+
watchdog.unref();
|
|
583
|
+
return new Promise((resolve, reject) => {
|
|
584
|
+
const candidates = resolvePortCandidates(options.port);
|
|
585
|
+
let candidateIndex = 0;
|
|
586
|
+
const startSandboxProxy = (parentOrigin) => {
|
|
587
|
+
const proxy = http.createServer((req, res) => {
|
|
588
|
+
try {
|
|
589
|
+
const method = req.method || "GET";
|
|
590
|
+
const hostHeader = req.headers.host;
|
|
591
|
+
const url = new URL(req.url || "/", `http://${hostHeader || "127.0.0.1"}`);
|
|
592
|
+
if (hostHeader !== undefined && !isAllowedHost(url.hostname)) {
|
|
593
|
+
sendText(res, 403, "Invalid host");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (method === "HEAD" && url.pathname === SANDBOX_PROXY_PATH) {
|
|
597
|
+
res.writeHead(200, {
|
|
598
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
599
|
+
"Cache-Control": "no-store",
|
|
600
|
+
"Content-Security-Policy": buildSandboxProxyCsp()
|
|
601
|
+
});
|
|
602
|
+
res.end();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (method === "GET" && url.pathname === SANDBOX_PROXY_PATH) {
|
|
606
|
+
res.writeHead(200, {
|
|
607
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
608
|
+
"Cache-Control": "no-store",
|
|
609
|
+
"Content-Security-Policy": buildSandboxProxyCsp(),
|
|
610
|
+
"Referrer-Policy": "no-referrer",
|
|
611
|
+
"X-Content-Type-Options": "nosniff"
|
|
612
|
+
});
|
|
613
|
+
res.end(buildSandboxProxyHtml({ parentOrigin }));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
sendJson(res, 404, { ok: false, error: "Not found" });
|
|
617
|
+
} catch (error) {
|
|
618
|
+
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
sandboxProxyServer = proxy;
|
|
622
|
+
return new Promise((resolveProxy, rejectProxy) => {
|
|
623
|
+
const onError = (error) => {
|
|
624
|
+
proxy.off("listening", onListening);
|
|
625
|
+
rejectProxy(error);
|
|
626
|
+
};
|
|
627
|
+
const onListening = () => {
|
|
628
|
+
proxy.off("error", onError);
|
|
629
|
+
const address = proxy.address();
|
|
630
|
+
if (!address || typeof address === "string") {
|
|
631
|
+
rejectProxy(new ServerError("invalid sandbox proxy address"));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
resolveProxy(address.port);
|
|
635
|
+
};
|
|
636
|
+
proxy.once("error", onError);
|
|
637
|
+
proxy.listen(0, "127.0.0.1", onListening);
|
|
638
|
+
});
|
|
639
|
+
};
|
|
640
|
+
const listen = () => {
|
|
641
|
+
const candidate = candidates[candidateIndex];
|
|
642
|
+
if (candidate === undefined) {
|
|
643
|
+
const error = new ServerError("no UI server port candidates available");
|
|
644
|
+
closeListeners();
|
|
645
|
+
reject(error);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
hostServer.once("error", onError);
|
|
649
|
+
hostServer.listen(candidate, "127.0.0.1", onListening);
|
|
650
|
+
};
|
|
651
|
+
const onError = (error) => {
|
|
652
|
+
hostServer.off("listening", onListening);
|
|
653
|
+
if (error.code === "EADDRINUSE" && candidateIndex < candidates.length - 1) {
|
|
654
|
+
candidateIndex += 1;
|
|
655
|
+
listen();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
log.error("Failed to start server", error);
|
|
659
|
+
const port = candidates[candidateIndex];
|
|
660
|
+
closeListeners();
|
|
661
|
+
reject(new ServerError(error.message, {
|
|
662
|
+
...port !== undefined ? { port } : {},
|
|
663
|
+
cause: error
|
|
664
|
+
}));
|
|
665
|
+
};
|
|
666
|
+
const onListening = () => {
|
|
667
|
+
hostServer.off("error", onError);
|
|
668
|
+
const address = hostServer.address();
|
|
669
|
+
if (!address || typeof address === "string") {
|
|
670
|
+
const err = new ServerError("invalid address");
|
|
671
|
+
log.error("Invalid server address", err);
|
|
672
|
+
closeListeners();
|
|
673
|
+
reject(err);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const parentOrigin = `http://localhost:${address.port}`;
|
|
677
|
+
startSandboxProxy(parentOrigin).then((proxyPort) => {
|
|
678
|
+
if (completed || listenersClosed) {
|
|
679
|
+
closeListeners();
|
|
680
|
+
reject(new ServerError("UI session completed before sandbox proxy was ready"));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
sandboxProxyUrl = `http://localhost:${proxyPort}${SANDBOX_PROXY_PATH}`;
|
|
684
|
+
log.debug("Servers started", { port: address.port, proxyPort });
|
|
685
|
+
rememberMoshiDiscoveryPort(address.port);
|
|
686
|
+
const handle = {
|
|
687
|
+
url: `http://localhost:${address.port}/?session=${sessionToken}`,
|
|
688
|
+
port: address.port,
|
|
689
|
+
proxyUrl: sandboxProxyUrl,
|
|
690
|
+
proxyPort,
|
|
691
|
+
sessionToken,
|
|
692
|
+
serverName: options.serverName,
|
|
693
|
+
toolName: options.toolName,
|
|
694
|
+
close: (reason) => {
|
|
695
|
+
markCompleted(reason ?? "closed");
|
|
696
|
+
closeListeners();
|
|
697
|
+
},
|
|
698
|
+
sendToolInput: (args) => {
|
|
699
|
+
pushEvent("tool-input", { arguments: args });
|
|
700
|
+
},
|
|
701
|
+
sendToolResult: (result) => {
|
|
702
|
+
pushEvent("tool-result", result);
|
|
703
|
+
},
|
|
704
|
+
sendResultPatch: (result) => {
|
|
705
|
+
pushEvent("result-patch", result);
|
|
706
|
+
},
|
|
707
|
+
sendToolCancelled: (reason) => {
|
|
708
|
+
pushEvent("tool-cancelled", { reason });
|
|
709
|
+
},
|
|
710
|
+
sendResourceUpdated: (uri) => {
|
|
711
|
+
pushEvent("resource-updated", { uri });
|
|
712
|
+
},
|
|
713
|
+
sendHostContext: (context) => {
|
|
714
|
+
Object.assign(hostContext, context);
|
|
715
|
+
pushEvent("host-context", context);
|
|
716
|
+
},
|
|
717
|
+
getSessionMessages: () => ({ ...sessionMessages }),
|
|
718
|
+
getStreamSummary: () => streamSummary ? { ...streamSummary, phases: [...streamSummary.phases] } : undefined
|
|
719
|
+
};
|
|
720
|
+
resolve(handle);
|
|
721
|
+
}).catch((error) => {
|
|
722
|
+
log.error("Failed to start sandbox proxy", error instanceof Error ? error : undefined);
|
|
723
|
+
closeListeners();
|
|
724
|
+
const wrapped = error instanceof ServerError ? error : new ServerError(error instanceof Error ? error.message : String(error), { cause: error });
|
|
725
|
+
reject(wrapped);
|
|
726
|
+
});
|
|
727
|
+
};
|
|
728
|
+
listen();
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
async function parseBody(req, res) {
|
|
732
|
+
try {
|
|
733
|
+
const body = await readBody(req);
|
|
734
|
+
if (!body || typeof body !== "object") {
|
|
735
|
+
sendJson(res, 400, { ok: false, error: "Invalid request body" });
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
return body;
|
|
739
|
+
} catch (error) {
|
|
740
|
+
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : "Invalid body" });
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function readBody(req) {
|
|
745
|
+
return new Promise((resolve, reject) => {
|
|
746
|
+
let size = 0;
|
|
747
|
+
const chunks = [];
|
|
748
|
+
req.on("data", (chunk) => {
|
|
749
|
+
size += chunk.length;
|
|
750
|
+
if (size > MAX_BODY_SIZE) {
|
|
751
|
+
req.destroy();
|
|
752
|
+
reject(new Error("Request body too large"));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
chunks.push(chunk);
|
|
756
|
+
});
|
|
757
|
+
req.on("end", () => {
|
|
758
|
+
try {
|
|
759
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
760
|
+
} catch (error) {
|
|
761
|
+
reject(error);
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
req.on("error", reject);
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
function resolvePortCandidates(port) {
|
|
768
|
+
if (port !== undefined)
|
|
769
|
+
return [port];
|
|
770
|
+
const candidates = [];
|
|
771
|
+
const count = MOSHI_DISCOVERY_PORT_END - MOSHI_DISCOVERY_PORT_START + 1;
|
|
772
|
+
for (let offset = 0;offset < count; offset += 1) {
|
|
773
|
+
const candidate = MOSHI_DISCOVERY_PORT_START + (nextMoshiDiscoveryPort - MOSHI_DISCOVERY_PORT_START + offset) % count;
|
|
774
|
+
candidates.push(candidate);
|
|
775
|
+
}
|
|
776
|
+
candidates.push(0);
|
|
777
|
+
return candidates;
|
|
778
|
+
}
|
|
779
|
+
function rememberMoshiDiscoveryPort(port) {
|
|
780
|
+
if (port < MOSHI_DISCOVERY_PORT_START || port > MOSHI_DISCOVERY_PORT_END)
|
|
781
|
+
return;
|
|
782
|
+
nextMoshiDiscoveryPort = port >= MOSHI_DISCOVERY_PORT_END ? MOSHI_DISCOVERY_PORT_START : port + 1;
|
|
783
|
+
}
|
|
784
|
+
function isAllowedHost(hostname) {
|
|
785
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
786
|
+
}
|
|
787
|
+
function validateTokenQuery(url, expected, res, parameter = "session") {
|
|
788
|
+
const token = url.searchParams.get(parameter);
|
|
789
|
+
if (token !== expected) {
|
|
790
|
+
sendJson(res, 403, { ok: false, error: "Invalid session" });
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
function validateTokenBody(body, expected, res) {
|
|
796
|
+
if (body.token !== expected) {
|
|
797
|
+
sendJson(res, 403, { ok: false, error: "Invalid session" });
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
function sendJson(res, status, payload) {
|
|
803
|
+
res.writeHead(status, {
|
|
804
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
805
|
+
"Cache-Control": "no-store"
|
|
806
|
+
});
|
|
807
|
+
res.end(JSON.stringify(payload));
|
|
808
|
+
}
|
|
809
|
+
function sendText(res, status, text) {
|
|
810
|
+
res.writeHead(status, {
|
|
811
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
812
|
+
"Cache-Control": "no-store"
|
|
813
|
+
});
|
|
814
|
+
res.end(text);
|
|
815
|
+
}
|