@omercnet/paseo-omp 0.2.1-next.72.1

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 (78) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +120 -0
  4. package/SUPPORT.md +42 -0
  5. package/TESTING.md +150 -0
  6. package/client/composer-pill-settings.tsx +157 -0
  7. package/client/hub-icon.tsx +12 -0
  8. package/client/hub-popover.tsx +132 -0
  9. package/client/hub-status.ts +29 -0
  10. package/client/mcp-authorization.tsx +168 -0
  11. package/client/mcp-popover.tsx +155 -0
  12. package/client/memory-panel.tsx +76 -0
  13. package/client/memory-popover.tsx +74 -0
  14. package/client/omp-config-surface.tsx +1433 -0
  15. package/client/omp-doc-links.ts +117 -0
  16. package/client/omp-plugin-manager.tsx +1004 -0
  17. package/client/omp-store-picker.tsx +89 -0
  18. package/client/omp-store-state.ts +45 -0
  19. package/client/provider-diagnostics-state.ts +262 -0
  20. package/client/provider-icon.tsx +27 -0
  21. package/client/provider-image.tsx +66 -0
  22. package/client/quota-popover.tsx +155 -0
  23. package/client/quota-state.ts +140 -0
  24. package/client/sessions-popover.tsx +78 -0
  25. package/docs/alpha-release-checklist.md +68 -0
  26. package/docs/configuration.md +126 -0
  27. package/docs/core-provider-issue-audit.md +108 -0
  28. package/docs/images/mcp-authorization-compact.png +0 -0
  29. package/docs/images/mcp-controls-wide.png +0 -0
  30. package/docs/images/plugin-manager.png +0 -0
  31. package/docs/images/workspace-settings.png +0 -0
  32. package/docs/installation.md +67 -0
  33. package/index.client.tsx +488 -0
  34. package/index.server.ts +81 -0
  35. package/package.json +84 -0
  36. package/paseo-plugin.json +5 -0
  37. package/scripts/prepare-dependencies.mjs +20 -0
  38. package/server/hub.ts +145 -0
  39. package/server/mcp-browser.ts +95 -0
  40. package/server/memory.ts +86 -0
  41. package/server/mutation-queue.ts +12 -0
  42. package/server/omp-config.ts +135 -0
  43. package/server/omp-plugins.ts +676 -0
  44. package/server/omp-settings.ts +499 -0
  45. package/server/paths.ts +181 -0
  46. package/server/provider/catalog.ts +172 -0
  47. package/server/provider/config-normalization.ts +148 -0
  48. package/server/provider/connection.ts +1196 -0
  49. package/server/provider/host-tools.ts +777 -0
  50. package/server/provider/image.ts +143 -0
  51. package/server/provider/mcp-transport.ts +394 -0
  52. package/server/provider/omp-rpc.ts +2806 -0
  53. package/server/provider/omp.svg +5 -0
  54. package/server/provider/profile-providers.ts +249 -0
  55. package/server/provider/provider-options.ts +27 -0
  56. package/server/provider/registration.ts +162 -0
  57. package/server/provider/security.ts +317 -0
  58. package/server/provider/session-descriptors.ts +736 -0
  59. package/server/provider/session.ts +4796 -0
  60. package/server/provider/settings.ts +78 -0
  61. package/server/provider/subsessions.ts +850 -0
  62. package/server/provider/timeline-projector.ts +1801 -0
  63. package/server/provider-diagnostics.ts +1143 -0
  64. package/server/quota.ts +55 -0
  65. package/server/sessions.ts +58 -0
  66. package/shared/composer-pill-settings.ts +28 -0
  67. package/shared/hub.ts +43 -0
  68. package/shared/mcp.ts +47 -0
  69. package/shared/memory.ts +24 -0
  70. package/shared/omp-config.ts +85 -0
  71. package/shared/omp-plugins.ts +264 -0
  72. package/shared/omp-settings.ts +214 -0
  73. package/shared/omp-store.ts +58 -0
  74. package/shared/provider-diagnostics.ts +126 -0
  75. package/shared/provider-image.ts +160 -0
  76. package/shared/quota.ts +23 -0
  77. package/shared/sessions.ts +24 -0
  78. package/tsconfig.json +16 -0
