machine-bridge-mcp 3.0.0-beta.21 → 3.0.0-beta.26
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 +134 -0
- package/CONTRIBUTING.md +3 -3
- package/GOVERNANCE.md +2 -2
- package/README.md +24 -6
- package/browser-extension/manifest.json +1 -1
- package/docs/AGENT_CONTEXT.md +10 -7
- package/docs/ARCHITECTURE.md +35 -22
- package/docs/AUDIT.md +85 -1
- package/docs/CLIENTS.md +6 -2
- package/docs/ENGINEERING.md +31 -9
- package/docs/LOCAL_AUTOMATION.md +4 -2
- package/docs/LOGGING.md +8 -8
- package/docs/OPERATIONS.md +43 -17
- package/docs/PRIVACY.md +18 -4
- package/docs/PROJECT_STANDARDS.md +2 -2
- package/docs/RELEASING.md +35 -11
- package/docs/TESTING.md +36 -16
- package/docs/THREAT_MODEL.md +20 -5
- package/docs/TOOL_REFERENCE.md +18 -12
- package/docs/UPGRADING.md +32 -0
- package/package.json +15 -6
- package/scripts/check-plan.mjs +8 -0
- package/scripts/coverage-check.mjs +30 -1
- package/scripts/foreground-daemon-recovery.mjs +88 -0
- package/scripts/github-release.mjs +22 -16
- package/scripts/install-published-prerelease.mjs +7 -7
- package/scripts/official-mcp-conformance.mjs +243 -0
- package/scripts/persistent-activation-process.mjs +36 -0
- package/scripts/release-candidate-manifest.mjs +12 -0
- package/scripts/release-publication-guard.mjs +65 -0
- package/scripts/release-state.mjs +1 -1
- package/scripts/sbom-check.mjs +99 -0
- package/scripts/start-release-candidate.mjs +39 -13
- package/src/local/agent-context-projection.mjs +26 -7
- package/src/local/agent-context.mjs +25 -4
- package/src/local/autostart-log-maintenance.mjs +36 -0
- package/src/local/capability-observer.mjs +5 -0
- package/src/local/child-process-settlement.mjs +103 -0
- package/src/local/cli-activate.mjs +42 -5
- package/src/local/cli-service.mjs +55 -5
- package/src/local/cli.mjs +59 -10
- package/src/local/daemon-process.mjs +24 -3
- package/src/local/delegated-process-sandbox.mjs +1 -0
- package/src/local/execution-routing.mjs +231 -0
- package/src/local/git-service.mjs +3 -1
- package/src/local/job-runner.mjs +55 -19
- package/src/local/macos-trust-broker.mjs +7 -0
- package/src/local/managed-job-runner-claim.mjs +54 -0
- package/src/local/managed-job-runner.mjs +13 -2
- package/src/local/process-execution.mjs +2 -2
- package/src/local/process-identity.mjs +11 -0
- package/src/local/process-tree-ownership-types.d.ts +37 -0
- package/src/local/process-tree-ownership.mjs +49 -41
- package/src/local/process-tree.mjs +1 -1
- package/src/local/relay-call-recovery.mjs +40 -21
- package/src/local/runtime-activation.mjs +357 -38
- package/src/local/runtime-capabilities.mjs +22 -6
- package/src/local/runtime-diagnostics.mjs +9 -2
- package/src/local/runtime.mjs +18 -4
- package/src/local/service-convergence.mjs +33 -0
- package/src/local/service-owner.mjs +147 -0
- package/src/local/service-restart-handoff.mjs +22 -8
- package/src/local/service-runtime.mjs +145 -0
- package/src/local/service.mjs +143 -25
- package/src/local/state.mjs +104 -7
- package/src/local/stdio.mjs +139 -45
- package/src/local/system-network-route.mjs +76 -0
- package/src/local/tool-executor.mjs +24 -6
- package/src/local/tools.mjs +6 -5
- package/src/local/windows-service-convergence.mjs +49 -0
- package/src/local/windows-service.mjs +30 -53
- package/src/shared/mcp-protocol.d.mts +27 -0
- package/src/shared/mcp-protocol.mjs +256 -0
- package/src/shared/mcp-subscriptions.d.mts +4 -0
- package/src/shared/mcp-subscriptions.mjs +59 -0
- package/src/shared/relay-contract.json +1 -0
- package/src/shared/result-projection.d.mts +2 -1
- package/src/shared/result-projection.mjs +13 -2
- package/src/shared/server-metadata.json +11 -4
- package/src/shared/tool-argument-validation.d.mts +17 -0
- package/src/shared/tool-argument-validation.mjs +325 -0
- package/src/shared/tool-catalog.json +18 -12
- package/src/worker/durable-stream-calls.ts +12 -24
- package/src/worker/http.ts +36 -2
- package/src/worker/index.ts +181 -165
- package/src/worker/mcp-http-contract.ts +276 -0
- package/src/worker/mcp-jsonrpc.ts +12 -6
- package/src/worker/mcp-legacy-dispatch.ts +104 -0
- package/src/worker/mcp-modern-controller.ts +199 -0
- package/src/worker/mcp-modern-proxy.ts +126 -0
- package/src/worker/mcp-modern-stream.ts +71 -0
- package/src/worker/mcp-session.ts +12 -3
- package/src/worker/mcp-stream-proxy-contract.ts +67 -0
- package/src/worker/mcp-stream-proxy.ts +17 -60
- package/src/worker/mcp-tool-call-input.ts +23 -0
- package/src/worker/tool-catalog.ts +29 -1
- package/src/worker/tool-timeout.ts +53 -12
- package/src/worker/worker-mcp-config.ts +23 -0
- package/src/worker/worker-metadata.ts +10 -1
- package/src/worker/worker-runtime-config.ts +19 -0
- package/src/worker/worker-static-routes.ts +7 -2
- package/tsconfig.local.json +7 -1
package/src/local/state.mjs
CHANGED
|
@@ -27,6 +27,7 @@ const MAX_MARKER_BYTES = 4096;
|
|
|
27
27
|
const STARTUP_LOCK_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
28
28
|
const MALFORMED_LOCK_GRACE_MS = 60_000;
|
|
29
29
|
const MAINTENANCE_LOCK_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
30
|
+
const MACHINE_SERVICE_LOCK_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
30
31
|
|
|
31
32
|
export function expandHome(input = "") {
|
|
32
33
|
if (!input || input === "~") return os.homedir();
|
|
@@ -34,6 +35,12 @@ export function expandHome(input = "") {
|
|
|
34
35
|
return input;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
function expandHomeFrom(input, home) {
|
|
39
|
+
if (!input || input === "~") return home;
|
|
40
|
+
if (input.startsWith("~/")) return path.join(home, input.slice(2));
|
|
41
|
+
return input;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
export function resolveWorkspace(input = process.cwd()) {
|
|
38
45
|
const resolved = path.resolve(expandHome(input));
|
|
39
46
|
return realpathSync.native ? realpathSync.native(resolved) : realpathSync(resolved);
|
|
@@ -49,13 +56,16 @@ export function ensureWorkspaceDirectory(input) {
|
|
|
49
56
|
return resolveWorkspace(requested);
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
export function defaultStateRoot() {
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
export function defaultStateRoot(options = {}) {
|
|
60
|
+
const platform = String(options.platform || process.platform);
|
|
61
|
+
const home = path.resolve(String(options.home || os.homedir()));
|
|
62
|
+
const environment = options.environment && typeof options.environment === "object" ? options.environment : process.env;
|
|
63
|
+
if (platform === "win32") {
|
|
64
|
+
const base = environment.APPDATA ? expandHomeFrom(String(environment.APPDATA), home) : path.join(home, "AppData", "Roaming");
|
|
55
65
|
return path.join(base, appName);
|
|
56
66
|
}
|
|
57
|
-
if (
|
|
58
|
-
return path.join(
|
|
67
|
+
if (environment.XDG_STATE_HOME) return path.join(expandHomeFrom(String(environment.XDG_STATE_HOME), home), appName);
|
|
68
|
+
return path.join(home, ".local", "state", appName);
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
|
|
@@ -272,6 +282,57 @@ function lockPathForState(state, name) {
|
|
|
272
282
|
return path.join(profileDir, name);
|
|
273
283
|
}
|
|
274
284
|
|
|
285
|
+
export function machineServiceControlRoot(options = {}) {
|
|
286
|
+
const home = path.resolve(String(options.home || os.homedir()));
|
|
287
|
+
if (options.controlRoot) return path.resolve(expandHomeFrom(String(options.controlRoot), home));
|
|
288
|
+
if (String(options.platform || process.platform) === "win32") {
|
|
289
|
+
const environment = options.environment && typeof options.environment === "object" ? options.environment : process.env;
|
|
290
|
+
const base = environment.APPDATA ? expandHomeFrom(String(environment.APPDATA), home) : path.join(home, "AppData", "Roaming");
|
|
291
|
+
return path.join(base, `${appName}-control`);
|
|
292
|
+
}
|
|
293
|
+
return path.join(home, ".local", "state", `${appName}-control`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function machineServiceLockPath(options = {}) {
|
|
297
|
+
return path.join(machineServiceControlRoot(options), "service-operation.lock");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function acquireMachineServiceLock(metadata = {}, options = {}) {
|
|
301
|
+
const root = machineServiceControlRoot(options);
|
|
302
|
+
ensureOwnerOnlyDir(root);
|
|
303
|
+
assertNoForeignMaintenance(root);
|
|
304
|
+
const operation = typeof metadata.operation === "string" && /^[a-z][a-z0-9._-]{0,63}$/.test(metadata.operation)
|
|
305
|
+
? metadata.operation
|
|
306
|
+
: "service-operation";
|
|
307
|
+
return acquireProcessLock(machineServiceLockPath({ controlRoot: root }), {
|
|
308
|
+
workspace: { path: "" },
|
|
309
|
+
paths: { profileDir: root, stateRoot: root },
|
|
310
|
+
}, "machine-service", { operation }, { maxAgeMs: MACHINE_SERVICE_LOCK_MAX_AGE_MS });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function acquireMachineServiceLockWithWait(options = {}) {
|
|
314
|
+
const timeoutMs = boundedPositiveInteger(options.timeoutMs, 30_000);
|
|
315
|
+
const pollMs = boundedPositiveInteger(options.pollMs, 100);
|
|
316
|
+
const logger = options.logger || { info() {} };
|
|
317
|
+
const metadata = { operation: options.operation };
|
|
318
|
+
let lock = acquireMachineServiceLock(metadata, options);
|
|
319
|
+
if (lock.acquired) return lock;
|
|
320
|
+
const ownerPid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "another process";
|
|
321
|
+
logger.info?.(`waiting for ${ownerPid} to finish the current machine-service operation`);
|
|
322
|
+
const deadline = createMonotonicDeadline(timeoutMs);
|
|
323
|
+
while (!deadline.expired()) {
|
|
324
|
+
await new Promise((resolvePromise) => { setTimeout(resolvePromise, Math.min(pollMs, Math.max(1, deadline.remainingMs()))); });
|
|
325
|
+
lock = acquireMachineServiceLock(metadata, options);
|
|
326
|
+
if (lock.acquired) {
|
|
327
|
+
logger.info?.("the previous machine-service operation finished; continuing");
|
|
328
|
+
return lock;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
|
|
332
|
+
const operation = typeof lock.owner?.operation === "string" ? ` (${lock.owner.operation})` : "";
|
|
333
|
+
throw new Error(`another machine-service operation did not finish within ${Math.ceil(timeoutMs / 1000)} seconds (${pid}${operation}); inspect the process before retrying`);
|
|
334
|
+
}
|
|
335
|
+
|
|
275
336
|
export function acquireMaintenanceLock(stateRoot, metadata = {}) {
|
|
276
337
|
const root = path.resolve(expandHome(stateRoot));
|
|
277
338
|
if (!existsSync(root)) throw new Error("cannot acquire maintenance lock for a missing state root");
|
|
@@ -316,7 +377,7 @@ function assertNoForeignMaintenance(stateRoot) {
|
|
|
316
377
|
|
|
317
378
|
export function acquireDaemonLock(state, metadata = {}) {
|
|
318
379
|
assertNoForeignMaintenance(state?.paths?.stateRoot);
|
|
319
|
-
const details = {};
|
|
380
|
+
const details = { startupReady: false, startupReadyAt: null };
|
|
320
381
|
if (metadata?.mode === "foreground" || metadata?.mode === "service") details.mode = metadata.mode;
|
|
321
382
|
if (typeof metadata?.version === "string" && /^[0-9A-Za-z.+_-]{1,64}$/.test(metadata.version)) details.version = metadata.version;
|
|
322
383
|
return acquireProcessLock(daemonLockPathForState(state), state, "daemon", details, { maxAgeMs: Number.POSITIVE_INFINITY });
|
|
@@ -371,12 +432,17 @@ function acquireProcessLock(lockPath, state, purpose, details = {}, options = {}
|
|
|
371
432
|
createExclusiveFileSync(lockPath, `${JSON.stringify(payload, null, 2)}
|
|
372
433
|
`, { mode: 0o600 });
|
|
373
434
|
ownerOnlyFile(lockPath);
|
|
374
|
-
|
|
435
|
+
const lock = {
|
|
375
436
|
acquired: true,
|
|
376
437
|
path: lockPath,
|
|
377
438
|
owner: payload,
|
|
439
|
+
update(patch) {
|
|
440
|
+
lock.owner = updateProcessLock(lockPath, token, patch);
|
|
441
|
+
return lock.owner;
|
|
442
|
+
},
|
|
378
443
|
release() { releaseProcessLock(lockPath, token); },
|
|
379
444
|
};
|
|
445
|
+
return lock;
|
|
380
446
|
} catch (error) {
|
|
381
447
|
if (error?.code !== "EEXIST") throw error;
|
|
382
448
|
const snapshot = readProcessLockSnapshot(lockPath);
|
|
@@ -452,6 +518,37 @@ function sameLockIdentity(left, right) {
|
|
|
452
518
|
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
|
453
519
|
}
|
|
454
520
|
|
|
521
|
+
function updateProcessLock(lockPath, token, patch) {
|
|
522
|
+
if (!isPlainRecord(patch)) throw new TypeError("process lock update must be a plain record");
|
|
523
|
+
const allowed = new Set(["startupReady", "startupReadyAt"]);
|
|
524
|
+
for (const key of Object.keys(patch)) {
|
|
525
|
+
if (!allowed.has(key)) throw new Error(`process lock field is immutable: ${key}`);
|
|
526
|
+
}
|
|
527
|
+
if (patch.startupReady !== true) {
|
|
528
|
+
throw new TypeError("process lock startup readiness can only be published as true");
|
|
529
|
+
}
|
|
530
|
+
if (typeof patch.startupReadyAt !== "string" || !Number.isFinite(Date.parse(patch.startupReadyAt))) {
|
|
531
|
+
throw new TypeError("process lock startupReadyAt update must be an ISO timestamp");
|
|
532
|
+
}
|
|
533
|
+
const snapshot = readProcessLockSnapshot(lockPath);
|
|
534
|
+
if (!snapshot?.owner || snapshot.owner.token !== token || snapshot.owner.pid !== process.pid) {
|
|
535
|
+
throw new Error("process lock ownership changed before update");
|
|
536
|
+
}
|
|
537
|
+
if (snapshot.owner.purpose !== "daemon") {
|
|
538
|
+
throw new Error("only daemon locks can publish startup readiness");
|
|
539
|
+
}
|
|
540
|
+
if (snapshot.owner.startupReady === true || snapshot.owner.startupReadyAt !== null) {
|
|
541
|
+
throw new Error("process lock startup readiness was already published");
|
|
542
|
+
}
|
|
543
|
+
const updated = { ...snapshot.owner, startupReady: true, startupReadyAt: patch.startupReadyAt };
|
|
544
|
+
const content = `${JSON.stringify(updated, null, 2)}
|
|
545
|
+
`;
|
|
546
|
+
if (Buffer.byteLength(content) > MAX_LOCK_BYTES) throw new Error("process lock update exceeds the size limit");
|
|
547
|
+
replaceFileAtomicallySync(lockPath, content, { mode: 0o600 });
|
|
548
|
+
ownerOnlyFile(lockPath);
|
|
549
|
+
return updated;
|
|
550
|
+
}
|
|
551
|
+
|
|
455
552
|
function releaseProcessLock(lockPath, token) {
|
|
456
553
|
let snapshot;
|
|
457
554
|
try { snapshot = readProcessLockSnapshot(lockPath); } catch { return; }
|
package/src/local/stdio.mjs
CHANGED
|
@@ -1,34 +1,49 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { LocalRuntime } from "./runtime.mjs";
|
|
4
|
+
import { validateToolArguments } from "./tool-executor.mjs";
|
|
4
5
|
import { classifyOperationalError, createLogger } from "./log.mjs";
|
|
5
6
|
import { publicError } from "./errors.mjs";
|
|
6
7
|
import {
|
|
7
8
|
MCP_INSTRUCTIONS,
|
|
8
|
-
MCP_PROTOCOL_VERSION,
|
|
9
|
-
MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
10
9
|
SERVER_NAME,
|
|
11
10
|
rpcError,
|
|
12
11
|
rpcResult,
|
|
13
12
|
toolResult,
|
|
14
13
|
toolsForPolicy,
|
|
15
14
|
} from "./tools.mjs";
|
|
15
|
+
import {
|
|
16
|
+
MCP_LEGACY_PROTOCOL_VERSION, MCP_LEGACY_PROTOCOL_VERSIONS, MCP_MODERN_PROTOCOL_VERSION,
|
|
17
|
+
MCP_MODERN_PROTOCOL_VERSIONS, McpProtocolError, isModernMcpRequest, modernCacheableResult,
|
|
18
|
+
modernDiscoverResult, serverImplementation, validateModernRequestMetadata,
|
|
19
|
+
} from "../shared/mcp-protocol.mjs";
|
|
20
|
+
import {
|
|
21
|
+
acceptedSubscriptionFilter, subscriptionAcknowledgedNotification, subscriptionCompleteResult, validateSubscriptionFilter,
|
|
22
|
+
} from "../shared/mcp-subscriptions.mjs";
|
|
16
23
|
|
|
17
24
|
const MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
18
25
|
const MAX_PENDING_TOOL_CALLS = 32;
|
|
19
26
|
const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version);
|
|
27
|
+
const MCP_SERVER_INFO = Object.freeze(serverImplementation({
|
|
28
|
+
name: SERVER_NAME, title: "Machine Bridge MCP", version: PACKAGE_VERSION,
|
|
29
|
+
description: "Workspace-scoped local coding tools over MCP stdio or authenticated remote relay.",
|
|
30
|
+
}));
|
|
31
|
+
const MCP_SERVER_CAPABILITIES = Object.freeze({ tools: Object.freeze({ listChanged: false }) });
|
|
32
|
+
const MCP_DISCOVERY_TTL_MS = 300_000;
|
|
33
|
+
const MCP_TOOL_LIST_TTL_MS = 300_000;
|
|
34
|
+
const MODERN_LIMIT_EXCEEDED = -31900;
|
|
20
35
|
|
|
21
36
|
export async function runStdioServer({ workspace, policy, logLevel = "info", jobRoot = "", resources = {}, resourceStatePath = "", browserStateRoot = "" }) {
|
|
22
37
|
const logger = createLogger({ component: "stdio", level: logLevel, stderrOnly: true, color: false });
|
|
23
38
|
const runtime = new LocalRuntime({ workspace, policy, logger, jobRoot, resources, resourceStatePath, browserStateRoot });
|
|
24
39
|
const pending = new Map();
|
|
25
|
-
let
|
|
26
|
-
let
|
|
27
|
-
|
|
40
|
+
let legacyInitialized = false;
|
|
41
|
+
let legacyVersion = MCP_LEGACY_PROTOCOL_VERSION;
|
|
28
42
|
const writes = new Set();
|
|
29
43
|
const send = (message) => {
|
|
30
44
|
if (message === null || message === undefined) return;
|
|
31
|
-
const line = `${JSON.stringify(message)}
|
|
45
|
+
const line = `${JSON.stringify(message)}
|
|
46
|
+
`;
|
|
32
47
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
33
48
|
process.stdout.write(line, (error) => error ? rejectPromise(error) : resolvePromise());
|
|
34
49
|
}).catch((error) => {
|
|
@@ -52,52 +67,113 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
52
67
|
|
|
53
68
|
async function handleLine(line) {
|
|
54
69
|
const message = parseJsonRpcLine(line, send);
|
|
55
|
-
if (!message
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
if (!message) return;
|
|
71
|
+
if (typeof message.method !== "string") {
|
|
72
|
+
send(rpcError(message.id, -32600, "Clients must not send JSON-RPC responses"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (message.method === "notifications/cancelled") {
|
|
76
|
+
cancelRequest(message);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (isModernMcpRequest(message)) {
|
|
80
|
+
let context;
|
|
81
|
+
try {
|
|
82
|
+
context = validateModernRequestMetadata(message, MCP_MODERN_PROTOCOL_VERSIONS);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof McpProtocolError) {
|
|
85
|
+
send(rpcError(message.id, error.code, error.message, error.data));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
await handleModernMessage(message, context.version);
|
|
59
91
|
return;
|
|
60
92
|
}
|
|
61
|
-
await handleInitializedMessage(message);
|
|
62
|
-
}
|
|
63
93
|
|
|
64
|
-
async function handleControlMessage(message) {
|
|
65
94
|
if (message.method === "initialize") {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
? requested
|
|
69
|
-
: MCP_PROTOCOL_VERSION;
|
|
70
|
-
initialized = true;
|
|
71
|
-
send(rpcResult(message.id, await initializationResult(negotiatedVersion, runtime)));
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
if (message.method === "notifications/initialized") return true;
|
|
75
|
-
if (message.method === "notifications/cancelled") {
|
|
76
|
-
const requestId = asObject(message.params).requestId;
|
|
77
|
-
const callId = pending.get(jsonRpcIdKey(requestId));
|
|
78
|
-
if (callId) runtime.cancelCall(callId, "MCP cancellation notification");
|
|
79
|
-
return true;
|
|
95
|
+
await initializeLegacy(message);
|
|
96
|
+
return;
|
|
80
97
|
}
|
|
98
|
+
if (message.method === "notifications/initialized") return;
|
|
81
99
|
if (message.method === "logging/setLevel" || message.method === "ping") {
|
|
82
100
|
send(rpcResult(message.id, {}));
|
|
83
|
-
return
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!legacyInitialized) {
|
|
104
|
+
send(rpcError(message.id, -32002, "Server is not initialized"));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await handleLegacyMessage(message);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function initializeLegacy(message) {
|
|
111
|
+
const requested = asObject(message.params).protocolVersion;
|
|
112
|
+
legacyVersion = typeof requested === "string" && MCP_LEGACY_PROTOCOL_VERSIONS.includes(requested)
|
|
113
|
+
? requested
|
|
114
|
+
: MCP_LEGACY_PROTOCOL_VERSION;
|
|
115
|
+
legacyInitialized = true;
|
|
116
|
+
send(rpcResult(message.id, await legacyInitializationResult(legacyVersion, runtime)));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function cancelRequest(message) {
|
|
120
|
+
const requestId = asObject(message.params).requestId;
|
|
121
|
+
const callId = pending.get(jsonRpcIdKey(requestId));
|
|
122
|
+
if (callId) runtime.cancelCall(callId, "MCP cancellation notification");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function handleModernMessage(message, protocolVersion) {
|
|
126
|
+
if (!("id" in message) || message.id === null) {
|
|
127
|
+
if (!message.method.startsWith("notifications/")) {
|
|
128
|
+
send(rpcError(null, -32600, "Modern MCP requests require a non-null request id"));
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (message.method === "server/discover") {
|
|
133
|
+
send(rpcResult(message.id, modernDiscoverResult({
|
|
134
|
+
supportedVersions: MCP_MODERN_PROTOCOL_VERSIONS,
|
|
135
|
+
capabilities: MCP_SERVER_CAPABILITIES,
|
|
136
|
+
instructions: MCP_INSTRUCTIONS,
|
|
137
|
+
ttlMs: MCP_DISCOVERY_TTL_MS,
|
|
138
|
+
serverInfo: MCP_SERVER_INFO,
|
|
139
|
+
})));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (message.method === "tools/list") {
|
|
143
|
+
send(rpcResult(message.id, modernCacheableResult(
|
|
144
|
+
{ tools: toolsForPolicy(policy) },
|
|
145
|
+
{ ttlMs: MCP_TOOL_LIST_TTL_MS, cacheScope: "private", serverInfo: MCP_SERVER_INFO },
|
|
146
|
+
)));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (message.method === "subscriptions/listen") {
|
|
150
|
+
const requested = subscriptionFilterOrError(asObject(message.params).notifications, message.id, send);
|
|
151
|
+
if (!requested) return;
|
|
152
|
+
const accepted = acceptedSubscriptionFilter(requested, MCP_SERVER_CAPABILITIES);
|
|
153
|
+
send(subscriptionAcknowledgedNotification(message.id, accepted));
|
|
154
|
+
send(rpcResult(message.id, subscriptionCompleteResult(message.id, MCP_SERVER_INFO)));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (message.method === "tools/call") {
|
|
158
|
+
await handleToolCall(message, protocolVersion);
|
|
159
|
+
return;
|
|
84
160
|
}
|
|
85
|
-
|
|
161
|
+
send(rpcError(message.id, -32601, "Method not found"));
|
|
86
162
|
}
|
|
87
163
|
|
|
88
|
-
async function
|
|
164
|
+
async function handleLegacyMessage(message) {
|
|
89
165
|
if (message.method === "tools/list") {
|
|
90
166
|
send(rpcResult(message.id, { tools: toolsForPolicy(policy) }));
|
|
91
167
|
return;
|
|
92
168
|
}
|
|
93
169
|
if (message.method === "tools/call") {
|
|
94
|
-
await handleToolCall(message);
|
|
170
|
+
await handleToolCall(message, legacyVersion);
|
|
95
171
|
return;
|
|
96
172
|
}
|
|
97
|
-
send(rpcError(message.id, -32601,
|
|
173
|
+
send(rpcError(message.id, -32601, "Method not found"));
|
|
98
174
|
}
|
|
99
175
|
|
|
100
|
-
async function handleToolCall(message) {
|
|
176
|
+
async function handleToolCall(message, protocolVersion) {
|
|
101
177
|
if (!("id" in message) || message.id === null) {
|
|
102
178
|
send(rpcError(null, -32600, "tools/call requires a non-null request id"));
|
|
103
179
|
return;
|
|
@@ -108,28 +184,51 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
108
184
|
send(rpcError(message.id, -32602, "tools/call requires a tool name"));
|
|
109
185
|
return;
|
|
110
186
|
}
|
|
111
|
-
|
|
187
|
+
if (!toolsForPolicy(policy).some((tool) => tool.name === name)) {
|
|
188
|
+
send(rpcError(message.id, -32602, "Unknown or unavailable tool"));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const rawArgs = params.arguments === undefined ? {} : params.arguments;
|
|
192
|
+
const validation = validateToolArguments(name, rawArgs);
|
|
193
|
+
if (!validation.known || !validation.valid) {
|
|
194
|
+
send(rpcError(message.id, -32602, !validation.known
|
|
195
|
+
? "Unknown tool"
|
|
196
|
+
: "Tool arguments do not match the input schema", validation.valid ? undefined : {
|
|
197
|
+
validation_issues: validation.issues,
|
|
198
|
+
}));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const args = rawArgs;
|
|
202
|
+
const modern = protocolVersion === MCP_MODERN_PROTOCOL_VERSION;
|
|
112
203
|
if (pending.size >= MAX_PENDING_TOOL_CALLS) {
|
|
113
|
-
send(rpcError(message.id, -32000, "Too many concurrent tool calls"));
|
|
204
|
+
send(rpcError(message.id, modern ? MODERN_LIMIT_EXCEEDED : -32000, "Too many concurrent tool calls"));
|
|
114
205
|
return;
|
|
115
206
|
}
|
|
116
|
-
const callId = `stdio_${randomBytes(16).toString("hex")}`;
|
|
117
207
|
const key = jsonRpcIdKey(message.id);
|
|
118
208
|
if (key && pending.has(key)) {
|
|
119
209
|
send(rpcError(message.id, -32600, "Duplicate in-flight JSON-RPC request id"));
|
|
120
210
|
return;
|
|
121
211
|
}
|
|
212
|
+
const callId = `stdio_${randomBytes(16).toString("hex")}`;
|
|
122
213
|
if (key) pending.set(key, callId);
|
|
123
214
|
try {
|
|
124
215
|
const result = await runtime.executeTool(name, args, { callId, origin: "stdio" });
|
|
125
|
-
send(rpcResult(message.id, toolResult(result)));
|
|
216
|
+
send(rpcResult(message.id, toolResult(result, false, protocolVersion, MCP_SERVER_INFO)));
|
|
126
217
|
} catch (error) {
|
|
127
|
-
send(rpcResult(message.id, toolResult({ error: publicError(error) }, true)));
|
|
218
|
+
send(rpcResult(message.id, toolResult({ error: publicError(error) }, true, protocolVersion, MCP_SERVER_INFO)));
|
|
128
219
|
} finally {
|
|
129
220
|
if (key) pending.delete(key);
|
|
130
221
|
}
|
|
131
222
|
}
|
|
223
|
+
}
|
|
132
224
|
|
|
225
|
+
function subscriptionFilterOrError(value, requestId, send) {
|
|
226
|
+
try { return validateSubscriptionFilter(value); }
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (!(error instanceof McpProtocolError)) throw error;
|
|
229
|
+
send(rpcError(requestId, error.code, error.message, error.data));
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
133
232
|
}
|
|
134
233
|
|
|
135
234
|
function parseJsonRpcLine(line, send) {
|
|
@@ -145,7 +244,7 @@ function parseJsonRpcLine(line, send) {
|
|
|
145
244
|
return message;
|
|
146
245
|
}
|
|
147
246
|
|
|
148
|
-
async function
|
|
247
|
+
async function legacyInitializationResult(protocolVersion, runtime) {
|
|
149
248
|
const bootstrap = await runtime.sessionBootstrap({ path: "." }).catch((error) => {
|
|
150
249
|
runtime.logger?.debug?.("session bootstrap unavailable during initialize", { error_class: classifyOperationalError(error) });
|
|
151
250
|
return null;
|
|
@@ -154,12 +253,7 @@ async function initializationResult(protocolVersion, runtime) {
|
|
|
154
253
|
return {
|
|
155
254
|
protocolVersion,
|
|
156
255
|
capabilities: { tools: { listChanged: false }, logging: {} },
|
|
157
|
-
serverInfo:
|
|
158
|
-
name: SERVER_NAME,
|
|
159
|
-
title: "Machine Bridge MCP",
|
|
160
|
-
version: PACKAGE_VERSION,
|
|
161
|
-
description: "Workspace-scoped local coding tools over MCP stdio or authenticated remote relay.",
|
|
162
|
-
},
|
|
256
|
+
serverInfo: MCP_SERVER_INFO,
|
|
163
257
|
instructions: `${MCP_INSTRUCTIONS}${localInstructions}`,
|
|
164
258
|
};
|
|
165
259
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const TUNNEL_INTERFACE = /^(?:utun|tun|tap|ppp|ipsec|wg|gif|stf)/i;
|
|
4
|
+
const LOOPBACK_INTERFACE = /^lo\d*$/i;
|
|
5
|
+
const PHYSICAL_INTERFACE = /^(?:en|eth|wlan|wl|wifi|wi-fi)\d*$/i;
|
|
6
|
+
|
|
7
|
+
/** @typedef {{code?: number, stdout?: string}} FixedProcessResult */
|
|
8
|
+
/** @typedef {(command: string, args: string[], timeoutMs: number, capture: boolean, maxOutputBytes: number, context: unknown) => Promise<FixedProcessResult>} FixedInternalRunner */
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{
|
|
11
|
+
* platform?: string,
|
|
12
|
+
* runFixedInternal?: FixedInternalRunner,
|
|
13
|
+
* classifyError?: (error: unknown) => string,
|
|
14
|
+
* context?: unknown,
|
|
15
|
+
* }} SystemNetworkRouteOptions
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @param {SystemNetworkRouteOptions} [options] */
|
|
19
|
+
export async function systemNetworkRouteCheck({
|
|
20
|
+
platform = process.platform,
|
|
21
|
+
runFixedInternal,
|
|
22
|
+
classifyError = () => "unavailable",
|
|
23
|
+
context = {},
|
|
24
|
+
} = {}) {
|
|
25
|
+
try {
|
|
26
|
+
const route = await inspectSystemNetworkRoute({ platform, runFixedInternal, context });
|
|
27
|
+
return route.supported && route.available ? {
|
|
28
|
+
layer: "system-network-route",
|
|
29
|
+
ok: true,
|
|
30
|
+
route_class: route.route_class,
|
|
31
|
+
operating_system_interception: route.operating_system_interception === true,
|
|
32
|
+
} : {
|
|
33
|
+
layer: "system-network-route",
|
|
34
|
+
ok: false,
|
|
35
|
+
skipped: true,
|
|
36
|
+
error_class: route.supported ? "unavailable" : "unsupported_platform",
|
|
37
|
+
};
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return {
|
|
40
|
+
layer: "system-network-route",
|
|
41
|
+
ok: false,
|
|
42
|
+
skipped: true,
|
|
43
|
+
error_class: classifyError(error),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {SystemNetworkRouteOptions} [options] */
|
|
49
|
+
export async function inspectSystemNetworkRoute({
|
|
50
|
+
platform = process.platform,
|
|
51
|
+
runFixedInternal,
|
|
52
|
+
context = {},
|
|
53
|
+
} = {}) {
|
|
54
|
+
if (platform !== "darwin" || typeof runFixedInternal !== "function") return { supported: false };
|
|
55
|
+
const result = await runFixedInternal("/sbin/route", ["-n", "get", "default"], 5_000, true, 16 * 1024, context);
|
|
56
|
+
if (result?.code !== 0) return { supported: true, available: false };
|
|
57
|
+
const match = String(result.stdout || "").match(/^\s*interface:\s*(\S+)\s*$/mi);
|
|
58
|
+
if (!match) return { supported: true, available: false };
|
|
59
|
+
const routeClass = classifySystemRouteInterface(match[1]);
|
|
60
|
+
return {
|
|
61
|
+
supported: true,
|
|
62
|
+
available: true,
|
|
63
|
+
route_class: routeClass,
|
|
64
|
+
operating_system_interception: routeClass === "tunnel-or-vpn",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {unknown} value */
|
|
69
|
+
export function classifySystemRouteInterface(value) {
|
|
70
|
+
const name = String(value || "").trim();
|
|
71
|
+
if (!name) return "unknown";
|
|
72
|
+
if (TUNNEL_INTERFACE.test(name)) return "tunnel-or-vpn";
|
|
73
|
+
if (LOOPBACK_INTERFACE.test(name)) return "loopback";
|
|
74
|
+
if (PHYSICAL_INTERFACE.test(name)) return "physical-or-other";
|
|
75
|
+
return "other";
|
|
76
|
+
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks";
|
|
2
|
-
import
|
|
2
|
+
import catalog from "../shared/tool-catalog.json" with { type: "json" };
|
|
3
|
+
import { compileToolArgumentValidators } from "../shared/tool-argument-validation.mjs";
|
|
4
|
+
import { BridgeError, errorCode, normalizeBridgeError } from "./errors.mjs";
|
|
3
5
|
import { normalizeToolResult } from "./tool-result-boundary.mjs";
|
|
4
6
|
|
|
7
|
+
const TOOL_ARGUMENTS = compileToolArgumentValidators(catalog);
|
|
8
|
+
|
|
9
|
+
export function validateToolArguments(tool, value) {
|
|
10
|
+
return TOOL_ARGUMENTS.validate(tool, value);
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
export class ToolExecutor {
|
|
6
14
|
constructor(options = {}) {
|
|
7
15
|
this.handlers = options.handlers || {};
|
|
@@ -18,11 +26,12 @@ export class ToolExecutor {
|
|
|
18
26
|
lifecycleMiddleware(this.callRegistry),
|
|
19
27
|
observabilityMiddleware(this.observability, this.securityAudit, this.logger, this.safeMessage, this.slowMs),
|
|
20
28
|
authorizeMiddleware(this.policyGate, this.accountAccessGate, this.operationAuthorizer),
|
|
29
|
+
validateArgumentsMiddleware(),
|
|
21
30
|
], invokeHandler(this.handlers));
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
execute(tool, args = {}, request = {}) {
|
|
25
|
-
return this.pipeline({ tool: String(tool || ""), args
|
|
34
|
+
return this.pipeline({ tool: String(tool || ""), args, request });
|
|
26
35
|
}
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -48,6 +57,19 @@ function authorizeMiddleware(policyGate, accountAccessGate, operationAuthorizer)
|
|
|
48
57
|
};
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
function validateArgumentsMiddleware() {
|
|
61
|
+
return async (operation, next) => {
|
|
62
|
+
const result = TOOL_ARGUMENTS.validate(operation.tool, operation.args);
|
|
63
|
+
if (!result.known) throw new BridgeError("not_found", `unknown tool: ${operation.tool}`);
|
|
64
|
+
if (!result.valid) {
|
|
65
|
+
throw new BridgeError("invalid_request", `tool arguments do not match the input schema: ${operation.tool}`, {
|
|
66
|
+
details: { tool: operation.tool, validation_issues: result.issues },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return next(operation);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
function lifecycleMiddleware(callRegistry) {
|
|
52
74
|
return async (operation, next) => {
|
|
53
75
|
const context = callRegistry.open({
|
|
@@ -138,7 +160,3 @@ function safeByteLength(value) {
|
|
|
138
160
|
function shortCallId(value) {
|
|
139
161
|
return String(value || "").slice(0, 20);
|
|
140
162
|
}
|
|
141
|
-
|
|
142
|
-
function isRecord(value) {
|
|
143
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
144
|
-
}
|
package/src/local/tools.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
|
|
2
2
|
import { projectMcpResult } from "../shared/result-projection.mjs";
|
|
3
|
+
import { MCP_LEGACY_PROTOCOL_VERSION, resultForProtocol } from "../shared/mcp-protocol.mjs";
|
|
3
4
|
export {
|
|
4
5
|
DEFAULT_POLICY_PROFILE,
|
|
5
6
|
DEFAULT_POLICY_REVISION,
|
|
@@ -27,13 +28,13 @@ export const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
|
27
28
|
export const MCP_SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(serverMetadata.supportedProtocolVersions.map((value) => String(value)));
|
|
28
29
|
export const MCP_INSTRUCTIONS = Object.freeze(serverMetadata.instructions.map((value) => String(value))).join("\n");
|
|
29
30
|
|
|
30
|
-
export function toolResult(value, isError = false) {
|
|
31
|
+
export function toolResult(value, isError = false, protocolVersion = MCP_LEGACY_PROTOCOL_VERSION, serverInfo) {
|
|
31
32
|
const special = specialMcpResult(value);
|
|
32
|
-
if (special) return { ...special, isError };
|
|
33
|
+
if (special) return resultForProtocol(protocolVersion, { ...special, isError }, { serverInfo });
|
|
33
34
|
const projection = projectMcpResult(value);
|
|
34
35
|
const result = { content: [{ type: "text", text: projection.text }], isError };
|
|
35
|
-
if (projection.
|
|
36
|
-
return result;
|
|
36
|
+
if (projection.hasStructuredContent) result.structuredContent = structuredClone(projection.structuredContent);
|
|
37
|
+
return resultForProtocol(protocolVersion, result, { serverInfo });
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export function rpcResult(id, result) {
|
|
@@ -51,7 +52,7 @@ function specialMcpResult(value) {
|
|
|
51
52
|
const special = value && typeof value === "object" && !Array.isArray(value) ? value.$mcp : null;
|
|
52
53
|
if (!special || typeof special !== "object" || !Array.isArray(special.content)) return null;
|
|
53
54
|
const result = { content: structuredClone(special.content) };
|
|
54
|
-
if (
|
|
55
|
+
if (Object.prototype.hasOwnProperty.call(special, "structuredContent")) {
|
|
55
56
|
result.structuredContent = structuredClone(special.structuredContent);
|
|
56
57
|
}
|
|
57
58
|
return result;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { waitForStableActiveStatus } from "./service-convergence.mjs";
|
|
4
|
+
|
|
5
|
+
/** @typedef {{ statusAttempts?: unknown, statusDelayMs?: unknown, stableSamples?: unknown, sleep?: (milliseconds: number) => Promise<void> }} WindowsConvergenceOptions */
|
|
6
|
+
/** @typedef {Record<string, unknown> & { active?: boolean }} WindowsStatus */
|
|
7
|
+
|
|
8
|
+
/** @param {() => WindowsStatus | Promise<WindowsStatus>} readStatus @param {WindowsConvergenceOptions} [options] */
|
|
9
|
+
export async function stableWindowsStatus(readStatus, options = {}) {
|
|
10
|
+
if (typeof readStatus !== "function") throw new TypeError("Windows status reader is required");
|
|
11
|
+
return waitForStableActiveStatus(readStatus, {
|
|
12
|
+
attempts: boundedPositiveInteger(options.statusAttempts, 10),
|
|
13
|
+
delayMs: boundedPositiveInteger(options.statusDelayMs, 100),
|
|
14
|
+
stableSamples: boundedPositiveInteger(options.stableSamples, 5),
|
|
15
|
+
sleep: typeof options.sleep === "function" ? options.sleep : undefined,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {() => WindowsStatus | Promise<WindowsStatus>} readStatus @param {(status: WindowsStatus) => boolean} predicate @param {WindowsConvergenceOptions} [options] */
|
|
20
|
+
export async function waitForWindowsStatus(readStatus, predicate, options = {}) {
|
|
21
|
+
if (typeof readStatus !== "function") throw new TypeError("Windows status reader is required");
|
|
22
|
+
if (typeof predicate !== "function") throw new TypeError("Windows status predicate is required");
|
|
23
|
+
const attempts = boundedPositiveInteger(options.statusAttempts, 10);
|
|
24
|
+
const delayMs = boundedPositiveInteger(options.statusDelayMs, 100);
|
|
25
|
+
const sleep = typeof options.sleep === "function"
|
|
26
|
+
? options.sleep
|
|
27
|
+
: (/** @type {number} */ milliseconds) => new Promise(resolve => { setTimeout(resolve, milliseconds); });
|
|
28
|
+
let status = await readStatus();
|
|
29
|
+
for (let index = 1; index < attempts && !predicate(status); index += 1) {
|
|
30
|
+
await sleep(delayMs);
|
|
31
|
+
status = await readStatus();
|
|
32
|
+
}
|
|
33
|
+
return status;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {WindowsConvergenceOptions} [options] */
|
|
37
|
+
export function windowsStatusWaitOptions(options = {}) {
|
|
38
|
+
return {
|
|
39
|
+
attempts: boundedPositiveInteger(options.statusAttempts, 10),
|
|
40
|
+
delayMs: boundedPositiveInteger(options.statusDelayMs, 100),
|
|
41
|
+
sleep: typeof options.sleep === "function" ? options.sleep : undefined,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {unknown} value @param {number} fallback */
|
|
46
|
+
export function boundedPositiveInteger(value, fallback) {
|
|
47
|
+
const number = Number(value);
|
|
48
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
49
|
+
}
|