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