machine-bridge-mcp 0.1.1 → 0.2.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/README.md CHANGED
@@ -46,6 +46,78 @@ npm install -g machine-bridge-mcp@latest && machine-mcp
46
46
 
47
47
  On repeat runs, the CLI reuses existing state and secrets unless you request rotation, skips Worker redeploys when the deployed Worker is healthy and unchanged, refreshes the autostart entry, stops any currently loaded autostart daemon before starting the foreground daemon, and refuses to start a second daemon for the same workspace if another foreground instance is already running.
48
48
 
49
+ ## Local OpenAI-compatible API provider
50
+
51
+ `machine-bridge-mcp` can also expose a local OpenAI-compatible API provider for desktop AI clients such as Cherry Studio, Chatbox, Continue, or other apps that accept an OpenAI-style base URL and API key.
52
+
53
+ Start only the local API service:
54
+
55
+ ```zsh
56
+ machine-mcp api
57
+ ```
58
+
59
+ Or run it together with the Remote MCP daemon:
60
+
61
+ ```zsh
62
+ machine-mcp start --api
63
+ ```
64
+
65
+ The CLI prints client settings like:
66
+
67
+ ```text
68
+ API Base URL: http://127.0.0.1:8765/v1
69
+ API key: local_api_key_...
70
+ Client type: OpenAI-compatible
71
+ ```
72
+
73
+ Use these values in the local AI client:
74
+
75
+ - Base URL: `http://127.0.0.1:8765/v1`
76
+ - API key: the `local_api_key_...` printed by the CLI
77
+ - Model: the model shown by `GET /v1/models` or your configured `--api-model`
78
+
79
+ If port `8765` conflicts with another local app, choose a different port explicitly:
80
+
81
+ ```zsh
82
+ machine-mcp api --api-port 8766
83
+ machine-mcp start --api --api-port 8766
84
+ ```
85
+
86
+ `--port` is also accepted on the `api` command:
87
+
88
+ ```zsh
89
+ machine-mcp api --port 8766
90
+ ```
91
+
92
+ By default, the local API binds to `127.0.0.1` and does not start unless you run `machine-mcp api` or pass `--api`. It stores a per-workspace local API key in the same owner-only state profile used by the MCP credentials. Rotate it with:
93
+
94
+ ```zsh
95
+ machine-mcp api --rotate-api-key
96
+ ```
97
+
98
+ Configure an upstream OpenAI-compatible provider:
99
+
100
+ ```zsh
101
+ machine-mcp api \
102
+ --api-upstream-url https://api.openai.com/v1 \
103
+ --api-upstream-key "$OPENAI_API_KEY" \
104
+ --api-model gpt-4.1
105
+ ```
106
+
107
+ Environment variables are also supported: `MBM_API_HOST`, `MBM_API_PORT`, `MBM_API_KEY`, `MBM_API_UPSTREAM_URL`, `MBM_API_UPSTREAM_KEY`, `MBM_API_MODEL`, plus common OpenAI names such as `OPENAI_API_KEY`, `OPENAI_BASE_URL`, and `OPENAI_MODEL`.
108
+
109
+ Supported local API routes:
110
+
111
+ - `GET /health` without authentication
112
+ - `GET /v1/models` with `Authorization: Bearer <local_api_key>` or `x-api-key`
113
+ - `POST /v1/chat/completions`
114
+ - `POST /v1/responses`
115
+ - `POST /v1/embeddings`
116
+ - `POST /v1/completions`
117
+
118
+ Model-producing routes proxy to the configured upstream provider. If no upstream key is configured, the API still starts and `/v1/models` works, but generation endpoints return a clear `503 upstream_not_configured` error. Logs record route, status, latency, and safe configuration metadata; request and response bodies and API keys are not logged.
119
+
120
+
49
121
  ## Re-select workspace
50
122
 
51
123
  ```zsh
@@ -165,7 +237,7 @@ Default state roots:
165
237
  - Linux with `XDG_STATE_HOME`: `$XDG_STATE_HOME/machine-bridge-mcp`
166
238
  - Windows: `%APPDATA%\machine-bridge-mcp`
167
239
 
168
- State contains the MCP password and daemon secret. Status/doctor output redacts secrets. The normal foreground `start` command prints the MCP password because users need to paste it into their MCP client. State files, temporary Worker secret files, lock files, and log directories are created under the user state root with owner-only permissions where the platform supports POSIX modes.
240
+ State contains the MCP password, daemon secret, and local API key when the API provider has been started. Status/doctor output redacts secrets. The normal foreground `start` command prints the MCP password because users need to paste it into their MCP client; `api` prints the local API key for the same reason. Use `--no-print-credentials` to redact console credentials. State files, temporary Worker secret files, lock files, and log directories are created under the user state root with owner-only permissions where the platform supports POSIX modes.
169
241
 
170
242
  The Worker rejects browser requests with an `Origin` header unless the origin is the Worker itself or a loopback HTTP origin. To allow additional browser-based MCP clients, set `MBM_ALLOWED_ORIGINS` to a comma-separated list of exact origins in `wrangler.jsonc` or Cloudflare Worker settings.
171
243
 
@@ -183,6 +255,8 @@ flowchart LR
183
255
  W --> DO["Durable Object broker"]
184
256
  D["Local daemon"] -- "outbound WebSocket" --> W
185
257
  D --> M["Local filesystem and shell"]
258
+ API["Optional local /v1 API"] -- "OpenAI-compatible HTTP" --> U["Configured upstream provider"]
259
+ CLI["machine-mcp CLI"] --> API
186
260
  CLI["machine-mcp CLI"] --> W
187
261
  CLI --> D
188
262
  CLI --> S["Autostart service"]
@@ -195,6 +269,7 @@ Why this architecture:
195
269
  - The public MCP URL is stable after deployment.
196
270
  - The Worker stores OAuth client/code/token metadata and relays tool calls.
197
271
  - The local daemon is the only process touching files or executing commands.
272
+ - The optional local `/v1` API binds to loopback by default and starts only when requested.
198
273
  - Autostart keeps the daemon alive across logins without requiring MCP clients to change URLs.
199
274
 
200
275
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "One-command hosted Remote MCP bridge to a local machine daemon.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "prepublishOnly": "npm run check && npm run version:check",
36
36
  "worker:types": "wrangler types src/worker/worker-configuration.d.ts",
37
37
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
38
- "syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/daemon.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/self-test.mjs",
38
+ "syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/daemon.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/self-test.mjs && node --check src/local/api-server.mjs && node --check src/local/log.mjs",
39
39
  "check": "npm run typecheck && npm run syntax && npm run self-test"
40
40
  },
41
41
  "dependencies": {
@@ -9,6 +9,7 @@ const packagePath = path.join(repoRoot, "package.json");
9
9
  const workerPath = path.join(repoRoot, "src", "worker", "index.ts");
10
10
  const args = new Set(process.argv.slice(2));
11
11
  const checkOnly = args.has("--check");
12
+ const log = message => process.stderr.write(`${message}\n`);
12
13
 
13
14
  const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
14
15
  const expected = String(pkg.version || "").trim();
@@ -23,7 +24,7 @@ if (!match) fail("Could not find `const SERVER_VERSION = \"...\";` in src/worker
23
24
 
