@docyrus/docyrus 0.0.19 → 0.0.21
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/agent-loader.js +37 -3
- package/agent-loader.js.map +2 -2
- package/main.js +498 -93
- package/main.js.map +4 -4
- package/package.json +14 -4
- package/resources/chrome-tools/browser-content.js +103 -0
- package/resources/chrome-tools/browser-cookies.js +35 -0
- package/resources/chrome-tools/browser-eval.js +53 -0
- package/resources/chrome-tools/browser-hn-scraper.js +108 -0
- package/resources/chrome-tools/browser-nav.js +44 -0
- package/resources/chrome-tools/browser-pick.js +162 -0
- package/resources/chrome-tools/browser-screenshot.js +34 -0
- package/resources/chrome-tools/browser-start.js +86 -0
- package/resources/pi-agent/extensions/answer.ts +532 -0
- package/resources/pi-agent/extensions/context.ts +578 -0
- package/resources/pi-agent/extensions/control.ts +1779 -0
- package/resources/pi-agent/extensions/diff.ts +218 -0
- package/resources/pi-agent/extensions/files.ts +199 -0
- package/resources/pi-agent/extensions/loop.ts +446 -0
- package/resources/pi-agent/extensions/multi-edit.ts +835 -0
- package/resources/pi-agent/extensions/notify.ts +88 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/CHANGELOG.md +192 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/LICENSE +21 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/README.md +296 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/app-bridge.bundle.js +67 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/cli.js +108 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/commands.ts +211 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/config.ts +227 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/consent-manager.ts +64 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/direct-tools.ts +301 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/errors.ts +219 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/glimpse-ui.ts +80 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/host-html-template.ts +427 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/index.ts +232 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/init.ts +319 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/lifecycle.ts +93 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/logger.ts +169 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/mcp-panel.ts +713 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/metadata-cache.ts +191 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/npx-resolver.ts +419 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/oauth-handler.ts +56 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/package.json +85 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/paths.ts +29 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/proxy-modes.ts +635 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/resource-tools.ts +17 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/server-manager.ts +330 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/state.ts +41 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-metadata.ts +144 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-registrar.ts +46 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/types.ts +367 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-resource-handler.ts +145 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-server.ts +623 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-session.ts +384 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-stream-types.ts +89 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/utils.ts +75 -0
- package/resources/pi-agent/extensions/prompt-editor.ts +1315 -0
- package/resources/pi-agent/extensions/prompt-url-widget.ts +158 -0
- package/resources/pi-agent/extensions/redraws.ts +24 -0
- package/resources/pi-agent/extensions/review.ts +2160 -0
- package/resources/pi-agent/extensions/todos.ts +2076 -0
- package/resources/pi-agent/extensions/tps.ts +47 -0
- package/resources/pi-agent/extensions/whimsical.ts +474 -0
- package/resources/pi-agent/prompts/coder-system.md +106 -0
- package/resources/pi-agent/skills/changelog-generator/SKILL.md +425 -0
- package/resources/pi-agent/skills/docyrus-chrome-devtools-cli/SKILL.md +80 -0
- package/resources/pi-agent/skills/docyrus-platform/SKILL.md +71 -0
- package/resources/pi-agent/skills/docyrus-platform/references/ai-capabilities.md +43 -0
- package/resources/pi-agent/skills/docyrus-platform/references/auth-and-multi-tenancy.md +35 -0
- package/resources/pi-agent/skills/docyrus-platform/references/automation-and-workflows.md +30 -0
- package/resources/pi-agent/skills/docyrus-platform/references/core-building-blocks.md +53 -0
- package/resources/pi-agent/skills/{docyrus-api-dev → docyrus-platform}/references/data-source-query-guide.md +32 -28
- package/resources/pi-agent/skills/docyrus-platform/references/developer-tools.md +28 -0
- package/resources/pi-agent/skills/docyrus-platform/references/docyrus-cli-usage.md +554 -0
- package/resources/pi-agent/skills/{docyrus-api-dev → docyrus-platform}/references/formula-design-guide-llm.md +15 -23
- package/resources/pi-agent/skills/docyrus-platform/references/integrations-and-events.md +60 -0
- package/resources/pi-agent/skills/docyrus-platform/references/platform-services.md +58 -0
- package/resources/pi-agent/skills/docyrus-platform/references/querying-and-data-operations.md +27 -0
- package/resources/pi-agent/prompts/coder-append-system.md +0 -19
- package/resources/pi-agent/skills/docyrus-ai/SKILL.md +0 -28
- package/resources/pi-agent/skills/docyrus-api-dev/SKILL.md +0 -161
- package/resources/pi-agent/skills/docyrus-api-dev/references/api-client.md +0 -349
- package/resources/pi-agent/skills/docyrus-api-dev/references/authentication.md +0 -238
- package/resources/pi-agent/skills/docyrus-api-dev/references/query-and-formulas.md +0 -592
- package/resources/pi-agent/skills/docyrus-api-doctor/SKILL.md +0 -70
- package/resources/pi-agent/skills/docyrus-api-doctor/references/checklist-details.md +0 -588
- package/resources/pi-agent/skills/docyrus-app-dev/SKILL.md +0 -159
- package/resources/pi-agent/skills/docyrus-app-dev/references/api-client-and-auth.md +0 -275
- package/resources/pi-agent/skills/docyrus-app-dev/references/collections-and-patterns.md +0 -352
- package/resources/pi-agent/skills/docyrus-app-dev/references/data-source-query-guide.md +0 -2059
- package/resources/pi-agent/skills/docyrus-app-dev/references/formula-design-guide-llm.md +0 -320
- package/resources/pi-agent/skills/docyrus-app-dev/references/query-guide.md +0 -525
- package/resources/pi-agent/skills/docyrus-app-ui-design/SKILL.md +0 -466
- package/resources/pi-agent/skills/docyrus-app-ui-design/references/component-selection-guide.md +0 -602
- package/resources/pi-agent/skills/docyrus-app-ui-design/references/icon-usage-guide.md +0 -463
- package/resources/pi-agent/skills/docyrus-app-ui-design/references/preferred-components-catalog.md +0 -242
- package/resources/pi-agent/skills/docyrus-apps/SKILL.md +0 -54
- package/resources/pi-agent/skills/docyrus-architect/SKILL.md +0 -174
- package/resources/pi-agent/skills/docyrus-architect/references/custom-query-guide.md +0 -410
- package/resources/pi-agent/skills/docyrus-architect/references/data-source-query-guide.md +0 -2059
- package/resources/pi-agent/skills/docyrus-architect/references/formula-design-guide-llm.md +0 -320
- package/resources/pi-agent/skills/docyrus-architect/references/formula-reference.md +0 -145
- package/resources/pi-agent/skills/docyrus-auth/SKILL.md +0 -100
- package/resources/pi-agent/skills/docyrus-cli-app/SKILL.md +0 -279
- package/resources/pi-agent/skills/docyrus-cli-app/references/cli-manifest.md +0 -532
- package/resources/pi-agent/skills/docyrus-cli-app/references/list-query-examples.md +0 -248
- package/resources/pi-agent/skills/docyrus-curl/SKILL.md +0 -32
- package/resources/pi-agent/skills/docyrus-discover/SKILL.md +0 -63
- package/resources/pi-agent/skills/docyrus-ds/SKILL.md +0 -95
- package/resources/pi-agent/skills/docyrus-env/SKILL.md +0 -21
- package/resources/pi-agent/skills/docyrus-studio/SKILL.md +0 -369
- package/resources/pi-agent/skills/docyrus-tui/SKILL.md +0 -15
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import http, { type IncomingMessage, type ServerResponse } 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 "@modelcontextprotocol/ext-apps/app-bridge";
|
|
6
|
+
import type {
|
|
7
|
+
CallToolRequest,
|
|
8
|
+
CallToolResult,
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import type { ConsentManager } from "./consent-manager.js";
|
|
11
|
+
import { ServerError, wrapError } from "./errors.js";
|
|
12
|
+
import { buildHostHtmlTemplate, buildCspMetaContent, applyCspMeta } from "./host-html-template.js";
|
|
13
|
+
import { logger } from "./logger.js";
|
|
14
|
+
import type { McpServerManager } from "./server-manager.js";
|
|
15
|
+
import {
|
|
16
|
+
extractUiPromptText,
|
|
17
|
+
getVisualizationStreamEnvelope,
|
|
18
|
+
type UiDisplayMode,
|
|
19
|
+
type UiDisplayModeRequest,
|
|
20
|
+
type UiDisplayModeResult,
|
|
21
|
+
type UiHostContext,
|
|
22
|
+
type UiMessageParams,
|
|
23
|
+
type UiModelContextParams,
|
|
24
|
+
type UiOpenLinkResult,
|
|
25
|
+
type UiProxyRequestBody,
|
|
26
|
+
type UiProxyResult,
|
|
27
|
+
type UiResourceContent,
|
|
28
|
+
type UiSessionMessages,
|
|
29
|
+
type UiStreamSummary,
|
|
30
|
+
} from "./types.js";
|
|
31
|
+
|
|
32
|
+
const MAX_BODY_SIZE = 2 * 1024 * 1024;
|
|
33
|
+
const ABANDONED_GRACE_MS = 60_000;
|
|
34
|
+
const WATCHDOG_INTERVAL_MS = 5_000;
|
|
35
|
+
const MAX_EVENT_LOG = 128;
|
|
36
|
+
|
|
37
|
+
export interface UiServerOptions {
|
|
38
|
+
serverName: string;
|
|
39
|
+
toolName: string;
|
|
40
|
+
toolArgs: Record<string, unknown>;
|
|
41
|
+
resource: UiResourceContent;
|
|
42
|
+
manager: McpServerManager;
|
|
43
|
+
consentManager: ConsentManager;
|
|
44
|
+
hostContext?: UiHostContext;
|
|
45
|
+
initialResultPromise?: Promise<CallToolResult>;
|
|
46
|
+
sessionToken?: string;
|
|
47
|
+
port?: number;
|
|
48
|
+
onMessage?: (params: UiMessageParams) => Promise<void> | void;
|
|
49
|
+
onContextUpdate?: (params: UiModelContextParams) => Promise<void> | void;
|
|
50
|
+
onComplete?: (reason: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UiServerHandle {
|
|
54
|
+
url: string;
|
|
55
|
+
port: number;
|
|
56
|
+
sessionToken: string;
|
|
57
|
+
serverName: string;
|
|
58
|
+
toolName: string;
|
|
59
|
+
close: (reason?: string) => void;
|
|
60
|
+
sendToolInput: (args: Record<string, unknown>) => void;
|
|
61
|
+
sendToolResult: (result: CallToolResult) => void;
|
|
62
|
+
sendResultPatch: (result: CallToolResult) => void;
|
|
63
|
+
sendToolCancelled: (reason: string) => void;
|
|
64
|
+
sendHostContext: (context: UiHostContext) => void;
|
|
65
|
+
/** Get accumulated messages from this session */
|
|
66
|
+
getSessionMessages: () => UiSessionMessages;
|
|
67
|
+
getStreamSummary: () => UiStreamSummary | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function startUiServer(options: UiServerOptions): Promise<UiServerHandle> {
|
|
71
|
+
const sessionToken = options.sessionToken ?? randomUUID();
|
|
72
|
+
const log = logger.child({
|
|
73
|
+
component: "UiServer",
|
|
74
|
+
server: options.serverName,
|
|
75
|
+
tool: options.toolName,
|
|
76
|
+
session: sessionToken.slice(0, 8),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
log.debug("Starting UI server");
|
|
80
|
+
|
|
81
|
+
const sseClients = new Set<ServerResponse>();
|
|
82
|
+
let completed = false;
|
|
83
|
+
let lastHeartbeatAt = Date.now();
|
|
84
|
+
let watchdog: NodeJS.Timeout | null = null;
|
|
85
|
+
let currentDisplayMode: UiDisplayMode = options.hostContext?.displayMode ?? "inline";
|
|
86
|
+
let nextEventId = 1;
|
|
87
|
+
const eventLog: Array<{ id: number; name: string; payload: unknown }> = [];
|
|
88
|
+
let streamSummary: UiStreamSummary | undefined;
|
|
89
|
+
|
|
90
|
+
// Track messages from UI for retrieval
|
|
91
|
+
const sessionMessages: UiSessionMessages = {
|
|
92
|
+
prompts: [],
|
|
93
|
+
notifications: [],
|
|
94
|
+
intents: [],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const hostContext: UiHostContext = {
|
|
98
|
+
displayMode: currentDisplayMode,
|
|
99
|
+
availableDisplayModes: ["inline", "fullscreen", "pip"],
|
|
100
|
+
platform: "desktop",
|
|
101
|
+
...options.hostContext,
|
|
102
|
+
// Only include toolInfo if caller provides full tool definition with inputSchema
|
|
103
|
+
// The App validates toolInfo.tool.inputSchema as required object
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const initialStreamContext = hostContext["pi-mcp-adapter/stream"];
|
|
107
|
+
if (initialStreamContext && typeof initialStreamContext === "object") {
|
|
108
|
+
const streamId = (initialStreamContext as { streamId?: unknown }).streamId;
|
|
109
|
+
const mode = (initialStreamContext as { mode?: unknown }).mode;
|
|
110
|
+
if (typeof streamId === "string" && (mode === "eager" || mode === "stream-first")) {
|
|
111
|
+
streamSummary = {
|
|
112
|
+
streamId,
|
|
113
|
+
mode,
|
|
114
|
+
frames: 0,
|
|
115
|
+
phases: [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const touchHeartbeat = () => {
|
|
121
|
+
lastHeartbeatAt = Date.now();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const updateStreamSummary = (payload: unknown) => {
|
|
125
|
+
const envelope = getVisualizationStreamEnvelope((payload as { structuredContent?: unknown } | null)?.structuredContent);
|
|
126
|
+
if (!envelope) return;
|
|
127
|
+
if (!streamSummary) {
|
|
128
|
+
streamSummary = {
|
|
129
|
+
streamId: envelope.streamId,
|
|
130
|
+
mode: "eager",
|
|
131
|
+
frames: 0,
|
|
132
|
+
phases: [],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
streamSummary.frames += 1;
|
|
136
|
+
if (!streamSummary.phases.includes(envelope.phase)) {
|
|
137
|
+
streamSummary.phases.push(envelope.phase);
|
|
138
|
+
}
|
|
139
|
+
streamSummary.finalStatus = envelope.status;
|
|
140
|
+
streamSummary.lastMessage = envelope.message;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const serializeEvent = (eventId: number, name: string, payload: unknown): string => {
|
|
144
|
+
return `id: ${eventId}\nevent: ${name}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const getLatestCheckpointIndex = () => {
|
|
148
|
+
for (let index = eventLog.length - 1; index >= 0; index -= 1) {
|
|
149
|
+
const entry = eventLog[index];
|
|
150
|
+
const envelope = getVisualizationStreamEnvelope((entry.payload as { structuredContent?: unknown } | null)?.structuredContent);
|
|
151
|
+
if (envelope?.frameType === "checkpoint" || envelope?.frameType === "final") {
|
|
152
|
+
return index;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return -1;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const pruneEventLog = () => {
|
|
159
|
+
if (eventLog.length <= MAX_EVENT_LOG) return;
|
|
160
|
+
const latestCheckpointIndex = getLatestCheckpointIndex();
|
|
161
|
+
|
|
162
|
+
if (latestCheckpointIndex > 0) {
|
|
163
|
+
eventLog.splice(0, latestCheckpointIndex);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (eventLog.length > MAX_EVENT_LOG) {
|
|
167
|
+
eventLog.splice(0, eventLog.length - MAX_EVENT_LOG);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const pushEvent = (name: string, payload: unknown) => {
|
|
172
|
+
if (completed) return;
|
|
173
|
+
const eventId = nextEventId++;
|
|
174
|
+
eventLog.push({ id: eventId, name, payload });
|
|
175
|
+
updateStreamSummary(payload);
|
|
176
|
+
pruneEventLog();
|
|
177
|
+
const chunk = serializeEvent(eventId, name, payload);
|
|
178
|
+
for (const client of sseClients) {
|
|
179
|
+
try {
|
|
180
|
+
client.write(chunk);
|
|
181
|
+
} catch {
|
|
182
|
+
sseClients.delete(client);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const replayEvents = (res: ServerResponse, lastEventIdHeader?: string | null) => {
|
|
188
|
+
const parsedLastId = lastEventIdHeader ? Number(lastEventIdHeader) : Number.NaN;
|
|
189
|
+
const eventsToReplay = Number.isFinite(parsedLastId)
|
|
190
|
+
? eventLog.filter((entry) => entry.id > parsedLastId)
|
|
191
|
+
: (() => {
|
|
192
|
+
const latestCheckpointIndex = getLatestCheckpointIndex();
|
|
193
|
+
return latestCheckpointIndex >= 0 ? eventLog.slice(latestCheckpointIndex) : eventLog;
|
|
194
|
+
})();
|
|
195
|
+
|
|
196
|
+
for (const entry of eventsToReplay) {
|
|
197
|
+
try {
|
|
198
|
+
res.write(serializeEvent(entry.id, entry.name, entry.payload));
|
|
199
|
+
} catch {
|
|
200
|
+
sseClients.delete(res);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const closeSse = () => {
|
|
207
|
+
for (const client of sseClients) {
|
|
208
|
+
try {
|
|
209
|
+
client.end();
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
sseClients.clear();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const stopWatchdog = () => {
|
|
216
|
+
if (!watchdog) return;
|
|
217
|
+
clearInterval(watchdog);
|
|
218
|
+
watchdog = null;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const markCompleted = (reason: string) => {
|
|
222
|
+
if (completed) return;
|
|
223
|
+
log.debug("Session completed", { reason });
|
|
224
|
+
pushEvent("session-complete", { reason });
|
|
225
|
+
completed = true;
|
|
226
|
+
stopWatchdog();
|
|
227
|
+
options.onComplete?.(reason);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const server = http.createServer(async (req, res) => {
|
|
231
|
+
try {
|
|
232
|
+
const method = req.method || "GET";
|
|
233
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
234
|
+
|
|
235
|
+
if (method === "GET" && url.pathname === "/") {
|
|
236
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
237
|
+
touchHeartbeat();
|
|
238
|
+
|
|
239
|
+
const html = buildHostHtmlTemplate({
|
|
240
|
+
sessionToken,
|
|
241
|
+
serverName: options.serverName,
|
|
242
|
+
toolName: options.toolName,
|
|
243
|
+
toolArgs: options.toolArgs,
|
|
244
|
+
resource: options.resource,
|
|
245
|
+
allowAttribute: buildAllowAttribute(options.resource.meta.permissions),
|
|
246
|
+
requireToolConsent: options.consentManager.requiresPrompt(options.serverName),
|
|
247
|
+
cacheToolConsent: options.consentManager.shouldCacheConsent(),
|
|
248
|
+
hostContext,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
res.writeHead(200, {
|
|
252
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
253
|
+
"Cache-Control": "no-store",
|
|
254
|
+
});
|
|
255
|
+
res.end(html);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (method === "GET" && url.pathname === "/events") {
|
|
260
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
261
|
+
touchHeartbeat();
|
|
262
|
+
log.debug("SSE client connected", { clientCount: sseClients.size + 1 });
|
|
263
|
+
res.writeHead(200, {
|
|
264
|
+
"Content-Type": "text/event-stream",
|
|
265
|
+
"Cache-Control": "no-cache, no-transform",
|
|
266
|
+
Connection: "keep-alive",
|
|
267
|
+
"X-Accel-Buffering": "no",
|
|
268
|
+
});
|
|
269
|
+
res.write(": connected\n\n");
|
|
270
|
+
sseClients.add(res);
|
|
271
|
+
replayEvents(res, req.headers["last-event-id"] ? String(req.headers["last-event-id"]) : null);
|
|
272
|
+
req.on("close", () => {
|
|
273
|
+
sseClients.delete(res);
|
|
274
|
+
});
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (method === "GET" && url.pathname === "/health") {
|
|
279
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
280
|
+
sendJson(res, 200, { ok: true, result: { healthy: true } });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (method === "GET" && url.pathname === "/ui-app") {
|
|
285
|
+
if (!validateTokenQuery(url, sessionToken, res)) return;
|
|
286
|
+
touchHeartbeat();
|
|
287
|
+
// Serve the MCP app's UI HTML directly (avoids blob URL security issues)
|
|
288
|
+
// Apply CSP meta tag if specified in resource metadata
|
|
289
|
+
const cspContent = buildCspMetaContent(options.resource.meta.csp);
|
|
290
|
+
const appHtml = applyCspMeta(options.resource.html, cspContent);
|
|
291
|
+
res.writeHead(200, {
|
|
292
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
293
|
+
"Cache-Control": "no-store",
|
|
294
|
+
});
|
|
295
|
+
res.end(appHtml);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (method === "GET" && url.pathname === "/app-bridge.bundle.js") {
|
|
300
|
+
// Serve the pre-bundled AppBridge module
|
|
301
|
+
const bundlePath = path.join(import.meta.dirname, "app-bridge.bundle.js");
|
|
302
|
+
try {
|
|
303
|
+
const content = await fs.readFile(bundlePath, "utf-8");
|
|
304
|
+
res.writeHead(200, {
|
|
305
|
+
"Content-Type": "application/javascript",
|
|
306
|
+
"Cache-Control": "public, max-age=31536000",
|
|
307
|
+
});
|
|
308
|
+
res.end(content);
|
|
309
|
+
} catch {
|
|
310
|
+
sendJson(res, 500, { ok: false, error: "Bundle not found" });
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (method !== "POST") {
|
|
316
|
+
sendJson(res, 404, { ok: false, error: "Not found" });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const body = await parseBody(req, res);
|
|
321
|
+
if (!body) return;
|
|
322
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
323
|
+
const params = body.params ?? {};
|
|
324
|
+
touchHeartbeat();
|
|
325
|
+
|
|
326
|
+
if (url.pathname === "/proxy/tools/call") {
|
|
327
|
+
options.consentManager.ensureApproved(options.serverName);
|
|
328
|
+
const callParams = params as CallToolRequest["params"];
|
|
329
|
+
if (!callParams || typeof callParams.name !== "string" || !callParams.name.trim()) {
|
|
330
|
+
sendJson(res, 400, { ok: false, error: "Invalid tools/call params" });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const connection = options.manager.getConnection(options.serverName);
|
|
335
|
+
if (!connection || connection.status !== "connected") {
|
|
336
|
+
sendJson(res, 503, { ok: false, error: `Server "${options.serverName}" is not connected` });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
options.manager.touch(options.serverName);
|
|
342
|
+
options.manager.incrementInFlight(options.serverName);
|
|
343
|
+
const result = await connection.client.callTool({
|
|
344
|
+
name: callParams.name,
|
|
345
|
+
arguments:
|
|
346
|
+
callParams.arguments && typeof callParams.arguments === "object" && !Array.isArray(callParams.arguments)
|
|
347
|
+
? callParams.arguments
|
|
348
|
+
: {},
|
|
349
|
+
});
|
|
350
|
+
sendJson(res, 200, { ok: true, result });
|
|
351
|
+
} finally {
|
|
352
|
+
options.manager.decrementInFlight(options.serverName);
|
|
353
|
+
options.manager.touch(options.serverName);
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (url.pathname === "/proxy/ui/consent") {
|
|
359
|
+
const approved = !!(params as { approved?: boolean }).approved;
|
|
360
|
+
options.consentManager.registerDecision(options.serverName, approved);
|
|
361
|
+
sendJson(res, 200, { ok: true, result: { approved } });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (url.pathname === "/proxy/ui/message") {
|
|
366
|
+
const msgParams = params as UiMessageParams;
|
|
367
|
+
const promptText = extractUiPromptText(msgParams);
|
|
368
|
+
|
|
369
|
+
// Track messages by type (order: prompt → intent → notify)
|
|
370
|
+
// Must match the order in index.ts onMessage handler
|
|
371
|
+
if (promptText) {
|
|
372
|
+
sessionMessages.prompts.push(promptText);
|
|
373
|
+
log.debug("UI prompt received", { prompt: promptText.slice(0, 100) });
|
|
374
|
+
} else if (msgParams.type === "intent" || msgParams.intent) {
|
|
375
|
+
const intentName = msgParams.intent ?? "";
|
|
376
|
+
if (intentName) {
|
|
377
|
+
sessionMessages.intents.push({
|
|
378
|
+
intent: intentName,
|
|
379
|
+
params: msgParams.params
|
|
380
|
+
});
|
|
381
|
+
log.debug("UI intent received", { intent: intentName });
|
|
382
|
+
}
|
|
383
|
+
} else if (msgParams.type === "notify" || msgParams.message) {
|
|
384
|
+
const notifyText = msgParams.message ?? "";
|
|
385
|
+
if (notifyText) {
|
|
386
|
+
sessionMessages.notifications.push(notifyText);
|
|
387
|
+
log.debug("UI notification", { message: notifyText.slice(0, 100) });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
await options.onMessage?.(msgParams);
|
|
392
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (url.pathname === "/proxy/ui/context") {
|
|
397
|
+
const ctxParams = params as UiModelContextParams;
|
|
398
|
+
log.debug("UI context update", { hasContent: !!ctxParams.content });
|
|
399
|
+
await options.onContextUpdate?.(ctxParams);
|
|
400
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (url.pathname === "/proxy/ui/open-link") {
|
|
405
|
+
const openParams = params as { url?: string };
|
|
406
|
+
if (!openParams?.url || typeof openParams.url !== "string") {
|
|
407
|
+
sendJson(res, 400, { ok: false, error: "Invalid open-link params" });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
let result: UiOpenLinkResult = {};
|
|
411
|
+
try {
|
|
412
|
+
new URL(openParams.url);
|
|
413
|
+
} catch {
|
|
414
|
+
result = { isError: true };
|
|
415
|
+
}
|
|
416
|
+
sendJson(res, 200, { ok: true, result });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (url.pathname === "/proxy/ui/download-file") {
|
|
421
|
+
sendJson(res, 200, { ok: true, result: { isError: true } });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (url.pathname === "/proxy/ui/request-display-mode") {
|
|
426
|
+
const displayParams = params as UiDisplayModeRequest;
|
|
427
|
+
const requested = displayParams?.mode;
|
|
428
|
+
const available = hostContext.availableDisplayModes ?? ["inline"];
|
|
429
|
+
if (requested && available.includes(requested)) {
|
|
430
|
+
currentDisplayMode = requested;
|
|
431
|
+
}
|
|
432
|
+
hostContext.displayMode = currentDisplayMode;
|
|
433
|
+
pushEvent("host-context", { displayMode: currentDisplayMode });
|
|
434
|
+
const result: UiDisplayModeResult = { mode: currentDisplayMode };
|
|
435
|
+
sendJson(res, 200, { ok: true, result });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (url.pathname === "/proxy/ui/heartbeat") {
|
|
440
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (url.pathname === "/proxy/ui/complete") {
|
|
445
|
+
const reason = typeof (params as { reason?: string }).reason === "string"
|
|
446
|
+
? (params as { reason?: string }).reason!
|
|
447
|
+
: "done";
|
|
448
|
+
markCompleted(reason);
|
|
449
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
450
|
+
setTimeout(() => {
|
|
451
|
+
try {
|
|
452
|
+
server.close();
|
|
453
|
+
} catch {}
|
|
454
|
+
closeSse();
|
|
455
|
+
}, 20).unref();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
sendJson(res, 404, { ok: false, error: "Not found" });
|
|
460
|
+
} catch (error) {
|
|
461
|
+
const wrapped = wrapError(error, { server: options.serverName, tool: options.toolName });
|
|
462
|
+
const status = /approval required|denied/i.test(wrapped.message) ? 403 : 500;
|
|
463
|
+
if (status === 500) {
|
|
464
|
+
log.error("Request handler error", error instanceof Error ? error : undefined);
|
|
465
|
+
}
|
|
466
|
+
sendJson(res, status, { ok: false, error: wrapped.message });
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
if (options.initialResultPromise) {
|
|
471
|
+
options.initialResultPromise.then(
|
|
472
|
+
(result) => pushEvent("tool-result", result),
|
|
473
|
+
(error) => {
|
|
474
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
475
|
+
pushEvent("tool-cancelled", { reason });
|
|
476
|
+
}
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
watchdog = setInterval(() => {
|
|
481
|
+
if (completed) return;
|
|
482
|
+
if (Date.now() - lastHeartbeatAt <= ABANDONED_GRACE_MS) return;
|
|
483
|
+
markCompleted("stale");
|
|
484
|
+
try {
|
|
485
|
+
server.close();
|
|
486
|
+
} catch {}
|
|
487
|
+
closeSse();
|
|
488
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
489
|
+
watchdog.unref();
|
|
490
|
+
|
|
491
|
+
return new Promise((resolve, reject) => {
|
|
492
|
+
const onError = (error: Error) => {
|
|
493
|
+
log.error("Failed to start server", error);
|
|
494
|
+
reject(new ServerError(error.message, { port: options.port, cause: error }));
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
server.once("error", onError);
|
|
498
|
+
server.listen(options.port ?? 0, "127.0.0.1", () => {
|
|
499
|
+
server.off("error", onError);
|
|
500
|
+
const address = server.address();
|
|
501
|
+
if (!address || typeof address === "string") {
|
|
502
|
+
const err = new ServerError("invalid address");
|
|
503
|
+
log.error("Invalid server address", err);
|
|
504
|
+
reject(err);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
log.debug("Server started", { port: address.port });
|
|
509
|
+
|
|
510
|
+
const handle: UiServerHandle = {
|
|
511
|
+
url: `http://localhost:${address.port}/?session=${sessionToken}`,
|
|
512
|
+
port: address.port,
|
|
513
|
+
sessionToken,
|
|
514
|
+
serverName: options.serverName,
|
|
515
|
+
toolName: options.toolName,
|
|
516
|
+
close: (reason?: string) => {
|
|
517
|
+
markCompleted(reason ?? "closed");
|
|
518
|
+
try {
|
|
519
|
+
server.close();
|
|
520
|
+
} catch {}
|
|
521
|
+
closeSse();
|
|
522
|
+
},
|
|
523
|
+
sendToolInput: (args: Record<string, unknown>) => {
|
|
524
|
+
pushEvent("tool-input", { arguments: args });
|
|
525
|
+
},
|
|
526
|
+
sendToolResult: (result: CallToolResult) => {
|
|
527
|
+
pushEvent("tool-result", result);
|
|
528
|
+
},
|
|
529
|
+
sendResultPatch: (result: CallToolResult) => {
|
|
530
|
+
pushEvent("result-patch", result);
|
|
531
|
+
},
|
|
532
|
+
sendToolCancelled: (reason: string) => {
|
|
533
|
+
pushEvent("tool-cancelled", { reason });
|
|
534
|
+
},
|
|
535
|
+
sendHostContext: (context: UiHostContext) => {
|
|
536
|
+
Object.assign(hostContext, context);
|
|
537
|
+
pushEvent("host-context", context);
|
|
538
|
+
},
|
|
539
|
+
getSessionMessages: () => ({ ...sessionMessages }),
|
|
540
|
+
getStreamSummary: () => streamSummary ? { ...streamSummary, phases: [...streamSummary.phases] } : undefined,
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
resolve(handle);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function parseBody(
|
|
549
|
+
req: IncomingMessage,
|
|
550
|
+
res: ServerResponse,
|
|
551
|
+
): Promise<UiProxyRequestBody<Record<string, unknown>> | null> {
|
|
552
|
+
try {
|
|
553
|
+
const body = await readBody(req);
|
|
554
|
+
if (!body || typeof body !== "object") {
|
|
555
|
+
sendJson(res, 400, { ok: false, error: "Invalid request body" });
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
return body as UiProxyRequestBody<Record<string, unknown>>;
|
|
559
|
+
} catch (error) {
|
|
560
|
+
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : "Invalid body" });
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function readBody(req: IncomingMessage): Promise<unknown> {
|
|
566
|
+
return new Promise((resolve, reject) => {
|
|
567
|
+
let size = 0;
|
|
568
|
+
const chunks: Buffer[] = [];
|
|
569
|
+
|
|
570
|
+
req.on("data", (chunk: Buffer) => {
|
|
571
|
+
size += chunk.length;
|
|
572
|
+
if (size > MAX_BODY_SIZE) {
|
|
573
|
+
req.destroy();
|
|
574
|
+
reject(new Error("Request body too large"));
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
chunks.push(chunk);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
req.on("end", () => {
|
|
581
|
+
try {
|
|
582
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
583
|
+
} catch (error) {
|
|
584
|
+
reject(error);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
req.on("error", reject);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function validateTokenQuery(url: URL, expected: string, res: ServerResponse): boolean {
|
|
593
|
+
const token = url.searchParams.get("session");
|
|
594
|
+
if (token !== expected) {
|
|
595
|
+
sendJson(res, 403, { ok: false, error: "Invalid session" });
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function validateTokenBody(
|
|
602
|
+
body: UiProxyRequestBody<Record<string, unknown>>,
|
|
603
|
+
expected: string,
|
|
604
|
+
res: ServerResponse,
|
|
605
|
+
): boolean {
|
|
606
|
+
if (body.token !== expected) {
|
|
607
|
+
sendJson(res, 403, { ok: false, error: "Invalid session" });
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function sendJson<T>(
|
|
614
|
+
res: ServerResponse,
|
|
615
|
+
status: number,
|
|
616
|
+
payload: UiProxyResult<T>,
|
|
617
|
+
): void {
|
|
618
|
+
res.writeHead(status, {
|
|
619
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
620
|
+
"Cache-Control": "no-store",
|
|
621
|
+
});
|
|
622
|
+
res.end(JSON.stringify(payload));
|
|
623
|
+
}
|