machine-bridge-mcp 0.2.0 → 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,22 +47,30 @@ 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.
48
53
 
49
54
  ## Local OpenAI-compatible API provider
50
55
 
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.
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
+ ```
52
63
 
53
- Start only the local API service:
64
+ Start only the local API service, without the Remote MCP daemon:
54
65
 
55
66
  ```zsh
56
67
  machine-mcp api
57
68
  ```
58
69
 
59
- Or run it together with the Remote MCP daemon:
70
+ Disable the default local API provider for a daemon run:
60
71
 
61
72
  ```zsh
62
- machine-mcp start --api
73
+ machine-mcp --no-api
63
74
  ```
64
75
 
65
76
  The CLI prints client settings like:
@@ -79,8 +90,8 @@ Use these values in the local AI client:
79
90
  If port `8765` conflicts with another local app, choose a different port explicitly:
80
91
 
81
92
  ```zsh
93
+ machine-mcp --api-port 8766
82
94
  machine-mcp api --api-port 8766
83
- machine-mcp start --api --api-port 8766
84
95
  ```
85
96
 
86
97
  `--port` is also accepted on the `api` command:
@@ -89,7 +100,7 @@ machine-mcp start --api --api-port 8766
89
100
  machine-mcp api --port 8766
90
101
  ```
91
102
 
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:
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:
93
104
 
94
105
  ```zsh
95
106
  machine-mcp api --rotate-api-key
@@ -237,7 +248,7 @@ Default state roots:
237
248
  - Linux with `XDG_STATE_HOME`: `$XDG_STATE_HOME/machine-bridge-mcp`
238
249
  - Windows: `%APPDATA%\machine-bridge-mcp`
239
250
 
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.
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.
241
252
 
242
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.
243
254
 
@@ -255,7 +266,7 @@ flowchart LR
255
266
  W --> DO["Durable Object broker"]
256
267
  D["Local daemon"] -- "outbound WebSocket" --> W
257
268
  D --> M["Local filesystem and shell"]
258
- API["Optional local /v1 API"] -- "OpenAI-compatible HTTP" --> U["Configured upstream provider"]
269
+ API["Default local /v1 API"] -- "OpenAI-compatible HTTP" --> U["Configured upstream provider"]
259
270
  CLI["machine-mcp CLI"] --> API
260
271
  CLI["machine-mcp CLI"] --> W
261
272
  CLI --> D
@@ -269,8 +280,8 @@ Why this architecture:
269
280
  - The public MCP URL is stable after deployment.
270
281
  - The Worker stores OAuth client/code/token metadata and relays tool calls.
271
282
  - 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.
273
- - 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.
274
285
 
275
286
  ## Development
276
287
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.2.0",
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",
@@ -1,5 +1,5 @@
1
1
  import http from "node:http";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { Readable } from "node:stream";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import { createLogger } from "./log.mjs";
@@ -53,7 +53,7 @@ export function normalizeBaseUrl(value, fallback = DEFAULT_UPSTREAM_URL) {
53
53
  try {
54
54
  parsed = new URL(raw);
55
55
  } catch {
56
- throw new Error(`Invalid upstream API URL: ${raw}`);
56
+ throw new Error("Invalid upstream API URL.");
57
57
  }
58
58
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid upstream API URL protocol: ${parsed.protocol}`);
59
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.");
@@ -121,7 +121,7 @@ async function handleRequest(req, res, context) {
121
121
 
122
122
  try {
123
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" });
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
125
 
126
126
  if (!isAuthorized(req, context.apiKey)) return sendOpenAiError(res, 401, "invalid_api_key", "Missing or invalid local API key.");
127
127
 
@@ -203,6 +203,10 @@ function readBody(req, maxBytes) {
203
203
  });
204
204
  }
205
205
 
