machine-bridge-mcp 0.1.0 → 0.1.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
@@ -38,6 +38,14 @@ MCP connection password: mcp_password_...
38
38
 
39
39
  Keep the foreground process running for the current session. The installed autostart entry keeps the daemon available after future logins.
40
40
 
41
+ The command is safe to run repeatedly:
42
+
43
+ ```zsh
44
+ npm install -g machine-bridge-mcp@latest && machine-mcp
45
+ ```
46
+
47
+ On repeat runs, the CLI reuses existing state and secrets unless you request rotation, skips Worker redeploys when the deployed Worker is healthy and unchanged, refreshes the autostart entry, stops any currently loaded autostart daemon before starting the foreground daemon, and refuses to start a second daemon for the same workspace if another foreground instance is already running.
48
+
41
49
  ## Re-select workspace
42
50
 
43
51
  ```zsh
@@ -80,7 +88,7 @@ machine-mcp service uninstall
80
88
  machine-mcp --no-autostart
81
89
  ```
82
90
 
83
- Autostart runs the daemon with `--daemon-only --no-print-credentials`, so service logs do not contain the MCP connection password. If you start with `--no-write`, `--no-exec`, or `--full-env`, those policy flags are preserved in the autostart entry.
91
+ Autostart runs the daemon with `--daemon-only --no-print-credentials`, so service logs do not contain the MCP connection password. If you start with `--no-write`, `--no-exec`, or `--full-env`, those policy flags are preserved in the autostart entry. macOS/Linux service definitions restart only on process failure; a normal duplicate-instance exit is not treated as a crash loop.
84
92
 
85
93
  ## Secrets rotation
86
94
 
@@ -157,7 +165,9 @@ Default state roots:
157
165
  - Linux with `XDG_STATE_HOME`: `$XDG_STATE_HOME/machine-bridge-mcp`
158
166
  - Windows: `%APPDATA%\machine-bridge-mcp`
159
167
 
160
- 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.
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.
169
+
170
+ 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.
161
171
 
162
172
  Override state root:
163
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "One-command hosted Remote MCP bridge to a local machine daemon.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "bin",
19
19
  "src/local",
20
20
  "src/worker/index.ts",
21
+ "scripts",
21
22
  "wrangler.jsonc",
22
23
  "tsconfig.json",
23
24
  "README.md",
@@ -27,6 +28,11 @@
27
28
  "start": "node bin/machine-mcp.mjs start",
28
29
  "doctor": "node bin/machine-mcp.mjs doctor",
29
30
  "self-test": "node src/local/self-test.mjs",
31
+ "version:sync": "node scripts/sync-worker-version.mjs",
32
+ "version:check": "node scripts/sync-worker-version.mjs --check",
33
+ "version": "node scripts/sync-worker-version.mjs && git add package.json package-lock.json src/worker/index.ts",
34
+ "prepack": "npm run version:check",
35
+ "prepublishOnly": "npm run check && npm run version:check",
30
36
  "worker:types": "wrangler types src/worker/worker-configuration.d.ts",
31
37
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
32
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",
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
+ const packagePath = path.join(repoRoot, "package.json");
9
+ const workerPath = path.join(repoRoot, "src", "worker", "index.ts");
10
+ const args = new Set(process.argv.slice(2));
11
+ const checkOnly = args.has("--check");
12
+
13
+ const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
14
+ const expected = String(pkg.version || "").trim();
15
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(expected)) {
16
+ fail(`package.json version is not a valid release version: ${JSON.stringify(expected)}`);
17
+ }
18
+
19
+ const source = readFileSync(workerPath, "utf8");
20
+ const pattern = /const SERVER_VERSION = "([^"]+)";/;
21
+ const match = source.match(pattern);
22
+ if (!match) fail("Could not find `const SERVER_VERSION = \"...\";` in src/worker/index.ts");
23
+
24
+ const current = match[1];
25
+ if (current === expected) {
26
+ console.log(`Worker version is in sync: ${expected}`);
27
+ process.exit(0);
28
+ }
29
+
30
+ if (checkOnly) {
31
+ fail(`Worker version mismatch: package.json=${expected}, src/worker/index.ts=${current}. Run npm run version:sync.`);
32
+ }
33
+
34
+ const updated = source.replace(pattern, `const SERVER_VERSION = "${expected}";`);
35
+ writeFileSync(workerPath, updated);
36
+ console.log(`Updated Worker version: ${current} -> ${expected}`);
37
+
38
+ function fail(message) {
39
+ console.error(message);
40
+ process.exit(1);
41
+ }
package/src/local/cli.mjs CHANGED
@@ -6,6 +6,7 @@ import readline from "node:readline/promises";
6
6
  import { LocalDaemon } from "./daemon.mjs";
