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 +25 -14
- package/package.json +1 -1
- package/src/local/api-server.mjs +9 -5
- package/src/local/cli.mjs +185 -44
- package/src/local/self-test.mjs +4 -0
- package/src/local/service.mjs +2 -0
- package/src/worker/index.ts +1 -1
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
|
|
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`
|
|
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
|
-
|
|
70
|
+
Disable the default local API provider for a daemon run:
|
|
60
71
|
|
|
61
72
|
```zsh
|
|
62
|
-
machine-mcp
|
|
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
|
|
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
|
|
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["
|
|
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
|
|
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
package/src/local/api-server.mjs
CHANGED
|
@@ -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(
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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 (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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"
|
|
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;
|
|
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
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
package/src/local/self-test.mjs
CHANGED
|
@@ -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" } });
|
package/src/local/service.mjs
CHANGED
|
@@ -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
|
|
package/src/worker/index.ts
CHANGED
|
@@ -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.
|
|
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;
|