machine-bridge-mcp 0.1.1 → 0.2.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/README.md CHANGED
@@ -28,15 +28,18 @@ Source checkout:
28
28
  3. Generates a stable MCP connection password and daemon secret.
29
29
  4. Checks `wrangler whoami`; if needed, opens `wrangler login`.
30
30
  5. Deploys the hosted Worker relay with `wrangler deploy --secrets-file`.
31
- 6. Installs login autostart for the local daemon.
32
- 7. Starts the local daemon and prints:
31
+ 6. Installs login autostart for the local daemon and default local API provider.
32
+ 7. Starts the local daemon and local OpenAI-compatible API provider.
33
+ 8. Prints MCP connection details on first run, plus local API settings:
33
34
 
34
35
  ```text
35
36
  MCP Server URL: https://<worker>.<account>.workers.dev/mcp
36
37
  MCP connection password: mcp_password_...
38
+ API Base URL: http://127.0.0.1:8765/v1
39
+ API key: local_api_key_...
37
40
  ```
38
41
 
39
- Keep the foreground process running for the current session. The installed autostart entry keeps the daemon available after future logins.
42
+ Keep the foreground process running for the current session. The installed autostart entry keeps the daemon and local API available after future logins.
40
43
 
41
44
  The command is safe to run repeatedly:
42
45
 
@@ -44,7 +47,87 @@ The command is safe to run repeatedly:
44
47
  npm install -g machine-bridge-mcp@latest && machine-mcp