24
25
  const current = match[1];
25
26
  if (current === expected) {
26
- console.log(`Worker version is in sync: ${expected}`);
27
+ log(`Worker version is in sync: ${expected}`);
27
28
  process.exit(0);
28
29
  }
29
30
 
@@ -33,7 +34,7 @@ if (checkOnly) {
33
34
 
34
35
  const updated = source.replace(pattern, `const SERVER_VERSION = "${expected}";`);
35
36
  writeFileSync(workerPath, updated);
36
- console.log(`Updated Worker version: ${current} -> ${expected}`);
37
+ log(`Updated Worker version: ${current} -> ${expected}`);
37
38
 
38
39
  function fail(message) {
39
40
  console.error(message);
@@ -0,0 +1,256 @@
1
+ import http from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import { Readable } from "node:stream";
4
+ import { pipeline } from "node:stream/promises";
5
+ import { createLogger } from "./log.mjs";
6
+
7
+ export const DEFAULT_API_HOST = "127.0.0.1";
8
+ export const DEFAULT_API_PORT = 8765;
9
+ export const DEFAULT_UPSTREAM_URL = "https://api.openai.com/v1";
10
+ export const DEFAULT_API_MODEL = "machine-bridge-mcp";
11
+ export const DEFAULT_API_MAX_BODY_BYTES = 32 * 1024 * 1024;
12
+
13
+ const PROXY_ROUTES = new Set([
14
+ "/v1/chat/completions",
15
+ "/v1/responses",
16
+ "/v1/embeddings",
17
+ "/v1/completions",
18
+ ]);
19
+
20
+ const HOP_BY_HOP_HEADERS = new Set([
21
+ "connection",
22
+ "keep-alive",
23
+ "proxy-authenticate",
24
+ "proxy-authorization",
25
+ "te",
26
+ "trailer",
27
+ "transfer-encoding",
28
+ "upgrade",
29
+ "host",
30
+ "content-length",
31
+ "authorization",
32
+ "x-api-key",
33
+ ]);
34
+
35
+ export function parseApiPort(value, fallback = DEFAULT_API_PORT) {
36
+ if (value === undefined || value === null || value === true || value === "") return fallback;
37
+ const port = Number(value);
38
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid API port: ${value}`);
39
+ return port;
40
+ }
41
+
42
+ export function normalizeApiHost(value, fallback = DEFAULT_API_HOST) {
43
+ if (value === undefined || value === null || value === true || value === "") return fallback;
44
+ const host = String(value).trim();
45
+ if (!host) return fallback;
46
+ if (/[/\\\s]/.test(host)) throw new Error(`Invalid API host: ${value}`);
47
+ return host;
48
+ }
49
+
50
+ export function normalizeBaseUrl(value, fallback = DEFAULT_UPSTREAM_URL) {
51
+ const raw = value === undefined || value === null || value === true || value === "" ? fallback : String(value).trim();
52
+ let parsed;
53
+ try {
54
+ parsed = new URL(raw);
55
+ } catch {
56
+ throw new Error(`Invalid upstream API URL: ${raw}`);
57
+ }
58
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid upstream API URL protocol: ${parsed.protocol}`);
59
+ if (parsed.username || parsed.password) throw new Error("Upstream API URL must not contain credentials; pass keys with --api-upstream-key or environment variables.");
60
+ if (parsed.search || parsed.hash) throw new Error("Upstream API URL must be a base URL without query strings or fragments.");
61
+ return parsed.toString().replace(/\/+$/, "");
62
+ }
63
+
64
+ export async function startLocalApiServer(options = {}) {
65
+ const logger = options.logger || createLogger({ component: "api", quiet: options.quiet });
66
+ const host = normalizeApiHost(options.host);
67
+ const port = parseApiPort(options.port);
68
+ const apiKey = String(options.apiKey || "");
69
+ const upstreamUrl = normalizeBaseUrl(options.upstreamUrl);
70
+ const upstreamKey = String(options.upstreamKey || "");
71
+ const model = String(options.model || DEFAULT_API_MODEL);
72
+ const maxBodyBytes = Number(options.maxBodyBytes || DEFAULT_API_MAX_BODY_BYTES);
73
+
74
+ if (!apiKey) throw new Error("Local API key is missing");
75
+
76
+ const server = http.createServer((req, res) => {
77
+ void handleRequest(req, res, { logger, apiKey, upstreamUrl, upstreamKey, model, maxBodyBytes });
78
+ });
79
+
80
+ server.keepAliveTimeout = 65_000;
81
+ server.headersTimeout = 70_000;
82
+ server.requestTimeout = 0;
83
+
84
+ await new Promise((resolve, reject) => {
85
+ const onError = error => {
86
+ server.off("listening", onListening);
87
+ reject(withPortHint(error, port));
88
+ };
89
+ const onListening = () => {
90
+ server.off("error", onError);
91
+ resolve();
92
+ };
93
+ server.once("error", onError);
94
+ server.once("listening", onListening);
95
+ server.listen({ host, port });
96
+ });
97
+
98
+ const address = server.address();
99
+ const actualPort = typeof address === "object" && address ? address.port : port;
100
+ const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
101
+ const baseUrl = `http://${urlHost}:${actualPort}/v1`;
102
+ logger.success("local OpenAI-compatible API started", { baseUrl, model, upstream: upstreamUrl, upstreamConfigured: Boolean(upstreamKey) });
103
+
104
+ return {
105
+ server,
106
+ host,
107
+ port: actualPort,
108
+ baseUrl,
109
+ url: `http://${urlHost}:${actualPort}`,
110
+ close() {
111
+ return new Promise(resolve => server.close(() => resolve()));
112
+ },
113
+ };
114
+ }
115
+
116
+ async function handleRequest(req, res, context) {
117
+ const requestId = randomUUID().slice(0, 8);
118
+ const started = Date.now();
119
+ const url = new URL(req.url || "/", "http://127.0.0.1");
120
+ setCorsHeaders(res);
121
+
122
+ try {
123
+ if (req.method === "OPTIONS") return sendEmpty(res, 204);
124
+ if (req.method === "GET" && url.pathname === "/health") return sendJson(res, 200, { ok: true, service: "machine-bridge-mcp-local-api" });
125
+
126
+ if (!isAuthorized(req, context.apiKey)) return sendOpenAiError(res, 401, "invalid_api_key", "Missing or invalid local API key.");
127
+
128
+ if (req.method === "GET" && url.pathname === "/v1/models") {
129
+ context.logger.info("request completed", { requestId, method: req.method, path: url.pathname, status: 200, durationMs: Date.now() - started });
130
+ return sendJson(res, 200, modelsPayload(context.model));
131
+ }
132
+
133
+ if (req.method === "POST" && PROXY_ROUTES.has(url.pathname)) {
134
+ if (!context.upstreamKey) {
135
+ return sendOpenAiError(res, 503, "upstream_not_configured", "No upstream API key is configured. Set --api-upstream-key or MBM_API_UPSTREAM_KEY/OPENAI_API_KEY.");
136
+ }
137
+ context.logger.info("proxy request started", { requestId, path: url.pathname, upstream: context.upstreamUrl });
138
+ await proxyRequest(req, res, url, context);
139
+ context.logger.info("proxy request completed", { requestId, path: url.pathname, status: res.statusCode, durationMs: Date.now() - started });
140
+ return;
141
+ }
142
+
143
+ return sendOpenAiError(res, 404, "not_found", `Unknown local API endpoint: ${url.pathname}`);
144
+ } catch (error) {
145
+ context.logger.error("request failed", { requestId, path: url.pathname, error: error.message, durationMs: Date.now() - started });
146
+ if (!res.headersSent) {
147
+ if (error?.code === "BODY_TOO_LARGE") return sendOpenAiError(res, 413, "request_too_large", error.message);
148
+ return sendOpenAiError(res, 502, "upstream_error", error.message || "Local API request failed.");
149
+ }
150
+ res.destroy(error);
151
+ }
152
+ }
153
+
154
+ async function proxyRequest(req, res, url, context) {
155
+ const body = await readBody(req, context.maxBodyBytes);
156
+ const upstreamTarget = `${context.upstreamUrl}${url.pathname.slice(3)}${url.search}`;
157
+ const headers = copyProxyHeaders(req.headers, context.upstreamKey);
158
+ const response = await fetch(upstreamTarget, {
159
+ method: "POST",
160
+ headers,
161
+ body,
162
+ signal: AbortSignal.timeout(10 * 60 * 1000),
163
+ });
164
+
165
+ res.statusCode = response.status;
166
+ for (const [key, value] of response.headers) {
167
+ if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) res.setHeader(key, value);
168
+ }
169
+ if (!res.hasHeader("content-type")) res.setHeader("content-type", "application/json");
170
+ if (response.body) await pipeline(Readable.fromWeb(response.body), res);
171
+ else res.end();
172
+ }
173
+
174
+ function copyProxyHeaders(source, upstreamKey) {
175
+ const out = new Headers();
176
+ for (const [key, value] of Object.entries(source)) {
177
+ if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
178
+ if (Array.isArray(value)) out.set(key, value.join(", "));
179
+ else if (value !== undefined) out.set(key, String(value));
180
+ }
181
+ out.set("authorization", `Bearer ${upstreamKey}`);
182
+ if (!out.has("content-type")) out.set("content-type", "application/json");
183
+ return out;
184
+ }
185
+
186
+ function readBody(req, maxBytes) {
187
+ return new Promise((resolve, reject) => {
188
+ const chunks = [];
189
+ let total = 0;
190
+ req.on("data", chunk => {
191
+ total += chunk.length;
192
+ if (total > maxBytes) {
193
+ const error = new Error(`Request body exceeds ${maxBytes} bytes`);
194
+ error.code = "BODY_TOO_LARGE";
195
+ req.destroy(error);
196
+ reject(error);
197
+ return;
198
+ }
199
+ chunks.push(chunk);
200
+ });
201
+ req.on("end", () => resolve(Buffer.concat(chunks)));
202
+ req.on("error", reject);
203
+ });
204
+ }
205
+
206
+ function isAuthorized(req, expectedKey) {
207
+ const auth = String(req.headers.authorization || "");
208
+ if (auth.startsWith("Bearer ") && auth.slice(7) === expectedKey) return true;
209
+ const apiKey = req.headers["x-api-key"];
210
+ return typeof apiKey === "string" && apiKey === expectedKey;
211
+ }
212
+
213
+ function modelsPayload(model) {
214
+ return {
215
+ object: "list",
216
+ data: [{
217
+ id: model,
218
+ object: "model",
219
+ created: 0,
220
+ owned_by: "machine-bridge-mcp",
221
+ }],
222
+ };
223
+ }
224
+
225
+ function sendOpenAiError(res, status, code, message) {
226
+ return sendJson(res, status, { error: { message, type: "invalid_request_error", param: null, code } });
227
+ }
228
+
229
+ function sendJson(res, status, payload) {
230
+ if (!res.headersSent) {
231
+ res.statusCode = status;
232
+ res.setHeader("content-type", "application/json; charset=utf-8");
233
+ }
234
+ res.end(`${JSON.stringify(payload)}\n`);
235
+ }
236
+
237
+ function sendEmpty(res, status) {
238
+ res.statusCode = status;
239
+ res.end();
240
+ }
241
+
242
+ function setCorsHeaders(res) {
243
+ res.setHeader("access-control-allow-origin", "*");
244
+ res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
245
+ res.setHeader("access-control-allow-headers", "authorization,content-type,x-api-key,openai-beta,openai-organization,openai-project");
246
+ }
247
+
248
+ function withPortHint(error, port) {
249
+ if (error?.code === "EADDRINUSE") {
250
+ error.message = `Local API port ${port} is already in use. Re-run with \`machine-mcp api --api-port <free_port>\` or \`machine-mcp start --api --api-port <free_port>\`.`;
251
+ }
252
+ if (error?.code === "EACCES") {
253
+ error.message = `Local API port ${port} is not permitted. Re-run with \`machine-mcp api --api-port <free_port>\`.`;
254
+ }
255
+ return error;
256
+ }
package/src/local/cli.mjs CHANGED
@@ -4,12 +4,15 @@ import path, { resolve } from "node:path";
4
4
  import process from "node:process";
