@fitchmultz/pi-mcp-adapter 5.0.0 → 7.0.0

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