machine-bridge-mcp 0.6.2 → 0.8.0
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 +71 -0
- package/CONTRIBUTING.md +31 -0
- package/README.md +20 -17
- package/SECURITY.md +11 -7
- package/docs/ARCHITECTURE.md +14 -12
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +159 -0
- package/docs/LOGGING.md +65 -26
- package/docs/MANAGED_JOBS.md +14 -13
- package/docs/OPERATIONS.md +15 -6
- package/docs/PRIVACY.md +43 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +23 -1
- package/package.json +35 -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 +369 -272
- package/src/local/full-access-test.mjs +2 -2
- package/src/local/job-runner.mjs +73 -31
- package/src/local/log.mjs +28 -2
- package/src/local/managed-jobs.mjs +194 -125
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/relay-connection.mjs +495 -0
- package/src/local/{daemon.mjs → runtime.mjs} +188 -198
- package/src/local/secure-file.mjs +27 -0
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +41 -21
- package/src/local/state.mjs +7 -5
- package/src/local/stdio.mjs +157 -81
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +5 -1
- package/src/worker/index.ts +45 -21
- package/wrangler.jsonc +1 -1
package/src/local/stdio.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import readline from "node:readline";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
2
|
import { randomBytes } from "node:crypto";
|
|
4
|
-
import {
|
|
3
|
+
import { LocalRuntime } from "./runtime.mjs";
|
|
5
4
|
import { classifyOperationalError, createLogger } from "./log.mjs";
|
|
6
5
|
import {
|
|
7
6
|
MCP_INSTRUCTIONS,
|
|
@@ -20,131 +19,208 @@ const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../../package.js
|
|
|
20
19
|
|
|
21
20
|
export async function runStdioServer({ workspace, policy, logLevel = "info", jobRoot = "", resources = {}, resourceStatePath = "" }) {
|
|
22
21
|
const logger = createLogger({ component: "stdio", level: logLevel, stderrOnly: true, color: false });
|
|
23
|
-
const runtime = new
|
|
22
|
+
const runtime = new LocalRuntime({ workspace, policy, logger, jobRoot, resources, resourceStatePath });
|
|
24
23
|
const pending = new Map();
|
|
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]);
|
|
55
50
|
runtime.stop();
|
|
56
51
|
|
|
57
52
|
async function handleLine(line) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (!isJsonRpcMessage(message)) {
|
|
64
|
-
send(rpcError(null, -32600, "Invalid JSON-RPC message"));
|
|
53
|
+
const message = parseJsonRpcLine(line, send);
|
|
54
|
+
if (!message || typeof message.method !== "string") return;
|
|
55
|
+
if (handleControlMessage(message)) return;
|
|
56
|
+
if (!initialized) {
|
|
57
|
+
send(rpcError(message.id, -32002, "Server is not initialized"));
|
|
65
58
|
return;
|
|
66
59
|
}
|
|
67
|
-
|
|
60
|
+
await handleInitializedMessage(message);
|
|
61
|
+
}
|
|
68
62
|
|
|
63
|
+
function handleControlMessage(message) {
|
|
69
64
|
if (message.method === "initialize") {
|
|
70
65
|
const requested = asObject(message.params).protocolVersion;
|
|
71
66
|
negotiatedVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
|
|
72
67
|
? requested
|
|
73
68
|
: MCP_PROTOCOL_VERSION;
|
|
74
69
|
initialized = true;
|
|
75
|
-
send(rpcResult(message.id,
|
|
76
|
-
|
|
77
|
-
capabilities: { tools: { listChanged: false }, logging: {} },
|
|
78
|
-
serverInfo: {
|
|
79
|
-
name: SERVER_NAME,
|
|
80
|
-
title: "Machine Bridge MCP",
|
|
81
|
-
version: PACKAGE_VERSION,
|
|
82
|
-
description: "Workspace-scoped local coding tools over MCP stdio or authenticated remote relay.",
|
|
83
|
-
},
|
|
84
|
-
instructions: MCP_INSTRUCTIONS,
|
|
85
|
-
}));
|
|
86
|
-
return;
|
|
70
|
+
send(rpcResult(message.id, initializationResult(negotiatedVersion)));
|
|
71
|
+
return true;
|
|
87
72
|
}
|
|
88
|
-
|
|
89
|
-
if (message.method === "notifications/initialized") return;
|
|
73
|
+
if (message.method === "notifications/initialized") return true;
|
|
90
74
|
if (message.method === "notifications/cancelled") {
|
|
91
75
|
const requestId = asObject(message.params).requestId;
|
|
92
|
-
const
|
|
93
|
-
const callId = pending.get(key);
|
|
76
|
+
const callId = pending.get(jsonRpcIdKey(requestId));
|
|
94
77
|
if (callId) runtime.cancelCall(callId, "MCP cancellation notification");
|
|
95
|
-
return;
|
|
78
|
+
return true;
|
|
96
79
|
}
|
|
97
|
-
if (message.method === "logging/setLevel") {
|
|
80
|
+
if (message.method === "logging/setLevel" || message.method === "ping") {
|
|
98
81
|
send(rpcResult(message.id, {}));
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function handleInitializedMessage(message) {
|
|
88
|
+
if (message.method === "tools/list") {
|
|
89
|
+
send(rpcResult(message.id, { tools: toolsForPolicy(policy) }));
|
|
99
90
|
return;
|
|
100
91
|
}
|
|
101
|
-
if (message.method === "
|
|
102
|
-
|
|
92
|
+
if (message.method === "tools/call") {
|
|
93
|
+
await handleToolCall(message);
|
|
103
94
|
return;
|
|
104
95
|
}
|
|
105
|
-
|
|
106
|
-
|
|
96
|
+
send(rpcError(message.id, -32601, `Method not found: ${message.method}`));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function handleToolCall(message) {
|
|
100
|
+
if (!("id" in message) || message.id === null) {
|
|
101
|
+
send(rpcError(null, -32600, "tools/call requires a non-null request id"));
|
|
107
102
|
return;
|
|
108
103
|
}
|
|
109
|
-
|
|
110
|
-
|
|
104
|
+
const params = asObject(message.params);
|
|
105
|
+
const name = typeof params.name === "string" ? params.name : "";
|
|
106
|
+
if (!name) {
|
|
107
|
+
send(rpcError(message.id, -32602, "tools/call requires a tool name"));
|
|
111
108
|
return;
|
|
112
109
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
const args = asObject(params.arguments);
|
|
121
|
-
const callId = `stdio_${randomBytes(16).toString("hex")}`;
|
|
122
|
-
const key = jsonRpcIdKey(message.id);
|
|
123
|
-
if (key && pending.has(key)) {
|
|
124
|
-
send(rpcError(message.id, -32600, "Duplicate in-flight JSON-RPC request id"));
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
if (key) pending.set(key, callId);
|
|
128
|
-
const started = Date.now();
|
|
129
|
-
logger.debug("tool call started", { call_id: callId.slice(0, 20), tool: name });
|
|
130
|
-
try {
|
|
131
|
-
const result = await runtime.executeTool(name, args, { callId });
|
|
132
|
-
send(rpcResult(message.id, toolResult(result)));
|
|
133
|
-
const durationMs = Date.now() - started;
|
|
134
|
-
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
|
-
} catch (error) {
|
|
136
|
-
const safeError = runtime.safeErrorMessage(error);
|
|
137
|
-
send(rpcResult(message.id, toolResult({ error: safeError }, true)));
|
|
138
|
-
const durationMs = Date.now() - started;
|
|
139
|
-
logger.debug("tool call failed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
140
|
-
} finally {
|
|
141
|
-
if (key) pending.delete(key);
|
|
142
|
-
runtime.finishCall(callId);
|
|
143
|
-
}
|
|
110
|
+
const args = asObject(params.arguments);
|
|
111
|
+
const callId = `stdio_${randomBytes(16).toString("hex")}`;
|
|
112
|
+
const key = jsonRpcIdKey(message.id);
|
|
113
|
+
if (key && pending.has(key)) {
|
|
114
|
+
send(rpcError(message.id, -32600, "Duplicate in-flight JSON-RPC request id"));
|
|
144
115
|
return;
|
|
145
116
|
}
|
|
146
|
-
|
|
117
|
+
if (key) pending.set(key, callId);
|
|
118
|
+
const started = Date.now();
|
|
119
|
+
logger.debug("tool call started", { call_id: callId.slice(0, 20), tool: name });
|
|
120
|
+
try {
|
|
121
|
+
const result = await runtime.executeTool(name, args, { callId });
|
|
122
|
+
send(rpcResult(message.id, toolResult(result)));
|
|
123
|
+
const durationMs = Date.now() - started;
|
|
124
|
+
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 });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
const safeError = runtime.safeErrorMessage(error, args);
|
|
127
|
+
send(rpcResult(message.id, toolResult({ error: safeError }, true)));
|
|
128
|
+
const durationMs = Date.now() - started;
|
|
129
|
+
logger.debug("tool call failed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
130
|
+
} finally {
|
|
131
|
+
if (key) pending.delete(key);
|
|
132
|
+
runtime.finishCall(callId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseJsonRpcLine(line, send) {
|
|
139
|
+
let message;
|
|
140
|
+
try { message = JSON.parse(line); } catch {
|
|
141
|
+
send(rpcError(null, -32700, "Parse error"));
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (!isJsonRpcMessage(message)) {
|
|
145
|
+
send(rpcError(null, -32600, "Invalid JSON-RPC message"));
|
|
146
|
+
return null;
|
|
147
147
|
}
|
|
148
|
+
return message;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function initializationResult(protocolVersion) {
|
|
152
|
+
return {
|
|
153
|
+
protocolVersion,
|
|
154
|
+
capabilities: { tools: { listChanged: false }, logging: {} },
|
|
155
|
+
serverInfo: {
|
|
156
|
+
name: SERVER_NAME,
|
|
157
|
+
title: "Machine Bridge MCP",
|
|
158
|
+
version: PACKAGE_VERSION,
|
|
159
|
+
description: "Workspace-scoped local coding tools over MCP stdio or authenticated remote relay.",
|
|
160
|
+
},
|
|
161
|
+
instructions: MCP_INSTRUCTIONS,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function consumeBoundedJsonLines(stream, { maxLineBytes, onLine, onOversize }) {
|
|
166
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
167
|
+
let chunks = [];
|
|
168
|
+
let bytes = 0;
|
|
169
|
+
let discarding = false;
|
|
170
|
+
let settled = false;
|
|
171
|
+
|
|
172
|
+
const resetLine = () => { chunks = []; bytes = 0; };
|
|
173
|
+
const emitLine = () => {
|
|
174
|
+
let line = bytes ? Buffer.concat(chunks, bytes) : Buffer.alloc(0);
|
|
175
|
+
if (line.length && line[line.length - 1] === 13) line = line.subarray(0, line.length - 1);
|
|
176
|
+
resetLine();
|
|
177
|
+
onLine(line.toString("utf8"));
|
|
178
|
+
};
|
|
179
|
+
const onData = (input) => {
|
|
180
|
+
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
181
|
+
let offset = 0;
|
|
182
|
+
while (offset < buffer.length) {
|
|
183
|
+
const newline = buffer.indexOf(10, offset);
|
|
184
|
+
const end = newline === -1 ? buffer.length : newline;
|
|
185
|
+
const segment = buffer.subarray(offset, end);
|
|
186
|
+
if (discarding) {
|
|
187
|
+
if (newline !== -1) discarding = false;
|
|
188
|
+
} else if (bytes + segment.length > maxLineBytes) {
|
|
189
|
+
resetLine();
|
|
190
|
+
onOversize();
|
|
191
|
+
if (newline === -1) discarding = true;
|
|
192
|
+
} else {
|
|
193
|
+
if (segment.length) chunks.push(segment);
|
|
194
|
+
bytes += segment.length;
|
|
195
|
+
if (newline !== -1) emitLine();
|
|
196
|
+
}
|
|
197
|
+
offset = newline === -1 ? buffer.length : newline + 1;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const cleanup = () => {
|
|
201
|
+
stream.off("data", onData);
|
|
202
|
+
stream.off("end", onEnd);
|
|
203
|
+
stream.off("close", onEnd);
|
|
204
|
+
stream.off("error", onError);
|
|
205
|
+
};
|
|
206
|
+
const onEnd = () => {
|
|
207
|
+
if (settled) return;
|
|
208
|
+
settled = true;
|
|
209
|
+
cleanup();
|
|
210
|
+
if (!discarding && bytes > 0) emitLine();
|
|
211
|
+
resolvePromise();
|
|
212
|
+
};
|
|
213
|
+
const onError = (error) => {
|
|
214
|
+
if (settled) return;
|
|
215
|
+
settled = true;
|
|
216
|
+
cleanup();
|
|
217
|
+
rejectPromise(error);
|
|
218
|
+
};
|
|
219
|
+
stream.on("data", onData);
|
|
220
|
+
stream.once("end", onEnd);
|
|
221
|
+
stream.once("close", onEnd);
|
|
222
|
+
stream.once("error", onError);
|
|
223
|
+
});
|
|
148
224
|
}
|
|
149
225
|
|
|
150
226
|
function isJsonRpcMessage(value) {
|
|
@@ -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.8.0";
|
|
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>;
|
|
@@ -292,13 +293,20 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
292
293
|
}
|
|
293
294
|
|
|
294
295
|
async webSocketClose(ws: WebSocket): Promise<void> {
|
|
296
|
+
await this.cleanupDaemonSocket(ws, "daemon disconnected");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
|
300
|
+
console.error(JSON.stringify({ level: "warn", message: "daemon_websocket_error", error_class: workerErrorClass(error) }));
|
|
301
|
+
await this.cleanupDaemonSocket(ws, "daemon transport error");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private async cleanupDaemonSocket(ws: WebSocket, message: string): Promise<void> {
|
|
295
305
|
await this.scheduleCandidateAlarm();
|
|
296
|
-
const attachment = this.daemonAttachment(ws);
|
|
297
|
-
if (!attachment) return;
|
|
298
306
|
for (const [id, pending] of this.pending) {
|
|
299
307
|
if (pending.socket !== ws) continue;
|
|
300
308
|
clearTimeout(pending.timeout);
|
|
301
|
-
pending.reject(new Error(
|
|
309
|
+
pending.reject(new Error(message));
|
|
302
310
|
this.pending.delete(id);
|
|
303
311
|
}
|
|
304
312
|
}
|
|
@@ -361,6 +369,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
361
369
|
if (request.method === "ping") return rpcResult(request.id, {});
|
|
362
370
|
if (request.method === "tools/list") return rpcResult(request.id, { tools: this.allTools() });
|
|
363
371
|
if (request.method === "tools/call") {
|
|
372
|
+
if (request.id === undefined || request.id === null) return rpcError(null, -32600, "tools/call requires a non-null request id");
|
|
364
373
|
const params = asObject(request.params);
|
|
365
374
|
const name = requiredString(params, "name");
|
|
366
375
|
const args = asObject(params.arguments);
|
|
@@ -376,13 +385,22 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
376
385
|
|
|
377
386
|
private async callTool(name: string, args: Record<string, unknown>, base: string, requestKey?: string): Promise<unknown> {
|
|
378
387
|
if (name === "server_info") {
|
|
388
|
+
const daemon = this.daemonStatus(true);
|
|
389
|
+
const tools = this.allTools().map((tool) => tool.name);
|
|
379
390
|
return {
|
|
380
391
|
name: SERVER_NAME,
|
|
381
392
|
version: SERVER_VERSION,
|
|
382
393
|
mcp_url: `${base}/mcp`,
|
|
383
394
|
oauth: this.authorizationServerMetadata(base),
|
|
384
|
-
daemon
|
|
385
|
-
tools
|
|
395
|
+
daemon,
|
|
396
|
+
tools,
|
|
397
|
+
tool_delivery: {
|
|
398
|
+
full_profile_scope: "local-daemon-and-relay-advertisement",
|
|
399
|
+
daemon_advertised_tool_count: daemon.tool_count,
|
|
400
|
+
relay_advertised_tool_count: tools.length,
|
|
401
|
+
host_exposed_tools_known_to_server: false,
|
|
402
|
+
host_may_expose_subset: true,
|
|
403
|
+
},
|
|
386
404
|
};
|
|
387
405
|
}
|
|
388
406
|
if (workspaceTools.some((tool) => tool.name === name)) {
|
|
@@ -657,13 +675,15 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
657
675
|
if (redirectUris.length > 5) {
|
|
658
676
|
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most 5 entries" }, 400);
|
|
659
677
|
}
|
|
660
|
-
const
|
|
661
|
-
if (
|
|
678
|
+
const suppliedRedirectUris = redirectUris.map((item) => String(item));
|
|
679
|
+
if (suppliedRedirectUris.some((item) => item.length > 1024)) {
|
|
662
680
|
return json({ error: "invalid_client_metadata", error_description: "redirect_uri is too long" }, 400);
|
|
663
681
|
}
|
|
664
|
-
|
|
665
|
-
|
|
682
|
+
const canonicalRedirectUris = suppliedRedirectUris.map(normalizeRedirectUri);
|
|
683
|
+
if (canonicalRedirectUris.some((item) => item === null)) {
|
|
684
|
+
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be canonicalizable https or local http URLs without credentials or fragments" }, 400);
|
|
666
685
|
}
|
|
686
|
+
const normalized = [...new Set(canonicalRedirectUris as string[])];
|
|
667
687
|
|
|
668
688
|
return this.withOAuthLock(async () => {
|
|
669
689
|
const store = await this.oauthStore();
|
|
@@ -725,7 +745,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
725
745
|
.filter(([key]) => AUTHORIZATION_FIELDS.has(key))
|
|
726
746
|
.map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
|
|
727
747
|
.join("\n");
|
|
728
|
-
const resource =
|
|
748
|
+
const resource = normalizeDisplayText(
|
|
749
|
+
authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`),
|
|
750
|
+
1024,
|
|
751
|
+
`${base}/mcp`,
|
|
752
|
+
);
|
|
729
753
|
const clientBlock = authorization
|
|
730
754
|
? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
|
|
731
755
|
<p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
|
|
@@ -971,7 +995,7 @@ function isFreshDaemonCandidate(connectedAt: string): boolean {
|
|
|
971
995
|
|
|
972
996
|
function sanitizeMetadataText(value: unknown, maxLength: number): string | undefined {
|
|
973
997
|
if (typeof value !== "string") return undefined;
|
|
974
|
-
const normalized = value.replace(
|
|
998
|
+
const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
|
|
975
999
|
return normalized ? normalized.slice(0, maxLength) : undefined;
|
|
976
1000
|
}
|
|
977
1001
|
|
|
@@ -1272,15 +1296,15 @@ async function pkceS256(verifier: string): Promise<string> {
|
|
|
1272
1296
|
return base64Url(new Uint8Array(digest));
|
|
1273
1297
|
}
|
|
1274
1298
|
|
|
1275
|
-
function
|
|
1299
|
+
function normalizeRedirectUri(value: string): string | null {
|
|
1276
1300
|
try {
|
|
1277
1301
|
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
|
|
1302
|
+
if (url.username || url.password || url.hash) return null;
|
|
1303
|
+
if (url.protocol === "https:" && url.hostname) return url.toString();
|
|
1304
|
+
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url.toString();
|
|
1305
|
+
return null;
|
|
1282
1306
|
} catch {
|
|
1283
|
-
return
|
|
1307
|
+
return null;
|
|
1284
1308
|
}
|
|
1285
1309
|
}
|
|
1286
1310
|
|
|
@@ -1359,9 +1383,9 @@ function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
|
|
|
1359
1383
|
return out;
|
|
1360
1384
|
}
|
|
1361
1385
|
|
|
1362
|
-
function normalizeDisplayText(value: string, maxLength: number): string {
|
|
1363
|
-
const normalized = value.replace(
|
|
1364
|
-
return (normalized ||
|
|
1386
|
+
function normalizeDisplayText(value: string, maxLength: number, fallback = "MCP Client"): string {
|
|
1387
|
+
const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
|
|
1388
|
+
return (normalized || fallback).slice(0, maxLength);
|
|
1365
1389
|
}
|
|
1366
1390
|
|
|
1367
1391
|
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": {
|