7
7
  import { runWrangler } from "./shell.mjs";
8
8
  import {
9
+ acquireDaemonLock,
9
10
  appName,
10
11
  defaultStateRoot,
11
12
  ensureOwnerOnlyDir,
@@ -172,18 +173,35 @@ async function startCommand(args) {
172
173
  await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], policy: state.policy });
173
174
  }
174
175
 
175
- const daemon = new LocalDaemon({
176
- workerUrl: state.worker.url,
177
- secret: state.worker.daemonSecret,
178
- workspace,
179
- policy: state.policy,
180
- logger: structuredLogger(args.quiet),
181
- });
176
+ if (!args.daemonOnly) await stopAutostartBestEffort(Boolean(args.quiet));
182
177
 
183
- const waitForConnect = daemon.start();
184
- await waitForConnectWithNotice(waitForConnect, 20_000);
185
- printConnection(state, { json: Boolean(args.json), noPrintCredentials: Boolean(args.noPrintCredentials) });
186
- keepProcessAlive(daemon);
178
+ const lock = acquireDaemonLock(state);
179
+ if (!lock.acquired) {
180
+ if (!args.quiet) {
181
+ 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) });
184
+ }
185
+ return;
186
+ }
187
+
188
+ try {
189
+ const daemon = new LocalDaemon({
190
+ workerUrl: state.worker.url,
191
+ secret: state.worker.daemonSecret,
192
+ workspace,
193
+ policy: state.policy,
194
+ logger: structuredLogger(args.quiet),
195
+ });
196
+
197
+ 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);
201
+ } catch (error) {
202
+ lock.release();
203
+ throw error;
204
+ }
187
205
  }
188
206
 
