@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.
- package/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/SUPPORT.md +40 -0
- package/TESTING.md +147 -0
- package/client/hub-icon.tsx +12 -0
- package/client/hub-popover.tsx +132 -0
- package/client/hub-status.ts +29 -0
- package/client/memory-panel.tsx +71 -0
- package/client/memory-popover.tsx +70 -0
- package/client/omp-config-surface.tsx +1274 -0
- package/client/omp-doc-links.ts +117 -0
- package/client/omp-plugin-manager.tsx +833 -0
- package/client/provider-diagnostics-state.ts +250 -0
- package/client/provider-icon.tsx +27 -0
- package/client/provider-image.tsx +66 -0
- package/client/quota-popover.tsx +150 -0
- package/client/quota-state.ts +131 -0
- package/client/sessions-popover.tsx +73 -0
- package/docs/alpha-release-checklist.md +70 -0
- package/docs/configuration.md +122 -0
- package/docs/core-provider-issue-audit.md +108 -0
- package/docs/installation.md +73 -0
- package/index.client.tsx +272 -0
- package/index.server.ts +51 -0
- package/package.json +84 -0
- package/paseo-plugin.json +5 -0
- package/server/hub.ts +145 -0
- package/server/memory.ts +86 -0
- package/server/mutation-queue.ts +12 -0
- package/server/omp-config.ts +126 -0
- package/server/omp-plugins.ts +627 -0
- package/server/omp-settings.ts +291 -0
- package/server/paths.ts +64 -0
- package/server/provider/catalog.ts +173 -0
- package/server/provider/config-normalization.ts +148 -0
- package/server/provider/connection.ts +992 -0
- package/server/provider/host-tools.ts +706 -0
- package/server/provider/image.ts +143 -0
- package/server/provider/mcp-transport.ts +394 -0
- package/server/provider/omp-rpc.ts +2739 -0
- package/server/provider/omp.svg +5 -0
- package/server/provider/provider-options.ts +27 -0
- package/server/provider/registration.ts +151 -0
- package/server/provider/security.ts +317 -0
- package/server/provider/session-descriptors.ts +431 -0
- package/server/provider/session.ts +4451 -0
- package/server/provider/settings.ts +78 -0
- package/server/provider/subsessions.ts +847 -0
- package/server/provider/timeline-projector.ts +1764 -0
- package/server/provider-diagnostics.ts +1057 -0
- package/server/quota.ts +54 -0
- package/server/sessions.ts +58 -0
- package/shared/hub.ts +43 -0
- package/shared/memory.ts +23 -0
- package/shared/omp-config.ts +81 -0
- package/shared/omp-plugins.ts +223 -0
- package/shared/omp-settings.ts +207 -0
- package/shared/provider-diagnostics.ts +117 -0
- package/shared/provider-image.ts +160 -0
- package/shared/quota.ts +22 -0
- package/shared/sessions.ts +23 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, lstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export type OmpImageMimeType = "image/gif" | "image/jpeg" | "image/png" | "image/webp";
|
|
7
|
+
|
|
8
|
+
const IMAGE_MIME_TYPES: Readonly<Record<string, true>> = {
|
|
9
|
+
"image/gif": true,
|
|
10
|
+
"image/jpeg": true,
|
|
11
|
+
"image/png": true,
|
|
12
|
+
"image/webp": true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function isOmpImageMimeType(value: string): value is OmpImageMimeType {
|
|
16
|
+
return IMAGE_MIME_TYPES[value] === true;
|
|
17
|
+
}
|
|
18
|
+
const ATTACHMENT_DIRECTORY_PREFIX = "paseo-omp-attachments-";
|
|
19
|
+
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
20
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
21
|
+
const MAX_MATERIALIZED_IMAGE_BYTES = 16 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
function imageExtension(mimeType: OmpImageMimeType): string {
|
|
24
|
+
if (mimeType === "image/jpeg") return "jpg";
|
|
25
|
+
return mimeType.slice("image/".length);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class OmpImageMaterializer {
|
|
29
|
+
private directory: string | null = null;
|
|
30
|
+
private readonly files = new Map<string, { path: string; bytes: number; references: number }>();
|
|
31
|
+
private retainedBytes = 0;
|
|
32
|
+
|
|
33
|
+
constructor(private readonly maxBytes = MAX_MATERIALIZED_IMAGE_BYTES) {}
|
|
34
|
+
|
|
35
|
+
materialize(data: string, mimeType: OmpImageMimeType): string {
|
|
36
|
+
const bytes = Buffer.from(data, "base64");
|
|
37
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
38
|
+
const existing = this.files.get(hash);
|
|
39
|
+
if (existing) {
|
|
40
|
+
existing.references += 1;
|
|
41
|
+
return existing.path;
|
|
42
|
+
}
|
|
43
|
+
if (this.retainedBytes + bytes.byteLength > this.maxBytes) {
|
|
44
|
+
throw new Error("OMP materialized image budget exceeded");
|
|
45
|
+
}
|
|
46
|
+
if (!this.directory || !this.directoryIsReusable()) {
|
|
47
|
+
this.directory = mkdtempSync(join(tmpdir(), ATTACHMENT_DIRECTORY_PREFIX));
|
|
48
|
+
chmodSync(this.directory, PRIVATE_DIRECTORY_MODE);
|
|
49
|
+
this.files.clear();
|
|
50
|
+
this.retainedBytes = 0;
|
|
51
|
+
}
|
|
52
|
+
const path = join(this.directory, `${hash}.${imageExtension(mimeType)}`);
|
|
53
|
+
try {
|
|
54
|
+
writeFileSync(path, bytes, { mode: PRIVATE_FILE_MODE });
|
|
55
|
+
chmodSync(path, PRIVATE_FILE_MODE);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
rmSync(path, { force: true });
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
this.files.set(hash, { path, bytes: bytes.byteLength, references: 1 });
|
|
61
|
+
this.retainedBytes += bytes.byteLength;
|
|
62
|
+
return path;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
release(paths: readonly string[]): void {
|
|
66
|
+
for (const path of paths) {
|
|
67
|
+
const entry = [...this.files.entries()].find(([, candidate]) => candidate.path === path);
|
|
68
|
+
if (!entry) continue;
|
|
69
|
+
const [hash, file] = entry;
|
|
70
|
+
file.references -= 1;
|
|
71
|
+
if (file.references > 0) continue;
|
|
72
|
+
rmSync(file.path, { force: true });
|
|
73
|
+
this.files.delete(hash);
|
|
74
|
+
this.retainedBytes -= file.bytes;
|
|
75
|
+
}
|
|
76
|
+
if (this.files.size === 0 && this.directory) {
|
|
77
|
+
rmSync(this.directory, { force: true, recursive: true });
|
|
78
|
+
this.directory = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
clear(): void {
|
|
83
|
+
if (this.directory) rmSync(this.directory, { force: true, recursive: true });
|
|
84
|
+
this.files.clear();
|
|
85
|
+
this.retainedBytes = 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private directoryIsReusable(): boolean {
|
|
89
|
+
if (!this.directory) return false;
|
|
90
|
+
try {
|
|
91
|
+
if (!lstatSync(this.directory).isDirectory()) return false;
|
|
92
|
+
chmodSync(this.directory, PRIVATE_DIRECTORY_MODE);
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isValidImagePayload(
|
|
101
|
+
data: string,
|
|
102
|
+
mimeType: string,
|
|
103
|
+
maxEncodedLength: number,
|
|
104
|
+
): boolean {
|
|
105
|
+
if (
|
|
106
|
+
data.length === 0 ||
|
|
107
|
+
data.length > maxEncodedLength ||
|
|
108
|
+
data.length % 4 !== 0 ||
|
|
109
|
+
!/^[A-Za-z0-9+/]*={0,2}$/u.test(data) ||
|
|
110
|
+
!isOmpImageMimeType(mimeType)
|
|
111
|
+
) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const bytes = Buffer.from(data, "base64");
|
|
115
|
+
if (mimeType === "image/png") {
|
|
116
|
+
return (
|
|
117
|
+
bytes.length >= 8 &&
|
|
118
|
+
bytes[0] === 0x89 &&
|
|
119
|
+
bytes[1] === 0x50 &&
|
|
120
|
+
bytes[2] === 0x4e &&
|
|
121
|
+
bytes[3] === 0x47 &&
|
|
122
|
+
bytes[4] === 0x0d &&
|
|
123
|
+
bytes[5] === 0x0a &&
|
|
124
|
+
bytes[6] === 0x1a &&
|
|
125
|
+
bytes[7] === 0x0a
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (mimeType === "image/jpeg") {
|
|
129
|
+
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
|
130
|
+
}
|
|
131
|
+
if (mimeType === "image/gif") {
|
|
132
|
+
return (
|
|
133
|
+
bytes.length >= 6 &&
|
|
134
|
+
(bytes.subarray(0, 6).toString("ascii") === "GIF87a" ||
|
|
135
|
+
bytes.subarray(0, 6).toString("ascii") === "GIF89a")
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return (
|
|
139
|
+
bytes.length >= 12 &&
|
|
140
|
+
bytes.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
141
|
+
bytes.subarray(8, 12).toString("ascii") === "WEBP"
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
|
+
import type { ProviderMcpServerConfig } from "@getpaseo/plugin/server/provider";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
5
|
+
import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
6
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
7
|
+
import { deserializeMessage, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
|
|
8
|
+
import type { FetchLike, Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
9
|
+
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { terminateSpawnedProcessTree } from "./omp-rpc";
|
|
11
|
+
import { OmpCleanupFailure } from "./security";
|
|
12
|
+
|
|
13
|
+
const MAX_MCP_TRANSPORT_FRAME_BYTES = 1024 * 1024;
|
|
14
|
+
const PROCESS_EXIT_TIMEOUT_MS = 750;
|
|
15
|
+
|
|
16
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
17
|
+
|
|
18
|
+
function isConfirmedNoProcessSpawnFailure(error: unknown): boolean {
|
|
19
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
20
|
+
return code === "ENOENT" || code === "EACCES" || code === "EPERM";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type StdioTransportDependencies = {
|
|
24
|
+
spawnProcess?: (
|
|
25
|
+
command: string,
|
|
26
|
+
args: readonly string[],
|
|
27
|
+
options: {
|
|
28
|
+
cwd: string;
|
|
29
|
+
env: NodeJS.ProcessEnv;
|
|
30
|
+
detached: boolean;
|
|
31
|
+
windowsHide: boolean;
|
|
32
|
+
stdio: ["pipe", "pipe", "pipe"];
|
|
33
|
+
},
|
|
34
|
+
) => ChildProcessWithoutNullStreams;
|
|
35
|
+
terminateProcessTree?: (pid: number, platform: NodeJS.Platform) => Promise<boolean>;
|
|
36
|
+
platform?: NodeJS.Platform;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface ConnectedMcpTool {
|
|
40
|
+
name: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
inputSchema: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ConnectedMcpToolPage {
|
|
47
|
+
tools: readonly ConnectedMcpTool[];
|
|
48
|
+
nextCursor?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ConnectedMcpClient {
|
|
52
|
+
listTools(options: { signal: AbortSignal; cursor?: string }): Promise<ConnectedMcpToolPage>;
|
|
53
|
+
callTool(
|
|
54
|
+
name: string,
|
|
55
|
+
input: Record<string, unknown>,
|
|
56
|
+
options: {
|
|
57
|
+
signal: AbortSignal;
|
|
58
|
+
onProgress: (progress: unknown) => void;
|
|
59
|
+
maxTotalTimeoutMs: number;
|
|
60
|
+
},
|
|
61
|
+
): Promise<unknown>;
|
|
62
|
+
close(): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function waitForExit(exit: Promise<void>, timeoutMs: number): Promise<boolean> {
|
|
66
|
+
const result = Promise.withResolvers<boolean>();
|
|
67
|
+
const timeout: TimerHandle = setTimeout(() => result.resolve(false), timeoutMs);
|
|
68
|
+
void exit.then(() => result.resolve(true));
|
|
69
|
+
return result.promise.finally(() => clearTimeout(timeout));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class SupervisedStdioClientTransport implements Transport {
|
|
73
|
+
onclose?: () => void;
|
|
74
|
+
onerror?: (error: Error) => void;
|
|
75
|
+
onmessage?: <T extends JSONRPCMessage>(message: T) => void;
|
|
76
|
+
|
|
77
|
+
private child: ChildProcessWithoutNullStreams | null = null;
|
|
78
|
+
private lineParts: Buffer[] = [];
|
|
79
|
+
private lineBytes = 0;
|
|
80
|
+
private exited = false;
|
|
81
|
+
private closeNotified = false;
|
|
82
|
+
private readonly exit = Promise.withResolvers<void>();
|
|
83
|
+
private treeCleanup: Promise<boolean> | null = null;
|
|
84
|
+
private closePromise: Promise<void> | null = null;
|
|
85
|
+
private spawnFailedWithoutProcess = false;
|
|
86
|
+
private readonly platform: NodeJS.Platform;
|
|
87
|
+
|
|
88
|
+
constructor(
|
|
89
|
+
private readonly server: {
|
|
90
|
+
command: string;
|
|
91
|
+
args?: string[];
|
|
92
|
+
env?: Record<string, string>;
|
|
93
|
+
cwd: string;
|
|
94
|
+
},
|
|
95
|
+
private readonly dependencies: StdioTransportDependencies = {},
|
|
96
|
+
) {
|
|
97
|
+
this.platform = dependencies.platform ?? process.platform;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
start(): Promise<void> {
|
|
101
|
+
if (this.child || this.closePromise) {
|
|
102
|
+
return Promise.reject(new Error("MCP stdio transport already started or closed"));
|
|
103
|
+
}
|
|
104
|
+
const spawnProcess =
|
|
105
|
+
this.dependencies.spawnProcess ?? ((command, args, options) => spawn(command, args, options));
|
|
106
|
+
let child: ChildProcessWithoutNullStreams;
|
|
107
|
+
try {
|
|
108
|
+
child = spawnProcess(this.server.command, this.server.args ?? [], {
|
|
109
|
+
cwd: this.server.cwd,
|
|
110
|
+
env: { ...getDefaultEnvironment(), ...this.server.env },
|
|
111
|
+
detached: this.platform !== "win32",
|
|
112
|
+
windowsHide: true,
|
|
113
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return Promise.reject(error);
|
|
117
|
+
}
|
|
118
|
+
this.child = child;
|
|
119
|
+
child.stdout.on("data", (chunk: Buffer | string) => this.receiveData(chunk));
|
|
120
|
+
child.stdout.once("end", () => {
|
|
121
|
+
if (this.lineBytes > 0) this.fail(new Error("MCP stdio response ended mid-frame"));
|
|
122
|
+
});
|
|
123
|
+
child.stderr.resume();
|
|
124
|
+
child.stdin.on("error", (error) => this.fail(error));
|
|
125
|
+
child.once("exit", () => {
|
|
126
|
+
this.exited = true;
|
|
127
|
+
this.exit.resolve();
|
|
128
|
+
void this.startTreeCleanup().catch((error) => this.onerror?.(error));
|
|
129
|
+
this.notifyClose();
|
|
130
|
+
});
|
|
131
|
+
const started = Promise.withResolvers<void>();
|
|
132
|
+
child.once("spawn", started.resolve);
|
|
133
|
+
child.once("error", (error) => {
|
|
134
|
+
if (child.pid === undefined && isConfirmedNoProcessSpawnFailure(error)) {
|
|
135
|
+
this.spawnFailedWithoutProcess = true;
|
|
136
|
+
}
|
|
137
|
+
started.reject(error);
|
|
138
|
+
this.fail(error);
|
|
139
|
+
});
|
|
140
|
+
return started.promise;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
send(message: JSONRPCMessage): Promise<void> {
|
|
144
|
+
const child = this.child;
|
|
145
|
+
if (!child?.stdin.writable || this.closePromise) {
|
|
146
|
+
return Promise.reject(new Error("MCP stdio transport is closed"));
|
|
147
|
+
}
|
|
148
|
+
const payload = Buffer.from(serializeMessage(message));
|
|
149
|
+
if (payload.byteLength > MAX_MCP_TRANSPORT_FRAME_BYTES) {
|
|
150
|
+
return Promise.reject(new Error("MCP stdio request exceeds the transport frame limit"));
|
|
151
|
+
}
|
|
152
|
+
return new Promise<void>((resolve, reject) => {
|
|
153
|
+
child.stdin.write(payload, (error) => {
|
|
154
|
+
if (error) reject(error);
|
|
155
|
+
else resolve();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
close(): Promise<void> {
|
|
161
|
+
this.closePromise ??= this.closeTransport();
|
|
162
|
+
return this.closePromise;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private receiveData(chunk: Buffer | string): void {
|
|
166
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
167
|
+
let offset = 0;
|
|
168
|
+
while (offset < bytes.byteLength) {
|
|
169
|
+
const newline = bytes.indexOf(10, offset);
|
|
170
|
+
const end = newline < 0 ? bytes.byteLength : newline;
|
|
171
|
+
const part = bytes.subarray(offset, end);
|
|
172
|
+
this.lineBytes += part.byteLength;
|
|
173
|
+
if (this.lineBytes > MAX_MCP_TRANSPORT_FRAME_BYTES) {
|
|
174
|
+
this.fail(new Error("MCP stdio response exceeds the transport frame limit"));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (part.byteLength > 0) this.lineParts.push(part);
|
|
178
|
+
if (newline < 0) return;
|
|
179
|
+
this.emitLine();
|
|
180
|
+
offset = newline + 1;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private emitLine(): void {
|
|
185
|
+
const payload = Buffer.concat(this.lineParts, this.lineBytes);
|
|
186
|
+
this.lineParts = [];
|
|
187
|
+
this.lineBytes = 0;
|
|
188
|
+
if (payload.byteLength === 0) return;
|
|
189
|
+
try {
|
|
190
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(payload);
|
|
191
|
+
this.onmessage?.(deserializeMessage(text));
|
|
192
|
+
} catch {
|
|
193
|
+
this.fail(new Error("MCP stdio response is invalid"));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private fail(error: Error): void {
|
|
198
|
+
this.onerror?.(error);
|
|
199
|
+
void this.close().catch((cleanupError) => this.onerror?.(cleanupError));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private notifyClose(): void {
|
|
203
|
+
if (this.closeNotified) return;
|
|
204
|
+
this.closeNotified = true;
|
|
205
|
+
this.onclose?.();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private startTreeCleanup(): Promise<boolean> {
|
|
209
|
+
if (this.treeCleanup) return this.treeCleanup;
|
|
210
|
+
const pid = this.child?.pid;
|
|
211
|
+
this.treeCleanup =
|
|
212
|
+
pid === undefined
|
|
213
|
+
? Promise.resolve(this.spawnFailedWithoutProcess)
|
|
214
|
+
: (this.dependencies.terminateProcessTree ?? terminateSpawnedProcessTree)(
|
|
215
|
+
pid,
|
|
216
|
+
this.platform,
|
|
217
|
+
);
|
|
218
|
+
return this.treeCleanup;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async closeTransport(): Promise<void> {
|
|
222
|
+
const child = this.child;
|
|
223
|
+
if (!child) {
|
|
224
|
+
this.notifyClose();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!this.exited) {
|
|
228
|
+
try {
|
|
229
|
+
child.stdin.end();
|
|
230
|
+
} catch {
|
|
231
|
+
// Process-tree cleanup remains authoritative when stdin is already closed.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const terminated = await this.startTreeCleanup();
|
|
235
|
+
const exited =
|
|
236
|
+
this.spawnFailedWithoutProcess ||
|
|
237
|
+
this.exited ||
|
|
238
|
+
(await waitForExit(this.exit.promise, PROCESS_EXIT_TIMEOUT_MS));
|
|
239
|
+
this.notifyClose();
|
|
240
|
+
if (!terminated || !exited) throw new Error("MCP stdio process tree cleanup failed");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function boundedBody(
|
|
245
|
+
body: ReadableStream<Uint8Array>,
|
|
246
|
+
eventStream: boolean,
|
|
247
|
+
): ReadableStream<Uint8Array> {
|
|
248
|
+
let frameBytes = 0;
|
|
249
|
+
let lineBytes = 0;
|
|
250
|
+
let previousByte = -1;
|
|
251
|
+
return body.pipeThrough(
|
|
252
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
253
|
+
transform(chunk, controller) {
|
|
254
|
+
for (const byte of chunk) {
|
|
255
|
+
frameBytes += 1;
|
|
256
|
+
lineBytes += 1;
|
|
257
|
+
if (frameBytes > MAX_MCP_TRANSPORT_FRAME_BYTES) {
|
|
258
|
+
controller.error(new Error("MCP response exceeds the transport frame limit"));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (eventStream && byte === 10) {
|
|
262
|
+
const blankLine = lineBytes === 1 || (lineBytes === 2 && previousByte === 13);
|
|
263
|
+
if (blankLine) frameBytes = 0;
|
|
264
|
+
lineBytes = 0;
|
|
265
|
+
}
|
|
266
|
+
previousByte = byte;
|
|
267
|
+
}
|
|
268
|
+
controller.enqueue(chunk);
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function createBoundedMcpFetch(baseFetch: FetchLike = fetch): FetchLike {
|
|
275
|
+
return async (input, init) => {
|
|
276
|
+
const response = await baseFetch(input, init);
|
|
277
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
278
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_MCP_TRANSPORT_FRAME_BYTES) {
|
|
279
|
+
await response.body?.cancel().catch(() => undefined);
|
|
280
|
+
throw new Error("MCP response exceeds the transport frame limit");
|
|
281
|
+
}
|
|
282
|
+
if (!response.body) return response;
|
|
283
|
+
const eventStream =
|
|
284
|
+
response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
285
|
+
return new Response(boundedBody(response.body, eventStream), {
|
|
286
|
+
status: response.status,
|
|
287
|
+
statusText: response.statusText,
|
|
288
|
+
headers: response.headers,
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface McpConnectingClient {
|
|
294
|
+
connect(transport: Transport, options?: { signal?: AbortSignal }): Promise<void>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function closeMcpOwnership(
|
|
298
|
+
client: Pick<Client, "close">,
|
|
299
|
+
transport: Transport,
|
|
300
|
+
): Promise<void> {
|
|
301
|
+
const results = await Promise.allSettled([
|
|
302
|
+
Promise.resolve().then(() => client.close()),
|
|
303
|
+
Promise.resolve().then(() => transport.close()),
|
|
304
|
+
]);
|
|
305
|
+
const failures = results
|
|
306
|
+
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
|
|
307
|
+
.map((result) => result.reason);
|
|
308
|
+
if (failures.length > 0) throw new AggregateError(failures, "OMP MCP transport cleanup failed");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function connectMcpTransport(
|
|
312
|
+
client: McpConnectingClient,
|
|
313
|
+
transport: Transport,
|
|
314
|
+
signal: AbortSignal,
|
|
315
|
+
): Promise<void> {
|
|
316
|
+
const interrupted = Promise.withResolvers<never>();
|
|
317
|
+
let abortCleanup: Promise<void> | null = null;
|
|
318
|
+
const abort = () => {
|
|
319
|
+
abortCleanup ??= Promise.resolve().then(() => transport.close());
|
|
320
|
+
void abortCleanup.catch(() => undefined);
|
|
321
|
+
interrupted.reject(
|
|
322
|
+
new OmpCleanupFailure("OMP MCP connection initialization was interrupted", abortCleanup),
|
|
323
|
+
);
|
|
324
|
+
};
|
|
325
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
326
|
+
if (signal.aborted) {
|
|
327
|
+
abort();
|
|
328
|
+
signal.removeEventListener("abort", abort);
|
|
329
|
+
return await interrupted.promise;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const connecting = client.connect(transport, { signal });
|
|
333
|
+
await Promise.race([connecting, interrupted.promise]);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (error instanceof OmpCleanupFailure) throw error;
|
|
336
|
+
const cleanup = abortCleanup ?? Promise.resolve().then(() => transport.close());
|
|
337
|
+
try {
|
|
338
|
+
await cleanup;
|
|
339
|
+
} catch {
|
|
340
|
+
throw new OmpCleanupFailure("OMP MCP connection cleanup failed", cleanup);
|
|
341
|
+
}
|
|
342
|
+
throw error;
|
|
343
|
+
} finally {
|
|
344
|
+
signal.removeEventListener("abort", abort);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function connectMcpServer(
|
|
349
|
+
_name: string,
|
|
350
|
+
config: ProviderMcpServerConfig,
|
|
351
|
+
cwd: string,
|
|
352
|
+
signal: AbortSignal,
|
|
353
|
+
): Promise<ConnectedMcpClient> {
|
|
354
|
+
const client = new Client({ name: "paseo-omp-provider", version: "1.0.0" });
|
|
355
|
+
const requestInit = config.type === "stdio" ? undefined : { headers: config.headers };
|
|
356
|
+
const boundedFetch = createBoundedMcpFetch();
|
|
357
|
+
const transport =
|
|
358
|
+
config.type === "stdio"
|
|
359
|
+
? new SupervisedStdioClientTransport({
|
|
360
|
+
command: config.command,
|
|
361
|
+
args: config.args,
|
|
362
|
+
env: config.env,
|
|
363
|
+
cwd,
|
|
364
|
+
})
|
|
365
|
+
: config.type === "http"
|
|
366
|
+
? new StreamableHTTPClientTransport(new URL(config.url), {
|
|
367
|
+
requestInit,
|
|
368
|
+
fetch: boundedFetch,
|
|
369
|
+
})
|
|
370
|
+
: new SSEClientTransport(new URL(config.url), {
|
|
371
|
+
requestInit,
|
|
372
|
+
fetch: boundedFetch,
|
|
373
|
+
});
|
|
374
|
+
await connectMcpTransport(client, transport, signal);
|
|
375
|
+
return {
|
|
376
|
+
async listTools(options) {
|
|
377
|
+
const page = await client.listTools(options.cursor ? { cursor: options.cursor } : {}, {
|
|
378
|
+
signal: options.signal,
|
|
379
|
+
});
|
|
380
|
+
return { tools: page.tools, ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}) };
|
|
381
|
+
},
|
|
382
|
+
async callTool(name, input, options) {
|
|
383
|
+
return await client.callTool({ name, arguments: input }, undefined, {
|
|
384
|
+
signal: options.signal,
|
|
385
|
+
onprogress: (progress) => options.onProgress(progress),
|
|
386
|
+
resetTimeoutOnProgress: true,
|
|
387
|
+
maxTotalTimeout: options.maxTotalTimeoutMs,
|
|
388
|
+
});
|
|
389
|
+
},
|
|
390
|
+
async close() {
|
|
391
|
+
await closeMcpOwnership(client, transport);
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|