machine-bridge-mcp 0.3.3 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ # Operations
2
+
3
+ ## Health and diagnosis
4
+
5
+ ```sh
6
+ machine-mcp status
7
+ machine-mcp doctor
8
+ machine-mcp service status
9
+ ```
10
+
11
+ `status` prints redacted profile state and verifies the deployed Worker version. `doctor` checks Node.js, the package-installed Wrangler binary, Cloudflare login, and Worker health. Public `/healthz` output contains only server identity and version; daemon details require an authenticated `server_info` call.
12
+
13
+ ## Logs
14
+
15
+ Remote autostart logs are stored under the state root in `logs/daemon.out.log` and `logs/daemon.err.log`. Files are owner-only where supported and tail-trimmed before daemon startup.
16
+
17
+ Structured operational events may include:
18
+
19
+ - component and severity;
20
+ - tool name;
21
+ - shortened random call identifier;
22
+ - duration and success/failure;
23
+ - coarse error class;
24
+ - connection/reconnect status.
25
+
26
+ They intentionally omit:
27
+
28
+ - file contents and image data;
29
+ - write, edit, or patch payloads;
30
+ - command argv, shell text, stdin, stdout, and stderr;
31
+ - MCP connection passwords, daemon secrets, authorization codes, and access tokens;
32
+ - full request bodies.
33
+
34
+ Unexpected Worker failures log endpoint path and error class rather than raw exception messages. Cloudflare observability is sampled and is not a complete audit log.
35
+
36
+ ## Reconnect and replacement
37
+
38
+ The daemon sends heartbeats and reconnects with bounded exponential backoff and jitter. A new socket remains a candidate until it authenticates and sends a valid `hello`; only then does it replace the previous daemon.
39
+
40
+ Pending calls are bound to the socket that received them. Results from another socket are ignored. A lost or replaced socket rejects only its own pending calls and terminates locally tracked child process trees. Process sessions are in-memory and do not survive daemon restart or replacement.
41
+
42
+ ## Limits
43
+
44
+ Defense-in-depth limits include:
45
+
46
+ - Worker MCP body: 8 MiB by default, hard cap 16 MiB;
47
+ - OAuth body: 64 KiB;
48
+ - daemon WebSocket message: 8 MiB;
49
+ - text writes and patch envelopes: 5 MiB;
50
+ - images: 4 MiB before base64 encoding;
51
+ - shell/argv envelope: 64 KiB;
52
+ - captured one-shot output: 512 KiB per stream by default;
53
+ - process-session retained output: 1 MiB per stream, with lossless base64 fallback for non-UTF-8 slices;
54
+ - process sessions: 8 retained per runtime;
55
+ - process stdin write: 64 KiB per call;
56
+ - local simultaneous tool calls: 16;
57
+ - Worker pending daemon calls: 32;
58
+ - command timeout: 1–600 seconds;
59
+ - process-session read wait: at most 30 seconds;
60
+ - direct directory result: 10,000 entries and 4 MiB of path metadata;
61
+ - recursive walk: 200,000 visited entries.
62
+
63
+ ## Upgrade behavior
64
+
65
+ Version 0.4.1 changes the default for newly selected workspaces to the maximum-permission `full` profile. Existing state retains its saved policy. To intentionally migrate an older workspace to the new default:
66
+
67
+ ```sh
68
+ machine-mcp --workspace /path/to/project --profile full
69
+ ```
70
+
71
+ `full` enables writes, direct processes, process sessions, shell execution, unrestricted direct filesystem paths, absolute path output, and the complete parent environment. Use `agent`, `edit`, or `review` when that authority is unnecessary.
72
+
73
+ A remote policy change is saved locally, propagated in the daemon handshake, and persisted in the autostart definition.
74
+
75
+ ## Incident response
76
+
77
+ After suspected credential or client compromise:
78
+
79
+ 1. stop foreground and autostart daemons;
80
+ 2. run `machine-mcp rotate-secrets --no-print-credentials`;
81
+ 3. restart without broad flags and redeploy;
82
+ 4. inspect Cloudflare account access, Worker configuration, local state permissions, and service logs;
83
+ 5. remove the Worker and local state if continued remote access is unnecessary.
@@ -0,0 +1,62 @@
1
+ # Release process
2
+
3
+ The release invariant is:
4
+
5
+ - `main` points to the release commit.
6
+ - `v<package version>` points to that same commit locally and on GitHub.
7
+ - A final GitHub Release exists for the tag.
8
+ - The GitHub Release contains the npm tarball generated from that commit.
9
+ - `package.json`, `package-lock.json`, and the Worker-reported version agree.
10
+
11
+ `npm publish` runs `release:check` through `prepublishOnly`, so npm publication is blocked until all GitHub state is synchronized.
12
+
13
+ ## Prepare a version
14
+
15
+ 1. Set the new version without creating an automatic npm tag:
16
+
17
+ ```sh
18
+ npm version <version> --no-git-tag-version
19
+ ```
20
+
21
+ 2. Add the matching `CHANGELOG.md` section.
22
+ 3. Run `npm run check`, review the diff, and commit all release changes to `main`.
23
+
24
+ ## Publish GitHub source and release
25
+
26
+ From a clean `main` worktree:
27
+
28
+ ```sh
29
+ npm run release:publish
30
+ ```
31
+
32
+ The command validates the project, fast-forwards `origin/main`, creates or verifies the annotated version tag, pushes it, builds the npm tarball, creates or updates the GitHub Release, uploads the tarball, and verifies the resulting state.
33
+
34
+ To verify without changing anything:
35
+
36
+ ```sh
37
+ npm run release:check
38
+ ```
39
+
40
+ To create GitHub Release records for existing remote version tags that lack releases:
41
+
42
+ ```sh
43
+ npm run release:backfill
44
+ ```
45
+
46
+ ## Publish npm
47
+
48
+ Only after `release:check` succeeds:
49
+
50
+ ```sh
51
+ npm publish --access public
52
+ ```
53
+
54
+ The npm lifecycle repeats the full project checks and the GitHub synchronization check before upload.
55
+
56
+ ## Authentication requirements
57
+
58
+ - Git push access to `origin`.
59
+ - An authenticated GitHub CLI session with repository release permission.
60
+ - An npm account that owns the package or has maintainer permission.
61
+
62
+ GitHub Actions is intentionally not required by this release path. This avoids coupling releases to an OAuth token with the separate `workflow` scope while retaining fail-closed synchronization.
@@ -0,0 +1,51 @@
1
+ # Testing strategy
2
+
3
+ The project treats transport, authorization, local authority, and state removal as separate failure domains.
4
+
5
+ ## Required suite
6
+
7
+ ```sh
8
+ npm run check
9
+ ```
10
+
11
+ The suite includes:
12
+
13
+ - generated Cloudflare Worker types and strict TypeScript checking;
14
+ - syntax validation for every shipped JavaScript entry point;
15
+ - shared tool-catalog schema, annotation, and profile-inventory checks;
16
+ - canonical path and symbolic-link escape tests;
17
+ - relative-path privacy and error-path redaction tests;
18
+ - atomic create/update, optimistic hash, exact edit, and patch transaction tests;
19
+ - patch ambiguity, canonical collision, rollback, move, and delete tests;
20
+ - UTF-8 and binary handling;
21
+ - image content formatting;
22
+ - nested Git repository detection and helper suppression;
23
+ - author-email privacy in `git_log`;
24
+ - isolated command HOME/temp/cache behavior;
25
+ - one-shot timeout, descendant termination, cancellation, and process-session interaction;
26
+ - daemon/startup locking and state corruption recovery;
27
+ - guarded state-root removal and legacy-state migration;
28
+ - log redaction, control-character handling, and service-log trimming;
29
+ - CLI parsing, policy profiles, and client configuration boundaries;
30
+ - live stdio MCP initialization, discovery, calls, rich content, sessions, cancellation, and continued responsiveness;
31
+ - live local Worker OAuth registration, consent, PKCE, token replay rejection, throttling, CORS, protocol negotiation, dynamic tool advertisement, rich content, daemon replacement, and cancellation.
32
+
33
+ ## Additional release checks
34
+
35
+ ```sh
36
+ npm run worker:dry-run
37
+ npm audit --omit=dev --audit-level=high
38
+ npm pack --dry-run
39
+ npm run version:check
40
+ ```
41
+
42
+ GitHub Actions executes the main suite on Linux, macOS, and Windows. Node 22 and 24 are covered on Linux; Node 22 is covered on macOS and Windows. A separate package-audit job runs production dependency auditing and a dry-run package build. Dependency and GitHub Actions updates are monitored by Dependabot.
43
+
44
+ ## Test design rules
45
+
46
+ - Tests should exercise the public boundary rather than only helper functions when practical.
47
+ - Every permission-expanding feature needs a denial test.
48
+ - Every bounded resource needs an over-limit test.
49
+ - Every multi-stage mutation needs a no-partial-commit test.
50
+ - Every remote call correlation change needs daemon replacement and cancellation coverage.
51
+ - Logs and public metadata should be tested for absence of sensitive fields, not only presence of expected fields.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.3.3",
4
- "description": "One-command hosted Remote MCP bridge to a local machine daemon.",
3
+ "version": "0.4.2",
4
+ "description": "Cross-client MCP bridge for policy-controlled local files, Git, images, patches, and processes over stdio or OAuth-protected remote relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": {
@@ -17,6 +17,7 @@
17
17
  "mbm.cmd",
18
18
  "bin",
19
19
  "src/local",
20
+ "src/shared",
20
21
  "src/worker/index.ts",
21
22
  "scripts/sync-worker-version.mjs",
22
23
  "wrangler.jsonc",
@@ -30,18 +31,23 @@
30
31
  "scripts": {
31
32
  "start": "node bin/machine-mcp.mjs start",
32
33
  "doctor": "node bin/machine-mcp.mjs doctor",
33
- "self-test": "node src/local/self-test.mjs",
34
+ "self-test": "node tests/local-self-test.mjs",
34
35
  "version:sync": "node scripts/sync-worker-version.mjs",
35
36
  "version:check": "node scripts/sync-worker-version.mjs --check",
36
37
  "version": "node scripts/sync-worker-version.mjs && git add package.json package-lock.json src/worker/index.ts",
37
38
  "prepack": "npm run version:check",
38
- "prepublishOnly": "npm run check && npm run version:check",
39
+ "prepublishOnly": "npm run check && npm run version:check && npm run release:check",
39
40
  "worker:types": "wrangler types src/worker/worker-configuration.d.ts",
40
41
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
41
- "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/log.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/worker-integration-test.mjs",
42
- "check": "npm run typecheck && npm run syntax && npm run self-test && npm run worker:integration-test",
42
+ "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/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/local-self-test.mjs && node --check tests/daemon-self-test.mjs",
43
+ "check": "npm run typecheck && npm run syntax && npm run catalog:test && npm run self-test && npm run stdio:integration-test && npm run worker:integration-test",
43
44
  "worker:dry-run": "wrangler deploy --dry-run",
44
- "worker:integration-test": "node scripts/worker-integration-test.mjs"
45
+ "worker:integration-test": "node tests/worker-integration-test.mjs",
46
+ "release:check": "node scripts/github-release.mjs --check",
47
+ "release:publish": "node scripts/github-release.mjs --publish",
48
+ "release:backfill": "node scripts/github-release.mjs --backfill",
49
+ "stdio:integration-test": "node tests/stdio-integration-test.mjs",
50
+ "catalog:test": "node tests/catalog-test.mjs"
45
51
  },
46
52
  "dependencies": {
47
53
  "wrangler": "^4.110.0",
@@ -57,7 +63,11 @@
57
63
  "cloudflare-workers",
58
64
  "durable-objects",
59
65
  "wrangler",
60
- "local-machine"
66
+ "local-machine",
67
+ "stdio",
68
+ "coding-agent",
69
+ "oauth",
70
+ "cross-platform"
61
71
  ],
62
72
  "repository": {
63
73
  "type": "git",
package/src/local/cli.mjs CHANGED
@@ -4,6 +4,8 @@ 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 { runStdioServer } from "./stdio.mjs";
8
+ import { normalizePolicy } from "./tools.mjs";
7
9
  import { createLogger, sanitizeLogText } from "./log.mjs";
8
10
  import { runWrangler } from "./shell.mjs";
9
11
  import {
@@ -34,11 +36,11 @@ import {
34
36
  const BOOLEAN_OPTIONS = new Set([
35
37
  "help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
36
38
  "daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
37
- "printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths",
39
+ "printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
38
40
  "yes", "keepWorker", "noApi", "api", "rotateApiKey",
39
41
  ]);
40
42
  const VALUE_OPTIONS = new Set([
41
- "workspace", "stateDir", "workerName", "apiPort", "apiHost", "apiKey", "apiModel", "port",
43
+ "workspace", "stateDir", "workerName", "profile", "execMode", "client", "apiPort", "apiHost", "apiKey", "apiModel", "port",
42
44
  ]);
43
45
 
44
46
  export async function main(argv = process.argv.slice(2)) {
@@ -52,6 +54,8 @@ export async function main(argv = process.argv.slice(2)) {
52
54
 
53
55
  switch (command) {
54
56
  case "start": return startCommand(args);
57
+ case "stdio": return stdioCommand(args);
58
+ case "client-config": return clientConfigCommand(args);
55
59
  case "status": return statusCommand(args);
56
60
  case "doctor": return doctorCommand(args);
57
61
  case "workspace": return workspaceCommand(args);
@@ -70,9 +74,11 @@ const COMMAND_OPTIONS = {
70
74
  start: new Set([
71
75
  "workspace", "stateDir", "workerName", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
72
76
  "daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials", "printCredentials",
73
- "noWrite", "noExec", "fullEnv", "unrestrictedPaths",
77
+ "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
74
78
  "noApi", "api", "apiPort", "apiHost", "apiKey", "rotateApiKey", "apiModel", "port",
75
79
  ]),
80
+ stdio: new Set(["workspace", "stateDir", "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths", "verbose"]),
81
+ "client-config": new Set(["workspace", "stateDir", "profile", "client", "json"]),
76
82
  status: new Set(["workspace", "stateDir"]),
77
83
  doctor: new Set(["workspace", "stateDir"]),
78
84
  "rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "quiet"]),
@@ -99,7 +105,7 @@ export function validatePositionals(command, args) {
99
105
  const count = args._.length;
100
106
  const conflict = Boolean(args.workspace) && count > (command === "workspace" || command === "service" || command === "autostart" ? 1 : 0);
101
107
  if (conflict) throw new Error("workspace path was provided both positionally and with --workspace");
102
- if (["start", "status", "doctor", "rotate-secrets"].includes(command)) {
108
+ if (["start", "stdio", "status", "doctor", "rotate-secrets"].includes(command)) {
103
109
  if (count > 1) throw new Error(`${command} accepts at most one positional workspace path`);
104
110
  if (args.workspace && count) throw new Error("workspace path was provided both positionally and with --workspace");
105
111
  return;
@@ -118,6 +124,10 @@ export function validatePositionals(command, args) {
118
124
  if (args.workspace && count > 1) throw new Error("workspace path was provided both positionally and with --workspace");
119
125
  return;
120
126
  }
127
+ if (command === "client-config") {
128
+ if (count > 1) throw new Error("client-config accepts at most one positional client name");
129
+ return;
130
+ }
121
131
  if (command === "uninstall" && count) throw new Error("uninstall does not accept positional arguments");
122
132
  }
123
133
 
@@ -255,60 +265,65 @@ async function startCommand(args) {
255
265
  const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
256
266
  throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
257
267
  }
268
+
258
269
  try {
259
270
  if (args.daemonOnly) {
260
271
  const { trimAutostartLogs } = await import("./service.mjs");
261
272
  trimAutostartLogs(state.paths.stateRoot);
273
+ } else {
274
+ // Stop an installed service before acquiring the runtime lock. If a
275
+ // foreground daemon owns the lock, no new policy or secret state is saved.
276
+ await stopAutostartBestEffort(Boolean(args.quiet));
262
277
  }
263
- const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
264
- const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
265
-
266
- const workerName = validateWorkerName(args.workerName);
267
- ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
268
- state.policy = {
269
- allowWrite: args.noWrite ? false : true,
270
- allowExec: args.noExec ? false : true,
271
- unrestrictedPaths: Boolean(args.unrestrictedPaths),
272
- minimalEnv: args.fullEnv ? false : true,
273
- updatedAt: new Date().toISOString(),
274
- };
275
- saveState(state);
276
-
277
- if (!args.daemonOnly) await ensureWorker(state, args);
278
- else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
279
-
280
- const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
281
- const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
282
-
283
- if (!args.daemonOnly && !args.noAutostart) {
284
- await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], policy: state.policy });
285
- }
286
-
287
- if (!args.daemonOnly) await stopAutostartBestEffort(Boolean(args.quiet));
288
278
 
289
279
  const lock = acquireDaemonLock(state);
290
- let daemon = null;
291
280
  if (!lock.acquired) {
292
- if (!args.quiet) {
293
- const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
294
- logger.warn(`local daemon already running for this workspace (${pid}); not starting a duplicate`);
295
- if (!args.json) printMcpConnection(state, {
296
- noPrintCredentials: Boolean(args.noPrintCredentials),
297
- includeCredentials: shouldPrintMcpCredentials,
298
- quiet: Boolean(args.quiet),
299
- });
300
- }
301
- if (args.json) printStartJson(state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
281
+ const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
282
+ logger.warn(`local daemon already running for this workspace (${pid}); requested changes were not applied`);
283
+ if (args.json) printStartJson(state, {
284
+ noPrintCredentials: Boolean(args.noPrintCredentials),
285
+ requestedChangesApplied: false,
286
+ notice: "local daemon already running; requested changes were not applied",
287
+ });
288
+ else printMcpConnection(state, {
289
+ noPrintCredentials: Boolean(args.noPrintCredentials),
290
+ includeCredentials: Boolean(args.printMcpCredentials || args.printCredentials),
291
+ quiet: Boolean(args.quiet),
292
+ });
302
293
  return;
303
294
  }
304
295
 
296
+ let daemon = null;
305
297
  try {
298
+ const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
299
+ const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
300
+ const workerName = validateWorkerName(args.workerName);
301
+ ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
302
+ state.policy = resolvePolicy(args, state.policy);
303
+ state.policy.updatedAt = new Date().toISOString();
304
+ saveState(state);
305
+
306
+ if (!args.daemonOnly) await ensureWorker(state, args);
307
+ else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
308
+
309
+ const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
310
+ const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
311
+
312
+ if (!args.daemonOnly && !args.noAutostart) {
313
+ await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1] });
314
+ }
315
+
306
316
  daemon = new LocalDaemon({
307
317
  workerUrl: state.worker.url,
308
318
  secret: state.worker.daemonSecret,
309
319
  workspace,
310
320
  policy: state.policy,
311
321
  logger: createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "daemon" }),
322
+ onSuperseded: () => {
323
+ logger.warn("this daemon was replaced by a newer authenticated instance; exiting without reconnecting");
324
+ lock.release();
325
+ process.exit(0);
326
+ },
312
327
  });
313
328
 
314
329
  const waitForConnect = daemon.start();
@@ -332,6 +347,85 @@ async function startCommand(args) {
332
347
  }
333
348
  }
334
349
 
350
+ const POLICY_PROFILES = Object.freeze({
351
+ review: Object.freeze({ profile: "review", allowWrite: false, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
352
+ edit: Object.freeze({ profile: "edit", allowWrite: true, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
353
+ agent: Object.freeze({ profile: "agent", allowWrite: true, execMode: "direct", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
354
+ full: Object.freeze({ profile: "full", allowWrite: true, execMode: "shell", unrestrictedPaths: true, minimalEnv: false, exposeAbsolutePaths: true }),
355
+ });
356
+
357
+ export function resolvePolicy(args = {}, stored = {}) {
358
+ const hasStored = stored && typeof stored === "object" && (
359
+ typeof stored.allowWrite === "boolean" || typeof stored.allowExec === "boolean" || typeof stored.execMode === "string"
360
+ );
361
+ const explicitKeys = ["profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
362
+ const hasExplicit = explicitKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key));
363
+ let base;
364
+ if (args.profile !== undefined) {
365
+ const profile = String(args.profile).trim().toLowerCase();
366
+ if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
367
+ base = { ...POLICY_PROFILES[profile] };
368
+ } else if (hasStored) {
369
+ base = normalizePolicy(stored);
370
+ } else {
371
+ base = { ...POLICY_PROFILES.full };
372
+ }
373
+
374
+ if (!hasExplicit) return normalizePolicy(base);
375
+ if (args.execMode !== undefined) {
376
+ const execMode = String(args.execMode).trim().toLowerCase();
377
+ if (!["off", "direct", "shell"].includes(execMode)) throw new Error("--exec-mode must be off, direct, or shell");
378
+ base.execMode = execMode;
379
+ }
380
+ if (args.noWrite === true) base.allowWrite = false;
381
+ if (args.noWrite === false) base.allowWrite = true;
382
+ if (args.noExec === true) base.execMode = "off";
383
+ if (args.noExec === false && base.execMode === "off") base.execMode = "direct";
384
+ if (args.fullEnv === true) base.minimalEnv = false;
385
+ if (args.fullEnv === false) base.minimalEnv = true;
386
+ if (args.unrestrictedPaths === true) base.unrestrictedPaths = true;
387
+ if (args.unrestrictedPaths === false) base.unrestrictedPaths = false;
388
+ if (args.absolutePaths === true) base.exposeAbsolutePaths = true;
389
+ if (args.absolutePaths === false) base.exposeAbsolutePaths = false;
390
+ const overrideKeys = ["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
391
+ if (args.profile === undefined || overrideKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key))) base.profile = "custom";
392
+ return normalizePolicy(base);
393
+ }
394
+
395
+ async function stdioCommand(args) {
396
+ assertNodeVersion();
397
+ const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
398
+ const state = loadState(workspace, { stateDir: args.stateDir });
399
+ const policy = resolvePolicy(args, state.policy);
400
+ await runStdioServer({ workspace, policy, verbose: Boolean(args.verbose) });
401
+ }
402
+
403
+ async function clientConfigCommand(args) {
404
+ const workspaceArgs = { ...args, _: [] };
405
+ const workspace = await chooseWorkspace(workspaceArgs, { promptOnFirstRun: false, save: false, allowPositional: false });
406
+ const requested = String(args.client || args._[0] || "all").trim().toLowerCase();
407
+ const profile = String(args.profile || "full").trim().toLowerCase();
408
+ if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
409
+ if (!["all", "claude", "cursor", "codex", "generic"].includes(requested)) throw new Error("client must be all, claude, cursor, codex, or generic");
410
+ const command = process.execPath;
411
+ const argsList = [resolve(process.argv[1]), "stdio", "--workspace", workspace, "--profile", profile];
412
+ const jsonConfig = { mcpServers: { "machine-bridge": { command, args: argsList } } };
413
+ const codex = `[mcp_servers.machine_bridge]\ncommand = ${JSON.stringify(command)}\nargs = ${JSON.stringify(argsList)}\n`;
414
+ if (args.json) {
415
+ console.log(JSON.stringify({ workspace, profile, claude: jsonConfig, cursor: jsonConfig, generic: jsonConfig, codex_toml: codex }, null, 2));
416
+ return;
417
+ }
418
+ if (["all", "claude", "cursor", "generic"].includes(requested)) {
419
+ console.log(`${requested === "all" ? "Claude Desktop / Cursor / generic stdio" : requested}:`);
420
+ console.log(JSON.stringify(jsonConfig, null, 2));
421
+ }
422
+ if (["all", "codex"].includes(requested)) {
423
+ if (requested === "all") console.log("");
424
+ console.log("Codex CLI:");
425
+ console.log(codex.trimEnd());
426
+ }
427
+ }
428
+
335
429
  function assertNoRemovedLocalApiOptions(args) {
336
430
  const removed = ["api", "noApi", "apiPort", "apiHost", "apiKey", "rotateApiKey", "apiModel", "port"].filter((key) => args[key] !== undefined);
337
431
  if (removed.length) throw removedLocalApiError();
@@ -438,7 +532,7 @@ function workerHashContent(file) {
438
532
 
439
533
  function workerDeployHashFiles() {
440
534
  const files = [];
441
- for (const item of ["src/worker", "wrangler.jsonc", "tsconfig.json"]) {
535
+ for (const item of ["src/worker", "src/shared", "wrangler.jsonc", "tsconfig.json"]) {
442
536
  collectHashFiles(resolve(packageRoot, item), files);
443
537
  }
444
538
  return files.sort();
@@ -500,7 +594,7 @@ async function waitForConnectWithNotice(promise, timeoutMs, quiet = false) {
500
594
  }
501
595
 
502
596
 
503
- function printStartJson(state, { noPrintCredentials = false } = {}) {
597
+ function printStartJson(state, { noPrintCredentials = false, requestedChangesApplied = true, notice = "" } = {}) {
504
598
  const mcpPassword = noPrintCredentials ? previewSecret(state.worker.oauthPassword) : state.worker.oauthPassword;
505
599
  createLogger({ component: "ready" }).json({
506
600
  mcp: {
@@ -512,6 +606,8 @@ function printStartJson(state, { noPrintCredentials = false } = {}) {
512
606
  workspace: state.workspace.path,
513
607
  state_path: state.paths.statePath,
514
608
  policy: state.policy,
609
+ requested_changes_applied: requestedChangesApplied,
610
+ ...(notice ? { notice } : {}),
515
611
  });
516
612
  }
517
613
 
@@ -541,7 +637,7 @@ function printMcpConnection(state, { json = false, noPrintCredentials = false, i
541
637
  logger.plain(" Use --print-mcp-credentials only when a ChatGPT app needs to reconnect.");
542
638
  }
543
639
  logger.plain(` Workspace cwd: ${payload.workspace}`);
544
- logger.plain(` Policy: write=${payload.policy.allowWrite ? "on" : "off"}, exec=${payload.policy.allowExec ? "on" : "off"}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "on" : "off"}`);
640
+ logger.plain(` Policy: profile=${payload.policy.profile || "custom"}, write=${payload.policy.allowWrite ? "on" : "off"}, exec_mode=${payload.policy.execMode || (payload.policy.allowExec ? "shell" : "off")}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "on" : "off"}, absolute_paths=${payload.policy.exposeAbsolutePaths ? "on" : "off"}`);
545
641
  logger.plain(` State: ${payload.state_path}`);
546
642
  }
547
643
 
@@ -624,7 +720,7 @@ async function serviceCommand(args) {
624
720
  if (!state.worker?.url) {
625
721
  throw new Error("No deployed Worker is recorded for this workspace. Run `machine-mcp` once before `machine-mcp service install`.");
626
722
  }
627
- const result = await installAutostart({ workspace, stateRoot, entryScript: process.argv[1], policy: state.policy, logger: structuredLogger(Boolean(args.quiet)) });
723
+ const result = await installAutostart({ workspace, stateRoot, entryScript: process.argv[1], logger: structuredLogger(Boolean(args.quiet)) });
628
724
  console.log(JSON.stringify(result, null, 2));
629
725
  return;
630
726
  }
@@ -646,10 +742,10 @@ async function serviceCommand(args) {
646
742
  throw new Error(`Unknown service action: ${action}`);
647
743
  }
648
744
 
649
- async function installAutostartBestEffort({ workspace, stateRoot, entryScript, policy }) {
745
+ async function installAutostartBestEffort({ workspace, stateRoot, entryScript }) {
650
746
  try {
651
747
  const { installAutostart } = await import("./service.mjs");
652
- const result = await installAutostart({ workspace, stateRoot, entryScript, policy, logger: structuredLogger(false) });
748
+ const result = await installAutostart({ workspace, stateRoot, entryScript, logger: structuredLogger(false) });
653
749
  if (result?.ok) console.log("Autostart installed for future logins. Use `machine-mcp service status` to inspect or `machine-mcp service uninstall` to remove.");
654
750
  else console.warn("Autostart installation returned a warning; run `machine-mcp service status` for details.");
655
751
  } catch (error) {
@@ -820,7 +916,9 @@ Usage:
820
916
  .\\mbm.cmd # from source checkout on Windows cmd
821
917
 
822
918
  Commands:
823
- start Deploy/update Worker, install autostart, start daemon
919
+ start Deploy/update Worker, install autostart, start remote daemon
920
+ stdio Run a local MCP stdio server for Claude, Cursor, Codex, and compatible clients
921
+ client-config Print stdio client configuration snippets
824
922
  workspace show Show remembered workspace
825
923
  workspace set Re-select workspace; prompts with current/default path
826
924
  service status Show autostart status
@@ -842,10 +940,13 @@ Start options:
842
940
  --no-autostart Do not install login autostart during start
843
941
  --no-print-credentials Redact credentials in console output
844
942
  --print-mcp-credentials Print MCP URL/password again for reconnecting ChatGPT apps
845
- --no-write Disable write_file (default: write enabled)
846
- --no-exec Disable exec_command (default: exec enabled)
847
- --full-env Pass full parent environment to exec_command (default: minimal env)
848
- --unrestricted-paths Allow absolute and parent paths outside the workspace (default: blocked)
943
+ --profile NAME Policy profile: full (default), agent, edit, or review
944
+ --exec-mode MODE Command mode: off, direct argv, or full shell
945
+ --no-write Disable write_file, edit_file, and apply_patch
946
+ --no-exec Disable run_process and exec_command
947
+ --full-env Pass the full parent environment to local commands
948
+ --unrestricted-paths Allow filesystem tools outside the workspace
949
+ --absolute-paths Return absolute local paths in tool results (off by default)
849
950
  --state-dir DIR Override state root
850
951
  --json Print MCP connection details as JSON
851
952