pum-agent 0.2.34-beta.2 → 0.2.35-beta.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 (48) hide show
  1. package/README.md +10 -2
  2. package/package.json +1 -1
  3. package/src/animation.tsx +20 -7
  4. package/src/app.tsx +786 -40
  5. package/src/check-mode-prompt.ts +13 -13
  6. package/src/check-mode.ts +105 -10
  7. package/src/cli.ts +36 -1
  8. package/src/commands.ts +37 -1
  9. package/src/context-estimate.ts +55 -0
  10. package/src/context-guidance.ts +5 -5
  11. package/src/context-recovery.ts +22 -0
  12. package/src/context-window.ts +127 -48
  13. package/src/conversation-branch-popup.tsx +46 -0
  14. package/src/conversation-branch.ts +227 -0
  15. package/src/file-checkpoints.ts +337 -0
  16. package/src/file-mutation-guard.ts +224 -0
  17. package/src/filesystem-sandbox.ts +5 -2
  18. package/src/headless.ts +87 -9
  19. package/src/help-popup.tsx +6 -0
  20. package/src/index.tsx +2 -0
  21. package/src/lsp-files.ts +102 -0
  22. package/src/lsp-protocol.ts +275 -0
  23. package/src/lsp.ts +203 -0
  24. package/src/main.tsx +154 -16
  25. package/src/mcp-config.ts +100 -0
  26. package/src/mcp-process.ts +244 -0
  27. package/src/mcp-protocol.ts +385 -0
  28. package/src/mcp.ts +230 -0
  29. package/src/memory-context.ts +91 -0
  30. package/src/memory.ts +25 -23
  31. package/src/operational-context.ts +91 -0
  32. package/src/plan-mode.ts +139 -0
  33. package/src/project-validation.ts +331 -0
  34. package/src/replay.ts +6 -0
  35. package/src/request-diagnostics-access.ts +25 -0
  36. package/src/request-diagnostics.ts +210 -0
  37. package/src/runtime-settings.ts +352 -0
  38. package/src/sandbox/linux.ts +31 -6
  39. package/src/sandbox/types.ts +5 -0
  40. package/src/session-lock-runtime.ts +332 -25
  41. package/src/status-bar.tsx +2 -0
  42. package/src/status-metadata.ts +8 -1
  43. package/src/subagents/manager.ts +137 -23
  44. package/src/subagents/readonly.ts +34 -11
  45. package/src/theme.ts +10 -9
  46. package/src/tool-groups.ts +60 -26
  47. package/src/transcript-history.ts +93 -18
  48. package/src/web-search.ts +228 -33