189
207
  async function ensureWorker(state, args) {
@@ -357,14 +375,16 @@ function printConnection(state, { json = false, noPrintCredentials = false } = {
357
375
  console.log(`State: ${payload.state_path}\n`);
358
376
  }
359
377
 
360
- function keepProcessAlive(daemon) {
378
+ function keepProcessAlive(daemon, lock) {
361
379
  const stop = () => {
362
380
  console.log("Stopping daemon...");
363
381
  daemon.stop();
382
+ lock?.release?.();
364
383
  process.exit(0);
365
384
  };
366
385
  process.once("SIGINT", stop);
367
386
  process.once("SIGTERM", stop);
387
+ process.once("exit", () => lock?.release?.());
368
388
  setInterval(() => {}, 2 ** 31 - 1);
369
389
  }
370
390
 
@@ -449,6 +469,15 @@ async function installAutostartBestEffort({ workspace, stateRoot, entryScript, p
449
469
  }
450
470
  }
451
471
 
472
+ async function stopAutostartBestEffort(quiet = false) {
473
+ try {
474
+ const { stopAutostart } = await import("./service.mjs");
475
+ await stopAutostart({ logger: structuredLogger(true) });
476
+ } catch (error) {
477
+ if (!quiet) console.warn(`Autostart stop skipped: ${error.message}`);
478
+ }
479
+ }
480
+
452
481
  async function uninstallCommand(args) {
453
482
  const stateRoot = stateRootFromArgs(args);
454
483
  const deleteRemote = !args.keepWorker;
@@ -568,6 +597,7 @@ Start options:
568
597
  --rotate-secrets Rotate secrets before deploying
569
598
  --daemon-only Skip deploy and only connect daemon from existing state
570
599
  --no-autostart Do not install login autostart during start
600
+ --no-print-credentials Redact the MCP password in console output
571
601
  --no-write Disable write_file (default: write enabled)
572
602
  --no-exec Disable exec_command (default: exec enabled)
573
603
  --full-env Pass full parent environment to exec_command (default: minimal env)
@@ -2,7 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { daemonSelfTest } from "./daemon.mjs";
5
- import { ensureWorkerSecrets, loadState, previewSecret, redactState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
5
+ import { acquireDaemonLock, ensureWorkerSecrets, loadState, previewSecret, redactState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
6
6
 
7
7
  await daemonSelfTest();
8
8
  await stateSelfTest();
@@ -16,6 +16,19 @@ async function stateSelfTest() {
16
16
  if (selectedWorkspace(stateRoot) !== workspace) throw new Error("selected workspace was not persisted");
17
17
  const state = loadState(workspace, { stateDir: stateRoot });
18
18
  ensureWorkerSecrets(state, { rotateSecrets: true });
19
+ const lock = acquireDaemonLock(state);
20
+ if (!lock.acquired) throw new Error("first daemon lock acquisition failed");
21
+ try {
22
+ const duplicate = acquireDaemonLock(state);
23
+ if (duplicate.acquired) throw new Error("duplicate daemon lock acquisition should fail");
24
+ if (duplicate.owner?.pid !== process.pid) throw new Error("duplicate daemon lock owner was not reported");
25
+ } finally {
26
+ lock.release();
27
+ }
28
+ const relock = acquireDaemonLock(state);
29
+ if (!relock.acquired) throw new Error("daemon lock was not released");
30
+ relock.release();
31
+
19
32
  const redacted = redactState(state);
20
33
  if (redacted.worker.oauthPassword === state.worker.oauthPassword) throw new Error("oauthPassword was not redacted");
21
34
  if (redacted.worker.daemonSecret === state.worker.daemonSecret) throw new Error("daemonSecret was not redacted");
@@ -132,7 +132,10 @@ function launchdPlist({ args, stdout, stderr }) {
132
132
  ${args.map(arg => ` <string>${escapeXml(arg)}</string>`).join("\n")}
133
133
  </array>
134
134
  <key>RunAtLoad</key><true/>
135
- <key>KeepAlive</key><true/>
135
+ <key>KeepAlive</key>
136
+ <dict>
137
+ <key>SuccessfulExit</key><false/>
138
+ </dict>
136
139
  <key>StandardOutPath</key><string>${escapeXml(stdout)}</string>
137
140
  <key>StandardErrorPath</key><string>${escapeXml(stderr)}</string>
138
141
  </dict>
@@ -179,7 +182,7 @@ After=network-online.target
179
182
  [Service]
180
183
  Type=simple
181
184
  ExecStart=${execArgs}
182
- Restart=always
185
+ Restart=on-failure
183
186
  RestartSec=5
184
187
  StandardOutput=append:${spec.stdout}
185
188
  StandardError=append:${spec.stderr}
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, realpathSync, rmSync } from "node:fs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, chmodSync, realpathSync, rmSync, unlinkSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -89,7 +89,7 @@ export function loadState(workspace, options = {}) {
89
89
  ensureOwnerOnlyDir(profileDir);
90
90
  let state = {};
91
91
  if (existsSync(statePath)) {
92
- state = JSON.parse(readFileSync(statePath, "utf8"));
92
+ state = readJsonObjectOrBackup(statePath);
93
93
  }
94
94
  state.schemaVersion = 1;
95
95
  state.workspace = {
@@ -112,6 +112,93 @@ export function saveState(state) {
112
112
  ownerOnlyFile(statePath);
113
113
  }
114
114
 
115
+ export function daemonLockPathForState(state) {
116
+ const profileDir = state?.paths?.profileDir;
117
+ if (!profileDir) throw new Error("state profile dir is missing");
118
+ return path.join(profileDir, "daemon.lock");
119
+ }
120
+
121
+ export function acquireDaemonLock(state) {
122
+ const lockPath = daemonLockPathForState(state);
123
+ ensureOwnerOnlyDir(path.dirname(lockPath));
124
+ const token = randomBytes(16).toString("hex");
125
+ const payload = {
126
+ pid: process.pid,
127
+ token,
128
+ workspace: state?.workspace?.path || "",
129
+ startedAt: new Date().toISOString(),
130
+ entryScript: process.argv[1] || "",
131
+ };
132
+
133
+ for (let attempt = 0; attempt < 2; attempt += 1) {
134
+ let fd;
135
+ try {
136
+ fd = openSync(lockPath, "wx", 0o600);
137
+ writeFileSync(fd, JSON.stringify(payload, null, 2) + "\n");
138
+ closeSync(fd);
139
+ ownerOnlyFile(lockPath);
140
+ return {
141
+ acquired: true,
142
+ path: lockPath,
143
+ owner: payload,
144
+ release() {
145
+ releaseDaemonLock(lockPath, token);
146
+ },
147
+ };
148
+ } catch (error) {
149
+ if (fd !== undefined) {
150
+ try { closeSync(fd); } catch {}
151
+ }
152
+ if (error?.code !== "EEXIST") throw error;
153
+ const owner = readDaemonLockOwner(lockPath);
154
+ if (owner?.pid && isPidAlive(owner.pid)) {
155
+ return { acquired: false, path: lockPath, owner, release() {} };
156
+ }
157
+ try { unlinkSync(lockPath); } catch {}
158
+ }
159
+ }
160
+ const owner = readDaemonLockOwner(lockPath);
161
+ return { acquired: false, path: lockPath, owner, release() {} };
162
+ }
163
+
164
+ function releaseDaemonLock(lockPath, token) {
165
+ const owner = readDaemonLockOwner(lockPath);
166
+ if (owner?.token !== token) return;
167
+ try { unlinkSync(lockPath); } catch {}
168
+ }
169
+
170
+ export function readDaemonLockOwner(lockPath) {
171
+ try {
172
+ const parsed = JSON.parse(readFileSync(lockPath, "utf8"));
173
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ function isPidAlive(pid) {
180
+ const parsed = Number(pid);
181
+ if (!Number.isInteger(parsed) || parsed <= 0) return false;
182
+ try {
183
+ process.kill(parsed, 0);
184
+ return true;
185
+ } catch (error) {
186
+ return error?.code === "EPERM";
187
+ }
188
+ }
189
+
190
+ function readJsonObjectOrBackup(filePath) {
191
+ try {
192
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
193
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
194
+ } catch {
195
+ const backupPath = `${filePath}.corrupt-${Date.now()}`;
196
+ try { renameSync(filePath, backupPath); } catch {}
197
+ return {};
198
+ }
199
+ }
200
+
201
+
115
202
  export function ensureWorkerSecrets(state, options = {}) {
116
203
  state.worker ||= {};
117
204
  if (!state.worker.oauthPassword || options.rotateSecrets) state.worker.oauthPassword = randomToken("mcp_password");
@@ -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.0";
4
+ const SERVER_VERSION = "0.1.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;
@@ -353,7 +353,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
353
353
  tools: this.allTools().map((tool) => tool.name),
354
354
  };
355
355
  }
356
- if (workspaceTools.some((tool) => tool.name === name)) return this.callDaemonTool(name, args);
356
+ if (workspaceTools.some((tool) => tool.name === name)) {
357
+ if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
358
+ return this.callDaemonTool(name, args);
359
+ }
357
360
  throw new Error(`unknown tool: ${name}`);
358
361
  }
359
362
 
@@ -399,7 +402,23 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
399
402
  }
400
403
 
401
404
  private allTools(): Array<Record<string, unknown>> {
402
- return [serverInfoTool, ...workspaceTools].map((tool) => ({ ...tool }));
405
+ const advertised = this.daemonAdvertisedTools();
406
+ const localTools = advertised
407
+ ? workspaceTools.filter((tool) => advertised.has(tool.name))
408
+ : workspaceTools;
409
+ return [serverInfoTool, ...localTools].map((tool) => ({ ...tool }));
410
+ }
411
+
412
+ private daemonToolEnabled(name: string): boolean {
413
+ const advertised = this.daemonAdvertisedTools();
414
+ return !advertised || advertised.has(name);
415
+ }
416
+
417
+ private daemonAdvertisedTools(): Set<string> | null {
418
+ const socket = this.daemonSockets()[0];
419
+ const attachment = socket ? this.daemonAttachment(socket) : undefined;
420
+ if (!attachment?.tools?.length) return null;
421
+ return new Set(attachment.tools);
403
422
  }
404
423
 
405
424
  private daemonSockets(): WebSocket[] {
@@ -847,7 +866,6 @@ function validateOrigin(request: Request, base: string, configured = ""): boolea
847
866
  try {
848
867
  const parsed = new URL(origin);
849
868
  if (origin === base) return true;
850
- if (parsed.protocol === "https:") return true;
851
869
  return parsed.protocol === "http:" && isLoopbackHost(parsed.hostname);
852
870
  } catch {
853
871
  return false;