machine-bridge-mcp 0.18.1 → 1.0.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 (46) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +3 -1
  4. package/SECURITY.md +8 -6
  5. package/browser-extension/manifest.json +2 -2
  6. package/browser-extension/page-automation.js +0 -1
  7. package/docs/AGENT_CONTEXT.md +2 -1
  8. package/docs/ARCHITECTURE.md +3 -3
  9. package/docs/AUDIT.md +33 -0
  10. package/docs/ENGINEERING.md +5 -1
  11. package/docs/GETTING_STARTED.md +3 -0
  12. package/docs/LOCAL_AUTOMATION.md +1 -1
  13. package/docs/OPERATIONS.md +21 -1
  14. package/docs/PROJECT_STANDARDS.md +1 -1
  15. package/docs/TESTING.md +9 -3
  16. package/package.json +11 -3
  17. package/scripts/privacy-check.mjs +13 -13
  18. package/scripts/sarif-security-gate.mjs +146 -0
  19. package/src/local/account-access.mjs +0 -1
  20. package/src/local/atomic-fs.mjs +1 -1
  21. package/src/local/browser-bridge.mjs +2 -2
  22. package/src/local/browser-extension-protocol.mjs +1 -1
  23. package/src/local/browser-pairing-store.mjs +3 -4
  24. package/src/local/cli.mjs +22 -55
  25. package/src/local/default-instructions.mjs +4 -3
  26. package/src/local/errors.mjs +1 -5
  27. package/src/local/exclusive-file.mjs +9 -3
  28. package/src/local/full-access-test.mjs +5 -5
  29. package/src/local/job-runner.mjs +1 -1
  30. package/src/local/managed-jobs.mjs +27 -51
  31. package/src/local/relay-connection.mjs +7 -0
  32. package/src/local/runtime.mjs +20 -6
  33. package/src/local/secure-file.mjs +89 -10
  34. package/src/local/service.mjs +25 -35
  35. package/src/local/shell.mjs +32 -10
  36. package/src/local/ssh-key.mjs +71 -45
  37. package/src/local/state.mjs +46 -56
  38. package/src/local/worker-secret-file.mjs +130 -0
  39. package/src/local/workspace-file-service.mjs +10 -2
  40. package/src/shared/server-metadata.json +2 -3
  41. package/src/worker/http.ts +7 -8
  42. package/src/worker/index.ts +31 -41
  43. package/src/worker/mcp-session.ts +72 -0
  44. package/src/worker/oauth-state.ts +1 -1
  45. package/src/worker/pending-calls.ts +36 -5
  46. package/src/worker/tool-timeout.ts +18 -0