5
5
  import readline from "node:readline/promises";
6
6
  import { LocalDaemon } from "./daemon.mjs";
7
+ import { startLocalApiServer, DEFAULT_API_HOST, DEFAULT_API_PORT, DEFAULT_API_MODEL, DEFAULT_UPSTREAM_URL } from "./api-server.mjs";
8
+ import { createLogger, redactSecret } from "./log.mjs";
7
9
  import { runWrangler } from "./shell.mjs";
8
10
  import {
9
11
  acquireDaemonLock,
10
12
  appName,
11
13
  defaultStateRoot,
12
14
  ensureOwnerOnlyDir,
15
+ ensureLocalApiKey,
13
16
  ensureWorkerSecrets,
14
17
  expandHome,
15
18
  loadGlobalConfig,
@@ -29,11 +32,13 @@ import {
29
32
  export async function main(argv = process.argv.slice(2)) {
30
33
  const [command, rest] = normalizeCommand(argv);
31
34
  const args = parseArgs(rest);
35
+ if (command === "api" && args.help) return apiUsage();
32
36
  if (args.help || command === "help") return usage();
33
37
  if (args.version || command === "version") return version();
34
38
 
35
39
  switch (command) {
36
40
  case "start": return startCommand(args);
41
+ case "api": return apiCommand(args);
37
42
  case "status": return statusCommand(args);
38
43
  case "doctor": return doctorCommand(args);
39
44
  case "workspace": return workspaceCommand(args);
@@ -154,9 +159,11 @@ async function confirm(prompt, assumeYes = false) {
154
159
 
155
160
  async function startCommand(args) {
156
161
  assertNodeVersion();
162
+ const logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "cli" });
157
163
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
158
164
  const state = loadState(workspace, { stateDir: args.stateDir });
159
165
  ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName: args.workerName && String(args.workerName) });
166
+ if (args.api) ensureLocalApiKey(state, { apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY"), rotateApiKey: Boolean(args.rotateApiKey) });
160
167
  state.policy = {
161
168
  allowWrite: args.noWrite ? false : true,
162
169
  allowExec: args.noExec ? false : true,
@@ -176,12 +183,17 @@ async function startCommand(args) {
176
183
  if (!args.daemonOnly) await stopAutostartBestEffort(Boolean(args.quiet));
177
184
 
178
185
  const lock = acquireDaemonLock(state);
186
+ let apiServer = null;
179
187
  if (!lock.acquired) {
180
188
  if (!args.quiet) {
181
189
  const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
182
- console.warn(`Local daemon is already running for this workspace (${pid}). Not starting a duplicate.`);
190
+ logger.warn(`local daemon already running for this workspace (${pid}); not starting a duplicate`);
183
191
  printConnection(state, { json: Boolean(args.json), noPrintCredentials: Boolean(args.noPrintCredentials) });
184
192
  }
193
+ if (!args.api) return;
194
+ apiServer = await startConfiguredApiServer(state, args);
195
+ printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
196
+ keepProcessAlive({ apiServer, logger });
185
197
  return;
186
198
  }
187
199
 
@@ -191,39 +203,82 @@ async function startCommand(args) {
191
203
  secret: state.worker.daemonSecret,
192
204
  workspace,
193
205
  policy: state.policy,
194
- logger: structuredLogger(args.quiet),
206
+ logger: createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "daemon" }),
195
207
  });
196
208
 
197
209
  const waitForConnect = daemon.start();
198
210
  await waitForConnectWithNotice(waitForConnect, 20_000);
211
+ if (args.api) apiServer = await startConfiguredApiServer(state, args);
199
212
  printConnection(state, { json: Boolean(args.json), noPrintCredentials: Boolean(args.noPrintCredentials) });
200
- keepProcessAlive(daemon, lock);
213
+ if (apiServer) printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
214
+ keepProcessAlive({ daemon, lock, apiServer, logger });
201
215
  } catch (error) {
202
216
  lock.release();
217
+ if (apiServer) await apiServer.close().catch(() => {});
203
218
  throw error;
204
219
  }
205
220
  }
206
221
 
222
+ async function apiCommand(args) {
223
+ assertNodeVersion();
224
+ const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
225
+ const state = loadState(workspace, { stateDir: args.stateDir });
226
+ ensureLocalApiKey(state, { apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY"), rotateApiKey: Boolean(args.rotateApiKey) });
227
+ saveState(state);
228
+ const logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "api" });
229
+ const apiServer = await startConfiguredApiServer(state, args, logger);
230
+ printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
231
+ keepProcessAlive({ apiServer, logger });
232
+ }
233
+
234
+ async function startConfiguredApiServer(state, args, logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "api" })) {
235
+ const apiOptions = apiOptionsFromArgs(state, args);
236
+ return startLocalApiServer({ ...apiOptions, logger });
237
+ }
238
+
239
+ function apiOptionsFromArgs(state, args = {}) {
240
+ const upstreamKey = valueFromArgsEnv(args.apiUpstreamKey, "MBM_API_UPSTREAM_KEY", "OPENAI_API_KEY");
241
+ const explicitPort = args.apiPort !== undefined && args.apiPort !== true ? args.apiPort : (args.port !== undefined && args.port !== true ? args.port : undefined);
242
+ const envPort = valueFromArgsEnv(undefined, "MBM_API_PORT", "PORT");
243
+ return {
244
+ host: valueFromArgsEnv(args.apiHost, "MBM_API_HOST") || DEFAULT_API_HOST,
245
+ port: explicitPort ?? envPort ?? DEFAULT_API_PORT,
246
+ apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY") || state.localApi?.apiKey || ensureLocalApiKey(state, { rotateApiKey: Boolean(args.rotateApiKey) }),
247
+ upstreamUrl: valueFromArgsEnv(args.apiUpstreamUrl, "MBM_API_UPSTREAM_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE") || DEFAULT_UPSTREAM_URL,
248
+ upstreamKey: upstreamKey || "",
249
+ model: valueFromArgsEnv(args.apiModel, "MBM_API_MODEL", "OPENAI_MODEL") || DEFAULT_API_MODEL,
250
+ };
251
+ }
252
+
253
+ function valueFromArgsEnv(argValue, ...envNames) {
254
+ if (argValue !== undefined && argValue !== null && argValue !== true) return String(argValue);
255
+ for (const name of envNames) {
256
+ if (process.env[name]) return process.env[name];
257
+ }
258
+ return undefined;
259
+ }
260
+
207
261
  async function ensureWorker(state, args) {
262
+ const logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "worker" });
208
263
  const desiredHash = workerDeployHash(state);