@@ -0,0 +1,244 @@
1
+ import { lstat, opendir, realpath } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { posix } from "node:path";
5
+ import { AGENT_DIR } from "./config";
6
+ import { isCredentialSensitivePath } from "./credential-path";
7
+ import { createBubblewrapBackend } from "./sandbox/linux";
8
+ import type { SandboxPolicy, SandboxProcessHandle } from "./sandbox/types";
9
+
10
+ export interface McpProcessRequest {
11
+ executable: string;
12
+ args: string[];
13
+ cwd: string;
14
+ onStdout(data: Uint8Array): void;
15
+ onStderr(data: Uint8Array): void;
16
+ signal?: AbortSignal;
17
+ }
18
+ export interface McpProcessHandle {
19
+ write(data: string): Promise<void>;
20
+ close(): void;
21
+ kill(): void;
22
+ completed: Promise<unknown>;
23
+ }
24
+ export interface McpProcessAdapter { spawn(request: McpProcessRequest): Promise<McpProcessHandle> }
25
+
26
+ export const MCP_MAX_PENDING_WRITE_BYTES = 1024 * 1024;
27
+ export const MCP_MAX_PENDING_WRITES = 64;
28
+ export const MCP_WRITE_TIMEOUT_MS = 5_000;
29
+ export const MCP_CLOSE_GRACE_MS = 1_000;
30
+ const failure = () => new Error("MCP process unavailable or blocked by native policy");
31
+
32
+ export const MCP_SCAN_MAX_ENTRIES = 50_000;
33
+ export const MCP_SCAN_MAX_DEPTH = 32;
34
+ export const MCP_SCAN_MAX_MASKS = 2_048;
35
+ export const MCP_SCAN_MAX_PATH_BYTES = 256 * 1024;
36
+ export const MCP_SCAN_TIMEOUT_MS = 5_000;
37
+ export interface McpProcessAdapterOptions { configDir?: string }
38
+
39
+ const within = (root: string, path: string) => path === root || path.startsWith(root === "/" ? "/" : `${root}/`);
40
+ /** Pure comparison after caller validation/canonicalization; no authority grant. */
41
+ export function mcpConfigOverlapsSystemSources(configPaths: readonly string[], systemSources: readonly string[]): boolean {
42
+ return configPaths.some((config) => systemSources.some((source) =>
43
+ within(source, config) || within(config, source)));
44
+ }
45
+ // No broad /etc or home mount. Runtime libraries are readonly; the project is
46
+ // explicitly shared live. An outside executable gets its FILE, never its parent.
47
+ export const MCP_SYSTEM_MOUNTS: readonly string[] = Object.freeze(["/usr", "/bin", "/sbin", "/lib", "/lib64"].filter(existsSync));
48
+
49
+ /** Bind the existing config directory and every ancestor for this policy build.
50
+ * Missing, linked, malformed or replaced boundaries are never guessed. */
51
+ async function configBoundary(path: string): Promise<{ path: string; identity: string }> {
52
+ if (typeof path !== "string" || !posix.isAbsolute(path) || Buffer.byteLength(path) > 4096
53
+ || path.split("/").some((part) => part === "." || part === "..")
54
+ || /[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/.test(path)) throw failure();
55
+ const canonical = await exactPath(path, true);
56
+ const identities: string[] = [];
57
+ let prefix = "";
58
+ for (const part of canonical.split("/").filter(Boolean)) {
59
+ prefix += `/${part}`;
60
+ const stat = await lstat(prefix);
61
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw failure();
62
+ identities.push(`${prefix}:${stat.dev}:${stat.ino}`);
63
+ }
64
+ if (await exactPath(path, true) !== canonical) throw failure();
65
+ return { path: canonical, identity: identities.join("\n") };
66
+ }
67
+
68
+ async function exactPath(path: string, directory: boolean): Promise<string> {
69
+ if (!posix.isAbsolute(path) || path === "/" || /[\x00-\x1f\x7f]/.test(path)) throw failure();
70
+ const normalized = posix.normalize(path);
71
+ // Project/config boundaries must not be reached through links.
72
+ let prefix = "";
73
+ for (const part of normalized.split("/").filter(Boolean)) {
74
+ prefix += `/${part}`;
75
+ if ((await lstat(prefix)).isSymbolicLink()) throw failure();
76
+ }
77
+ const stat = await lstat(normalized);
78
+ if (directory ? !stat.isDirectory() : !stat.isFile() || !(stat.mode & 0o111)) throw failure();
79
+ return realpath(normalized);
80
+ }
81
+
82
+ /** Bounded check-time defense in depth, NOT a sanitized snapshot. Unknown names,
83
+ * future additions and aliases may expose secrets through server tool results. */
84
+ export async function buildMcpProcessPolicy(
85
+ request: Pick<McpProcessRequest, "cwd" | "executable" | "args" | "signal">,
86
+ options: McpProcessAdapterOptions = {},
87
+ ): Promise<SandboxPolicy> {
88
+ try {
89
+ const start = Date.now();
90
+ const check = () => { if (request.signal?.aborted || Date.now() - start > MCP_SCAN_TIMEOUT_MS) throw failure(); };
91
+ check();
92
+ const cwd = await exactPath(request.cwd, true);
93
+ // Standard /bin -> /usr/bin aliases are resolved before exact-file binding.
94
+ if (!posix.isAbsolute(request.executable) || Buffer.byteLength(request.executable) > 4096
95
+ || /[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/.test(request.executable)) throw failure();
96
+ const executable = await exactPath(await realpath(request.executable), false);
97
+ const configInput = options.configDir ?? AGENT_DIR;
98
+ const boundary = await configBoundary(configInput);
99
+ const forbidden = [...new Set([posix.normalize(configInput), boundary.path])];
100
+ // /lib and /usr/lib (likewise bin/sbin/lib64) can expose the same source
101
+ // through independent binds. A mask at one destination cannot protect all
102
+ // aliases. Refuse either containment direction, never project alias masks.
103
+ const systemSources: string[] = [];
104
+ for (const root of MCP_SYSTEM_MOUNTS) {
105
+ const source = await exactPath(await realpath(root), true);
106
+ systemSources.push(root, source);
107
+ }
108
+ if (mcpConfigOverlapsSystemSources(forbidden, systemSources)) throw failure();
109
+ const home = await realpath(homedir());
110
+ if (cwd === home || within(cwd, home) || isCredentialSensitivePath(cwd)
111
+ || forbidden.some((path) => within(path, cwd) || within(path, executable))
112
+ || isCredentialSensitivePath(executable)
113
+ || ["/proc", "/sys", "/dev", "/etc", ...MCP_SYSTEM_MOUNTS].some((path) => within(path, cwd))) throw failure();
114
+ if (!Array.isArray(request.args) || request.args.length > 16 || request.args.some((arg) =>
115
+ typeof arg !== "string" || Buffer.byteLength(arg) > 1024 || /[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/.test(arg))) throw failure();
116
+ let entries = 0;
117
+ let maskBytes = 0;
118
+ const deniedPaths: string[] = [];
119
+ const mask = (path: string) => {
120
+ deniedPaths.push(path);
121
+ maskBytes += Buffer.byteLength(path);
122
+ if (deniedPaths.length > MCP_SCAN_MAX_MASKS || maskBytes > MCP_SCAN_MAX_PATH_BYTES) throw failure();
123
+ };
124
+ const scan = async (directory: string, depth: number): Promise<void> => {
125
+ check();
126
+ if (depth > MCP_SCAN_MAX_DEPTH || (await lstat(directory)).isSymbolicLink()
127
+ || await realpath(directory) !== directory) throw failure();
128
+ for await (const entry of await opendir(directory)) {
129
+ check();
130
+ if (++entries > MCP_SCAN_MAX_ENTRIES) throw failure();
131
+ const path = posix.join(directory, entry.name);
132
+ const sensitive = isCredentialSensitivePath(`${path}/`) || isCredentialSensitivePath(path)
133
+ || forbidden.some((root) => within(root, path));
134
+ const stat = await lstat(path);
135
+ if (sensitive) {
136
+ // Mounting over a link could mask its target instead of this name.
137
+ if (stat.isSymbolicLink()) throw failure();
138
+ mask(path);
139
+ } else if (stat.isDirectory()) await scan(path, depth + 1);
140
+ else if (!stat.isFile() && !stat.isSymbolicLink()) throw failure();
141
+ // Never traverse symlinks. Aliases are a disclosed confidentiality limit.
142
+ // Refuse sockets/FIFOs/devices: readonly does not confine their IPC.
143
+ }
144
+ };
145
+ await scan(cwd, 0);
146
+ const currentBoundary = await configBoundary(configInput);
147
+ if (currentBoundary.path !== boundary.path || currentBoundary.identity !== boundary.identity) throw failure();
148
+ check();
149
+ if (within("/pum-mcp-private-tmp", cwd) || within("/pum-mcp-private-tmp", executable)) throw failure();
150
+ if (deniedPaths.some((path) => within(path, executable))) throw failure();
151
+ return {
152
+ version: 1, exactCommand: "MCP approved stdio process", executable, args: [...request.args], cwd,
153
+ readOnlyPaths: [cwd, executable], readWritePaths: [], deniedPaths,
154
+ privateTemp: "/pum-mcp-private-tmp", environment: { PATH: "/usr/bin:/bin", HOME: "/pum-mcp-private-tmp", TMPDIR: "/pum-mcp-private-tmp", LANG: "C.UTF-8" },
155
+ network: "deny", rationale: "Explicitly approved readonly live-project MCP; masks are check-time defense in depth", accesses: [],
156
+ };
157
+ } catch { throw failure(); }
158
+ }
159
+
160
+ /** No startup work until spawn. The controller, not this adapter, owns consent.
161
+ * Mandatory Linux Bubblewrap regardless of Check mode; never a direct fallback. */
162
+ export function createMcpProcessAdapter(options: McpProcessAdapterOptions = {}): McpProcessAdapter {
163
+ return {
164
+ async spawn(request) {
165
+ try {
166
+ if (process.platform !== "linux" || request.signal?.aborted) throw failure();
167
+ const bubblewrap = ["/usr/bin/bwrap", "/bin/bwrap"].find(existsSync);
168
+ if (!bubblewrap) throw failure();
169
+ const backend = createBubblewrapBackend({ executable: bubblewrap, systemMounts: MCP_SYSTEM_MOUNTS });
170
+ if ((await backend.probe()).state !== "enforced" || request.signal?.aborted) throw failure();
171
+ const policy = await buildMcpProcessPolicy(request, options);
172
+ if (request.signal?.aborted) throw failure();
173
+ return manageMcpProcessHandle(backend.spawn(policy, {
174
+ persistentStdin: true, signal: request.signal,
175
+ onStdout: request.onStdout, onStderr: request.onStderr,
176
+ }), request.signal ?? new AbortController().signal);
177
+ } catch { throw failure(); }
178
+ },
179
+ };
180
+ }
181
+
182
+ /** Transport-only lifecycle helper. It grants no process creation or filesystem access. */
183
+ export function manageMcpProcessHandle(child: SandboxProcessHandle, signal: AbortSignal): McpProcessHandle {
184
+ if (!child.write || !child.closeInput) {
185
+ void child.completed.catch(() => {});
186
+ try { child.kill(); } catch { /* Unsupported backend: no raw process errors. */ }
187
+ throw failure();
188
+ }
189
+ let closing = false;
190
+ let settled = false;
191
+ let killed = false;
192
+ let bytes = 0;
193
+ const pending = new Set<(error: Error) => void>();
194
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
195
+ const failWrites = () => { for (const reject of [...pending]) reject(failure()); };
196
+ const kill = () => {
197
+ if (settled || killed) return;
198
+ killed = closing = true;
199
+ failWrites();
200
+ try { child.kill(); } catch { /* Never surface backend process errors. */ }
201
+ };
202
+ signal.addEventListener("abort", kill, { once: true });
203
+ if (signal.aborted) kill();
204
+ const completed = child.completed.catch(() => { throw failure(); }).finally(() => {
205
+ settled = closing = true;
206
+ failWrites();
207
+ if (closeTimer) clearTimeout(closeTimer);
208
+ signal.removeEventListener("abort", kill);
209
+ });
210
+ void completed.catch(() => {});
211
+ return {
212
+ completed, kill,
213
+ close() {
214
+ if (closing || settled) return;
215
+ closing = true;
216
+ try { child.closeInput!(); } catch { kill(); return; }
217
+ closeTimer = setTimeout(kill, MCP_CLOSE_GRACE_MS);
218
+ },
219
+ write(data) {
220
+ const size = typeof data === "string" ? Buffer.byteLength(data) : Infinity;
221
+ if (closing || settled || signal.aborted || size > MCP_MAX_PENDING_WRITE_BYTES
222
+ || bytes + size > MCP_MAX_PENDING_WRITE_BYTES || pending.size >= MCP_MAX_PENDING_WRITES) {
223
+ return Promise.reject(failure());
224
+ }
225
+ bytes += size;
226
+ return new Promise<void>((resolve, reject) => {
227
+ let done = false;
228
+ const finish = (error?: Error) => {
229
+ if (done) return;
230
+ done = true;
231
+ clearTimeout(timer);
232
+ pending.delete(fail);
233
+ bytes -= size;
234
+ error ? reject(error) : resolve();
235
+ };
236
+ const fail = (error: Error) => finish(error);
237
+ const timer = setTimeout(() => { finish(failure()); kill(); }, MCP_WRITE_TIMEOUT_MS);
238
+ pending.add(fail);
239
+ try { void child.write!(data).then(() => finish(), () => { finish(failure()); kill(); }); }
240
+ catch { finish(failure()); kill(); }
241
+ });
242
+ },
243
+ };
244
+ }
@@ -0,0 +1,385 @@
1
+ /** Bounded, tools-only stdio subset of MCP 2025-11-25. No network or schema code generation.
2
+ * Reviewed: official specification/{2025-11-25}/{basic,basic/transports,
3
+ * basic/lifecycle,basic/utilities/cancellation,server/tools}.
4
+ * This deliberately rejects unsupported JSON Schema keywords rather than claiming full coverage.
5
+ */
6
+ export const MCP_PROTOCOL_VERSION = "2025-11-25";
7
+ export const MCP_LIMITS = Object.freeze({ frameBytes: 256 * 1024, stdoutBytes: 16 * 1024 * 1024,
8
+ discoveryBytes: 256 * 1024, tools: 32, pages: 4, schemaBytes: 16 * 1024,
9
+ argumentBytes: 16 * 1024, resultBytes: 16 * 1024, resultLines: 200,
10
+ depth: 8, nodes: 256, inFlight: 4, startupMs: 10_000, callMs: 30_000 });
11
+ export type McpSchema = { type: "object" | "array" | "string" | "number" | "integer" | "boolean" | "null";
12
+ description?: string; title?: string; $schema?: string; properties?: Record<string, McpSchema>;
13
+ required?: string[]; additionalProperties?: boolean; items?: McpSchema;
14
+ enum?: (string | number | boolean | null)[]; minimum?: number; maximum?: number;
15
+ minLength?: number; maxLength?: number; minItems?: number; maxItems?: number };
16
+ export interface McpTool { name: string; description?: string; inputSchema: McpSchema }
17
+ export interface McpToolResult { text: string; isError: boolean }
18
+ export interface McpProcessHandle { write(data: string): Promise<void>; close(): void; kill(): void; completed: Promise<unknown> }
19
+ export interface McpLaunchConfig { executable: string; args: string[]; cwd: string }
20
+ export interface McpSpawnRequest extends McpLaunchConfig { onStdout(chunk: Uint8Array | string): void;
21
+ onStderr(chunk: Uint8Array | string): void; signal?: AbortSignal }
22
+ export type McpSpawn = (request: McpSpawnRequest) => McpProcessHandle | Promise<McpProcessHandle>;
23
+ export interface McpProtocolOptions { signal?: AbortSignal; onClose?(reason: string): void; onToolsChanged?(): void }
24
+ const badKeys = new Set(["__proto__", "prototype", "constructor"]);
25
+ const controls = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
26
+ const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
27
+ const own = (value: object, key: string) => Object.hasOwn(value, key);
28
+ const fail = () => new Error("MCP data is invalid or unsupported.");
29
+ const bytes = (value: string) => Buffer.byteLength(value, "utf8");
30
+ const textField = (value: unknown, max = 2048): value is string => typeof value === "string" && bytes(value) <= max && !controls.test(value);
31
+
32
+ /** Walk before stringify: excludes cycles, getters, toJSON hooks, non-JSON values and excessive structure. */
33
+ function jsonData(value: unknown, maxBytes: number, depthLimit: number = MCP_LIMITS.depth, nodeLimit: number = MCP_LIMITS.nodes): string {
34
+ let nodes = 0;
35
+ let payloadBytes = 0;
36
+ const countText = (text: string): void => {
37
+ payloadBytes += bytes(text);
38
+ if (payloadBytes > maxBytes) throw fail();
39
+ };
40
+ const seen = new Set<object>();
41
+ const walk = (v: unknown, depth: number): void => {
42
+ if (++nodes > nodeLimit || depth > depthLimit) throw fail();
43
+ if (typeof v === "string") { countText(v); return; }
44
+ if (v === null || typeof v === "boolean") return;
45
+ if (typeof v === "number" && Number.isFinite(v)) return;
46
+ if (typeof v !== "object" || v === null || seen.has(v)) throw fail();
47
+ const proto = Object.getPrototypeOf(v);
48
+ if (proto !== Object.prototype && proto !== null && !(Array.isArray(v) && proto === Array.prototype)) throw fail();
49
+ seen.add(v);
50
+ const descriptors = Object.getOwnPropertyDescriptors(v);
51
+ if (Reflect.ownKeys(v).some(k => typeof k !== "string")) throw fail();
52
+ for (const [key, descriptor] of Object.entries(descriptors)) {
53
+ if (Array.isArray(v) && key === "length") continue;
54
+ if (badKeys.has(key) || !own(descriptor, "value") || !descriptor.enumerable) throw fail();
55
+ if (Array.isArray(v) && !/^(0|[1-9][0-9]*)$/.test(key)) throw fail();
56
+ if (!Array.isArray(v)) countText(key);
57
+ walk(descriptor.value, depth + 1);
58
+ }
59
+ if (Array.isArray(v) && Object.keys(descriptors).length !== v.length + 1) throw fail();
60
+ seen.delete(v);
61
+ };
62
+ walk(value, 0);
63
+ const serialized = JSON.stringify(value);
64
+ if (bytes(serialized) > maxBytes) throw fail();
65
+ return serialized;
66
+ }
67
+
68
+ /** Returns a detached, validated narrow 2020-12 schema. Unknown keywords fail closed. */
69
+ export function validateMcpSchema(value: unknown): McpSchema {
70
+ // Schema syntax has properties-map/required-array containers in addition to schema depth.
71
+ const serialized = jsonData(value, MCP_LIMITS.schemaBytes, 24, 2048);
72
+ let nodes = 0;
73
+ const check = (v: unknown, depth: number): void => {
74
+ if (!record(v) || ++nodes > MCP_LIMITS.nodes || depth > MCP_LIMITS.depth) throw fail();
75
+ const common = ["type", "description", "title", "$schema", "enum"];
76
+ const byType: Record<string, string[]> = { object: ["properties", "required", "additionalProperties"], array: ["items", "minItems", "maxItems"],
77
+ string: ["minLength", "maxLength"], number: ["minimum", "maximum"], integer: ["minimum", "maximum"], boolean: [], null: [] };
78
+ if (typeof v.type !== "string" || !own(byType, v.type)) throw fail();
79
+ if (Object.keys(v).some(key => !common.includes(key) && !byType[v.type as string]!.includes(key))) throw fail();
80
+ for (const field of ["description", "title"]) if (own(v, field) && !textField(v[field])) throw fail();
81
+ if (own(v, "$schema") && (depth !== 0 || v.$schema !== "https://json-schema.org/draft/2020-12/schema")) throw fail();
82
+ if (own(v, "enum")) {
83
+ if (!Array.isArray(v.enum) || !v.enum.length || v.enum.length > 64) throw fail();
84
+ const unique = new Set<string>();
85
+ for (const item of v.enum) {
86
+ if (item !== null && !["string", "number", "boolean"].includes(typeof item)) throw fail();
87
+ if (!matchesType(v.type, item) || unique.has(JSON.stringify(item))) throw fail();
88
+ unique.add(JSON.stringify(item));
89
+ }
90
+ }
91
+ if (v.type === "object") {
92
+ if (own(v, "properties")) {
93
+ if (!record(v.properties)) throw fail();
94
+ for (const [key, child] of Object.entries(v.properties)) {
95
+ if (!textField(key, 128) || !key || badKeys.has(key)) throw fail();
96
+ check(child, depth + 1);
97
+ }
98
+ }
99
+ if (own(v, "required")) {
100
+ if (!Array.isArray(v.required) || new Set(v.required).size !== v.required.length) throw fail();
101
+ for (const key of v.required) if (typeof key !== "string" || !record(v.properties) || !own(v.properties, key)) throw fail();
102
+ }
103
+ if (own(v, "additionalProperties") && typeof v.additionalProperties !== "boolean") throw fail();
104
+ }
105
+ if (v.type === "array") {
106
+ if (!own(v, "items")) throw fail();
107
+ check(v.items, depth + 1);
108
+ }
109
+ for (const [min, max] of [["minimum", "maximum"], ["minLength", "maxLength"], ["minItems", "maxItems"]] as const) {
110
+ for (const key of [min, max]) if (own(v, key) && (typeof v[key] !== "number" || !Number.isFinite(v[key]) ||
111
+ (key !== "minimum" && key !== "maximum" && (!Number.isSafeInteger(v[key]) || (v[key] as number) < 0)))) throw fail();
112
+ if (own(v, min) && own(v, max) && (v[min] as number) > (v[max] as number)) throw fail();
113
+ }
114
+ };
115
+ check(value, 0);
116
+ if (!record(value) || value.type !== "object") throw fail();
117
+ return JSON.parse(serialized) as McpSchema;
118
+ }
119
+ function matchesType(type: string, value: unknown): boolean {
120
+ switch (type) {
121
+ case "null": return value === null;
122
+ case "object": return record(value);
123
+ case "array": return Array.isArray(value);
124
+ case "number": return typeof value === "number" && Number.isFinite(value);
125
+ case "integer": return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value);
126
+ default: return typeof value === type;
127
+ }
128
+ }
129
+ export function validateMcpArguments(schema: McpSchema, value: unknown): Record<string, unknown> {
130
+ const serialized = jsonData(value, MCP_LIMITS.argumentBytes);
131
+ const check = (s: McpSchema, v: unknown): void => {
132
+ if (!matchesType(s.type, v) || (s.enum && !s.enum.some(item => item === v))) throw fail();
133
+ if (typeof v === "number" && ((s.minimum !== undefined && v < s.minimum) || (s.maximum !== undefined && v > s.maximum))) throw fail();
134
+ if (typeof v === "string") {
135
+ const length = [...v].length;
136
+ if ((s.minLength !== undefined && length < s.minLength) || (s.maxLength !== undefined && length > s.maxLength)) throw fail();
137
+ }
138
+ if (Array.isArray(v)) {
139
+ if ((s.minItems !== undefined && v.length < s.minItems) || (s.maxItems !== undefined && v.length > s.maxItems)) throw fail();
140
+ for (const item of v) check(s.items!, item);
141
+ } else if (record(v)) {
142
+ for (const key of s.required ?? []) if (!own(v, key)) throw fail();
143
+ for (const [key, item] of Object.entries(v)) {
144
+ if (s.properties && own(s.properties, key)) check(s.properties[key]!, item);
145
+ else if (s.additionalProperties === false) throw fail();
146
+ }
147
+ }
148
+ };
149
+ check(schema, value);
150
+ return JSON.parse(serialized) as Record<string, unknown>;
151
+ }
152
+ function parseTool(value: unknown): McpTool {
153
+ if (!record(value) || typeof value.name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(value.name) || badKeys.has(value.name)) throw fail();
154
+ if (own(value, "description") && !textField(value.description)) throw fail();
155
+ // Required task execution cannot be fulfilled by this tools-only client.
156
+ if (own(value, "execution") && (!record(value.execution) || (value.execution.taskSupport !== undefined &&
157
+ !["optional", "forbidden"].includes(value.execution.taskSupport as string)))) throw fail();
158
+ return { name: value.name, ...(typeof value.description === "string" ? { description: value.description } : {}), inputSchema: validateMcpSchema(value.inputSchema) };
159
+ }
160
+ function resultText(name: string, value: unknown): McpToolResult {
161
+ if (!record(value) || !Array.isArray(value.content) || (own(value, "isError") && typeof value.isError !== "boolean")) throw fail();
162
+ const pieces: string[] = [];
163
+ let omitted = own(value, "structuredContent");
164
+ for (const block of value.content) {
165
+ if (!record(block) || typeof block.type !== "string") throw fail();
166
+ if (block.type !== "text") { omitted = true; continue; }
167
+ if (typeof block.text !== "string") throw fail();
168
+ pieces.push(block.text);
169
+ }
170
+ const prefix = `[MCP tool ${name}: untrusted server output]\n`;
171
+ const marker = "\n[MCP output truncated or unsupported content omitted]";
172
+ const original = pieces.join("\n").replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, "");
173
+ const lines = original.split("\n");
174
+ let body = lines.slice(0, MCP_LIMITS.resultLines - 2).join("\n");
175
+ omitted ||= lines.length > MCP_LIMITS.resultLines - 2;
176
+ const budget = MCP_LIMITS.resultBytes - bytes(prefix) - bytes(marker);
177
+ if (bytes(body) > budget) {
178
+ let length = 0;
179
+ const chars: string[] = [];
180
+ for (const char of body) { const size = bytes(char); if (length + size > budget) break; chars.push(char); length += size; }
181
+ body = chars.join(""); omitted = true;
182
+ }
183
+ return { text: prefix + body + (omitted ? marker : ""), isError: value.isError === true };
184
+ }
185
+ interface Pending { method: string; resolve(value: { result: unknown; size: number }): void; reject(error: Error): void; cleanup(): void }
186
+
187
+ export class McpProtocolClient {
188
+ private handle?: McpProcessHandle;
189
+ private closed = false;
190
+ private ready = false;
191
+ private buffer = Buffer.alloc(0);
192
+ private bufferLength = 0;
193
+ private stdoutBytes = 0;
194
+ private nextId = 1;
195
+ private writes = 0;
196
+ private pending = new Map<number, Pending>();
197
+ private tools = new Map<string, McpTool>();
198
+ private listing = false;
199
+ private lifetime = new AbortController();
200
+ private detachSignal?: () => void;
201
+ private constructor(private options: McpProtocolOptions) {}
202
+
203
+ static async connect(spawn: McpSpawn, config: McpLaunchConfig, options: McpProtocolOptions = {}): Promise<McpProtocolClient> {
204
+ const client = new McpProtocolClient(options);
205
+ let rejectStartup!: (error: Error) => void;
206
+ const stopped = new Promise<never>((_, reject) => { rejectStartup = reject; });
207
+ const stop = () => { client.cancelPending(); client.closeWith("MCP connection cancelled."); rejectStartup(new Error("MCP connection cancelled.")); };
208
+ const timer = setTimeout(() => { client.closeWith("MCP startup timed out."); rejectStartup(new Error("MCP startup timed out.")); }, MCP_LIMITS.startupMs);
209
+ const signal = options.signal;
210
+ if (signal) {
211
+ signal.addEventListener("abort", stop, { once: true });
212
+ client.detachSignal = () => signal.removeEventListener("abort", stop);
213
+ }
214
+ // The rejection race is installed even for synchronous spawn failures/early aborts.
215
+ const start = async () => {
216
+ if (signal?.aborted) { stop(); throw new Error("MCP connection cancelled."); }
217
+ const handle = await spawn({ ...config, args: [...config.args], signal: client.lifetime.signal,
218
+ onStdout: chunk => client.receive(chunk), onStderr: () => {} });
219
+ if (client.closed) { client.disposeHandle(handle); throw new Error("MCP connection closed."); }
220
+ client.handle = handle;
221
+ void handle.completed.then(() => client.closeWith("MCP server exited."), () => client.closeWith("MCP server exited."));
222
+ const { result } = await client.request("initialize", { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "pum", version: "1" } }, MCP_LIMITS.startupMs);
223
+ if (!record(result) || result.protocolVersion !== MCP_PROTOCOL_VERSION || !record(result.capabilities) || !record(result.capabilities.tools) ||
224
+ !record(result.serverInfo) || !textField(result.serverInfo.name) || !textField(result.serverInfo.version)) throw fail();
225
+ await client.send({ jsonrpc: "2.0", method: "notifications/initialized" });
226
+ client.assertOpen();
227
+ client.ready = true;
228
+ return client;
229
+ };
230
+ try { return await Promise.race([start(), stopped]); }
231
+ catch { client.closeWith("MCP connection failed."); throw new Error("MCP connection failed."); }
232
+ finally { clearTimeout(timer); }
233
+ }
234
+ private assertOpen(): void { if (this.closed || !this.handle) throw new Error("MCP connection closed."); }
235
+ private assertRequestActive(signal?: AbortSignal): void {
236
+ // A response removes its pending listener before the awaiting caller resumes.
237
+ // Abort in that microtask gap still withdraws the connection and its toolset.
238
+ if (signal?.aborted && !this.closed) {
239
+ this.cancelPending();
240
+ this.closeWith("MCP request cancelled.");
241
+ }
242
+ this.assertOpen();
243
+ }
244
+ private disposeHandle(handle: McpProcessHandle): void {
245
+ try { handle.close(); } catch { /* best effort */ }
246
+ try { handle.kill(); } catch { /* best effort */ }
247
+ void handle.completed.catch(() => {});
248
+ }
249
+ close(): void { this.closeWith("MCP connection closed."); }
250
+ private cancelPending(): void {
251
+ if (this.closed) return;
252
+ for (const [id, request] of this.pending) {
253
+ if (request.method !== "initialize") void this.send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id } }).catch(() => {});
254
+ }
255
+ }
256
+ private closeWith(reason: string): void {
257
+ if (this.closed) return;
258
+ this.closed = true; this.ready = false; this.buffer = Buffer.alloc(0); this.bufferLength = 0; this.tools.clear();
259
+ this.detachSignal?.();
260
+ for (const request of this.pending.values()) { request.cleanup(); request.reject(new Error(reason)); }
261
+ this.pending.clear();
262
+ this.lifetime.abort();
263
+ if (this.handle) this.disposeHandle(this.handle);
264
+ try { this.options.onClose?.(reason); } catch { /* observers cannot prevent cleanup */ }
265
+ }
266
+ private send(message: unknown): Promise<void> {
267
+ try {
268
+ this.assertOpen();
269
+ if (++this.writes > 8) { this.writes--; throw fail(); }
270
+ const result = this.handle!.write(JSON.stringify(message) + "\n");
271
+ return Promise.resolve(result).catch(() => { this.closeWith("MCP transport failed."); throw new Error("MCP transport failed."); }).finally(() => { this.writes--; });
272
+ } catch { this.closeWith("MCP transport failed."); return Promise.reject(new Error("MCP transport failed.")); }
273
+ }
274
+ private request(method: string, params: unknown, timeout: number, signal?: AbortSignal): Promise<{ result: unknown; size: number }> {
275
+ this.assertOpen();
276
+ if (signal?.aborted) { this.closeWith("MCP request cancelled."); return Promise.reject(new Error("MCP request cancelled.")); }
277
+ if (this.pending.size >= MCP_LIMITS.inFlight) return Promise.reject(new Error("MCP request limit reached."));
278
+ const id = this.nextId++;
279
+ return new Promise((resolve, reject) => {
280
+ const cancel = (reason: string) => {
281
+ if (!this.pending.has(id)) return;
282
+ this.cancelPending();
283
+ this.closeWith(reason);
284
+ };
285
+ const abort = () => cancel("MCP request cancelled.");
286
+ const timer = setTimeout(() => cancel("MCP request timed out."), timeout);
287
+ this.pending.set(id, { method, resolve, reject, cleanup: () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); } });
288
+ signal?.addEventListener("abort", abort, { once: true });
289
+ void this.send({ jsonrpc: "2.0", id, method, params }).catch(() => {});
290
+ });
291
+ }
292
+ private receive(chunk: Uint8Array | string): void {
293
+ if (this.closed) return;
294
+ try {
295
+ const data = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
296
+ this.stdoutBytes += data.byteLength;
297
+ if (this.stdoutBytes > MCP_LIMITS.stdoutBytes) throw fail();
298
+ let start = 0;
299
+ while (start < data.length && !this.closed) {
300
+ const newline = data.indexOf(10, start);
301
+ const end = newline < 0 ? data.length : newline;
302
+ const length = this.bufferLength + end - start;
303
+ if (length > MCP_LIMITS.frameBytes) throw fail();
304
+ // Geometric growth prevents quadratic copying from one-byte stdout chunks.
305
+ if (length > this.buffer.length) {
306
+ const capacity = Math.min(MCP_LIMITS.frameBytes, Math.max(length, 1024, this.buffer.length * 2));
307
+ const grown = Buffer.allocUnsafe(capacity);
308
+ this.buffer.copy(grown, 0, 0, this.bufferLength);
309
+ this.buffer = grown;
310
+ }
311
+ data.copy(this.buffer, this.bufferLength, start, end);
312
+ this.bufferLength = length;
313
+ if (newline < 0) break;
314
+ const frame = this.buffer.subarray(0, this.bufferLength);
315
+ this.bufferLength = 0;
316
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(frame);
317
+ this.message(JSON.parse(text), frame.length);
318
+ start = end + 1;
319
+ }
320
+ } catch { this.closeWith("MCP protocol failed."); }
321
+ }
322
+ private message(value: unknown, size: number): void {
323
+ if (!record(value) || value.jsonrpc !== "2.0") throw fail();
324
+ if (own(value, "method")) {
325
+ if (typeof value.method !== "string" || own(value, "result") || own(value, "error") || (own(value, "params") && !record(value.params))) throw fail();
326
+ if (own(value, "id")) {
327
+ if (!((typeof value.id === "string" && bytes(value.id) <= 128) || (typeof value.id === "number" && Number.isSafeInteger(value.id)))) throw fail();
328
+ void this.send(value.method === "ping" ? { jsonrpc: "2.0", id: value.id, result: {} } :
329
+ { jsonrpc: "2.0", id: value.id, error: { code: -32601, message: "Method not found" } }).catch(() => {});
330
+ } else if (value.method === "notifications/tools/list_changed") {
331
+ this.closeWith("MCP tool list changed.");
332
+ try { this.options.onToolsChanged?.(); } catch { /* closed already */ }
333
+ }
334
+ return;
335
+ }
336
+ if (typeof value.id !== "number" || !Number.isSafeInteger(value.id) || own(value, "result") === own(value, "error")) throw fail();
337
+ const pending = this.pending.get(value.id);
338
+ if (!pending) throw fail(); // unknown, mismatched, duplicate and late responses cannot revive anything
339
+ if (own(value, "error")) {
340
+ if (!record(value.error) || !Number.isInteger(value.error.code) || typeof value.error.message !== "string") throw fail();
341
+ this.closeWith("MCP server request failed."); return;
342
+ }
343
+ if (!record(value.result)) throw fail();
344
+ this.pending.delete(value.id); pending.cleanup(); pending.resolve({ result: value.result, size });
345
+ }
346
+ async listTools(signal?: AbortSignal): Promise<McpTool[]> {
347
+ this.assertOpen();
348
+ if (!this.ready || this.listing) throw new Error("MCP discovery unavailable.");
349
+ this.listing = true;
350
+ const deadline = Date.now() + MCP_LIMITS.startupMs;
351
+ try {
352
+ const found = new Map<string, McpTool>();
353
+ const cursors = new Set<string>();
354
+ let cursor: string | undefined;
355
+ let size = 0;
356
+ for (let page = 0; page < MCP_LIMITS.pages; page++) {
357
+ if (Date.now() >= deadline) throw fail();
358
+ const response = await this.request("tools/list", cursor === undefined ? {} : { cursor }, deadline - Date.now(), signal);
359
+ this.assertRequestActive(signal);
360
+ size += response.size;
361
+ const result = response.result;
362
+ if (size > MCP_LIMITS.discoveryBytes || !record(result) || !Array.isArray(result.tools) || result.tools.length + found.size > MCP_LIMITS.tools) throw fail();
363
+ for (const value of result.tools) { const tool = parseTool(value); if (found.has(tool.name)) throw fail(); found.set(tool.name, tool); }
364
+ if (!own(result, "nextCursor")) {
365
+ this.tools = found;
366
+ return structuredClone([...found.values()]);
367
+ }
368
+ if (!textField(result.nextCursor, 1024) || !result.nextCursor || cursors.has(result.nextCursor)) throw fail();
369
+ cursor = result.nextCursor; cursors.add(cursor);
370
+ }
371
+ throw fail();
372
+ } catch { this.closeWith("MCP discovery failed."); throw new Error("MCP discovery failed."); }
373
+ finally { this.listing = false; }
374
+ }
375
+ async callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpToolResult> {
376
+ this.assertOpen();
377
+ const tool = this.tools.get(name);
378
+ if (!this.ready || !tool) throw new Error("MCP tool unavailable.");
379
+ const argumentsValue = validateMcpArguments(tool.inputSchema, args);
380
+ const response = await this.request("tools/call", { name, arguments: argumentsValue }, MCP_LIMITS.callMs, signal);
381
+ this.assertRequestActive(signal);
382
+ try { return resultText(name, response.result); }
383
+ catch { this.closeWith("MCP result failed."); throw new Error("MCP result failed."); }
384
+ }
385
+ }