45
48
  ```
46
49
 
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.
50
+ On repeat runs, the CLI reuses existing state and secrets unless you request rotation, skips Worker redeploys when the deployed Worker is healthy and Worker source/config/secrets are 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. Local-only package, CLI, logging, and API-provider changes do not by themselves force a Worker redeploy.
51
+
52
+ MCP connection details are printed on first run, after secret rotation, when the MCP URL changes, or when you explicitly pass `--print-mcp-credentials`. Routine runs print that MCP details are unchanged.
53
+
54
+ ## Local OpenAI-compatible API provider
55
+
56
+ `machine-bridge-mcp` exposes a local OpenAI-compatible API provider by default for desktop AI clients such as Cherry Studio, Chatbox, Continue, or other apps that accept an OpenAI-style base URL and API key. No extra command is needed; the recommended `machine-mcp` command starts both the Remote MCP daemon and the local API provider.
57
+
58
+ Start the normal daemon and API provider:
59
+
60
+ ```zsh
61
+ machine-mcp
62
+ ```
63
+
64
+ Start only the local API service, without the Remote MCP daemon:
65
+
66
+ ```zsh
67
+ machine-mcp api
68
+ ```
69
+
70
+ Disable the default local API provider for a daemon run:
71
+
72
+ ```zsh
73
+ machine-mcp --no-api
74
+ ```
75
+
76
+ The CLI prints client settings like:
77
+
78
+ ```text
79
+ API Base URL: http://127.0.0.1:8765/v1
80
+ API key: local_api_key_...
81
+ Client type: OpenAI-compatible
82
+ ```
83
+
84
+ Use these values in the local AI client:
85
+
86
+ - Base URL: `http://127.0.0.1:8765/v1`
87
+ - API key: the `local_api_key_...` printed by the CLI
88
+ - Model: the model shown by `GET /v1/models` or your configured `--api-model`
89
+
90
+ If port `8765` conflicts with another local app, choose a different port explicitly:
91
+
92
+ ```zsh
93
+ machine-mcp --api-port 8766
94
+ machine-mcp api --api-port 8766
95
+ ```
96
+
97
+ `--port` is also accepted on the `api` command:
98
+
99
+ ```zsh
100
+ machine-mcp api --port 8766
101
+ ```
102
+
103
+ By default, the local API binds to `127.0.0.1`, starts with `machine-mcp`, and stores a per-workspace local API key in the same owner-only state profile used by the MCP credentials. Explicit `--api-host`, `--api-port`, `--api-model`, and `--api-upstream-url` values are persisted for the workspace so autostart uses the same non-secret API settings; upstream API keys are not written to state. Rotate the local API key with:
104
+
105
+ ```zsh
106
+ machine-mcp api --rotate-api-key
107
+ ```
108
+
109
+ Configure an upstream OpenAI-compatible provider:
110
+
111
+ ```zsh
112
+ machine-mcp api \
113
+ --api-upstream-url https://api.openai.com/v1 \
114
+ --api-upstream-key "$OPENAI_API_KEY" \
115
+ --api-model gpt-4.1
116
+ ```
117
+
118
+ 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`.
119
+
120
+ Supported local API routes:
121
+
122
+ - `GET /health` without authentication
123
+ - `GET /v1/models` with `Authorization: Bearer <local_api_key>` or `x-api-key`
124
+ - `POST /v1/chat/completions`
125
+ - `POST /v1/responses`
126
+ - `POST /v1/embeddings`
127
+ - `POST /v1/completions`
128
+
129
+ 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.
130
+
48
131
 
49
132
  ## Re-select workspace
50
133
 
@@ -165,7 +248,7 @@ Default state roots:
165
248
  - Linux with `XDG_STATE_HOME`: `$XDG_STATE_HOME/machine-bridge-mcp`
166
249
  - Windows: `%APPDATA%\machine-bridge-mcp`
167
250
 
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.
251
+ State contains the MCP password, daemon secret, and local API key. Status/doctor output redacts secrets. The normal foreground `start` command prints the MCP password only when a ChatGPT app is likely to need reconnection: first run, secret rotation, MCP URL changes, or `--print-mcp-credentials`. The local API base URL and API key print on normal foreground starts because desktop AI clients need them. 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
252
 
170
253
  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
254
 
@@ -183,6 +266,8 @@ flowchart LR
183
266
  W --> DO["Durable Object broker"]
184
267
  D["Local daemon"] -- "outbound WebSocket" --> W
185
268
  D --> M["Local filesystem and shell"]
269
+ API["Default local /v1 API"] -- "OpenAI-compatible HTTP" --> U["Configured upstream provider"]
270
+ CLI["machine-mcp CLI"] --> API
186
271
  CLI["machine-mcp CLI"] --> W
187
272
  CLI --> D
188
273
  CLI --> S["Autostart service"]
@@ -195,7 +280,8 @@ Why this architecture:
195
280
  - The public MCP URL is stable after deployment.
196
281
  - The Worker stores OAuth client/code/token metadata and relays tool calls.
197
282
  - The local daemon is the only process touching files or executing commands.
198
- - Autostart keeps the daemon alive across logins without requiring MCP clients to change URLs.
283
+ - The local `/v1` API binds to loopback by default, starts automatically with the daemon, and can be disabled with `--no-api`.
284
+ - Autostart keeps the daemon and local API alive across logins without requiring MCP clients to change URLs.
199
285
 
200
286
  ## Development
201
287
 
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.1",
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,260 @@
1
+ import http from "node:http";
2
+ import { createHash, 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.");
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", api_key_sha256: sha256String(context.apiKey) });
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 sha256String(value) {
207
+ return createHash("sha256").update(String(value)).digest("hex");
208
+ }
209
+
210
+ function isAuthorized(req, expectedKey) {
211
+ const auth = String(req.headers.authorization || "");
212
+ if (auth.startsWith("Bearer ") && auth.slice(7) === expectedKey) return true;
213
+ const apiKey = req.headers["x-api-key"];
214
+ return typeof apiKey === "string" && apiKey === expectedKey;
215
+ }
216
+
217
+ function modelsPayload(model) {
218
+ return {
219
+ object: "list",
220
+ data: [{
221
+ id: model,
222
+ object: "model",
223
+ created: 0,
224
+ owned_by: "machine-bridge-mcp",
225
+ }],
226
+ };
227
+ }
228
+
229
+ function sendOpenAiError(res, status, code, message) {
230
+ return sendJson(res, status, { error: { message, type: "invalid_request_error", param: null, code } });
231
+ }
232
+
233
+ function sendJson(res, status, payload) {
234
+ if (!res.headersSent) {
235
+ res.statusCode = status;
236
+ res.setHeader("content-type", "application/json; charset=utf-8");
237
+ }
238
+ res.end(`${JSON.stringify(payload)}\n`);
239
+ }
240
+
241
+ function sendEmpty(res, status) {
242
+ res.statusCode = status;
243
+ res.end();
244
+ }
245
+
246
+ function setCorsHeaders(res) {
247
+ res.setHeader("access-control-allow-origin", "*");
248
+ res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
249
+ res.setHeader("access-control-allow-headers", "authorization,content-type,x-api-key,openai-beta,openai-organization,openai-project");
250
+ }
251
+
252
+ function withPortHint(error, port) {
253
+ if (error?.code === "EADDRINUSE") {
254
+ error.message = `Local API port ${port} is already in use. Re-run with \`machine-mcp --api-port <free_port>\` or \`machine-mcp api --api-port <free_port>\`.`;
255
+ }
256
+ if (error?.code === "EACCES") {
257
+ error.message = `Local API port ${port} is not permitted. Re-run with \`machine-mcp --api-port <free_port>\` or \`machine-mcp api --api-port <free_port>\`.`;
258
+ }
259
+ return error;
260
+ }
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,14 +159,21 @@ 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 || args.json), 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 });
165
+ const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
166
+ const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
167
+ const apiEnabled = args.noApi ? false : true;
168
+
159
169
  ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName: args.workerName && String(args.workerName) });