209
264
  const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.oauthPassword && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
210
265
  if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
211
266
  const health = await workerHealth(state.worker.url);
212
267
  if (health.ok) {
213
- console.log(`Worker unchanged and healthy: ${state.worker.url}`);
268
+ logger.success("Worker unchanged and healthy", { url: state.worker.url });
214
269
  return state.worker;
215
270
  }
216
- console.warn(`Worker health check failed, redeploying: ${health.error}`);
271
+ logger.warn("Worker health check failed; redeploying", { error: health.error });
217
272
  }
218
273
 
219
- console.log("Checking Cloudflare Wrangler login...");
274
+ logger.info("Checking Cloudflare Wrangler login");
220
275
  const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
221
276
  if (whoami.code !== 0) {
222
- console.log("Wrangler is not logged in; opening Cloudflare login...");
277
+ logger.info("Wrangler is not logged in; opening Cloudflare login");
223
278
  await runWrangler(["login"]);
224
279
  }
225
280
 
226
- console.log(`Deploying Cloudflare Worker '${state.worker.name}'...`);
281
+ logger.info("Deploying Cloudflare Worker", { name: state.worker.name });
227
282
  const deploy = await withSecretsFile(state, secretFile => runWrangler([
228
283
  "deploy",
229
284
  "--name", state.worker.name,
@@ -241,8 +296,8 @@ async function ensureWorker(state, args) {
241
296
  saveState(state);
242
297
 
243
298
  const health = await retryHealth(state.worker.url, 8);
244
- if (!health.ok) console.warn(`Worker deployed but health check did not pass yet: ${health.error}`);
245
- else console.log(`Worker ready: ${state.worker.url}`);
299
+ if (!health.ok) logger.warn("Worker deployed but health check did not pass yet", { error: health.error });
300
+ else logger.success("Worker ready", { url: state.worker.url });
246
301
  return state.worker;
247
302
  }
248
303
 
@@ -348,10 +403,11 @@ async function waitForConnectWithNotice(promise, timeoutMs) {
348
403
  });
349
404
  const result = await Promise.race([promise.then(() => "connected"), timed]);
350
405
  clearTimeout(timeout);
351
- if (result === "timeout") console.warn("Daemon is still connecting; credentials are printed now and the process will keep retrying.");
406
+ if (result === "timeout") createLogger({ component: "daemon" }).warn("Still connecting; credentials are printed now and the process will keep retrying");
352
407
  }
353
408
 
354
409
  function printConnection(state, { json = false, noPrintCredentials = false } = {}) {
410
+ const logger = createLogger({ component: "ready" });
355
411
  const payload = {
356
412
  mcp_server_url: state.worker.mcpServerUrl,
357
413
  mcp_connection_password: state.worker.oauthPassword,
@@ -363,27 +419,39 @@ function printConnection(state, { json = false, noPrintCredentials = false } = {
363
419
  };
364
420
  if (json) {
365
421
  const safePayload = noPrintCredentials ? { ...payload, mcp_connection_password: previewSecret(payload.mcp_connection_password) } : payload;
366
- console.log(JSON.stringify(safePayload, null, 2));
422
+ logger.json(safePayload);
367
423
  return;
368
424
  }
369
- console.log("\nMachine Bridge MCP is ready. Keep this process running.\n");
370
- console.log(`MCP Server URL: ${payload.mcp_server_url}`);
371
- if (!noPrintCredentials) console.log(`MCP connection password: ${payload.mcp_connection_password}`);
372
- else console.log(`MCP connection password: ${previewSecret(payload.mcp_connection_password)} (redacted)`);
373
- console.log(`Workspace cwd for relative paths: ${payload.workspace}`);
374
- console.log(`Policy: write=${payload.policy.allowWrite ? "on" : "off"}, exec=${payload.policy.allowExec ? "on" : "off"}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "on" : "off"}`);
375
- console.log(`State: ${payload.state_path}\n`);
376
- }
377
-
378
- function keepProcessAlive(daemon, lock) {
379
- const stop = () => {
380
- console.log("Stopping daemon...");
381
- daemon.stop();
382
- lock?.release?.();
425
+ logger.success("Remote MCP bridge is ready; keep this process running");
426
+ logger.plain(` MCP Server URL: ${payload.mcp_server_url}`);
427
+ if (!noPrintCredentials) logger.plain(` MCP connection password: ${payload.mcp_connection_password}`);
428
+ else logger.plain(` MCP connection password: ${previewSecret(payload.mcp_connection_password)} (redacted)`);
429
+ logger.plain(` Workspace cwd: ${payload.workspace}`);
430
+ logger.plain(` Policy: write=${payload.policy.allowWrite ? "on" : "off"}, exec=${payload.policy.allowExec ? "on" : "off"}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "on" : "off"}`);
431
+ logger.plain(` State: ${payload.state_path}`);
432
+ }
433
+
434
+ function printApiConnection(apiServer, state, { noPrintCredentials = false } = {}) {
435
+ const logger = createLogger({ component: "ready" });
436
+ logger.success("Local model API provider is ready");
437
+ logger.plain(` API Base URL: ${apiServer.baseUrl}`);
438
+ logger.plain(` API key: ${noPrintCredentials ? `${redactSecret(state.localApi.apiKey)} (redacted)` : state.localApi.apiKey}`);
439
+ logger.plain(" Client type: OpenAI-compatible");
440
+ }
441
+
442
+ function keepProcessAlive({ daemon = null, lock = null, apiServer = null, logger = createLogger({ component: "cli" }) } = {}) {
443
+ let stopping = false;
444
+ const stop = async () => {
445
+ if (stopping) return;
446
+ stopping = true;
447
+ logger.info("stopping local services");
448
+ try { daemon?.stop?.(); } catch {}
449
+ try { await apiServer?.close?.(); } catch {}
450
+ try { lock?.release?.(); } catch {}
383
451
  process.exit(0);
384
452
  };
385
- process.once("SIGINT", stop);
386
- process.once("SIGTERM", stop);
453
+ process.once("SIGINT", () => void stop());
454
+ process.once("SIGTERM", () => void stop());
387
455
  process.once("exit", () => lock?.release?.());
388
456
  setInterval(() => {}, 2 ** 31 - 1);
389
457
  }
@@ -537,12 +605,7 @@ async function removeAutostartBestEffort(stateRoot) {
537
605
  }
538
606
 
539
607
  function structuredLogger(quiet) {
540
- if (quiet) return { info() {}, warn() {}, error() {} };
541
- return {
542
- info: msg => console.log(`[daemon] ${msg}`),
543
- warn: msg => console.warn(`[daemon] ${msg}`),
544
- error: msg => console.error(`[daemon] ${msg}`),
545
- };
608
+ return createLogger({ quiet, component: "daemon" });
546
609
  }
547
610
 
548
611
  function sanitizeLines(text) {
@@ -578,6 +641,7 @@ Usage:
578
641
 
579
642
  Commands:
580
643
  start Deploy/update Worker, install autostart, start local daemon
644
+ api Start local OpenAI-compatible API provider only
581
645
  workspace show Show remembered workspace
582
646
  workspace set Re-select workspace; prompts with current/default path
583
647
  service status Show autostart status
@@ -603,6 +667,15 @@ Start options:
603
667
  --full-env Pass full parent environment to exec_command (default: minimal env)
604
668
  --state-dir DIR Override state root
605
669
  --json Print connection details as JSON
670
+ --api Also start local OpenAI-compatible API provider
671
+ --api-port PORT Local API port (default: 8765)
672
+ --port PORT Alias for --api-port on the api command
673
+ --api-host HOST Local API host (default: 127.0.0.1)
674
+ --api-key KEY Set or replace local API key in state
675
+ --rotate-api-key Rotate local API key
676
+ --api-upstream-url URL Upstream OpenAI-compatible base URL (default: https://api.openai.com/v1)
677
+ --api-upstream-key KEY Upstream provider key; env OPENAI_API_KEY also works
678
+ --api-model MODEL Model id advertised by /v1/models
606
679
 
607
680
  Uninstall options:
608
681
  --keep-worker Do not delete deployed Worker(s) during uninstall
@@ -610,6 +683,38 @@ Uninstall options:
610
683
  `);
611
684
  }
612
685
 
686
+ function apiUsage() {
687
+ console.log(`machine-bridge-mcp local API provider
688
+
689
+ Usage:
690
+ machine-mcp api
691
+ machine-mcp api --api-port 8766
692
+ machine-mcp start --api --api-port 8766
693
+
694
+ Client settings:
695
+ API Base URL: http://127.0.0.1:<port>/v1
696
+ API key: printed on startup unless --no-print-credentials is set
697
+ Client type: OpenAI-compatible
698
+
699
+ Options:
700
+ --workspace PATH Use and remember this workspace path
701
+ --api-host HOST Local API host (default: 127.0.0.1)
702
+ --api-port PORT Local API port (default: 8765)
703
+ --port PORT Alias for --api-port
704
+ --api-key KEY Set or replace local API key in state
705
+ --rotate-api-key Rotate local API key
706
+ --api-upstream-url URL Upstream OpenAI-compatible base URL
707
+ --api-upstream-key KEY Upstream provider key; env OPENAI_API_KEY also works
708
+ --api-model MODEL Model id advertised by /v1/models
709
+ --no-print-credentials Redact the local API key in console output
710
+ --state-dir DIR Override state root
711
+
712
+ Environment:
713
+ MBM_API_HOST, MBM_API_PORT, MBM_API_KEY, MBM_API_UPSTREAM_URL, MBM_API_UPSTREAM_KEY, MBM_API_MODEL
714
+ OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_API_BASE, OPENAI_MODEL
715
+ `);
716
+ }
717
+
613
718
  function version() {
614
719
  const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
615
720
  console.log(`${pkg.name} ${pkg.version}`);
@@ -0,0 +1,60 @@
1
+ import process from "node:process";
2
+
3
+ const COLORS = {
4
+ dim: "\x1b[2m",
5
+ reset: "\x1b[0m",
6
+ blue: "\x1b[34m",
7
+ green: "\x1b[32m",
8
+ yellow: "\x1b[33m",
9
+ red: "\x1b[31m",
10
+ gray: "\x1b[90m",
11
+ };
12
+
13
+ export function createLogger(options = {}) {
14
+ const quiet = Boolean(options.quiet);
15
+ const verbose = Boolean(options.verbose);
16
+ const component = options.component ? String(options.component) : "cli";
17
+ const useColor = shouldUseColor(options);
18
+
19
+ const write = (stream, level, label, color, message, fields) => {
20
+ if (quiet && level !== "error") return;
21
+ if (level === "debug" && !verbose) return;
22
+ const prefix = useColor ? `${color}${label}${COLORS.reset}` : label;
23
+ const suffix = formatFields(fields);
24
+ stream.write(`${prefix} ${component}: ${String(message)}${suffix}\n`);
25
+ };
26
+
27
+ return {
28
+ child(childComponent) {
29
+ return createLogger({ ...options, component: childComponent });
30
+ },
31
+ info(message, fields) { write(process.stdout, "info", "[info]", COLORS.blue, message, fields); },
32
+ success(message, fields) { write(process.stdout, "success", "[ok]", COLORS.green, message, fields); },
33
+ warn(message, fields) { write(process.stderr, "warn", "[warn]", COLORS.yellow, message, fields); },
34
+ error(message, fields) { write(process.stderr, "error", "[error]", COLORS.red, message, fields); },
35
+ debug(message, fields) { write(process.stderr, "debug", "[debug]", COLORS.gray, message, fields); },
36
+ plain(message = "") { if (!quiet) process.stdout.write(`${String(message)}\n`); },
37
+ json(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); },
38
+ };
39
+ }
40
+
41
+ export function redactSecret(value) {
42
+ const text = String(value || "");
43
+ if (!text) return "<empty>";
44
+ if (text.length <= 12) return "<redacted>";
45
+ return `${text.slice(0, 6)}...${text.slice(-4)}`;
46
+ }
47
+
48
+ export function formatFields(fields) {
49
+ if (!fields || typeof fields !== "object" || !Object.keys(fields).length) return "";
50
+ return ` ${JSON.stringify(fields, (_key, value) => {
51
+ if (value instanceof Error) return value.message;
52
+ return value;
53
+ })}`;
54
+ }
55
+
56
+ function shouldUseColor(options) {
57
+ if (options.color === false || process.env.NO_COLOR) return false;
58
+ if (options.color === true) return true;
59
+ return Boolean(process.stderr.isTTY || process.stdout.isTTY);
60
+ }
@@ -2,11 +2,14 @@ import { mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { daemonSelfTest } from "./daemon.mjs";
5
- import { acquireDaemonLock, ensureWorkerSecrets, loadState, previewSecret, redactState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
5
+ import { normalizeBaseUrl, startLocalApiServer } from "./api-server.mjs";
6
+ import { createLogger, redactSecret } from "./log.mjs";
7
+ import { acquireDaemonLock, ensureLocalApiKey, ensureWorkerSecrets, loadState, previewSecret, redactState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
6
8
 
7
9
  await daemonSelfTest();
8
10
  await stateSelfTest();
9
- console.log("local daemon/state self-test ok");
11
+ await apiSelfTest();
12
+ console.log("local daemon/state/api self-test ok");
10
13
 
11
14
  async function stateSelfTest() {
12
15
  const stateRoot = await mkdtemp(join(tmpdir(), "mbm-state-test-"));
@@ -16,6 +19,7 @@ async function stateSelfTest() {
16
19
  if (selectedWorkspace(stateRoot) !== workspace) throw new Error("selected workspace was not persisted");
17
20
  const state = loadState(workspace, { stateDir: stateRoot });
18
21
  ensureWorkerSecrets(state, { rotateSecrets: true });
22
+ ensureLocalApiKey(state, { rotateApiKey: true });
19
23
  const lock = acquireDaemonLock(state);
20
24
  if (!lock.acquired) throw new Error("first daemon lock acquisition failed");
21
25
  try {
@@ -33,9 +37,56 @@ async function stateSelfTest() {
33
37
  if (redacted.worker.oauthPassword === state.worker.oauthPassword) throw new Error("oauthPassword was not redacted");
34
38
  if (redacted.worker.daemonSecret === state.worker.daemonSecret) throw new Error("daemonSecret was not redacted");
35
39
  if (redacted.worker.oauthTokenVersion === state.worker.oauthTokenVersion) throw new Error("oauthTokenVersion was not redacted");
40
+ if (redacted.localApi.apiKey === state.localApi.apiKey) throw new Error("local API key was not redacted");
36
41
  if (!previewSecret(state.worker.oauthPassword).includes("...")) throw new Error("previewSecret did not preview long secret");
42
+ if (!redactSecret(state.localApi.apiKey).includes("...")) throw new Error("redactSecret did not preview long secret");
37
43
  } finally {
38
44
  await rm(stateRoot, { recursive: true, force: true }).catch(() => {});
39
45
  await rm(workspace, { recursive: true, force: true }).catch(() => {});
40
46
  }
41
47
  }
48
+
49
+
50
+ async function apiSelfTest() {
51
+ try {
52
+ normalizeBaseUrl("https://user:pass@example.com/v1");
53
+ throw new Error("upstream URL credentials were accepted");
54
+ } catch (error) {
55
+ if (!/must not contain credentials/.test(error.message)) throw error;
56
+ }
57
+ try {
58
+ normalizeBaseUrl("https://example.com/v1?api_key=secret");
59
+ throw new Error("upstream URL query string was accepted");
60
+ } catch (error) {
61
+ if (!/without query strings/.test(error.message)) throw error;
62
+ }
63
+
64
+ const logger = createLogger({ quiet: true, component: "api-test" });
65
+ const api = await startLocalApiServer({
66
+ host: "127.0.0.1",
67
+ port: 0,
68
+ apiKey: "local-test-key",
69
+ upstreamKey: "",
70
+ model: "test-model",
71
+ logger,
72
+ });
73
+ const base = `http://${api.host}:${api.port}`;
74
+ try {
75
+ const health = await fetch(`${base}/health`);
76
+ if (health.status !== 200) throw new Error(`health returned ${health.status}`);
77
+ const unauth = await fetch(`${base}/v1/models`);
78
+ if (unauth.status !== 401) throw new Error(`unauthorized models returned ${unauth.status}`);
79
+ const models = await fetch(`${base}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
80
+ if (models.status !== 200) throw new Error(`authorized models returned ${models.status}`);
81
+ const payload = await models.json();
82
+ if (payload?.data?.[0]?.id !== "test-model") throw new Error("model payload did not include configured model");
83
+ const chat = await fetch(`${base}/v1/chat/completions`, {
84
+ method: "POST",
85
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
86
+ body: JSON.stringify({ model: "test-model", messages: [] }),
87
+ });
88
+ if (chat.status !== 503) throw new Error(`missing upstream key should return 503, got ${chat.status}`);
89
+ } finally {
90
+ await api.close();
91
+ }
92
+ }
@@ -207,6 +207,14 @@ export function ensureWorkerSecrets(state, options = {}) {
207
207
  if (!state.worker.name || options.workerName) state.worker.name = options.workerName || defaultWorkerName(state.workspace.hash);
208
208
  }
209
209
 
210
+ export function ensureLocalApiKey(state, options = {}) {
211
+ state.localApi ||= {};
212
+ if (options.apiKey && options.apiKey !== true) state.localApi.apiKey = String(options.apiKey);
213
+ if (!state.localApi.apiKey || options.rotateApiKey) state.localApi.apiKey = randomToken("local_api_key");
214
+ state.localApi.updatedAt = new Date().toISOString();
215
+ return state.localApi.apiKey;
216
+ }
217
+
210
218
  export function defaultWorkerName(hash) {
211
219
  return `mbm-${String(hash || "default").slice(0, 12)}`;
212
220
  }
@@ -237,6 +245,7 @@ export function redactState(state) {
237
245
  if (clone.worker?.oauthPassword) clone.worker.oauthPassword = previewSecret(clone.worker.oauthPassword);
238
246
  if (clone.worker?.daemonSecret) clone.worker.daemonSecret = previewSecret(clone.worker.daemonSecret);
239
247
  if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = previewSecret(clone.worker.oauthTokenVersion);
248
+ if (clone.localApi?.apiKey) clone.localApi.apiKey = previewSecret(clone.localApi.apiKey);
240
249
  return clone;
241
250
  }
242
251
 
@@ -1,7 +1,7 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
 
3
3
  const SERVER_NAME = "machine-bridge-mcp";
4
- const SERVER_VERSION = "0.1.1";
4
+ const SERVER_VERSION = "0.2.0";
5
5
  const MCP_PROTOCOL_VERSION = "2025-06-18";
6
6
  const JSONRPC_VERSION = "2.0";
7
7
  const DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024;