206
+ function sha256String(value) {
207
+ return createHash("sha256").update(String(value)).digest("hex");
208
+ }
209
+
206
210
  function isAuthorized(req, expectedKey) {
207
211
  const auth = String(req.headers.authorization || "");
208
212
  if (auth.startsWith("Bearer ") && auth.slice(7) === expectedKey) return true;
@@ -247,10 +251,10 @@ function setCorsHeaders(res) {
247
251
 
248
252
  function withPortHint(error, port) {
249
253
  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>\`.`;
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>\`.`;
251
255
  }
252
256
  if (error?.code === "EACCES") {
253
- error.message = `Local API port ${port} is not permitted. Re-run with \`machine-mcp api --api-port <free_port>\`.`;
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>\`.`;
254
258
  }
255
259
  return error;
256
260
  }
package/src/local/cli.mjs CHANGED
@@ -159,16 +159,21 @@ async function confirm(prompt, assumeYes = false) {
159
159
 
160
160
  async function startCommand(args) {
161
161
  assertNodeVersion();
162
- const logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "cli" });
162
+ const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "cli" });
163
163
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
164
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
+
165
169
  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) });
170
+ if (apiEnabled) configureLocalApiState(state, args);
167
171
  state.policy = {
168
172
  allowWrite: args.noWrite ? false : true,
169
173
  allowExec: args.noExec ? false : true,
170
174
  unrestrictedPaths: true,
171
175
  minimalEnv: args.fullEnv ? false : true,
176
+ apiEnabled,
172
177
  updatedAt: new Date().toISOString(),
173
178
  };
174
179
  saveState(state);
@@ -176,6 +181,9 @@ async function startCommand(args) {
176
181
  if (!args.daemonOnly) await ensureWorker(state, args);
177
182
  else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
178
183
 
184
+ const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
185
+ const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
186
+
179
187
  if (!args.daemonOnly && !args.noAutostart) {
180
188
  await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], policy: state.policy });
181
189
  }
@@ -183,38 +191,55 @@ async function startCommand(args) {
183
191
  if (!args.daemonOnly) await stopAutostartBestEffort(Boolean(args.quiet));
184
192
 
185
193
  const lock = acquireDaemonLock(state);
194
+ let daemon = null;
186
195
  let apiServer = null;
187
196
  if (!lock.acquired) {
188
197
  if (!args.quiet) {
189
198
  const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
190
199
  logger.warn(`local daemon already running for this workspace (${pid}); not starting a duplicate`);
191
- printConnection(state, { json: Boolean(args.json), noPrintCredentials: Boolean(args.noPrintCredentials) });
200
+ if (!args.json) printMcpConnection(state, {
201
+ noPrintCredentials: Boolean(args.noPrintCredentials),
202
+ includeCredentials: shouldPrintMcpCredentials,
203
+ quiet: Boolean(args.quiet),
204
+ });
205
+ }
206
+ if (!apiEnabled) {
207
+ if (args.json) printStartJson(state, null, { noPrintCredentials: Boolean(args.noPrintCredentials) });
208
+ return;
192
209
  }
193
- if (!args.api) return;
194
- apiServer = await startConfiguredApiServer(state, args);
195
- printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
196
- keepProcessAlive({ apiServer, logger });
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 });
197
214
  return;
198
215
  }
199
216
 
200
217
  try {
201
- const daemon = new LocalDaemon({
218
+ daemon = new LocalDaemon({
202
219
  workerUrl: state.worker.url,
203
220
  secret: state.worker.daemonSecret,
204
221
  workspace,
205
222
  policy: state.policy,
206
- logger: createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "daemon" }),
223
+ logger: createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "daemon" }),
207
224
  });
208
225
 
209
226
  const waitForConnect = daemon.start();
210
- await waitForConnectWithNotice(waitForConnect, 20_000);
211
- if (args.api) apiServer = await startConfiguredApiServer(state, args);
212
- printConnection(state, { json: Boolean(args.json), noPrintCredentials: Boolean(args.noPrintCredentials) });
213
- if (apiServer) printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
214
- keepProcessAlive({ daemon, lock, apiServer, logger });
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 });
215
239
  } catch (error) {
240
+ try { daemon?.stop?.(); } catch {}
216
241
  lock.release();
217
- if (apiServer) await apiServer.close().catch(() => {});
242
+ if (apiServer && !apiServer.alreadyRunning) await apiServer.close().catch(() => {});
218
243
  throw error;
219
244
  }
220
245
  }