170
+ if (apiEnabled) configureLocalApiState(state, args);
160
171
  state.policy = {
161
172
  allowWrite: args.noWrite ? false : true,
162
173
  allowExec: args.noExec ? false : true,
163
174
  unrestrictedPaths: true,
164
175
  minimalEnv: args.fullEnv ? false : true,
176
+ apiEnabled,
165
177
  updatedAt: new Date().toISOString(),
166
178
  };
167
179
  saveState(state);
@@ -169,6 +181,9 @@ async function startCommand(args) {
169
181
  if (!args.daemonOnly) await ensureWorker(state, args);
170
182
  else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
171
183
 
184
+ const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
185
+ const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
186
+
172
187
  if (!args.daemonOnly && !args.noAutostart) {
173
188
  await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], policy: state.policy });
174
189
  }
@@ -176,54 +191,183 @@ async function startCommand(args) {
176
191
  if (!args.daemonOnly) await stopAutostartBestEffort(Boolean(args.quiet));
177
192
 
178
193
  const lock = acquireDaemonLock(state);
194
+ let daemon = null;
195
+ let apiServer = null;
179
196
  if (!lock.acquired) {
180
197
  if (!args.quiet) {
181
198
  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.`);
183
- printConnection(state, { json: Boolean(args.json), noPrintCredentials: Boolean(args.noPrintCredentials) });
199
+ logger.warn(`local daemon already running for this workspace (${pid}); not starting a duplicate`);
200
+ if (!args.json) printMcpConnection(state, {
201
+ noPrintCredentials: Boolean(args.noPrintCredentials),
202
+ includeCredentials: shouldPrintMcpCredentials,
203
+ quiet: Boolean(args.quiet),
204
+ });
184
205
  }
206
+ if (!apiEnabled) {
207
+ if (args.json) printStartJson(state, null, { noPrintCredentials: Boolean(args.noPrintCredentials) });
208
+ return;
209
+ }
210
+ apiServer = await startOptionalApiServer(state, args, logger);
211
+ if (args.json) printStartJson(state, apiServer, { noPrintCredentials: Boolean(args.noPrintCredentials) });
212
+ else if (apiServer) printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials), quiet: Boolean(args.quiet) });
213
+ if (apiServer && !apiServer.alreadyRunning) keepProcessAlive({ apiServer, logger });
185
214
  return;
186
215
  }
187
216
 
188
217
  try {
189
- const daemon = new LocalDaemon({
218
+ daemon = new LocalDaemon({
190
219
  workerUrl: state.worker.url,
191
220
  secret: state.worker.daemonSecret,
192
221
  workspace,
193
222
  policy: state.policy,
194
- logger: structuredLogger(args.quiet),
223
+ logger: createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "daemon" }),
195
224
  });
196
225
 
197
226
  const waitForConnect = daemon.start();
198
- await waitForConnectWithNotice(waitForConnect, 20_000);
199
- printConnection(state, { json: Boolean(args.json), noPrintCredentials: Boolean(args.noPrintCredentials) });
200
- keepProcessAlive(daemon, lock);
227
+ await waitForConnectWithNotice(waitForConnect, 20_000, Boolean(args.quiet || args.json));
228
+ if (apiEnabled) apiServer = await startOptionalApiServer(state, args, logger);
229
+ if (args.json) printStartJson(state, apiServer, { noPrintCredentials: Boolean(args.noPrintCredentials) });
230
+ else {
231
+ printMcpConnection(state, {
232
+ noPrintCredentials: Boolean(args.noPrintCredentials),
233
+ includeCredentials: shouldPrintMcpCredentials,
234
+ quiet: Boolean(args.quiet),
235
+ });
236
+ if (apiServer) printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials), quiet: Boolean(args.quiet) });
237
+ }
238
+ keepProcessAlive({ daemon, lock, apiServer: apiServer?.alreadyRunning ? null : apiServer, logger });
201
239
  } catch (error) {
240
+ try { daemon?.stop?.(); } catch {}
202
241
  lock.release();
242
+ if (apiServer && !apiServer.alreadyRunning) await apiServer.close().catch(() => {});
203
243
  throw error;
204
244
  }
205
245
  }
206
246
 
247
+ async function apiCommand(args) {
248
+ assertNodeVersion();
249
+ const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
250
+ const state = loadState(workspace, { stateDir: args.stateDir });
251
+ configureLocalApiState(state, args);
252
+ saveState(state);
253
+ const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" });
254
+ const apiServer = await startConfiguredApiServer(state, args, logger);
255
+ if (args.json) printApiJson(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
256
+ else printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials), quiet: Boolean(args.quiet) });
257
+ keepProcessAlive({ apiServer, logger });
258
+ }
259
+
260
+ async function startConfiguredApiServer(state, args, logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" })) {
261
+ const apiOptions = apiOptionsFromArgs(state, args);
262
+ return startLocalApiServer({ ...apiOptions, logger });
263
+ }
264
+
265
+ async function startOptionalApiServer(state, args, parentLogger) {
266
+ const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" });
267
+ const apiOptions = apiOptionsFromArgs(state, args);
268
+ try {
269
+ return await startLocalApiServer({ ...apiOptions, logger });
270
+ } catch (error) {
271
+ if (error?.code === "EADDRINUSE") {
272
+ const baseUrl = apiBaseUrl(apiOptions.host, apiOptions.port);
273
+ const health = await probeLocalApiHealth(baseUrl, state.localApi?.apiKey);
274
+ if (health.ok) {
275
+ logger.success("local OpenAI-compatible API already running", { baseUrl });
276
+ return { ...health, baseUrl, host: apiOptions.host, port: Number(apiOptions.port), alreadyRunning: true, close() { return Promise.resolve(); } };
277
+ }
278
+ parentLogger.warn(error.message);
279
+ return null;
280
+ }
281
+ parentLogger.warn(`Local API provider skipped: ${error.message}`);
282
+ return null;
283
+ }
284
+ }
285
+
286
+ function apiBaseUrl(host, port) {
287
+ const textHost = String(host || DEFAULT_API_HOST);
288
+ const urlHost = textHost.includes(":") && !textHost.startsWith("[") ? `[${textHost}]` : textHost;
289
+ return `http://${urlHost}:${port || DEFAULT_API_PORT}/v1`;
290
+ }
291
+
292
+ async function probeLocalApiHealth(baseUrl, expectedApiKey = "") {
293
+ try {
294
+ const healthUrl = `${String(baseUrl).replace(/\/v1$/, "")}/health`;
295
+ const response = await fetch(healthUrl, { signal: AbortSignal.timeout(750) });
296
+ if (!response.ok) return { ok: false };
297
+ const body = await response.json().catch(() => null);
298
+ if (body?.service !== "machine-bridge-mcp-local-api") return { ok: false };
299
+ if (body.api_key_sha256 && expectedApiKey && body.api_key_sha256 !== sha256String(expectedApiKey)) return { ok: false, reason: "api_key_mismatch" };
300
+ return { ok: true };
301
+ } catch {
302
+ return { ok: false };
303
+ }
304
+ }
305
+
306
+ function apiOptionsFromArgs(state, args = {}) {
307
+ const upstreamKey = valueFromArgsEnv(args.apiUpstreamKey, "MBM_API_UPSTREAM_KEY", "OPENAI_API_KEY");
308
+ const explicitPort = explicitArg(args.apiPort) ?? explicitArg(args.port);
309
+ const envPort = valueFromArgsEnv(undefined, "MBM_API_PORT", "PORT");
310
+ return {
311
+ host: valueFromArgsEnv(args.apiHost, "MBM_API_HOST") || state.localApi?.host || DEFAULT_API_HOST,
312
+ port: explicitPort ?? envPort ?? state.localApi?.port ?? DEFAULT_API_PORT,
313
+ apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY") || state.localApi?.apiKey || ensureLocalApiKey(state, { rotateApiKey: Boolean(args.rotateApiKey) }),
314
+ upstreamUrl: valueFromArgsEnv(args.apiUpstreamUrl, "MBM_API_UPSTREAM_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE") || state.localApi?.upstreamUrl || DEFAULT_UPSTREAM_URL,
315
+ upstreamKey: upstreamKey || "",
316
+ model: valueFromArgsEnv(args.apiModel, "MBM_API_MODEL", "OPENAI_MODEL") || state.localApi?.model || DEFAULT_API_MODEL,
317
+ };
318
+ }
319
+
320
+ function configureLocalApiState(state, args = {}) {
321
+ state.localApi ||= {};
322
+ ensureLocalApiKey(state, { apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY"), rotateApiKey: Boolean(args.rotateApiKey) });
323
+ const port = explicitArg(args.apiPort) ?? explicitArg(args.port);
324
+ if (port !== undefined && String(port) !== "0") state.localApi.port = String(port);
325
+ const host = explicitArg(args.apiHost);
326
+ if (host !== undefined) state.localApi.host = String(host);
327
+ const upstreamUrl = explicitArg(args.apiUpstreamUrl);
328
+ if (upstreamUrl !== undefined) state.localApi.upstreamUrl = String(upstreamUrl);
329
+ const model = explicitArg(args.apiModel);
330
+ if (model !== undefined) state.localApi.model = String(model);
331
+ state.localApi.updatedAt = new Date().toISOString();
332
+ }
333
+
334
+ function explicitArg(value) {
335
+ return value !== undefined && value !== null && value !== true ? String(value) : undefined;
336
+ }
337
+
338
+ function sha256String(value) {
339
+ return createHash("sha256").update(String(value)).digest("hex");
340
+ }
341
+
342
+ function valueFromArgsEnv(argValue, ...envNames) {
343
+ if (argValue !== undefined && argValue !== null && argValue !== true) return String(argValue);
344
+ for (const name of envNames) {
345
+ if (process.env[name]) return process.env[name];
346
+ }
347
+ return undefined;
348
+ }
349
+
207
350
  async function ensureWorker(state, args) {
351
+ const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "worker" });
208
352
  const desiredHash = workerDeployHash(state);
209
353
  const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.oauthPassword && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
210
354
  if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
211
355
  const health = await workerHealth(state.worker.url);
212
356
  if (health.ok) {
213
- console.log(`Worker unchanged and healthy: ${state.worker.url}`);
357
+ logger.success("Worker unchanged and healthy", { url: state.worker.url });
214
358
  return state.worker;
215
359
  }
216
- console.warn(`Worker health check failed, redeploying: ${health.error}`);
360
+ logger.warn("Worker health check failed; redeploying", { error: health.error });
217
361
  }
218
362
 
219
- console.log("Checking Cloudflare Wrangler login...");
363
+ logger.info("Checking Cloudflare Wrangler login");
220
364
  const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
221
365
  if (whoami.code !== 0) {
222
- console.log("Wrangler is not logged in; opening Cloudflare login...");
366
+ logger.info("Wrangler is not logged in; opening Cloudflare login");
223
367
  await runWrangler(["login"]);
224
368
  }
225
369
 
226
- console.log(`Deploying Cloudflare Worker '${state.worker.name}'...`);
370
+ logger.info("Deploying Cloudflare Worker", { name: state.worker.name });
227
371
  const deploy = await withSecretsFile(state, secretFile => runWrangler([
228
372
  "deploy",
229
373
  "--name", state.worker.name,
@@ -241,8 +385,8 @@ async function ensureWorker(state, args) {
241
385
  saveState(state);
242
386
 
243
387
  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}`);
388
+ if (!health.ok) logger.warn("Worker deployed but health check did not pass yet", { error: health.error });
389
+ else logger.success("Worker ready", { url: state.worker.url });
246
390
  return state.worker;
247
391
  }
248
392
 
@@ -284,14 +428,22 @@ function workerDeployHash(state) {
284
428
  hash.update(String(state.worker.oauthTokenVersion || ""));
285
429
  for (const file of workerDeployHashFiles()) {
286
430
  hash.update(path.relative(packageRoot, file));
287
- hash.update(readFileSync(file));
431
+ hash.update(workerHashContent(file));
288
432
  }
289
433
  return hash.digest("hex");
290
434
  }
291
435
 
436
+ function workerHashContent(file) {
437
+ const content = readFileSync(file, "utf8");
438
+ if (path.relative(packageRoot, file).replaceAll("\\", "/") === "src/worker/index.ts") {
439
+ return content.replace(/const SERVER_VERSION = "[^"]+";/, 'const SERVER_VERSION = "<ignored-for-deploy-hash>";');
440
+ }
441
+ return content;
442
+ }
443
+
292
444
  function workerDeployHashFiles() {
293
445
  const files = [];
294
- for (const item of ["src/worker", "wrangler.jsonc", "tsconfig.json", "package.json", "package-lock.json"]) {
446
+ for (const item of ["src/worker", "wrangler.jsonc", "tsconfig.json"]) {
295
447
  collectHashFiles(resolve(packageRoot, item), files);
296
448
  }
297
449
  return files.sort();
@@ -341,17 +493,53 @@ function extractWorkerUrl(text = "") {
341
493
  return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
342
494
  }
343
495
 
344
- async function waitForConnectWithNotice(promise, timeoutMs) {
496
+ async function waitForConnectWithNotice(promise, timeoutMs, quiet = false) {
345
497
  let timeout;
346
498
  const timed = new Promise(resolvePromise => {
347
499
  timeout = setTimeout(() => resolvePromise("timeout"), timeoutMs);
348
500
  });
349
501
  const result = await Promise.race([promise.then(() => "connected"), timed]);
350
502
  clearTimeout(timeout);
351
- if (result === "timeout") console.warn("Daemon is still connecting; credentials are printed now and the process will keep retrying.");
503
+ if (result === "timeout") createLogger({ component: "daemon", quiet }).warn("Still connecting; the process will keep retrying");
504
+ }
505
+
506
+
507
+ function printStartJson(state, apiServer, { noPrintCredentials = false } = {}) {
508
+ const mcpPassword = noPrintCredentials ? previewSecret(state.worker.oauthPassword) : state.worker.oauthPassword;
509
+ const apiKey = state.localApi?.apiKey ? (noPrintCredentials ? previewSecret(state.localApi.apiKey) : state.localApi.apiKey) : null;
510
+ createLogger({ component: "ready" }).json({
511
+ mcp: {
512
+ server_url: state.worker.mcpServerUrl,
513
+ connection_password: mcpPassword,
514
+ worker_url: state.worker.url,
515
+ worker_name: state.worker.name,
516
+ },
517
+ local_api: apiServer ? {
518
+ base_url: apiServer.baseUrl,
519
+ api_key: apiKey,
520
+ already_running: Boolean(apiServer.alreadyRunning),
521
+ client_type: "OpenAI-compatible",
522
+ } : null,
523
+ workspace: state.workspace.path,
524
+ state_path: state.paths.statePath,
525
+ policy: state.policy,
526
+ });
527
+ }
528
+
529
+ function printApiJson(apiServer, state, { noPrintCredentials = false } = {}) {
530
+ createLogger({ component: "ready" }).json({
531
+ local_api: {
532
+ base_url: apiServer.baseUrl,
533
+ api_key: noPrintCredentials ? previewSecret(state.localApi.apiKey) : state.localApi.apiKey,
534
+ client_type: "OpenAI-compatible",
535
+ },
536
+ workspace: state.workspace.path,
537
+ state_path: state.paths.statePath,
538
+ });
352
539
  }
353
540
 
354
- function printConnection(state, { json = false, noPrintCredentials = false } = {}) {
541
+ function printMcpConnection(state, { json = false, noPrintCredentials = false, includeCredentials = false, quiet = false } = {}) {
542
+ const logger = createLogger({ component: "ready", quiet });
355
543
  const payload = {
356
544
  mcp_server_url: state.worker.mcpServerUrl,
357
545
  mcp_connection_password: state.worker.oauthPassword,
@@ -363,27 +551,44 @@ function printConnection(state, { json = false, noPrintCredentials = false } = {
363
551
  };
364
552
  if (json) {
365
553
  const safePayload = noPrintCredentials ? { ...payload, mcp_connection_password: previewSecret(payload.mcp_connection_password) } : payload;
366
- console.log(JSON.stringify(safePayload, null, 2));
554
+ logger.json(safePayload);
367
555
  return;
368
556
  }
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?.();
557
+ if (includeCredentials) {
558
+ logger.success("Remote MCP bridge is ready; save these connection details if your ChatGPT app needs to reconnect");
559
+ logger.plain(` MCP Server URL: ${payload.mcp_server_url}`);
560
+ if (!noPrintCredentials) logger.plain(` MCP connection password: ${payload.mcp_connection_password}`);
561
+ else logger.plain(` MCP connection password: ${previewSecret(payload.mcp_connection_password)} (redacted)`);
562
+ } else {
563
+ logger.success("Remote MCP bridge is ready; MCP connection details unchanged");
564
+ logger.plain(" Use --print-mcp-credentials only when a ChatGPT app needs to reconnect.");
565
+ }
566
+ logger.plain(` Workspace cwd: ${payload.workspace}`);
567
+ logger.plain(` Policy: write=${payload.policy.allowWrite ? "on" : "off"}, exec=${payload.policy.allowExec ? "on" : "off"}, local_api=${payload.policy.apiEnabled === false ? "off" : "on"}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "on" : "off"}`);
568
+ logger.plain(` State: ${payload.state_path}`);
569
+ }
570
+
571
+ function printApiConnection(apiServer, state, { noPrintCredentials = false, quiet = false } = {}) {
572
+ const logger = createLogger({ component: "ready", quiet });
573
+ logger.success("Local model API provider is ready");
574
+ logger.plain(` API Base URL: ${apiServer.baseUrl}`);
575
+ logger.plain(` API key: ${noPrintCredentials ? `${redactSecret(state.localApi.apiKey)} (redacted)` : state.localApi.apiKey}`);
576
+ logger.plain(" Client type: OpenAI-compatible");
577
+ }
578
+
579
+ function keepProcessAlive({ daemon = null, lock = null, apiServer = null, logger = createLogger({ component: "cli" }) } = {}) {
580
+ let stopping = false;
581
+ const stop = async () => {
582
+ if (stopping) return;
583
+ stopping = true;
584
+ logger.info("stopping local services");
585
+ try { daemon?.stop?.(); } catch {}
586
+ try { await apiServer?.close?.(); } catch {}
587
+ try { lock?.release?.(); } catch {}
383
588
  process.exit(0);
384
589
  };
385
- process.once("SIGINT", stop);
386
- process.once("SIGTERM", stop);
590
+ process.once("SIGINT", () => void stop());
591
+ process.once("SIGTERM", () => void stop());
387
592
  process.once("exit", () => lock?.release?.());
388
593
  setInterval(() => {}, 2 ** 31 - 1);
389
594
  }
@@ -537,12 +742,7 @@ async function removeAutostartBestEffort(stateRoot) {
537
742
  }
538
743
 
539
744
  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
- };
745
+ return createLogger({ quiet, component: "daemon" });
546
746
  }
547
747
 
548
748
  function sanitizeLines(text) {
@@ -577,7 +777,8 @@ Usage:
577
777
  .\\mbm.cmd # from source checkout on Windows cmd
578
778
 
579
779
  Commands:
580
- start Deploy/update Worker, install autostart, start local daemon
780
+ start Deploy/update Worker, install autostart, start daemon and local API
781
+ api Start local OpenAI-compatible API provider only
581
782
  workspace show Show remembered workspace
582
783
  workspace set Re-select workspace; prompts with current/default path
583
784
  service status Show autostart status
@@ -597,12 +798,23 @@ Start options:
597
798
  --rotate-secrets Rotate secrets before deploying
598
799
  --daemon-only Skip deploy and only connect daemon from existing state
599
800
  --no-autostart Do not install login autostart during start
600
- --no-print-credentials Redact the MCP password in console output
801
+ --no-print-credentials Redact credentials in console output
802
+ --print-mcp-credentials Print MCP URL/password again for reconnecting ChatGPT apps
601
803
  --no-write Disable write_file (default: write enabled)
602
804
  --no-exec Disable exec_command (default: exec enabled)
603
805
  --full-env Pass full parent environment to exec_command (default: minimal env)
604
806
  --state-dir DIR Override state root
605
- --json Print connection details as JSON
807
+ --json Print MCP and local API connection details as JSON
808
+ --no-api Do not start the local OpenAI-compatible API provider
809
+ --api Deprecated no-op; local API starts by default
810
+ --api-port PORT Local API port (default: 8765)
811
+ --port PORT Alias for --api-port on the api command
812
+ --api-host HOST Local API host (default: 127.0.0.1)
813
+ --api-key KEY Set or replace local API key in state
814
+ --rotate-api-key Rotate local API key
815
+ --api-upstream-url URL Upstream OpenAI-compatible base URL (default: https://api.openai.com/v1)
816
+ --api-upstream-key KEY Upstream provider key; env OPENAI_API_KEY also works
817
+ --api-model MODEL Model id advertised by /v1/models
606
818
 
607
819
  Uninstall options:
608
820
  --keep-worker Do not delete deployed Worker(s) during uninstall
@@ -610,6 +822,40 @@ Uninstall options:
610
822
  `);
611
823
  }
612
824
 
825
+ function apiUsage() {
826
+ console.log(`machine-bridge-mcp local API provider
827
+
828
+ Usage:
829
+ machine-mcp
830
+ machine-mcp --api-port 8766
831
+ machine-mcp api # API provider only
832
+ machine-mcp api --api-port 8766
833
+
834
+ Client settings:
835
+ API Base URL: http://127.0.0.1:<port>/v1
836
+ API key: printed on startup unless --no-print-credentials is set
837
+ Client type: OpenAI-compatible
838
+
839
+ Options:
840
+ --workspace PATH Use and remember this workspace path
841
+ --api-host HOST Local API host (default: 127.0.0.1)
842
+ --api-port PORT Local API port (default: 8765)
843
+ --port PORT Alias for --api-port
844
+ --api-key KEY Set or replace local API key in state
845
+ --rotate-api-key Rotate local API key
846
+ --api-upstream-url URL Upstream OpenAI-compatible base URL
847
+ --api-upstream-key KEY Upstream provider key; env OPENAI_API_KEY also works
848
+ --api-model MODEL Model id advertised by /v1/models
849
+ --no-print-credentials Redact the local API key in console output
850
+ --no-api Only valid for start; disables the default local API provider
851
+ --state-dir DIR Override state root
852
+
853
+ Environment:
854
+ MBM_API_HOST, MBM_API_PORT, MBM_API_KEY, MBM_API_UPSTREAM_URL, MBM_API_UPSTREAM_KEY, MBM_API_MODEL
855
+ OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_API_BASE, OPENAI_MODEL
856
+ `);
857
+ }
858
+
613
859
  function version() {
614
860
  const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
615
861
  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
+ }
@@ -1,12 +1,16 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { mkdtemp, rm } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { daemonSelfTest } from "./daemon.mjs";
5
- import { acquireDaemonLock, ensureWorkerSecrets, loadState, previewSecret, redactState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
6
+ import { normalizeBaseUrl, startLocalApiServer } from "./api-server.mjs";
7
+ import { createLogger, redactSecret } from "./log.mjs";
8
+ import { acquireDaemonLock, ensureLocalApiKey, ensureWorkerSecrets, loadState, previewSecret, redactState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
6
9
 
7
10
  await daemonSelfTest();
8
11
  await stateSelfTest();
9
- console.log("local daemon/state self-test ok");
12
+ await apiSelfTest();
13
+ console.log("local daemon/state/api self-test ok");
10
14
 
11
15
  async function stateSelfTest() {
12
16
  const stateRoot = await mkdtemp(join(tmpdir(), "mbm-state-test-"));
@@ -16,6 +20,7 @@ async function stateSelfTest() {
16
20
  if (selectedWorkspace(stateRoot) !== workspace) throw new Error("selected workspace was not persisted");
17
21
  const state = loadState(workspace, { stateDir: stateRoot });
18
22
  ensureWorkerSecrets(state, { rotateSecrets: true });
23
+ ensureLocalApiKey(state, { rotateApiKey: true });
19
24
  const lock = acquireDaemonLock(state);
20
25
  if (!lock.acquired) throw new Error("first daemon lock acquisition failed");
21
26
  try {
@@ -33,9 +38,59 @@ async function stateSelfTest() {
33
38
  if (redacted.worker.oauthPassword === state.worker.oauthPassword) throw new Error("oauthPassword was not redacted");
34
39
  if (redacted.worker.daemonSecret === state.worker.daemonSecret) throw new Error("daemonSecret was not redacted");
35
40
  if (redacted.worker.oauthTokenVersion === state.worker.oauthTokenVersion) throw new Error("oauthTokenVersion was not redacted");
41
+ if (redacted.localApi.apiKey === state.localApi.apiKey) throw new Error("local API key was not redacted");
36
42
  if (!previewSecret(state.worker.oauthPassword).includes("...")) throw new Error("previewSecret did not preview long secret");
43
+ if (!redactSecret(state.localApi.apiKey).includes("...")) throw new Error("redactSecret did not preview long secret");
37
44
  } finally {
38
45
  await rm(stateRoot, { recursive: true, force: true }).catch(() => {});
39
46
  await rm(workspace, { recursive: true, force: true }).catch(() => {});
40
47
  }
41
48
  }
49
+
50
+
51
+ async function apiSelfTest() {
52
+ try {
53
+ normalizeBaseUrl("https://user:pass@example.com/v1");
54
+ throw new Error("upstream URL credentials were accepted");
55
+ } catch (error) {
56
+ if (!/must not contain credentials/.test(error.message)) throw error;
57
+ }
58
+ try {
59
+ normalizeBaseUrl("https://example.com/v1?api_key=secret");
60
+ throw new Error("upstream URL query string was accepted");
61
+ } catch (error) {
62
+ if (!/without query strings/.test(error.message)) throw error;
63
+ }
64
+
65
+ const logger = createLogger({ quiet: true, component: "api-test" });
66
+ const api = await startLocalApiServer({
67
+ host: "127.0.0.1",
68
+ port: 0,
69
+ apiKey: "local-test-key",
70
+ upstreamKey: "",
71
+ model: "test-model",
72
+ logger,
73
+ });
74
+ const base = `http://${api.host}:${api.port}`;
75
+ try {
76
+ const health = await fetch(`${base}/health`);
77
+ if (health.status !== 200) throw new Error(`health returned ${health.status}`);
78
+ const healthPayload = await health.json();
79
+ const expectedHash = createHash("sha256").update("local-test-key").digest("hex");
80
+ if (healthPayload.api_key_sha256 !== expectedHash) throw new Error("health did not expose expected API key hash");
81
+ const unauth = await fetch(`${base}/v1/models`);
82
+ if (unauth.status !== 401) throw new Error(`unauthorized models returned ${unauth.status}`);
83
+ const models = await fetch(`${base}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
84
+ if (models.status !== 200) throw new Error(`authorized models returned ${models.status}`);
85
+ const payload = await models.json();
86
+ if (payload?.data?.[0]?.id !== "test-model") throw new Error("model payload did not include configured model");
87
+ const chat = await fetch(`${base}/v1/chat/completions`, {
88
+ method: "POST",
89
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
90
+ body: JSON.stringify({ model: "test-model", messages: [] }),
91
+ });
92
+ if (chat.status !== 503) throw new Error(`missing upstream key should return 503, got ${chat.status}`);
93
+ } finally {
94
+ await api.close();
95
+ }
96
+ }
@@ -54,6 +54,7 @@ function serviceSpec({ workspace, stateRoot, entryScript, policy = {} }) {
54
54
  allowWrite: policy.allowWrite !== false,
55
55
  allowExec: policy.allowExec !== false,
56
56
  minimalEnv: policy.minimalEnv !== false,
57
+ apiEnabled: policy.apiEnabled !== false,
57
58
  },
58
59
  };
59
60
  }
@@ -71,6 +72,7 @@ function daemonArgs(spec) {
71
72
  if (spec.policy.allowWrite === false) args.push("--no-write");
72
73
  if (spec.policy.allowExec === false) args.push("--no-exec");
73
74
  if (spec.policy.minimalEnv === false) args.push("--full-env");
75
+ if (spec.policy.apiEnabled === false) args.push("--no-api");
74
76
  return args;
75
77
  }
76
78
 
@@ -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.1";
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;