@@ -0,0 +1,777 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ ProviderMcpServerConfig,
4
+ ProviderSessionConfig,
5
+ } from "@getpaseo/plugin/server/provider";
6
+ import {
7
+ type ConnectedMcpClient,
8
+ type ConnectedMcpTool,
9
+ type ConnectedMcpToolPage,
10
+ connectMcpServer,
11
+ } from "./mcp-transport";
12
+ import {
13
+ OMP_HOST_TOOL_FRAME_LIMIT_ERROR,
14
+ type OmpHostToolCall,
15
+ type OmpHostToolDefinition,
16
+ type OmpHostToolResult,
17
+ type OmpRuntimeSession,
18
+ parseOmpHostToolAgentResult,
19
+ } from "./omp-rpc";
20
+ import {
21
+ boundedJsonBytes,
22
+ OmpCleanupFailure,
23
+ OmpPublicError,
24
+ truncateUtf8,
25
+ utf8Bytes,
26
+ } from "./security";
27
+
28
+ const INTERNAL_PASEO_MCP_PATH = "/mcp/agents";
29
+ const RESERVED_PASEO_NAMESPACE = "paseo";
30
+ const MAX_MCP_SERVERS = 32;
31
+ const MAX_MCP_TOOL_PAGES = 32;
32
+ const MAX_MCP_TOOLS_PER_SERVER = 256;
33
+ const MAX_HOST_TOOLS = 256;
34
+ const MAX_HOST_TOOL_NAME_BYTES = 256;
35
+ const MAX_HOST_TOOL_LABEL_BYTES = 256;
36
+ const MAX_HOST_TOOL_DESCRIPTION_BYTES = 64 * 1024;
37
+ const MAX_HOST_TOOL_SCHEMA_BYTES = 256 * 1024;
38
+ const MAX_HOST_TOOL_CATALOG_BYTES = 768 * 1024;
39
+ const MAX_HOST_TOOL_RESULT_BYTES = 12 * 1024 * 1024;
40
+ const MAX_STRUCTURED_CONTENT_FALLBACK_BYTES = 1024 * 1024;
41
+ const MAX_PENDING_HOST_TOOL_CALLS = 64;
42
+ const MAX_PENDING_HOST_TOOL_BYTES = 8 * 1024 * 1024;
43
+ const DEFAULT_INITIALIZATION_TIMEOUT_MS = 20_000;
44
+ const DEFAULT_MCP_CALL_LIFETIME_MS = 5 * 60 * 1000;
45
+ const MAX_DIRECT_BROWSER_CALLS = 4;
46
+ const BROWSER_CALL_TIMEOUT_MS = 20_000;
47
+
48
+ export type OmpMcpTool = ConnectedMcpTool;
49
+ export type OmpMcpToolPage = ConnectedMcpToolPage;
50
+ export type OmpMcpConnection = ConnectedMcpClient;
51
+
52
+ export type OmpMcpConnector = (
53
+ name: string,
54
+ config: ProviderMcpServerConfig,
55
+ cwd: string,
56
+ signal: AbortSignal,
57
+ ) => Promise<OmpMcpConnection>;
58
+
59
+ export interface OmpHostToolScheduler {
60
+ set(callback: () => void, delayMs: number): unknown;
61
+ clear(handle: unknown): void;
62
+ }
63
+
64
+ const DEFAULT_CALL_SCHEDULER: OmpHostToolScheduler = {
65
+ set: (callback, delayMs) => setTimeout(callback, delayMs),
66
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
67
+ };
68
+
69
+ export interface OmpHostToolsOpenOptions {
70
+ connectMcp?: OmpMcpConnector;
71
+ signal?: AbortSignal;
72
+ initializationTimeoutMs?: number;
73
+ callTimeoutMs?: number;
74
+ callScheduler?: OmpHostToolScheduler;
75
+ }
76
+
77
+ type ClassifiedServer = {
78
+ name: string;
79
+ config: ProviderMcpServerConfig;
80
+ internal: boolean;
81
+ canonical: boolean;
82
+ };
83
+
84
+ type ToolTarget = {
85
+ toolName: string;
86
+ connection: OmpMcpConnection;
87
+ };
88
+
89
+ type PendingCall = {
90
+ controller: AbortController;
91
+ runtime: OmpRuntimeSession;
92
+ generation: number;
93
+ retainedBytes: number;
94
+ deadline: unknown | null;
95
+ };
96
+
97
+ function safeName(value: string, fallback: string): string {
98
+ const normalized = value
99
+ .toLowerCase()
100
+ .replace(/[^a-z_]+/gu, "_")
101
+ .replace(/_+/gu, "_")
102
+ .replace(/^_+|_+$/gu, "");
103
+ return normalized || fallback;
104
+ }
105
+ function humanizeName(value: string): string {
106
+ const normalized = value
107
+ .trim()
108
+ .replace(/[-_.]+/gu, " ")
109
+ .replace(/\s+/gu, " ");
110
+ return normalized ? `${normalized[0]?.toUpperCase() ?? ""}${normalized.slice(1)}` : "Tool";
111
+ }
112
+
113
+ function digest(value: string): string {
114
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
115
+ }
116
+
117
+ function boundedText(value: string, maxBytes: number, fallback: string): string {
118
+ const source = value.trim() || fallback;
119
+ if (utf8Bytes(source) <= maxBytes) return source;
120
+ let result = "";
121
+ let bytes = 0;
122
+ for (const character of source) {
123
+ const characterBytes = utf8Bytes(character);
124
+ if (bytes + characterBytes > maxBytes) break;
125
+ result += character;
126
+ bytes += characterBytes;
127
+ }
128
+ return result || fallback;
129
+ }
130
+
131
+ function normalizedPathname(url: URL): string {
132
+ const pathname = url.pathname.replace(/\/+$/u, "");
133
+ return pathname || "/";
134
+ }
135
+
136
+ function remoteUrl(config: ProviderMcpServerConfig): URL | undefined {
137
+ if (config.type !== "http" && config.type !== "sse") return;
138
+ try {
139
+ return new URL(config.url);
140
+ } catch {
141
+ return;
142
+ }
143
+ }
144
+
145
+ function classifyServers(config: ProviderSessionConfig): ClassifiedServer[] {
146
+ const entries = Object.entries(config.mcpServers).map(([name, serverConfig]) => ({
147
+ name,
148
+ config: serverConfig,
149
+ url: remoteUrl(serverConfig),
150
+ }));
151
+ const endpoints = entries.filter(
152
+ (entry) => entry.url && normalizedPathname(entry.url) === INTERNAL_PASEO_MCP_PATH,
153
+ );
154
+ const daemonOrigins = new Set(endpoints.map((entry) => entry.url?.origin));
155
+ if (daemonOrigins.size > 1) {
156
+ throw new OmpPublicError("Paseo host tool endpoint origin is ambiguous");
157
+ }
158
+ const daemonOrigin = endpoints[0]?.url?.origin;
159
+ const canonical =
160
+ endpoints.find((entry) => entry.name === RESERVED_PASEO_NAMESPACE) ?? endpoints[0];
161
+ const classified = entries.map((entry) => ({
162
+ name: entry.name,
163
+ config: entry.config,
164
+ internal: daemonOrigin !== undefined && entry.url?.origin === daemonOrigin,
165
+ canonical: entry === canonical,
166
+ }));
167
+ const internal = classified.filter((entry) => entry.internal);
168
+ if (internal.length > 0) {
169
+ const callerAgentId = config.env.PASEO_AGENT_ID?.trim();
170
+ const workspaceId = config.env.PASEO_WORKSPACE_ID?.trim();
171
+ if (!callerAgentId || !workspaceId) {
172
+ throw new OmpPublicError(
173
+ "Paseo host tools require caller agent and workspace identity from the plugin host",
174
+ );
175
+ }
176
+ for (const entry of internal) {
177
+ const url = remoteUrl(entry.config);
178
+ if (url?.searchParams.get("callerAgentId") !== callerAgentId) {
179
+ throw new OmpPublicError(
180
+ "Paseo host tool caller identity does not match the provider session",
181
+ );
182
+ }
183
+ }
184
+ }
185
+ return classified.sort((left, right) => {
186
+ if (left.canonical !== right.canonical) return left.canonical ? -1 : 1;
187
+ return left.name.localeCompare(right.name);
188
+ });
189
+ }
190
+
191
+ function serverNamespaces(servers: readonly ClassifiedServer[]): ReadonlyMap<string, string> {
192
+ const counts = new Map<string, number>();
193
+ for (const server of servers) {
194
+ if (server.canonical) continue;
195
+ const normalized = safeName(server.name, "server");
196
+ counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
197
+ }
198
+ return new Map(
199
+ servers.map((server) => {
200
+ if (server.canonical) return [server.name, RESERVED_PASEO_NAMESPACE];
201
+ const normalized = safeName(server.name, "server");
202
+ const reserved =
203
+ normalized === RESERVED_PASEO_NAMESPACE ||
204
+ normalized.startsWith(`${RESERVED_PASEO_NAMESPACE}_`);
205
+ const mustHash = reserved || (counts.get(normalized) ?? 0) > 1;
206
+ return [server.name, mustHash ? `external_${normalized}_${digest(server.name)}` : normalized];
207
+ }),
208
+ );
209
+ }
210
+
211
+ function exposedToolName(server: ClassifiedServer, namespace: string, toolName: string): string {
212
+ const tool = safeName(toolName, "tool");
213
+ if (server.canonical) return tool;
214
+ const unprefixed = tool.startsWith(`${namespace}_`) ? tool.slice(namespace.length + 1) : tool;
215
+ const base = `mcp__${namespace}_${unprefixed}`;
216
+ if (utf8Bytes(base) <= MAX_HOST_TOOL_NAME_BYTES) return base;
217
+ const suffix = digest(`${server.name}\0${toolName}`);
218
+ return `${base.slice(0, MAX_HOST_TOOL_NAME_BYTES - suffix.length - 1)}_${suffix}`;
219
+ }
220
+
221
+ export function validateOmpHostToolConfig(config: ProviderSessionConfig): void {
222
+ if (Object.keys(config.mcpServers).length > MAX_MCP_SERVERS) {
223
+ throw new OmpPublicError("OMP MCP server count exceeds the supported limit");
224
+ }
225
+ if (config.toolPolicy !== undefined) {
226
+ throw new OmpPublicError(
227
+ "OMP set_host_tools cannot preserve exact MCP policy; refusing to broaden access",
228
+ );
229
+ }
230
+ }
231
+
232
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
233
+ const reason = () =>
234
+ signal.reason instanceof Error ? signal.reason : new Error("Operation was aborted");
235
+ if (signal.aborted) return Promise.reject(reason());
236
+ return new Promise<T>((resolve, reject) => {
237
+ const abort = () => reject(reason());
238
+ signal.addEventListener("abort", abort, { once: true });
239
+ void promise.then(
240
+ (value) => {
241
+ signal.removeEventListener("abort", abort);
242
+ resolve(value);
243
+ },
244
+ (error) => {
245
+ signal.removeEventListener("abort", abort);
246
+ reject(error);
247
+ },
248
+ );
249
+ });
250
+ }
251
+
252
+ async function discoverMcpTools(
253
+ connection: OmpMcpConnection,
254
+ signal: AbortSignal,
255
+ ): Promise<OmpMcpTool[]> {
256
+ const tools: OmpMcpTool[] = [];
257
+ const cursors = new Set<string>();
258
+ let cursor: string | undefined;
259
+ for (let pageIndex = 0; pageIndex < MAX_MCP_TOOL_PAGES; pageIndex += 1) {
260
+ const page = await abortable(connection.listTools({ signal, cursor }), signal);
261
+ if (tools.length + page.tools.length > MAX_MCP_TOOLS_PER_SERVER) {
262
+ throw new OmpPublicError("MCP server tool count exceeds the supported limit");
263
+ }
264
+ tools.push(...page.tools);
265
+ if (!page.nextCursor) return tools;
266
+ if (cursors.has(page.nextCursor)) {
267
+ throw new OmpPublicError("MCP server repeated a tool-list cursor");
268
+ }
269
+ cursors.add(page.nextCursor);
270
+ cursor = page.nextCursor;
271
+ }
272
+ throw new OmpPublicError("MCP server tool-list pagination exceeds the supported limit");
273
+ }
274
+
275
+ function normalizeResult(result: unknown): OmpHostToolResult["result"] {
276
+ if (
277
+ !result ||
278
+ typeof result !== "object" ||
279
+ boundedJsonBytes(
280
+ result,
281
+ MAX_HOST_TOOL_RESULT_BYTES,
282
+ 1_024,
283
+ MAX_HOST_TOOL_RESULT_BYTES,
284
+ 4_096,
285
+ ) === Number.POSITIVE_INFINITY
286
+ ) {
287
+ throw new Error("MCP tool returned an invalid or oversized result");
288
+ }
289
+ const record = result as Record<string, unknown>;
290
+ if (Array.isArray(record.content)) {
291
+ const details = record.structuredContent;
292
+ const content =
293
+ record.content.length === 0 && details !== undefined
294
+ ? [
295
+ {
296
+ type: "text",
297
+ text: truncateUtf8(JSON.stringify(details), MAX_STRUCTURED_CONTENT_FALLBACK_BYTES),
298
+ },
299
+ ]
300
+ : record.content;
301
+ return parseOmpHostToolAgentResult({
302
+ content,
303
+ ...(details !== undefined ? { details } : {}),
304
+ ...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
305
+ });
306
+ }
307
+ if (Object.hasOwn(record, "toolResult")) {
308
+ return parseOmpHostToolAgentResult({
309
+ content: [{ type: "text", text: "MCP tool completed" }],
310
+ details: record.toolResult,
311
+ });
312
+ }
313
+ throw new Error("MCP tool returned an unsupported result");
314
+ }
315
+
316
+ function errorResult(id: string, message: string): OmpHostToolResult {
317
+ return {
318
+ type: "host_tool_result",
319
+ id,
320
+ result: {
321
+ content: [{ type: "text", text: message }],
322
+ details: {},
323
+ isError: true,
324
+ },
325
+ isError: true,
326
+ };
327
+ }
328
+
329
+ async function settleCleanup(promises: readonly Promise<void>[]): Promise<void> {
330
+ const results = await Promise.allSettled(promises);
331
+ const failures = results
332
+ .filter((result): result is PromiseRejectedResult => result.status === "rejected")
333
+ .map((result) => result.reason);
334
+ if (failures.length > 0) throw new AggregateError(failures, "OMP MCP cleanup failed");
335
+ }
336
+ function paseoBrowserFailure(error: unknown): OmpPublicError {
337
+ const detail = error instanceof Error ? error.message : String(error);
338
+ if (/browser_no_host/iu.test(detail)) {
339
+ return new OmpPublicError("No Paseo desktop browser host is connected");
340
+ }
341
+ if (/browser_disabled/iu.test(detail)) {
342
+ return new OmpPublicError("Paseo browser tools are disabled on this host");
343
+ }
344
+ return new OmpPublicError("Paseo could not open the MCP authorization browser tab");
345
+ }
346
+
347
+ export class OmpHostToolsBridge {
348
+ private runtime: OmpRuntimeSession | null = null;
349
+ private readonly pending = new Map<string, PendingCall>();
350
+ private pendingBytes = 0;
351
+ private generation = 0;
352
+ private closePromise: Promise<void> | null = null;
353
+ private fatalHandler: ((error: Error) => void) | null = null;
354
+ private activeDirectBrowserCalls = 0;
355
+
356
+ readonly labels: ReadonlyMap<string, string>;
357
+ private constructor(
358
+ private readonly connections: readonly OmpMcpConnection[],
359
+ private readonly definitions: readonly OmpHostToolDefinition[],
360
+ private readonly targets: ReadonlyMap<string, ToolTarget>,
361
+ private readonly callTimeoutMs: number,
362
+ private readonly callScheduler: OmpHostToolScheduler,
363
+ ) {
364
+ this.labels = new Map(definitions.map(({ name, label }) => [name, label ?? name]));
365
+ }
366
+ static async open(
367
+ config: ProviderSessionConfig,
368
+ options: OmpHostToolsOpenOptions = {},
369
+ ): Promise<OmpHostToolsBridge> {
370
+ validateOmpHostToolConfig(config);
371
+ const servers = classifyServers(config);
372
+ const namespaces = serverNamespaces(servers);
373
+ const connectMcp = options.connectMcp ?? connectMcpServer;
374
+ const callTimeoutMs = options.callTimeoutMs ?? DEFAULT_MCP_CALL_LIFETIME_MS;
375
+ if (!Number.isInteger(callTimeoutMs) || callTimeoutMs <= 0 || callTimeoutMs > 60 * 60 * 1000) {
376
+ throw new OmpPublicError("OMP MCP call lifetime is invalid");
377
+ }
378
+ const callScheduler = options.callScheduler ?? DEFAULT_CALL_SCHEDULER;
379
+ const initialization = new AbortController();
380
+ const abortFromCaller = () => initialization.abort(options.signal?.reason);
381
+ options.signal?.addEventListener("abort", abortFromCaller, { once: true });
382
+ if (options.signal?.aborted) abortFromCaller();
383
+ const timeout = setTimeout(
384
+ () => initialization.abort(new Error("OMP MCP host tool initialization timed out")),
385
+ options.initializationTimeoutMs ?? DEFAULT_INITIALIZATION_TIMEOUT_MS,
386
+ );
387
+ const connections: OmpMcpConnection[] = [];
388
+ const definitions: OmpHostToolDefinition[] = [];
389
+ const targets = new Map<string, ToolTarget>();
390
+ try {
391
+ for (const server of servers) {
392
+ initialization.signal.throwIfAborted();
393
+ const connecting = connectMcp(
394
+ server.name,
395
+ server.config,
396
+ config.cwd,
397
+ initialization.signal,
398
+ );
399
+ let connection: OmpMcpConnection;
400
+ try {
401
+ connection =
402
+ connectMcp === connectMcpServer
403
+ ? await connecting
404
+ : await abortable(connecting, initialization.signal);
405
+ } catch (error) {
406
+ if (connectMcp !== connectMcpServer && initialization.signal.aborted) {
407
+ const cleanup = connecting.then(
408
+ (lateConnection) => lateConnection.close(),
409
+ () => undefined,
410
+ );
411
+ throw new OmpCleanupFailure(
412
+ "OMP MCP connection initialization was interrupted",
413
+ cleanup,
414
+ );
415
+ }
416
+ throw error;
417
+ }
418
+ connections.push(connection);
419
+ const tools = (await discoverMcpTools(connection, initialization.signal)).sort(
420
+ (left, right) => left.name.localeCompare(right.name),
421
+ );
422
+ const namespace = namespaces.get(server.name);
423
+ if (!namespace) throw new Error("MCP server namespace is unavailable");
424
+ for (const tool of tools) {
425
+ if (
426
+ !tool.name ||
427
+ utf8Bytes(tool.name) > MAX_HOST_TOOL_NAME_BYTES ||
428
+ utf8Bytes(tool.description ?? "") > MAX_HOST_TOOL_DESCRIPTION_BYTES ||
429
+ !tool.inputSchema ||
430
+ typeof tool.inputSchema !== "object" ||
431
+ Array.isArray(tool.inputSchema) ||
432
+ boundedJsonBytes(tool.inputSchema, MAX_HOST_TOOL_SCHEMA_BYTES) ===
433
+ Number.POSITIVE_INFINITY
434
+ ) {
435
+ throw new Error("MCP server exposed an invalid host tool definition");
436
+ }
437
+ if (definitions.length >= MAX_HOST_TOOLS) {
438
+ throw new Error("MCP servers exposed too many host tools");
439
+ }
440
+ let name = exposedToolName(server, namespace, tool.name);
441
+ if (targets.has(name)) {
442
+ const suffix = digest(`${server.name}\0${tool.name}`);
443
+ name = `${name.slice(0, MAX_HOST_TOOL_NAME_BYTES - suffix.length - 1)}_${suffix}`;
444
+ }
445
+ if (targets.has(name)) throw new Error("MCP host tool names collide");
446
+ const normalizedServerName = safeName(server.name, "server");
447
+ const normalizedToolName = safeName(tool.name, "tool");
448
+ const friendlyToolName = normalizedToolName.startsWith(`${normalizedServerName}_`)
449
+ ? normalizedToolName.slice(normalizedServerName.length + 1)
450
+ : tool.name;
451
+ const fallbackLabel = `${humanizeName(server.name)}: ${humanizeName(friendlyToolName)}`;
452
+ definitions.push({
453
+ name,
454
+ label: boundedText(tool.title ?? fallbackLabel, MAX_HOST_TOOL_LABEL_BYTES, name),
455
+ description: boundedText(
456
+ tool.description ?? `MCP tool from ${server.name}`,
457
+ MAX_HOST_TOOL_DESCRIPTION_BYTES,
458
+ "MCP tool",
459
+ ),
460
+ loadMode:
461
+ server.internal || server.config.alwaysLoad === true ? "essential" : "discoverable",
462
+ parameters: structuredClone(tool.inputSchema),
463
+ });
464
+ targets.set(name, { toolName: tool.name, connection });
465
+ }
466
+ }
467
+ if (
468
+ boundedJsonBytes(definitions, MAX_HOST_TOOL_CATALOG_BYTES, MAX_HOST_TOOLS, 64 * 1024) ===
469
+ Number.POSITIVE_INFINITY
470
+ ) {
471
+ throw new OmpPublicError("OMP host tool catalog exceeds the RPC frame limit");
472
+ }
473
+ return new OmpHostToolsBridge(
474
+ connections,
475
+ definitions,
476
+ targets,
477
+ callTimeoutMs,
478
+ callScheduler,
479
+ );
480
+ } catch (error) {
481
+ const cleanupTasks = [
482
+ ...connections.map((connection) => Promise.resolve().then(() => connection.close())),
483
+ ...(error instanceof OmpCleanupFailure ? [error.cleanup] : []),
484
+ ];
485
+ const cleanup = settleCleanup(cleanupTasks);
486
+ if (initialization.signal.aborted || error instanceof OmpCleanupFailure) {
487
+ throw new OmpCleanupFailure("OMP MCP host tool initialization cleanup pending", cleanup);
488
+ }
489
+ try {
490
+ await cleanup;
491
+ } catch {
492
+ throw new OmpCleanupFailure("OMP MCP host tool cleanup failed", cleanup);
493
+ }
494
+ if (error instanceof OmpPublicError) throw error;
495
+ throw new OmpPublicError("OMP could not initialize configured MCP host tools");
496
+ } finally {
497
+ clearTimeout(timeout);
498
+ options.signal?.removeEventListener("abort", abortFromCaller);
499
+ }
500
+ }
501
+
502
+ async bind(runtime: OmpRuntimeSession): Promise<void> {
503
+ if (this.closePromise) throw new Error("OMP host tool bridge is closed");
504
+ this.detach();
505
+ this.runtime = runtime;
506
+ try {
507
+ const accepted = await runtime.setHostTools([...this.definitions]);
508
+ const expected = this.definitions.map(({ name }) => name);
509
+ const uniqueAccepted = new Set(accepted);
510
+ if (
511
+ accepted.length !== expected.length ||
512
+ uniqueAccepted.size !== accepted.length ||
513
+ expected.some((name) => !uniqueAccepted.has(name))
514
+ ) {
515
+ throw new OmpPublicError("OMP rejected the configured host tool catalog");
516
+ }
517
+ if (this.runtime !== runtime) {
518
+ throw new Error("OMP runtime detached during host tool binding");
519
+ }
520
+ } catch (error) {
521
+ if (this.runtime === runtime) this.detach();
522
+ throw error;
523
+ }
524
+ }
525
+ isBoundTo(runtime: OmpRuntimeSession): boolean {
526
+ return this.runtime === runtime && this.closePromise === null;
527
+ }
528
+
529
+ onFatal(handler: (error: Error) => void): void {
530
+ this.fatalHandler = handler;
531
+ }
532
+ async openPaseoBrowser(url: string): Promise<void> {
533
+ let parsed: URL;
534
+ try {
535
+ parsed = new URL(url);
536
+ } catch {
537
+ throw new OmpPublicError("MCP authorization URL is invalid");
538
+ }
539
+ if (
540
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
541
+ parsed.username ||
542
+ parsed.password
543
+ ) {
544
+ throw new OmpPublicError("MCP authorization URL is not safe to open");
545
+ }
546
+ if (!this.runtime || this.closePromise) {
547
+ throw new OmpPublicError("The OMP session is not ready for browser authorization");
548
+ }
549
+ const target = this.targets.get("browser_new_tab");
550
+ if (!target) {
551
+ throw new OmpPublicError("Paseo browser tools are unavailable in this OMP session");
552
+ }
553
+ if (this.activeDirectBrowserCalls >= MAX_DIRECT_BROWSER_CALLS) {
554
+ throw new OmpPublicError("Too many Paseo browser requests are already running");
555
+ }
556
+
557
+ this.activeDirectBrowserCalls += 1;
558
+ const controller = new AbortController();
559
+ const deadline = this.callScheduler.set(
560
+ () => controller.abort(new Error("Paseo browser request timed out")),
561
+ BROWSER_CALL_TIMEOUT_MS,
562
+ );
563
+ try {
564
+ const result = normalizeResult(
565
+ await target.connection.callTool(
566
+ target.toolName,
567
+ { i: "Opening MCP authorization", url },
568
+ {
569
+ signal: controller.signal,
570
+ maxTotalTimeoutMs: BROWSER_CALL_TIMEOUT_MS,
571
+ onProgress() {},
572
+ },
573
+ ),
574
+ );
575
+ if (result.isError) {
576
+ const detail = result.content
577
+ .flatMap((part) => (part.type === "text" && part.text ? [part.text] : []))
578
+ .join("\n");
579
+ throw paseoBrowserFailure(new Error(detail));
580
+ }
581
+ } catch (error) {
582
+ if (error instanceof OmpPublicError) throw error;
583
+ throw paseoBrowserFailure(error);
584
+ } finally {
585
+ this.callScheduler.clear(deadline);
586
+ this.activeDirectBrowserCalls -= 1;
587
+ }
588
+ }
589
+
590
+ handle(
591
+ event: OmpHostToolCall | { type: "host_tool_cancel"; id: string; targetId: string },
592
+ ): boolean {
593
+ if (event.type === "host_tool_cancel") {
594
+ const pending = this.pending.get(event.targetId);
595
+ if (pending) {
596
+ this.releasePending(event.targetId, pending);
597
+ pending.controller.abort(new Error("OMP host tool call cancelled"));
598
+ }
599
+ // OMP removes and rejects its pending host call before emitting host_tool_cancel. Sending a
600
+ // terminal result would be orphaned; aborting and releasing host state is the full handshake.
601
+ return true;
602
+ }
603
+ const runtime = this.runtime;
604
+ if (!runtime) return true;
605
+ const target = this.targets.get(event.toolName);
606
+ if (!target) {
607
+ this.sendTerminal(runtime, errorResult(event.id, "Unknown OMP host tool"));
608
+ return true;
609
+ }
610
+ const retainedBytes = boundedJsonBytes(
611
+ event.arguments,
612
+ MAX_PENDING_HOST_TOOL_BYTES,
613
+ 1_024,
614
+ MAX_PENDING_HOST_TOOL_BYTES,
615
+ 4_096,
616
+ );
617
+ if (
618
+ this.pending.has(event.id) ||
619
+ this.pending.size >= MAX_PENDING_HOST_TOOL_CALLS ||
620
+ retainedBytes === Number.POSITIVE_INFINITY ||
621
+ this.pendingBytes + retainedBytes > MAX_PENDING_HOST_TOOL_BYTES
622
+ ) {
623
+ this.sendTerminal(runtime, errorResult(event.id, "OMP host tool bridge is at capacity"));
624
+ return true;
625
+ }
626
+ const pending: PendingCall = {
627
+ controller: new AbortController(),
628
+ runtime,
629
+ generation: this.generation,
630
+ retainedBytes,
631
+ deadline: null,
632
+ };
633
+ pending.deadline = this.callScheduler.set(
634
+ () => this.expirePending(event.id, pending),
635
+ this.callTimeoutMs,
636
+ );
637
+ this.pending.set(event.id, pending);
638
+ this.pendingBytes += retainedBytes;
639
+ void target.connection
640
+ .callTool(target.toolName, event.arguments, {
641
+ signal: pending.controller.signal,
642
+ maxTotalTimeoutMs: this.callTimeoutMs,
643
+ onProgress: (progress) => {
644
+ if (!this.isCurrent(event.id, pending)) return;
645
+ if (boundedJsonBytes(progress, MAX_HOST_TOOL_RESULT_BYTES) === Number.POSITIVE_INFINITY) {
646
+ return;
647
+ }
648
+ try {
649
+ runtime.sendHostToolUpdate({
650
+ type: "host_tool_update",
651
+ id: event.id,
652
+ partialResult: { content: [], details: progress },
653
+ });
654
+ } catch {
655
+ // Progress is advisory. A failed update must not escape the MCP callback or settle the call.
656
+ }
657
+ },
658
+ })
659
+ .then((result) => {
660
+ if (!this.isCurrent(event.id, pending)) return;
661
+ let terminal: OmpHostToolResult;
662
+ try {
663
+ const normalized = normalizeResult(result);
664
+ terminal = {
665
+ type: "host_tool_result",
666
+ id: event.id,
667
+ result: normalized,
668
+ ...(normalized.isError !== undefined ? { isError: normalized.isError } : {}),
669
+ };
670
+ } catch {
671
+ terminal = errorResult(event.id, "MCP host tool execution failed");
672
+ }
673
+ this.sendTerminal(runtime, terminal, pending);
674
+ })
675
+ .catch(() => {
676
+ if (!this.isCurrent(event.id, pending)) return;
677
+ this.sendTerminal(
678
+ runtime,
679
+ errorResult(event.id, "MCP host tool execution failed"),
680
+ pending,
681
+ );
682
+ })
683
+ .catch(() => undefined)
684
+ .finally(() => this.releasePending(event.id, pending));
685
+ return true;
686
+ }
687
+
688
+ detach(): void {
689
+ this.runtime = null;
690
+ this.generation += 1;
691
+ for (const [id, pending] of this.pending) {
692
+ this.releasePending(id, pending);
693
+ pending.controller.abort(new Error("OMP runtime detached"));
694
+ }
695
+ }
696
+
697
+ close(): Promise<void> {
698
+ this.closePromise ??= this.closeConnections();
699
+ return this.closePromise;
700
+ }
701
+
702
+ private boundedTerminal(
703
+ runtime: OmpRuntimeSession,
704
+ result: OmpHostToolResult,
705
+ ): OmpHostToolResult {
706
+ const limit = runtime.maxHostToolFrameBytes ?? 1024 * 1024;
707
+ try {
708
+ if (Buffer.byteLength(`${JSON.stringify(result)}\n`) <= limit) return result;
709
+ } catch {
710
+ // Fall through to the bounded error result.
711
+ }
712
+ return errorResult(result.id, OMP_HOST_TOOL_FRAME_LIMIT_ERROR);
713
+ }
714
+
715
+ private sendTerminal(
716
+ runtime: OmpRuntimeSession,
717
+ result: OmpHostToolResult,
718
+ pending?: PendingCall,
719
+ ): void {
720
+ const bounded = this.boundedTerminal(runtime, result);
721
+ if (pending && !this.isCurrent(bounded.id, pending)) return;
722
+ try {
723
+ runtime.sendHostToolResult(bounded);
724
+ } catch (error) {
725
+ this.failRuntime(runtime, error);
726
+ }
727
+ }
728
+
729
+ private failRuntime(runtime: OmpRuntimeSession, error: unknown): void {
730
+ if (this.runtime !== runtime) return;
731
+ const failure =
732
+ error instanceof Error ? error : new Error("OMP host tool result delivery failed");
733
+ this.detach();
734
+ if (this.fatalHandler) this.fatalHandler(failure);
735
+ else void runtime.close().catch(() => undefined);
736
+ }
737
+
738
+ private isCurrent(id: string, pending: PendingCall): boolean {
739
+ return (
740
+ this.pending.get(id) === pending &&
741
+ pending.generation === this.generation &&
742
+ pending.runtime === this.runtime &&
743
+ !pending.controller.signal.aborted
744
+ );
745
+ }
746
+
747
+ private expirePending(id: string, pending: PendingCall): void {
748
+ if (!this.isCurrent(id, pending)) return;
749
+ this.releasePending(id, pending);
750
+ pending.controller.abort(new Error("OMP MCP host tool call timed out"));
751
+ this.sendTerminal(pending.runtime, errorResult(id, "OMP MCP host tool call timed out"));
752
+ }
753
+
754
+ private releasePending(id: string, pending: PendingCall): void {
755
+ if (this.pending.get(id) !== pending) return;
756
+ this.pending.delete(id);
757
+ this.pendingBytes -= pending.retainedBytes;
758
+ if (pending.deadline !== null) this.callScheduler.clear(pending.deadline);
759
+ pending.deadline = null;
760
+ }
761
+
762
+ private async closeConnections(): Promise<void> {
763
+ this.detach();
764
+ await settleCleanup(
765
+ this.connections.map((connection) => Promise.resolve().then(() => connection.close())),
766
+ );
767
+ }
768
+ }
769
+
770
+ export function withOmpWorkspaceIdentity<
771
+ T extends { agentId: string; workspaceId: string | null; env: Record<string, string> },
772
+ >(request: T): Omit<T, "env"> & { env: Record<string, string> } {
773
+ const env: Record<string, string> = { ...request.env, PASEO_AGENT_ID: request.agentId };
774
+ if (request.workspaceId) env.PASEO_WORKSPACE_ID = request.workspaceId;
775
+ else delete env.PASEO_WORKSPACE_ID;
776
+ return { ...request, env };
777
+ }