@@ -223,33 +248,97 @@ async function apiCommand(args) {
223
248
  assertNodeVersion();
224
249
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
225
250
  const state = loadState(workspace, { stateDir: args.stateDir });
226
- ensureLocalApiKey(state, { apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY"), rotateApiKey: Boolean(args.rotateApiKey) });
251
+ configureLocalApiState(state, args);
227
252
  saveState(state);
228
- const logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "api" });
253
+ const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" });
229
254
  const apiServer = await startConfiguredApiServer(state, args, logger);
230
- printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
255
+ if (args.json) printApiJson(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
256
+ else printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials), quiet: Boolean(args.quiet) });
231
257
  keepProcessAlive({ apiServer, logger });
232
258
  }
233
259
 
234
- async function startConfiguredApiServer(state, args, logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "api" })) {
260
+ async function startConfiguredApiServer(state, args, logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" })) {
235
261
  const apiOptions = apiOptionsFromArgs(state, args);
236
262
  return startLocalApiServer({ ...apiOptions, logger });
237
263
  }
238
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
+
239
306
  function apiOptionsFromArgs(state, args = {}) {
240
307
  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);
308
+ const explicitPort = explicitArg(args.apiPort) ?? explicitArg(args.port);
242
309
  const envPort = valueFromArgsEnv(undefined, "MBM_API_PORT", "PORT");
243
310
  return {
244
- host: valueFromArgsEnv(args.apiHost, "MBM_API_HOST") || DEFAULT_API_HOST,
245
- port: explicitPort ?? envPort ?? DEFAULT_API_PORT,
311
+ host: valueFromArgsEnv(args.apiHost, "MBM_API_HOST") || state.localApi?.host || DEFAULT_API_HOST,
312
+ port: explicitPort ?? envPort ?? state.localApi?.port ?? DEFAULT_API_PORT,
246
313
  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,
314
+ upstreamUrl: valueFromArgsEnv(args.apiUpstreamUrl, "MBM_API_UPSTREAM_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE") || state.localApi?.upstreamUrl || DEFAULT_UPSTREAM_URL,
248
315
  upstreamKey: upstreamKey || "",
249
- model: valueFromArgsEnv(args.apiModel, "MBM_API_MODEL", "OPENAI_MODEL") || DEFAULT_API_MODEL,
316
+ model: valueFromArgsEnv(args.apiModel, "MBM_API_MODEL", "OPENAI_MODEL") || state.localApi?.model || DEFAULT_API_MODEL,
250
317
  };
251
318
  }
252
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
+
253
342
  function valueFromArgsEnv(argValue, ...envNames) {
254
343
  if (argValue !== undefined && argValue !== null && argValue !== true) return String(argValue);
255
344
  for (const name of envNames) {
@@ -259,7 +348,7 @@ function valueFromArgsEnv(argValue, ...envNames) {
259
348
  }
260
349
 
261
350
  async function ensureWorker(state, args) {
262
- const logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "worker" });
351
+ const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "worker" });
263
352
  const desiredHash = workerDeployHash(state);
264
353
  const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.oauthPassword && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
265
354
  if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
@@ -339,14 +428,22 @@ function workerDeployHash(state) {
339
428
  hash.update(String(state.worker.oauthTokenVersion || ""));
340
429
  for (const file of workerDeployHashFiles()) {
341
430
  hash.update(path.relative(packageRoot, file));
342
- hash.update(readFileSync(file));
431
+ hash.update(workerHashContent(file));
343
432
  }
344
433
  return hash.digest("hex");
345
434
  }
346
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
+
347
444
  function workerDeployHashFiles() {
348
445
  const files = [];
349
- 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"]) {
350
447
  collectHashFiles(resolve(packageRoot, item), files);
351
448
  }
352
449
  return files.sort();
@@ -396,18 +493,53 @@ function extractWorkerUrl(text = "") {
396
493
  return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
397
494
  }
398
495
 
399
- async function waitForConnectWithNotice(promise, timeoutMs) {
496
+ async function waitForConnectWithNotice(promise, timeoutMs, quiet = false) {
400
497
  let timeout;
401
498
  const timed = new Promise(resolvePromise => {
402
499
  timeout = setTimeout(() => resolvePromise("timeout"), timeoutMs);
403
500
  });
404
501
  const result = await Promise.race([promise.then(() => "connected"), timed]);
405
502
  clearTimeout(timeout);
406
- if (result === "timeout") createLogger({ component: "daemon" }).warn("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
+ });
407
539
  }