@@ -0,0 +1,130 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { lstatSync, readdirSync, unlinkSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { createExclusiveFileSync } from "./exclusive-file.mjs";
5
+ import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
6
+ import { chmodRegularFileSync, ensureOwnerOnlyDirectorySync } from "./secure-file.mjs";
7
+
8
+ const SECRET_FILE_PATTERN = /^worker-secrets-(\d+)-(\d+)(?:-p(\d+))?(?:-([a-f0-9]+))?\.json$/;
9
+
10
+ export async function withWorkerSecretsFile(state, callback, options = {}) {
11
+ const dir = state.paths.profileDir;
12
+ ensureOwnerOnlyDirectorySync(dir, options.directoryOptions);
13
+ cleanupStaleWorkerSecretFiles(dir, options);
14
+ const createdAt = integerTimestamp(options.now?.() ?? Date.now());
15
+ const processStartedAt = integerTimestamp(options.processStartedAtMs ?? currentProcessStartTimeMs());
16
+ const random = (options.randomBytes || randomBytes)(6).toString("hex");
17
+ const tempPath = resolve(dir, `worker-secrets-${process.pid}-${createdAt}-p${processStartedAt}-${random}.json`);
18
+ const payload = {
19
+ ACCOUNT_ADMIN_SECRET: state.worker.accountAdminSecret,
20
+ DAEMON_SHARED_SECRET: state.worker.daemonSecret,
21
+ OAUTH_TOKEN_VERSION: state.worker.oauthTokenVersion,
22
+ };
23
+
24
+ let created = false;
25
+ let createdIdentity = null;
26
+ let result;
27
+ let primaryError;
28
+ try {
29
+ createExclusiveFileSync(tempPath, JSON.stringify(payload), { mode: 0o600 });
30
+ created = true;
31
+ createdIdentity = fileIdentity((options.lstatSync || lstatSync)(tempPath));
32
+ (options.chmodFile || chmodRegularFileSync)(tempPath, 0o600, "temporary Worker secrets file");
33
+ result = await callback(tempPath);
34
+ } catch (error) {
35
+ primaryError = error;
36
+ }
37
+
38
+ let cleanupError;
39
+ if (created) {
40
+ try {
41
+ removeFile(tempPath, options.removeFile || unlinkSync, "could not remove temporary Worker secrets file", createdIdentity, options.lstatSync || lstatSync);
42
+ } catch (error) {
43
+ cleanupError = error;
44
+ }
45
+ }
46
+
47
+ if (primaryError && cleanupError) {
48
+ const message = primaryError instanceof Error && primaryError.message
49
+ ? primaryError.message
50
+ : "Worker deployment failed";
51
+ throw new AggregateError(
52
+ [primaryError, cleanupError],
53
+ `${message}; temporary Worker secrets cleanup also failed`,
54
+ );
55
+ }
56
+ if (primaryError) throw primaryError;
57
+ if (cleanupError) throw cleanupError;
58
+ return result;
59
+ }
60
+
61
+ export function cleanupStaleWorkerSecretFiles(dir, options = {}) {
62
+ const inspect = options.inspectProcess || inspectProcessInstance;
63
+ const remove = options.removeFile || unlinkSync;
64
+ const readDirectory = options.readDirectory || readdirSync;
65
+ const inspectPath = options.lstatSync || lstatSync;
66
+ for (const entry of readDirectory(dir, { withFileTypes: true })) {
67
+ if (!entry.isFile()) continue;
68
+ const match = SECRET_FILE_PATTERN.exec(entry.name);
69
+ if (!match) continue;
70
+ const file = resolve(dir, entry.name);
71
+ let snapshot;
72
+ try {
73
+ const info = inspectPath(file);
74
+ if (info.isSymbolicLink() || !info.isFile()) continue;
75
+ snapshot = fileIdentity(info);
76
+ } catch (error) {
77
+ if (error?.code === "ENOENT") continue;
78
+ throw new Error(`could not inspect temporary Worker secrets file: ${entry.name}`, { cause: error });
79
+ }
80
+ const pid = Number(match[1]);
81
+ const createdAt = Number(match[2]);
82
+ const processStartedAt = match[3] ? Number(match[3]) : null;
83
+ let identity;
84
+ try {
85
+ identity = inspect({
86
+ pid,
87
+ startedAt: new Date(createdAt).toISOString(),
88
+ processStartedAt: processStartedAt ? new Date(processStartedAt).toISOString() : undefined,
89
+ });
90
+ } catch (error) {
91
+ throw new Error(`could not inspect temporary Worker secrets owner: ${entry.name}`, { cause: error });
92
+ }
93
+ if (!identity?.reclaimable) continue;
94
+ removeFile(file, remove, `could not remove stale temporary Worker secrets file: ${entry.name}`, snapshot, inspectPath);
95
+ }
96
+ }
97
+
98
+ function removeFile(file, remove, message = "could not remove temporary Worker secrets file", expectedIdentity = null, inspectPath = lstatSync) {
99
+ try {
100
+ if (expectedIdentity) {
101
+ const current = inspectPath(file);
102
+ if (current.isSymbolicLink() || !current.isFile() || !sameFileIdentity(expectedIdentity, fileIdentity(current))) {
103
+ throw new Error("temporary Worker secrets file identity changed before cleanup");
104
+ }
105
+ }
106
+ remove(file);
107
+ } catch (error) {
108
+ if (error?.code === "ENOENT") return;
109
+ throw new Error(message, { cause: error });
110
+ }
111
+ }
112
+
113
+ function fileIdentity(info) {
114
+ return {
115
+ dev: Number(info.dev),
116
+ ino: Number(info.ino),
117
+ size: Number(info.size),
118
+ mtimeMs: Number(info.mtimeMs),
119
+ };
120
+ }
121
+
122
+ function sameFileIdentity(left, right) {
123
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
124
+ }
125
+
126
+ function integerTimestamp(value) {
127
+ const timestamp = Math.floor(Number(value));
128
+ if (!Number.isSafeInteger(timestamp) || timestamp <= 0) throw new Error("temporary Worker secrets timestamp is invalid");
129
+ return timestamp;
130
+ }
@@ -331,12 +331,20 @@ async function readUtf8File(filePath) {
331
331
  return decodeUtf8(buffer);
332
332
  }
333
333
 
334
+ async function applyExactMode(filePath, mode) {
335
+ try {
336
+ await chmod(filePath, mode);
337
+ } catch (error) {
338
+ if (process.platform !== "win32") throw error;
339
+ }
340
+ }
341
+
334
342
  async function atomicWriteText(full, content, existing = null, options = {}) {
335
343
  await mkdir(dirname(full), { recursive: true });
336
344
  const temp = join(dirname(full), `.${basename(full)}.mbm-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
337
345
  try {
338
346
  await writeFile(temp, content, { encoding: "utf8", flag: "wx", mode: existing ? existing.mode & 0o777 : 0o600 });
339
- if (existing) await chmod(temp, existing.mode & 0o777).catch(() => {});
347
+ if (existing) await applyExactMode(temp, existing.mode & 0o777);
340
348
  if (options.expectedHash) {
341
349
  const current = await readUtf8File(full).catch(() => null);
342
350
  if (current === null || sha256(current) !== options.expectedHash) throw new Error("file changed before atomic commit");
@@ -377,7 +385,7 @@ async function commitPatchTransaction(operations) {
377
385
  await mkdir(dirname(operation.target), { recursive: true });
378
386
  const temp = join(dirname(operation.target), `.${basename(operation.target)}.mbm-patch-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
379
387
  await writeFile(temp, operation.content, { encoding: "utf8", flag: "wx", mode: operation.mode });
380
- await chmod(temp, operation.mode).catch(() => {});
388
+ await applyExactMode(temp, operation.mode);
381
389
  staged.push({ operation, temp });
382
390
  }
383
391
 
@@ -2,13 +2,12 @@
2
2
  "name": "machine-bridge-mcp",
3
3
  "protocolVersion": "2025-11-25",
4
4
  "supportedProtocolVersions": [
5
- "2025-11-25",
6
- "2025-06-18",
7
- "2025-03-26"
5
+ "2025-11-25"
8
6
  ],
9
7
  "instructions": [
10
8
  "You are connected to a local workspace through machine-bridge-mcp.",
11
9
  "At the start of each substantive local task, call resolve_task_capabilities with the current user request and target path. Apply the returned session/project instructions, follow the automatically selected relevant skill when present, and prefer registered local commands for repeatable workflows.",
10
+ "Before any GitHub read or mutation, apply the effective project control-plane instructions. When a project requires local git, gh, and gh api through Machine Bridge, never use a hosted GitHub connector or ChatGPT GitHub plugin; if the local control plane is unavailable, stop rather than substitute another GitHub tool.",
12
11
  "The MCP initialize response automatically appends conservative built-in working agreements, bounded automatic project facts, the configured global model_instructions_file, and the current root instruction chain when the local daemon is reachable; session_bootstrap exposes the same material explicitly.",
13
12
  "For existing-browser work, use the paired Machine Bridge browser extension. It operates the user current Chromium profile and tabs, can inspect DOM/source, fill complex forms, and inject sensitive text from registered local resources without returning those values.",
14
13
  "For local application work, use structured application discovery and Accessibility actions. Arbitrary caller-supplied AppleScript or JavaScript is intentionally not accepted.",
@@ -43,15 +43,14 @@ export function authorizationRedirectLocation(redirectUri: string, code: string,
43
43
  return location;
44
44
  }
45
45
 
46
- export function json(value: unknown, status = 200): Response {
47
- return new Response(JSON.stringify(value), {
48
- status,
49
- headers: {
50
- "content-type": "application/json; charset=utf-8",
51
- "cache-control": "no-store",
52
- "x-content-type-options": "nosniff",
53
- },
46
+ export function json(value: unknown, status = 200, extraHeaders: HeadersInit = {}): Response {
47
+ const headers = new Headers({
48
+ "content-type": "application/json; charset=utf-8",
49
+ "cache-control": "no-store",
50
+ "x-content-type-options": "nosniff",
54
51
  });
52
+ for (const [name, headerValue] of new Headers(extraHeaders)) headers.set(name, headerValue);
53
+ return new Response(JSON.stringify(value), { status, headers });
55
54
  }
56
55
 
57
56
  export function html(value: string, status = 200): Response {
@@ -1,6 +1,8 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
  import serverMetadata from "../shared/server-metadata.json";
3
- import { PendingCallRegistry } from "./pending-calls";
3
+ import { PendingCallRegistrationError, PendingCallRegistry } from "./pending-calls";
4
+ import { mcpClientRequestKey, resolveMcpSession } from "./mcp-session";
5
+ import { daemonToolTimeoutMs } from "./tool-timeout";
4
6
  import { WorkerObservability } from "./observability";
5
7
  import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors";
6
8
  import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy";
@@ -20,7 +22,7 @@ import {
20
22
  } from "./http";
21
23
 
22
24
  const SERVER_NAME = String(serverMetadata.name);
23
- const SERVER_VERSION = "0.18.1";
25
+ const SERVER_VERSION = "1.0.1";
24
26
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
25
27
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
26
28
  const JSONRPC_VERSION = "2.0";
@@ -289,12 +291,15 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
289
291
  if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
290
292
  const protocolError = validateProtocolVersionHeader(request, body);
291
293
  if (protocolError) return json(protocolError, 400);
292
- const response = await this.dispatchJsonRpc(body, base, authorized);
294
+
295
+ const session = await resolveMcpSession(request, body.method, this.identityKey(), authorized.tokenKey);
296
+ if (session.kind === "invalid") return json(rpcError(body.id, -32001, "MCP session not found"), 404);
297
+ const response = await this.dispatchJsonRpc(body, base, authorized, session.kind === "active" ? session.sessionId : "");
293
298
  if (response === null) return new Response(null, { status: 202 });
294
- return json(response);
299
+ return session.kind === "initialize" ? json(response, 200, { "mcp-session-id": session.sessionId }) : json(response);
295
300
  }
296
301
 
297
- private async dispatchJsonRpc(request: JsonRpcRequest, base: string, authorized: AuthorizedToken): Promise<Record<string, unknown> | null> {
302
+ private async dispatchJsonRpc(request: JsonRpcRequest, base: string, authorized: AuthorizedToken, sessionId: string): Promise<Record<string, unknown> | null> {
298
303
  if (request.method === "initialize") {
299
304
  const requested = asObject(request.params).protocolVersion;
300
305
  const protocolVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])
@@ -318,7 +323,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
318
323
  }
319
324
  if (request.method === "notifications/initialized") return null;
320
325
  if (request.method === "notifications/cancelled") {
321
- this.cancelClientRequest(clientRequestKey(authorized, asObject(request.params).requestId));
326
+ this.cancelClientRequest(mcpClientRequestKey(authorized.tokenKey, sessionId, asObject(request.params).requestId));
322
327
  return null;
323
328
  }
324
329
  if (request.method === "logging/setLevel") return rpcResult(request.id, {});
@@ -330,7 +335,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
330
335
  const name = requiredString(params, "name");
331
336
  const args = asObject(params.arguments);
332
337
  try {
333
- const result = await this.callTool(name, args, base, authorized, clientRequestKey(authorized, request.id));
338
+ const result = await this.callTool(name, args, base, authorized, mcpClientRequestKey(authorized.tokenKey, sessionId, request.id));
334
339
  return rpcResult(request.id, textToolResult(result));
335
340
  } catch (error) {
336
341
  return rpcResult(request.id, textToolResult({ error: publicWorkerToolError(error) }, true));
@@ -378,16 +383,25 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
378
383
  if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
379
384
  const id = randomToken("call");
380
385
  const timeoutMs = daemonToolTimeoutMs(name, args);
381
- const result = this.pending.register({
382
- id,
383
- socket,
384
- clientRequestKey: requestKey,
385
- timeoutMs,
386
- onTimeout: (record) => {
387
- sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
388
- return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
389
- },
390
- });
386
+ let result: Promise<unknown>;
387
+ try {
388
+ result = this.pending.register({
389
+ id,
390
+ socket,
391
+ clientRequestKey: requestKey,
392
+ tool: name,
393
+ timeoutMs,
394
+ onTimeout: (record) => {
395
+ sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
396
+ return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
397
+ },
398
+ });
399
+ } catch (error) {
400
+ if (error instanceof PendingCallRegistrationError) {
401
+ throw new WorkerToolError(error.code, error.message, error.retryable);
402
+ }
403
+ throw error;
404
+ }
391
405
  this.observability.callStarted(name);
392
406
  try {
393
407
  socket.send(JSON.stringify({
@@ -1004,25 +1018,6 @@ function stringOrUndefined(value: unknown): string | undefined {
1004
1018
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
1005
1019
  }
1006
1020
 
1007
- function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
1008
- const number = typeof value === "number" && Number.isFinite(value) ? value : Number.parseInt(String(value ?? ""), 10);
1009
- const safe = Number.isFinite(number) ? number : fallback;
1010
- return Math.min(Math.max(Math.floor(safe), min), max);
1011
- }
1012
-
1013
- function daemonToolTimeoutMs(name: string, args: Record<string, unknown>): number {
1014
- if (name === "session_bootstrap") return 10_000;
1015
- const configurable = new Set([
1016
- "exec_command", "run_process", "run_local_command", "open_local_application",
1017
- "inspect_local_application", "operate_local_application", "browser_list_tabs",
1018
- "browser_manage_tabs", "browser_wait", "browser_get_source", "browser_inspect_page", "browser_action", "browser_fill_form",
1019
- "browser_screenshot", "browser_upload_files",
1020
- ]);
1021
- if (!configurable.has(name)) return 60_000;
1022
- const seconds = clampNumber(args.timeout_seconds, name === "browser_fill_form" ? 60 : 120, 1, 600);
1023
- return Math.min((seconds + 5) * 1000, 610_000);
1024
- }
1025
-
1026
1021
  function sessionInstructionText(value: unknown): string {
1027
1022
  const object = asObject(value);
1028
1023
  const instructions = typeof object.instructions === "string" ? object.instructions : "";
@@ -1042,8 +1037,3 @@ function validateProtocolVersionHeader(request: Request, body: JsonRpcRequest):
1042
1037
  supported: [...MCP_SUPPORTED_PROTOCOL_VERSIONS],
1043
1038
  });
1044
1039
  }
1045
-
1046
- function clientRequestKey(authorized: AuthorizedToken, requestId: unknown): string | undefined {
1047
- if (requestId === null || (typeof requestId !== "string" && typeof requestId !== "number")) return undefined;
1048
- return `${authorized.tokenKey}:${typeof requestId}:${String(requestId)}`;
1049
- }
@@ -0,0 +1,72 @@
1
+ const SESSION_PATTERN = /^mcp_([A-Za-z0-9_-]{32})_([A-Za-z0-9_-]{43})$/;
2
+
3
+ export async function createMcpSessionId(identityKey: string, tokenKey: string): Promise<string> {
4
+ const nonceBytes = new Uint8Array(24);
5
+ crypto.getRandomValues(nonceBytes);
6
+ const nonce = base64Url(nonceBytes);
7
+ const signature = await sessionSignature(identityKey, tokenKey, nonce);
8
+ return `mcp_${nonce}_${signature}`;
9
+ }
10
+
11
+ export async function validateMcpSessionId(sessionId: string, identityKey: string, tokenKey: string): Promise<boolean> {
12
+ const match = String(sessionId || "").match(SESSION_PATTERN);
13
+ if (!match) return false;
14
+ const expected = await sessionSignature(identityKey, tokenKey, match[1]);
15
+ return constantTimeTextEqual(match[2], expected);
16
+ }
17
+
18
+ async function sessionSignature(identityKey: string, tokenKey: string, nonce: string): Promise<string> {
19
+ const key = await crypto.subtle.importKey(
20
+ "raw",
21
+ new TextEncoder().encode(identityKey),
22
+ { name: "HMAC", hash: "SHA-256" },
23
+ false,
24
+ ["sign"],
25
+ );
26
+ const signature = await crypto.subtle.sign(
27
+ "HMAC",
28
+ key,
29
+ new TextEncoder().encode(`machine-bridge-mcp-session-v1\0${tokenKey}\0${nonce}`),
30
+ );
31
+ return base64Url(new Uint8Array(signature));
32
+ }
33
+
34
+ function constantTimeTextEqual(left: string, right: string): boolean {
35
+ const leftBytes = new TextEncoder().encode(left);
36
+ const rightBytes = new TextEncoder().encode(right);
37
+ let difference = leftBytes.length ^ rightBytes.length;
38
+ for (let index = 0; index < Math.max(leftBytes.length, rightBytes.length); index += 1) {
39
+ difference |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
40
+ }
41
+ return difference === 0;
42
+ }
43
+
44
+ function base64Url(bytes: Uint8Array): string {
45
+ return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
46
+ }
47
+
48
+ export type McpSessionResolution =
49
+ | { kind: "initialize"; sessionId: string }
50
+ | { kind: "active"; sessionId: string }
51
+ | { kind: "invalid"; sessionId: string };
52
+
53
+ export async function resolveMcpSession(
54
+ request: Request,
55
+ method: string,
56
+ identityKey: string,
57
+ tokenKey: string,
58
+ ): Promise<McpSessionResolution> {
59
+ if (method === "initialize") {
60
+ return { kind: "initialize", sessionId: await createMcpSessionId(identityKey, tokenKey) };
61
+ }
62
+ const sessionId = request.headers.get("mcp-session-id")?.trim() || "";
63
+ if (sessionId && !(await validateMcpSessionId(sessionId, identityKey, tokenKey))) {
64
+ return { kind: "invalid", sessionId };
65
+ }
66
+ return { kind: "active", sessionId };
67
+ }
68
+
69
+ export function mcpClientRequestKey(tokenKey: string, sessionId: string, requestId: unknown): string | undefined {
70
+ if (!sessionId || requestId === null || (typeof requestId !== "string" && typeof requestId !== "number")) return undefined;
71
+ return `${tokenKey}:${sessionId}:${typeof requestId}:${String(requestId)}`;
72
+ }
@@ -1,6 +1,6 @@
1
1
  import { normalizeAccountRole, type AccountRole } from "./access";
2
2
 
3
- export const OAUTH_STORE_SCHEMA_VERSION = 1;
3
+ const OAUTH_STORE_SCHEMA_VERSION = 1;
4
4
  const PASSWORD_TOKEN_PATTERN = /^[a-z][a-z0-9_]{2,31}_[A-Za-z0-9_-]{43}$/;
5
5
 
6
6
  export interface AccountRecord {
@@ -1,7 +1,21 @@
1
+ export class PendingCallRegistrationError extends Error {
2
+ readonly code: "conflict" | "limit_exceeded";
3
+ readonly retryable: boolean;
4
+
5
+ constructor(code: "conflict" | "limit_exceeded", message: string, retryable = false) {
6
+ super(message);
7
+ this.name = "PendingCallRegistrationError";
8
+ this.code = code;
9
+ this.retryable = retryable;
10
+ }
11
+ }
12
+
1
13
  export interface PendingCallRecord {
2
14
  id: string;
3
15
  socket: WebSocket;
4
16
  clientRequestKey?: string;
17
+ tool: string;
18
+ startedAt: number;
5
19
  timeout: ReturnType<typeof setTimeout>;
6
20
  resolve: (value: unknown) => void;
7
21
  reject: (error: Error) => void;
@@ -11,6 +25,7 @@ interface RegisterPendingCall {
11
25
  id: string;
12
26
  socket: WebSocket;
13
27
  clientRequestKey?: string;
28
+ tool: string;
14
29
  timeoutMs: number;
15
30
  onTimeout: (record: PendingCallRecord) => Error;
16
31
  }
@@ -33,11 +48,12 @@ export class PendingCallRegistry {
33
48
  }
34
49
 
35
50
  register(input: RegisterPendingCall): Promise<unknown> {
36
- if (this.byId.size >= this.maximum) throw new Error("too many concurrent daemon tool calls");
37
- if (this.byId.has(input.id)) throw new Error("duplicate internal daemon call id");
51
+ if (this.byId.size >= this.maximum) throw new PendingCallRegistrationError("limit_exceeded", "too many concurrent daemon tool calls", true);
52
+ if (this.byId.has(input.id)) throw new PendingCallRegistrationError("conflict", "duplicate internal daemon call id");
38
53
  if (input.clientRequestKey && this.byRequestKey.has(input.clientRequestKey)) {
39
- throw new Error("duplicate in-flight JSON-RPC request id for this access token");
54
+ throw new PendingCallRegistrationError("conflict", "duplicate in-flight JSON-RPC request id within this MCP session");
40
55
  }
56
+ const startedAt = Date.now();
41
57
  return new Promise((resolve, reject) => {
42
58
  const timeout = setTimeout(() => {
43
59
  const record = this.take(input.id);
@@ -51,6 +67,8 @@ export class PendingCallRegistry {
51
67
  id: input.id,
52
68
  socket: input.socket,
53
69
  clientRequestKey: input.clientRequestKey,
70
+ tool: String(input.tool || "unknown"),
71
+ startedAt,
54
72
  timeout,
55
73
  resolve,
56
74
  reject,
@@ -92,8 +110,21 @@ export class PendingCallRegistry {
92
110
  return ids.length;
93
111
  }
94
112
 
95
- snapshot(): { active: number; request_keys: number; maximum: number } {
96
- return { active: this.byId.size, request_keys: this.byRequestKey.size, maximum: this.maximum };
113
+ snapshot(): { active: number; request_keys: number; maximum: number; oldest_ms: number; by_tool: Record<string, number> } {
114
+ const now = Date.now();
115
+ const byTool: Record<string, number> = {};
116
+ let oldestMs = 0;
117
+ for (const record of this.byId.values()) {
118
+ byTool[record.tool] = (byTool[record.tool] || 0) + 1;
119
+ oldestMs = Math.max(oldestMs, now - record.startedAt);
120
+ }
121
+ return {
122
+ active: this.byId.size,
123
+ request_keys: this.byRequestKey.size,
124
+ maximum: this.maximum,
125
+ oldest_ms: oldestMs,
126
+ by_tool: byTool,
127
+ };
97
128
  }
98
129
 
99
130
  private take(id: string): PendingCallRecord | undefined {
@@ -0,0 +1,18 @@
1
+ export function daemonToolTimeoutMs(name: string, args: Record<string, unknown>): number {
2
+ if (name === "session_bootstrap") return 10_000;
3
+ const configurable = new Set([
4
+ "exec_command", "run_process", "run_local_command", "open_local_application",
5
+ "inspect_local_application", "operate_local_application", "browser_list_tabs",
6
+ "browser_manage_tabs", "browser_wait", "browser_get_source", "browser_inspect_page",
7
+ "browser_action", "browser_fill_form", "browser_screenshot", "browser_upload_files",
8
+ ]);
9
+ if (!configurable.has(name)) return 60_000;
10
+ const seconds = clampNumber(args.timeout_seconds, name === "browser_fill_form" ? 60 : 120, 1, 600);
11
+ return Math.min((seconds + 5) * 1000, 610_000);
12
+ }
13
+
14
+ function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
15
+ const number = typeof value === "number" && Number.isFinite(value) ? value : Number.parseInt(String(value ?? ""), 10);
16
+ const safe = Number.isFinite(number) ? number : fallback;
17
+ return Math.min(Math.max(Math.floor(safe), min), max);
18
+ }