machine-bridge-mcp 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +16 -13
- package/SECURITY.md +11 -7
- package/docs/ARCHITECTURE.md +6 -6
- package/docs/CLIENTS.md +2 -2
- package/docs/LOGGING.md +4 -0
- package/docs/MANAGED_JOBS.md +14 -13
- package/docs/OPERATIONS.md +9 -6
- package/docs/PRIVACY.md +37 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +17 -1
- package/package.json +31 -8
- package/scripts/network-retry.mjs +47 -0
- package/scripts/privacy-check.mjs +177 -0
- package/scripts/release-impact-check.mjs +78 -0
- package/src/local/cli.mjs +54 -16
- package/src/local/daemon.mjs +49 -12
- package/src/local/job-runner.mjs +70 -13
- package/src/local/log.mjs +26 -2
- package/src/local/managed-jobs.mjs +163 -74
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +4 -2
- package/src/local/state.mjs +7 -5
- package/src/local/stdio.mjs +76 -16
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +5 -1
- package/src/worker/index.ts +35 -18
- package/wrangler.jsonc +1 -1
package/src/local/state.mjs
CHANGED
|
@@ -80,7 +80,7 @@ export function removeStateRoot(stateRoot = defaultStateRoot()) {
|
|
|
80
80
|
return true;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
function workspaceHash(workspace) {
|
|
84
84
|
const canonical = resolveWorkspace(workspace);
|
|
85
85
|
const identity = process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
86
86
|
return createHash("sha256").update(identity).digest("hex").slice(0, 24);
|
|
@@ -160,7 +160,7 @@ export function daemonLockPathForState(state) {
|
|
|
160
160
|
return lockPathForState(state, "daemon.lock");
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
function startupLockPathForState(state) {
|
|
164
164
|
return lockPathForState(state, "startup.lock");
|
|
165
165
|
}
|
|
166
166
|
|
|
@@ -362,7 +362,7 @@ function assertValidStateMarker(marker, options = {}) {
|
|
|
362
362
|
|
|
363
363
|
function hasOnlyStateEntries(entries) {
|
|
364
364
|
const allowed = new Set([STATE_MARKER, "config.json", "profiles", "logs"]);
|
|
365
|
-
return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d
|
|
365
|
+
return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d+(?:-[a-f0-9]{8})?$/.test(entry));
|
|
366
366
|
}
|
|
367
367
|
|
|
368
368
|
function looksLikeSourceTree(root) {
|
|
@@ -484,7 +484,7 @@ function randomToken(prefix) {
|
|
|
484
484
|
return `${prefix}_${randomBytes(32).toString("base64url")}`;
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
-
|
|
487
|
+
function sha256(value) {
|
|
488
488
|
return createHash("sha256").update(String(value)).digest("hex");
|
|
489
489
|
}
|
|
490
490
|
|
|
@@ -508,7 +508,9 @@ export function redactState(state) {
|
|
|
508
508
|
if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
|
|
509
509
|
if (clone.resources && typeof clone.resources === "object") {
|
|
510
510
|
for (const value of Object.values(clone.resources)) {
|
|
511
|
-
if (value
|
|
511
|
+
if (!value || typeof value !== "object") continue;
|
|
512
|
+
if (value.path) value.path = "<local-resource-path>";
|
|
513
|
+
delete value.pathAliases;
|
|
512
514
|
}
|
|
513
515
|
}
|
|
514
516
|
return clone;
|
package/src/local/stdio.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import readline from "node:readline";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
2
|
import { randomBytes } from "node:crypto";
|
|
4
3
|
import { LocalDaemon } from "./daemon.mjs";
|
|
@@ -25,30 +24,26 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
25
24
|
let negotiatedVersion = MCP_PROTOCOL_VERSION;
|
|
26
25
|
let initialized = false;
|
|
27
26
|
|
|
28
|
-
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
29
27
|
const writes = new Set();
|
|
30
28
|
const send = (message) => {
|
|
31
29
|
if (message === null || message === undefined) return;
|
|
32
30
|
const line = `${JSON.stringify(message)}\n`;
|
|
33
31
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
34
32
|
process.stdout.write(line, (error) => error ? rejectPromise(error) : resolvePromise());
|
|
33
|
+
}).catch((error) => {
|
|
34
|
+
logger.warn("stdio output write failed", { error_class: classifyOperationalError(error) });
|
|
35
35
|
}).finally(() => writes.delete(promise));
|
|
36
36
|
writes.add(promise);
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
await new Promise((resolvePromise, rejectPromise) => {
|
|
50
|
-
rl.once("close", resolvePromise);
|
|
51
|
-
rl.once("error", rejectPromise);
|
|
39
|
+
await consumeBoundedJsonLines(process.stdin, {
|
|
40
|
+
maxLineBytes: MAX_LINE_BYTES,
|
|
41
|
+
onOversize() { send(rpcError(null, -32600, "JSON-RPC message exceeds maximum size")); },
|
|
42
|
+
onLine(line) {
|
|
43
|
+
void handleLine(line).catch((error) => {
|
|
44
|
+
logger.error("stdio request handler failed", { error_class: classifyOperationalError(error) });
|
|
45
|
+
});
|
|
46
|
+
},
|
|
52
47
|
});
|
|
53
48
|
for (const callId of pending.values()) runtime.cancelCall(callId, "stdio input closed");
|
|
54
49
|
await Promise.allSettled([...writes]);
|
|
@@ -111,6 +106,10 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
111
106
|
return;
|
|
112
107
|
}
|
|
113
108
|
if (message.method === "tools/call") {
|
|
109
|
+
if (!("id" in message) || message.id === null) {
|
|
110
|
+
send(rpcError(null, -32600, "tools/call requires a non-null request id"));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
114
113
|
const params = asObject(message.params);
|
|
115
114
|
const name = typeof params.name === "string" ? params.name : "";
|
|
116
115
|
if (!name) {
|
|
@@ -133,7 +132,7 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
133
132
|
const durationMs = Date.now() - started;
|
|
134
133
|
logger.debug(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs });
|
|
135
134
|
} catch (error) {
|
|
136
|
-
const safeError = runtime.safeErrorMessage(error);
|
|
135
|
+
const safeError = runtime.safeErrorMessage(error, args);
|
|
137
136
|
send(rpcResult(message.id, toolResult({ error: safeError }, true)));
|
|
138
137
|
const durationMs = Date.now() - started;
|
|
139
138
|
logger.debug("tool call failed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
@@ -147,6 +146,67 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
147
146
|
}
|
|
148
147
|
}
|
|
149
148
|
|
|
149
|
+
function consumeBoundedJsonLines(stream, { maxLineBytes, onLine, onOversize }) {
|
|
150
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
151
|
+
let chunks = [];
|
|
152
|
+
let bytes = 0;
|
|
153
|
+
let discarding = false;
|
|
154
|
+
let settled = false;
|
|
155
|
+
|
|
156
|
+
const resetLine = () => { chunks = []; bytes = 0; };
|
|
157
|
+
const emitLine = () => {
|
|
158
|
+
let line = bytes ? Buffer.concat(chunks, bytes) : Buffer.alloc(0);
|
|
159
|
+
if (line.length && line[line.length - 1] === 13) line = line.subarray(0, line.length - 1);
|
|
160
|
+
resetLine();
|
|
161
|
+
onLine(line.toString("utf8"));
|
|
162
|
+
};
|
|
163
|
+
const onData = (input) => {
|
|
164
|
+
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
165
|
+
let offset = 0;
|
|
166
|
+
while (offset < buffer.length) {
|
|
167
|
+
const newline = buffer.indexOf(10, offset);
|
|
168
|
+
const end = newline === -1 ? buffer.length : newline;
|
|
169
|
+
const segment = buffer.subarray(offset, end);
|
|
170
|
+
if (discarding) {
|
|
171
|
+
if (newline !== -1) discarding = false;
|
|
172
|
+
} else if (bytes + segment.length > maxLineBytes) {
|
|
173
|
+
resetLine();
|
|
174
|
+
onOversize();
|
|
175
|
+
if (newline === -1) discarding = true;
|
|
176
|
+
} else {
|
|
177
|
+
if (segment.length) chunks.push(segment);
|
|
178
|
+
bytes += segment.length;
|
|
179
|
+
if (newline !== -1) emitLine();
|
|
180
|
+
}
|
|
181
|
+
offset = newline === -1 ? buffer.length : newline + 1;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const cleanup = () => {
|
|
185
|
+
stream.off("data", onData);
|
|
186
|
+
stream.off("end", onEnd);
|
|
187
|
+
stream.off("close", onEnd);
|
|
188
|
+
stream.off("error", onError);
|
|
189
|
+
};
|
|
190
|
+
const onEnd = () => {
|
|
191
|
+
if (settled) return;
|
|
192
|
+
settled = true;
|
|
193
|
+
cleanup();
|
|
194
|
+
if (!discarding && bytes > 0) emitLine();
|
|
195
|
+
resolvePromise();
|
|
196
|
+
};
|
|
197
|
+
const onError = (error) => {
|
|
198
|
+
if (settled) return;
|
|
199
|
+
settled = true;
|
|
200
|
+
cleanup();
|
|
201
|
+
rejectPromise(error);
|
|
202
|
+
};
|
|
203
|
+
stream.on("data", onData);
|
|
204
|
+
stream.once("end", onEnd);
|
|
205
|
+
stream.once("close", onEnd);
|
|
206
|
+
stream.once("error", onError);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
150
210
|
function isJsonRpcMessage(value) {
|
|
151
211
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
152
212
|
if (value.jsonrpc !== "2.0") return false;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"Filesystem scope and path display follow the active local policy; the default full profile is unrestricted.",
|
|
13
13
|
"Filename sensitivity is not classified by this server; the MCP host, connector gateway, local OS, or endpoint-security software may enforce additional independent rules.",
|
|
14
14
|
"A named full profile is canonical: it always enables writes, shell execution, unrestricted paths, the full parent environment, absolute paths, and the complete tool catalog. Any explicit narrowing is stored as custom.",
|
|
15
|
+
"The full catalog is what the local daemon and relay advertise. A connector host or platform can expose only a subset to a particular session; Machine Bridge cannot observe, authorize, or override that host-side filtering.",
|
|
15
16
|
"Use diagnose_runtime to distinguish a request that reached the daemon from local filesystem, process-spawn, shell, managed-job-storage, and resource failures. A tool call blocked before any response cannot be diagnosed by the server.",
|
|
16
17
|
"Never request or return secret-file contents when a local resource alias can be used. Resources are registered through the local machine-mcp CLI; under canonical full policy, generate_ssh_key_resource can generate and register an Ed25519 key without returning private content and may be injected by path, stdin, or environment without entering MCP arguments.",
|
|
17
18
|
"If execution-class tools are unavailable but write access remains, use stage_job to persist a reviewed plan without running it; the operator can launch it locally with machine-mcp job approve JOB_ID.",
|
|
@@ -638,7 +638,7 @@
|
|
|
638
638
|
{
|
|
639
639
|
"name": "generate_ssh_key_resource",
|
|
640
640
|
"title": "Generate SSH key resource",
|
|
641
|
-
"description": "Generate or reuse an Ed25519 SSH key pair on the local machine and register the private key file as a local resource alias.
|
|
641
|
+
"description": "Generate or reuse an Ed25519 SSH key pair on the local machine and register the private key file as a local resource alias. Private key content is never returned. Local paths are omitted unless expose_paths=true; fingerprint, permissions, and registration status are returned. Available only under canonical full policy.",
|
|
642
642
|
"availability": "full",
|
|
643
643
|
"annotations": {
|
|
644
644
|
"readOnlyHint": false,
|
|
@@ -660,6 +660,10 @@
|
|
|
660
660
|
"comment": {
|
|
661
661
|
"type": "string",
|
|
662
662
|
"maxLength": 256
|
|
663
|
+
},
|
|
664
|
+
"expose_paths": {
|
|
665
|
+
"type": "boolean",
|
|
666
|
+
"description": "Return local private/public key paths. Defaults to false."
|
|
663
667
|
}
|
|
664
668
|
},
|
|
665
669
|
"required": [
|
package/src/worker/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import toolCatalog from "../shared/tool-catalog.json";
|
|
|
3
3
|
import serverMetadata from "../shared/server-metadata.json";
|
|
4
4
|
|
|
5
5
|
const SERVER_NAME = String(serverMetadata.name);
|
|
6
|
-
const SERVER_VERSION = "0.
|
|
6
|
+
const SERVER_VERSION = "0.7.1";
|
|
7
7
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
8
8
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
9
9
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -27,6 +27,7 @@ const AUTH_FAILURE_WINDOW_SECONDS = 10 * 60;
|
|
|
27
27
|
const AUTH_BLOCK_SECONDS = 15 * 60;
|
|
28
28
|
const AUTH_FAILURE_LIMIT = 10;
|
|
29
29
|
const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
|
|
30
|
+
const UNSAFE_DISPLAY_CONTROLS = /[\u0000-\u001f\u007f\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
|
|
30
31
|
|
|
31
32
|
interface BridgeEnv {
|
|
32
33
|
BRIDGE: DurableObjectNamespace<BridgeRoom>;
|
|
@@ -361,6 +362,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
361
362
|
if (request.method === "ping") return rpcResult(request.id, {});
|
|
362
363
|
if (request.method === "tools/list") return rpcResult(request.id, { tools: this.allTools() });
|
|
363
364
|
if (request.method === "tools/call") {
|
|
365
|
+
if (request.id === undefined || request.id === null) return rpcError(null, -32600, "tools/call requires a non-null request id");
|
|
364
366
|
const params = asObject(request.params);
|
|
365
367
|
const name = requiredString(params, "name");
|
|
366
368
|
const args = asObject(params.arguments);
|
|
@@ -376,13 +378,22 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
376
378
|
|
|
377
379
|
private async callTool(name: string, args: Record<string, unknown>, base: string, requestKey?: string): Promise<unknown> {
|
|
378
380
|
if (name === "server_info") {
|
|
381
|
+
const daemon = this.daemonStatus(true);
|
|
382
|
+
const tools = this.allTools().map((tool) => tool.name);
|
|
379
383
|
return {
|
|
380
384
|
name: SERVER_NAME,
|
|
381
385
|
version: SERVER_VERSION,
|
|
382
386
|
mcp_url: `${base}/mcp`,
|
|
383
387
|
oauth: this.authorizationServerMetadata(base),
|
|
384
|
-
daemon
|
|
385
|
-
tools
|
|
388
|
+
daemon,
|
|
389
|
+
tools,
|
|
390
|
+
tool_delivery: {
|
|
391
|
+
full_profile_scope: "local-daemon-and-relay-advertisement",
|
|
392
|
+
daemon_advertised_tool_count: daemon.tool_count,
|
|
393
|
+
relay_advertised_tool_count: tools.length,
|
|
394
|
+
host_exposed_tools_known_to_server: false,
|
|
395
|
+
host_may_expose_subset: true,
|
|
396
|
+
},
|
|
386
397
|
};
|
|
387
398
|
}
|
|
388
399
|
if (workspaceTools.some((tool) => tool.name === name)) {
|
|
@@ -657,13 +668,15 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
657
668
|
if (redirectUris.length > 5) {
|
|
658
669
|
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most 5 entries" }, 400);
|
|
659
670
|
}
|
|
660
|
-
const
|
|
661
|
-
if (
|
|
671
|
+
const suppliedRedirectUris = redirectUris.map((item) => String(item));
|
|
672
|
+
if (suppliedRedirectUris.some((item) => item.length > 1024)) {
|
|
662
673
|
return json({ error: "invalid_client_metadata", error_description: "redirect_uri is too long" }, 400);
|
|
663
674
|
}
|
|
664
|
-
|
|
665
|
-
|
|
675
|
+
const canonicalRedirectUris = suppliedRedirectUris.map(normalizeRedirectUri);
|
|
676
|
+
if (canonicalRedirectUris.some((item) => item === null)) {
|
|
677
|
+
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be canonicalizable https or local http URLs without credentials or fragments" }, 400);
|
|
666
678
|
}
|
|
679
|
+
const normalized = [...new Set(canonicalRedirectUris as string[])];
|
|
667
680
|
|
|
668
681
|
return this.withOAuthLock(async () => {
|
|
669
682
|
const store = await this.oauthStore();
|
|
@@ -725,7 +738,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
725
738
|
.filter(([key]) => AUTHORIZATION_FIELDS.has(key))
|
|
726
739
|
.map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
|
|
727
740
|
.join("\n");
|
|
728
|
-
const resource =
|
|
741
|
+
const resource = normalizeDisplayText(
|
|
742
|
+
authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`),
|
|
743
|
+
1024,
|
|
744
|
+
`${base}/mcp`,
|
|
745
|
+
);
|
|
729
746
|
const clientBlock = authorization
|
|
730
747
|
? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
|
|
731
748
|
<p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
|
|
@@ -971,7 +988,7 @@ function isFreshDaemonCandidate(connectedAt: string): boolean {
|
|
|
971
988
|
|
|
972
989
|
function sanitizeMetadataText(value: unknown, maxLength: number): string | undefined {
|
|
973
990
|
if (typeof value !== "string") return undefined;
|
|
974
|
-
const normalized = value.replace(
|
|
991
|
+
const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
|
|
975
992
|
return normalized ? normalized.slice(0, maxLength) : undefined;
|
|
976
993
|
}
|
|
977
994
|
|
|
@@ -1272,15 +1289,15 @@ async function pkceS256(verifier: string): Promise<string> {
|
|
|
1272
1289
|
return base64Url(new Uint8Array(digest));
|
|
1273
1290
|
}
|
|
1274
1291
|
|
|
1275
|
-
function
|
|
1292
|
+
function normalizeRedirectUri(value: string): string | null {
|
|
1276
1293
|
try {
|
|
1277
1294
|
const url = new URL(value);
|
|
1278
|
-
if (url.username || url.password || url.hash) return
|
|
1279
|
-
if (url.protocol === "https:" && url.hostname) return
|
|
1280
|
-
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return
|
|
1281
|
-
return
|
|
1295
|
+
if (url.username || url.password || url.hash) return null;
|
|
1296
|
+
if (url.protocol === "https:" && url.hostname) return url.toString();
|
|
1297
|
+
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url.toString();
|
|
1298
|
+
return null;
|
|
1282
1299
|
} catch {
|
|
1283
|
-
return
|
|
1300
|
+
return null;
|
|
1284
1301
|
}
|
|
1285
1302
|
}
|
|
1286
1303
|
|
|
@@ -1359,9 +1376,9 @@ function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
|
|
|
1359
1376
|
return out;
|
|
1360
1377
|
}
|
|
1361
1378
|
|
|
1362
|
-
function normalizeDisplayText(value: string, maxLength: number): string {
|
|
1363
|
-
const normalized = value.replace(
|
|
1364
|
-
return (normalized ||
|
|
1379
|
+
function normalizeDisplayText(value: string, maxLength: number, fallback = "MCP Client"): string {
|
|
1380
|
+
const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
|
|
1381
|
+
return (normalized || fallback).slice(0, maxLength);
|
|
1365
1382
|
}
|
|
1366
1383
|
|
|
1367
1384
|
function escapeHtml(value: string): string {
|
package/wrangler.jsonc
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
|
3
3
|
"name": "machine-bridge-mcp",
|
|
4
4
|
"main": "src/worker/index.ts",
|
|
5
|
-
"compatibility_date": "2026-07-
|
|
5
|
+
"compatibility_date": "2026-07-11",
|
|
6
6
|
"compatibility_flags": ["nodejs_compat"],
|
|
7
7
|
"workers_dev": true,
|
|
8
8
|
"durable_objects": {
|