408
540
 
409
- function printConnection(state, { json = false, noPrintCredentials = false } = {}) {
410
- const logger = createLogger({ component: "ready" });
541
+ function printMcpConnection(state, { json = false, noPrintCredentials = false, includeCredentials = false, quiet = false } = {}) {
542
+ const logger = createLogger({ component: "ready", quiet });
411
543
  const payload = {
412
544
  mcp_server_url: state.worker.mcpServerUrl,
413
545
  mcp_connection_password: state.worker.oauthPassword,
@@ -422,17 +554,22 @@ function printConnection(state, { json = false, noPrintCredentials = false } = {
422
554
  logger.json(safePayload);
423
555
  return;
424
556
  }
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)`);
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
+ }
429
566
  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"}`);
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"}`);
431
568
  logger.plain(` State: ${payload.state_path}`);
432
569
  }
433
570
 
434
- function printApiConnection(apiServer, state, { noPrintCredentials = false } = {}) {
435
- const logger = createLogger({ component: "ready" });
571
+ function printApiConnection(apiServer, state, { noPrintCredentials = false, quiet = false } = {}) {
572
+ const logger = createLogger({ component: "ready", quiet });
436
573
  logger.success("Local model API provider is ready");
437
574
  logger.plain(` API Base URL: ${apiServer.baseUrl}`);
438
575
  logger.plain(` API key: ${noPrintCredentials ? `${redactSecret(state.localApi.apiKey)} (redacted)` : state.localApi.apiKey}`);
@@ -640,7 +777,7 @@ Usage:
640
777
  .\\mbm.cmd # from source checkout on Windows cmd
641
778
 
642
779
  Commands:
643
- start Deploy/update Worker, install autostart, start local daemon
780
+ start Deploy/update Worker, install autostart, start daemon and local API
644
781
  api Start local OpenAI-compatible API provider only
645
782
  workspace show Show remembered workspace
646
783
  workspace set Re-select workspace; prompts with current/default path
@@ -661,13 +798,15 @@ Start options:
661
798
  --rotate-secrets Rotate secrets before deploying
662
799
  --daemon-only Skip deploy and only connect daemon from existing state
663
800
  --no-autostart Do not install login autostart during start
664
- --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
665
803
  --no-write Disable write_file (default: write enabled)
666
804
  --no-exec Disable exec_command (default: exec enabled)
667
805
  --full-env Pass full parent environment to exec_command (default: minimal env)
668
806
  --state-dir DIR Override state root
669
- --json Print connection details as JSON
670
- --api Also start local OpenAI-compatible API provider
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
671
810
  --api-port PORT Local API port (default: 8765)
672
811
  --port PORT Alias for --api-port on the api command
673
812
  --api-host HOST Local API host (default: 127.0.0.1)
@@ -687,9 +826,10 @@ function apiUsage() {
687
826
  console.log(`machine-bridge-mcp local API provider
688
827
 
689
828
  Usage:
690
- machine-mcp api
829
+ machine-mcp
830
+ machine-mcp --api-port 8766
831
+ machine-mcp api # API provider only
691
832
  machine-mcp api --api-port 8766
692
- machine-mcp start --api --api-port 8766
693
833
 
694
834
  Client settings:
695
835
  API Base URL: http://127.0.0.1:<port>/v1
@@ -707,6 +847,7 @@ Options:
707
847
  --api-upstream-key KEY Upstream provider key; env OPENAI_API_KEY also works
708
848
  --api-model MODEL Model id advertised by /v1/models
709
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
710
851
  --state-dir DIR Override state root
711
852
 
712
853
  Environment:
@@ -1,3 +1,4 @@
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";
@@ -74,6 +75,9 @@ async function apiSelfTest() {
74
75
  try {
75
76
  const health = await fetch(`${base}/health`);
76
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");
77
81
  const unauth = await fetch(`${base}/v1/models`);
78
82
  if (unauth.status !== 401) throw new Error(`unauthorized models returned ${unauth.status}`);
79
83
  const models = await fetch(`${base}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
@@ -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
 
@@ -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.2.0";
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;