machine-bridge-mcp 0.2.5 → 0.4.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.
@@ -0,0 +1,194 @@
1
+ import readline from "node:readline";
2
+ import { readFileSync } from "node:fs";
3
+ import { randomBytes } from "node:crypto";
4
+ import { LocalDaemon } from "./daemon.mjs";
5
+ import { formatFields, sanitizeLogText } from "./log.mjs";
6
+ import {
7
+ MCP_INSTRUCTIONS,
8
+ MCP_PROTOCOL_VERSION,
9
+ MCP_SUPPORTED_PROTOCOL_VERSIONS,
10
+ rpcError,
11
+ rpcResult,
12
+ toolResult,
13
+ toolsForPolicy,
14
+ } from "./tools.mjs";
15
+
16
+ const MAX_LINE_BYTES = 8 * 1024 * 1024;
17
+ const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version);
18
+
19
+ export async function runStdioServer({ workspace, policy, verbose = false }) {
20
+ const logger = stderrLogger(verbose);
21
+ const runtime = new LocalDaemon({ workspace, policy, logger });
22
+ const pending = new Map();
23
+ let negotiatedVersion = MCP_PROTOCOL_VERSION;
24
+ let initialized = false;
25
+
26
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
27
+ const writes = new Set();
28
+ const send = (message) => {
29
+ if (message === null || message === undefined) return;
30
+ const line = `${JSON.stringify(message)}\n`;
31
+ const promise = new Promise((resolvePromise, rejectPromise) => {
32
+ process.stdout.write(line, (error) => error ? rejectPromise(error) : resolvePromise());
33
+ }).finally(() => writes.delete(promise));
34
+ writes.add(promise);
35
+ };
36
+
37
+ rl.on("line", (line) => {
38
+ if (Buffer.byteLength(line) > MAX_LINE_BYTES) {
39
+ send(rpcError(null, -32600, "JSON-RPC message exceeds maximum size"));
40
+ return;
41
+ }
42
+ void handleLine(line).catch((error) => {
43
+ logger.error("stdio request handler failed", { error: errorMessage(error) });
44
+ });
45
+ });
46
+
47
+ await new Promise((resolvePromise, rejectPromise) => {
48
+ rl.once("close", resolvePromise);
49
+ rl.once("error", rejectPromise);
50
+ });
51
+ for (const callId of pending.values()) runtime.cancelCall(callId, "stdio input closed");
52
+ await Promise.allSettled([...writes]);
53
+ runtime.stop();
54
+
55
+ async function handleLine(line) {
56
+ let message;
57
+ try { message = JSON.parse(line); } catch {
58
+ send(rpcError(null, -32700, "Parse error"));
59
+ return;
60
+ }
61
+ if (!isJsonRpcMessage(message)) {
62
+ send(rpcError(null, -32600, "Invalid JSON-RPC message"));
63
+ return;
64
+ }
65
+ if (typeof message.method !== "string") return;
66
+
67
+ if (message.method === "initialize") {
68
+ const requested = asObject(message.params).protocolVersion;
69
+ negotiatedVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
70
+ ? requested
71
+ : MCP_PROTOCOL_VERSION;
72
+ initialized = true;
73
+ send(rpcResult(message.id, {
74
+ protocolVersion: negotiatedVersion,
75
+ capabilities: { tools: { listChanged: false }, logging: {} },
76
+ serverInfo: {
77
+ name: "machine-bridge-mcp",
78
+ title: "Machine Bridge MCP",
79
+ version: PACKAGE_VERSION,
80
+ description: "Workspace-scoped local coding tools over MCP stdio or authenticated remote relay.",
81
+ },
82
+ instructions: MCP_INSTRUCTIONS,
83
+ }));
84
+ return;
85
+ }
86
+
87
+ if (message.method === "notifications/initialized") return;
88
+ if (message.method === "notifications/cancelled") {
89
+ const requestId = asObject(message.params).requestId;
90
+ const key = jsonRpcIdKey(requestId);
91
+ const callId = pending.get(key);
92
+ if (callId) runtime.cancelCall(callId, "MCP cancellation notification");
93
+ return;
94
+ }
95
+ if (message.method === "logging/setLevel") {
96
+ send(rpcResult(message.id, {}));
97
+ return;
98
+ }
99
+ if (message.method === "ping") {
100
+ send(rpcResult(message.id, {}));
101
+ return;
102
+ }
103
+ if (!initialized) {
104
+ send(rpcError(message.id, -32002, "Server is not initialized"));
105
+ return;
106
+ }
107
+ if (message.method === "tools/list") {
108
+ send(rpcResult(message.id, { tools: toolsForPolicy(policy) }));
109
+ return;
110
+ }
111
+ if (message.method === "tools/call") {
112
+ const params = asObject(message.params);
113
+ const name = typeof params.name === "string" ? params.name : "";
114
+ if (!name) {
115
+ send(rpcError(message.id, -32602, "tools/call requires a tool name"));
116
+ return;
117
+ }
118
+ const args = asObject(params.arguments);
119
+ const callId = `stdio_${randomBytes(16).toString("hex")}`;
120
+ const key = jsonRpcIdKey(message.id);
121
+ if (key && pending.has(key)) {
122
+ send(rpcError(message.id, -32600, "Duplicate in-flight JSON-RPC request id"));
123
+ return;
124
+ }
125
+ if (key) pending.set(key, callId);
126
+ const started = Date.now();
127
+ logger.debug("tool call started", { call_id: callId.slice(0, 20), tool: name });
128
+ try {
129
+ const result = await runtime.executeTool(name, args, { callId });
130
+ send(rpcResult(message.id, toolResult(result)));
131
+ logger.info("tool call completed", { call_id: callId.slice(0, 20), tool: name, duration_ms: Date.now() - started, ok: true });
132
+ } catch (error) {
133
+ const safeError = runtime.safeErrorMessage(error);
134
+ send(rpcResult(message.id, toolResult({ error: safeError }, true)));
135
+ logger.warn("tool call failed", { call_id: callId.slice(0, 20), tool: name, duration_ms: Date.now() - started, ok: false, error_class: classifyError(error) });
136
+ } finally {
137
+ if (key) pending.delete(key);
138
+ runtime.finishCall(callId);
139
+ }
140
+ return;
141
+ }
142
+ send(rpcError(message.id, -32601, `Method not found: ${message.method}`));
143
+ }
144
+ }
145
+
146
+ function isJsonRpcMessage(value) {
147
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
148
+ if (value.jsonrpc !== "2.0") return false;
149
+ if ("id" in value && !isJsonRpcId(value.id)) return false;
150
+ return true;
151
+ }
152
+
153
+ function isJsonRpcId(value) {
154
+ return value === null || typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
155
+ }
156
+
157
+ function jsonRpcIdKey(value) {
158
+ if (!isJsonRpcId(value) || value === null) return "";
159
+ return `${typeof value}:${String(value)}`;
160
+ }
161
+
162
+ function asObject(value) {
163
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
164
+ }
165
+
166
+ function classifyError(error) {
167
+ const message = error instanceof Error ? error.message : String(error);
168
+ if (/cancel/i.test(message)) return "cancelled";
169
+ if (/timed out/i.test(message)) return "timeout";
170
+ if (/outside the configured workspace/i.test(message)) return "path_boundary";
171
+ if (/disabled|requires .* mode/i.test(message)) return "policy_denied";
172
+ if (/not found|ENOENT/i.test(message)) return "not_found";
173
+ if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
174
+ if (/maximum|exceeds|max_bytes|too many/i.test(message)) return "limit_exceeded";
175
+ if (/invalid|must|requires|ambiguous|mismatch/i.test(message)) return "invalid_request";
176
+ return "execution_failed";
177
+ }
178
+
179
+ function errorMessage(error) {
180
+ return sanitizeLogText(error instanceof Error ? error.message : String(error)).slice(0, 4096) || "tool call failed";
181
+ }
182
+
183
+ function stderrLogger(verbose) {
184
+ const write = (level, message, fields) => {
185
+ if (level === "debug" && !verbose) return;
186
+ process.stderr.write(`[${level}] stdio: ${sanitizeLogText(message)}${formatFields(fields)}\n`);
187
+ };
188
+ return {
189
+ info(message, fields) { write("info", message, fields); },
190
+ warn(message, fields) { write("warn", message, fields); },
191
+ error(message, fields) { write("error", message, fields); },
192
+ debug(message, fields) { write("debug", message, fields); },
193
+ };
194
+ }
@@ -0,0 +1,96 @@
1
+ import catalog from "../shared/tool-catalog.json" with { type: "json" };
2
+
3
+ export const MCP_PROTOCOL_VERSION = "2025-11-25";
4
+ export const MCP_SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"];
5
+
6
+ export const MCP_INSTRUCTIONS = [
7
+ "You are connected to a local workspace through machine-bridge-mcp.",
8
+ "Remote mode uses a Cloudflare relay; stdio mode runs entirely on the local machine.",
9
+ "File and command operations execute on the user's local runtime, not in the Worker.",
10
+ "Relative paths use the configured workspace. Direct filesystem tools are workspace-scoped unless unrestricted paths are explicitly enabled.",
11
+ "run_process avoids shell parsing but is not an OS sandbox. exec_command is only exposed in shell execution mode and has the local user's authority.",
12
+ "Inspect before editing, prefer edit_file or apply_patch over whole-file replacement, and report commands that were run.",
13
+ ].join("\n");
14
+
15
+ export function normalizePolicy(policy = {}) {
16
+ const execMode = ["off", "direct", "shell"].includes(policy.execMode)
17
+ ? policy.execMode
18
+ : policy.allowExec === true
19
+ ? "shell"
20
+ : "off";
21
+ return {
22
+ profile: typeof policy.profile === "string" && policy.profile ? policy.profile : "custom",
23
+ allowWrite: policy.allowWrite === true,
24
+ allowExec: execMode !== "off",
25
+ execMode,
26
+ unrestrictedPaths: policy.unrestrictedPaths === true,
27
+ minimalEnv: policy.minimalEnv !== false,
28
+ exposeAbsolutePaths: policy.exposeAbsolutePaths === true,
29
+ };
30
+ }
31
+
32
+ export function toolsForPolicy(policy = {}) {
33
+ const normalized = normalizePolicy(policy);
34
+ return catalog
35
+ .filter((tool) => isAvailable(tool.availability, normalized))
36
+ .map(({ availability, ...tool }) => structuredClone(tool));
37
+ }
38
+
39
+ export function toolNamesForPolicy(policy = {}) {
40
+ return toolsForPolicy(policy).map((tool) => tool.name);
41
+ }
42
+
43
+ export function allToolNames() {
44
+ return catalog.map((tool) => tool.name);
45
+ }
46
+
47
+ export function findTool(name) {
48
+ return catalog.find((tool) => tool.name === name) || null;
49
+ }
50
+
51
+ export function toolResult(value, isError = false) {
52
+ const special = specialMcpResult(value);
53
+ if (special) return { ...special, isError };
54
+ const structuredContent = toStructuredContent(value);
55
+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
56
+ const result = {
57
+ content: [{ type: "text", text }],
58
+ isError,
59
+ };
60
+ if (structuredContent) result.structuredContent = structuredContent;
61
+ return result;
62
+ }
63
+
64
+ export function rpcResult(id, result) {
65
+ if (id === undefined) return null;
66
+ return { jsonrpc: "2.0", id, result };
67
+ }
68
+
69
+ export function rpcError(id, code, message, data) {
70
+ const error = { code, message };
71
+ if (data !== undefined) error.data = data;
72
+ return { jsonrpc: "2.0", id: id ?? null, error };
73
+ }
74
+
75
+ function isAvailable(availability, policy) {
76
+ if (availability === "always") return true;
77
+ if (availability === "write") return policy.allowWrite;
78
+ if (availability === "direct-exec") return policy.execMode === "direct" || policy.execMode === "shell";
79
+ if (availability === "shell-exec") return policy.execMode === "shell";
80
+ return false;
81
+ }
82
+
83
+ function specialMcpResult(value) {
84
+ const special = value && typeof value === "object" && !Array.isArray(value) ? value.$mcp : null;
85
+ if (!special || typeof special !== "object" || !Array.isArray(special.content)) return null;
86
+ const result = { content: structuredClone(special.content) };
87
+ if (special.structuredContent && typeof special.structuredContent === "object" && !Array.isArray(special.structuredContent)) {
88
+ result.structuredContent = structuredClone(special.structuredContent);
89
+ }
90
+ return result;
91
+ }
92
+
93
+ function toStructuredContent(value) {
94
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
95
+ return value;
96
+ }