machine-bridge-mcp 0.1.0 → 0.2.0
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 +87 -2
- package/package.json +8 -2
- package/scripts/sync-worker-version.mjs +42 -0
- package/src/local/api-server.mjs +256 -0
- package/src/local/cli.mjs +176 -41
- package/src/local/log.mjs +60 -0
- package/src/local/self-test.mjs +66 -2
- package/src/local/service.mjs +5 -2
- package/src/local/state.mjs +98 -2
- package/src/worker/index.ts +22 -4
package/README.md
CHANGED
|
@@ -38,6 +38,86 @@ 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
|
+
|
|
49
|
+
## Local OpenAI-compatible API provider
|
|
50
|
+
|
|
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.
|
|
52
|
+
|
|
53
|
+
Start only the local API service:
|
|
54
|
+
|
|
55
|
+
```zsh
|
|
56
|
+
machine-mcp api
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or run it together with the Remote MCP daemon:
|
|
60
|
+
|
|
61
|
+
```zsh
|
|
62
|
+
machine-mcp start --api
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The CLI prints client settings like:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
API Base URL: http://127.0.0.1:8765/v1
|
|
69
|
+
API key: local_api_key_...
|
|
70
|
+
Client type: OpenAI-compatible
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Use these values in the local AI client:
|
|
74
|
+
|
|
75
|
+
- Base URL: `http://127.0.0.1:8765/v1`
|
|
76
|
+
- API key: the `local_api_key_...` printed by the CLI
|
|
77
|
+
- Model: the model shown by `GET /v1/models` or your configured `--api-model`
|
|
78
|
+
|
|
79
|
+
If port `8765` conflicts with another local app, choose a different port explicitly:
|
|
80
|
+
|
|
81
|
+
```zsh
|
|
82
|
+
machine-mcp api --api-port 8766
|
|
83
|
+
machine-mcp start --api --api-port 8766
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`--port` is also accepted on the `api` command:
|
|
87
|
+
|
|
88
|
+
```zsh
|
|
89
|
+
machine-mcp api --port 8766
|
|
90
|
+
```
|
|
91
|
+
|
|
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:
|
|
93
|
+
|
|
94
|
+
```zsh
|
|
95
|
+
machine-mcp api --rotate-api-key
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Configure an upstream OpenAI-compatible provider:
|
|
99
|
+
|
|
100
|
+
```zsh
|
|
101
|
+
machine-mcp api \
|
|
102
|
+
--api-upstream-url https://api.openai.com/v1 \
|
|
103
|
+
--api-upstream-key "$OPENAI_API_KEY" \
|
|
104
|
+
--api-model gpt-4.1
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Environment variables are also supported: `MBM_API_HOST`, `MBM_API_PORT`, `MBM_API_KEY`, `MBM_API_UPSTREAM_URL`, `MBM_API_UPSTREAM_KEY`, `MBM_API_MODEL`, plus common OpenAI names such as `OPENAI_API_KEY`, `OPENAI_BASE_URL`, and `OPENAI_MODEL`.
|
|
108
|
+
|
|
109
|
+
Supported local API routes:
|
|
110
|
+
|
|
111
|
+
- `GET /health` without authentication
|
|
112
|
+
- `GET /v1/models` with `Authorization: Bearer <local_api_key>` or `x-api-key`
|
|
113
|
+
- `POST /v1/chat/completions`
|
|
114
|
+
- `POST /v1/responses`
|
|
115
|
+
- `POST /v1/embeddings`
|
|
116
|
+
- `POST /v1/completions`
|
|
117
|
+
|
|
118
|
+
Model-producing routes proxy to the configured upstream provider. If no upstream key is configured, the API still starts and `/v1/models` works, but generation endpoints return a clear `503 upstream_not_configured` error. Logs record route, status, latency, and safe configuration metadata; request and response bodies and API keys are not logged.
|
|
119
|
+
|
|
120
|
+
|
|
41
121
|
## Re-select workspace
|
|
42
122
|
|
|
43
123
|
```zsh
|
|
@@ -80,7 +160,7 @@ machine-mcp service uninstall
|
|
|
80
160
|
machine-mcp --no-autostart
|
|
81
161
|
```
|
|
82
162
|
|
|
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.
|
|
163
|
+
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
164
|
|
|
85
165
|
## Secrets rotation
|
|
86
166
|
|
|
@@ -157,7 +237,9 @@ Default state roots:
|
|
|
157
237
|
- Linux with `XDG_STATE_HOME`: `$XDG_STATE_HOME/machine-bridge-mcp`
|
|
158
238
|
- Windows: `%APPDATA%\machine-bridge-mcp`
|
|
159
239
|
|
|
160
|
-
State contains the MCP password and
|
|
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.
|
|
241
|
+
|
|
242
|
+
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
243
|
|
|
162
244
|
Override state root:
|
|
163
245
|
|
|
@@ -173,6 +255,8 @@ flowchart LR
|
|
|
173
255
|
W --> DO["Durable Object broker"]
|
|
174
256
|
D["Local daemon"] -- "outbound WebSocket" --> W
|
|
175
257
|
D --> M["Local filesystem and shell"]
|
|
258
|
+
API["Optional local /v1 API"] -- "OpenAI-compatible HTTP" --> U["Configured upstream provider"]
|
|
259
|
+
CLI["machine-mcp CLI"] --> API
|
|
176
260
|
CLI["machine-mcp CLI"] --> W
|
|
177
261
|
CLI --> D
|
|
178
262
|
CLI --> S["Autostart service"]
|
|
@@ -185,6 +269,7 @@ Why this architecture:
|
|
|
185
269
|
- The public MCP URL is stable after deployment.
|
|
186
270
|
- The Worker stores OAuth client/code/token metadata and relays tool calls.
|
|
187
271
|
- 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.
|
|
188
273
|
- Autostart keeps the daemon alive across logins without requiring MCP clients to change URLs.
|
|
189
274
|
|
|
190
275
|
## Development
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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,9 +28,14 @@
|
|
|
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
|
-
"syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/daemon.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/self-test.mjs",
|
|
38
|
+
"syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/daemon.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/self-test.mjs && node --check src/local/api-server.mjs && node --check src/local/log.mjs",
|
|
33
39
|
"check": "npm run typecheck && npm run syntax && npm run self-test"
|
|
34
40
|
},
|
|
35
41
|
"dependencies": {
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
const log = message => process.stderr.write(`${message}\n`);
|
|
13
|
+
|
|
14
|
+
const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
15
|
+
const expected = String(pkg.version || "").trim();
|
|
16
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(expected)) {
|
|
17
|
+
fail(`package.json version is not a valid release version: ${JSON.stringify(expected)}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const source = readFileSync(workerPath, "utf8");
|
|
21
|
+
const pattern = /const SERVER_VERSION = "([^"]+)";/;
|
|
22
|
+
const match = source.match(pattern);
|
|
23
|
+
if (!match) fail("Could not find `const SERVER_VERSION = \"...\";` in src/worker/index.ts");
|
|
24
|
+
|
|
25
|
+
const current = match[1];
|
|
26
|
+
if (current === expected) {
|
|
27
|
+
log(`Worker version is in sync: ${expected}`);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (checkOnly) {
|
|
32
|
+
fail(`Worker version mismatch: package.json=${expected}, src/worker/index.ts=${current}. Run npm run version:sync.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const updated = source.replace(pattern, `const SERVER_VERSION = "${expected}";`);
|
|
36
|
+
writeFileSync(workerPath, updated);
|
|
37
|
+
log(`Updated Worker version: ${current} -> ${expected}`);
|
|
38
|
+
|
|
39
|
+
function fail(message) {
|
|
40
|
+
console.error(message);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { createLogger } from "./log.mjs";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_API_HOST = "127.0.0.1";
|
|
8
|
+
export const DEFAULT_API_PORT = 8765;
|
|
9
|
+
export const DEFAULT_UPSTREAM_URL = "https://api.openai.com/v1";
|
|
10
|
+
export const DEFAULT_API_MODEL = "machine-bridge-mcp";
|
|
11
|
+
export const DEFAULT_API_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
const PROXY_ROUTES = new Set([
|
|
14
|
+
"/v1/chat/completions",
|
|
15
|
+
"/v1/responses",
|
|
16
|
+
"/v1/embeddings",
|
|
17
|
+
"/v1/completions",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
21
|
+
"connection",
|
|
22
|
+
"keep-alive",
|
|
23
|
+
"proxy-authenticate",
|
|
24
|
+
"proxy-authorization",
|
|
25
|
+
"te",
|
|
26
|
+
"trailer",
|
|
27
|
+
"transfer-encoding",
|
|
28
|
+
"upgrade",
|
|
29
|
+
"host",
|
|
30
|
+
"content-length",
|
|
31
|
+
"authorization",
|
|
32
|
+
"x-api-key",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export function parseApiPort(value, fallback = DEFAULT_API_PORT) {
|
|
36
|
+
if (value === undefined || value === null || value === true || value === "") return fallback;
|
|
37
|
+
const port = Number(value);
|
|
38
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid API port: ${value}`);
|
|
39
|
+
return port;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeApiHost(value, fallback = DEFAULT_API_HOST) {
|
|
43
|
+
if (value === undefined || value === null || value === true || value === "") return fallback;
|
|
44
|
+
const host = String(value).trim();
|
|
45
|
+
if (!host) return fallback;
|
|
46
|
+
if (/[/\\\s]/.test(host)) throw new Error(`Invalid API host: ${value}`);
|
|
47
|
+
return host;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeBaseUrl(value, fallback = DEFAULT_UPSTREAM_URL) {
|
|
51
|
+
const raw = value === undefined || value === null || value === true || value === "" ? fallback : String(value).trim();
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(raw);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`Invalid upstream API URL: ${raw}`);
|
|
57
|
+
}
|
|
58
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid upstream API URL protocol: ${parsed.protocol}`);
|
|
59
|
+
if (parsed.username || parsed.password) throw new Error("Upstream API URL must not contain credentials; pass keys with --api-upstream-key or environment variables.");
|
|
60
|
+
if (parsed.search || parsed.hash) throw new Error("Upstream API URL must be a base URL without query strings or fragments.");
|
|
61
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function startLocalApiServer(options = {}) {
|
|
65
|
+
const logger = options.logger || createLogger({ component: "api", quiet: options.quiet });
|
|
66
|
+
const host = normalizeApiHost(options.host);
|
|
67
|
+
const port = parseApiPort(options.port);
|
|
68
|
+
const apiKey = String(options.apiKey || "");
|
|
69
|
+
const upstreamUrl = normalizeBaseUrl(options.upstreamUrl);
|
|
70
|
+
const upstreamKey = String(options.upstreamKey || "");
|
|
71
|
+
const model = String(options.model || DEFAULT_API_MODEL);
|
|
72
|
+
const maxBodyBytes = Number(options.maxBodyBytes || DEFAULT_API_MAX_BODY_BYTES);
|
|
73
|
+
|
|
74
|
+
if (!apiKey) throw new Error("Local API key is missing");
|
|
75
|
+
|
|
76
|
+
const server = http.createServer((req, res) => {
|
|
77
|
+
void handleRequest(req, res, { logger, apiKey, upstreamUrl, upstreamKey, model, maxBodyBytes });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
server.keepAliveTimeout = 65_000;
|
|
81
|
+
server.headersTimeout = 70_000;
|
|
82
|
+
server.requestTimeout = 0;
|
|
83
|
+
|
|
84
|
+
await new Promise((resolve, reject) => {
|
|
85
|
+
const onError = error => {
|
|
86
|
+
server.off("listening", onListening);
|
|
87
|
+
reject(withPortHint(error, port));
|
|
88
|
+
};
|
|
89
|
+
const onListening = () => {
|
|
90
|
+
server.off("error", onError);
|
|
91
|
+
resolve();
|
|
92
|
+
};
|
|
93
|
+
server.once("error", onError);
|
|
94
|
+
server.once("listening", onListening);
|
|
95
|
+
server.listen({ host, port });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const address = server.address();
|
|
99
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
100
|
+
const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
101
|
+
const baseUrl = `http://${urlHost}:${actualPort}/v1`;
|
|
102
|
+
logger.success("local OpenAI-compatible API started", { baseUrl, model, upstream: upstreamUrl, upstreamConfigured: Boolean(upstreamKey) });
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
server,
|
|
106
|
+
host,
|
|
107
|
+
port: actualPort,
|
|
108
|
+
baseUrl,
|
|
109
|
+
url: `http://${urlHost}:${actualPort}`,
|
|
110
|
+
close() {
|
|
111
|
+
return new Promise(resolve => server.close(() => resolve()));
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function handleRequest(req, res, context) {
|
|
117
|
+
const requestId = randomUUID().slice(0, 8);
|
|
118
|
+
const started = Date.now();
|
|
119
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
120
|
+
setCorsHeaders(res);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
if (req.method === "OPTIONS") return sendEmpty(res, 204);
|
|
124
|
+
if (req.method === "GET" && url.pathname === "/health") return sendJson(res, 200, { ok: true, service: "machine-bridge-mcp-local-api" });
|
|
125
|
+
|
|
126
|
+
if (!isAuthorized(req, context.apiKey)) return sendOpenAiError(res, 401, "invalid_api_key", "Missing or invalid local API key.");
|
|
127
|
+
|
|
128
|
+
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
129
|
+
context.logger.info("request completed", { requestId, method: req.method, path: url.pathname, status: 200, durationMs: Date.now() - started });
|
|
130
|
+
return sendJson(res, 200, modelsPayload(context.model));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (req.method === "POST" && PROXY_ROUTES.has(url.pathname)) {
|
|
134
|
+
if (!context.upstreamKey) {
|
|
135
|
+
return sendOpenAiError(res, 503, "upstream_not_configured", "No upstream API key is configured. Set --api-upstream-key or MBM_API_UPSTREAM_KEY/OPENAI_API_KEY.");
|
|
136
|
+
}
|
|
137
|
+
context.logger.info("proxy request started", { requestId, path: url.pathname, upstream: context.upstreamUrl });
|
|
138
|
+
await proxyRequest(req, res, url, context);
|
|
139
|
+
context.logger.info("proxy request completed", { requestId, path: url.pathname, status: res.statusCode, durationMs: Date.now() - started });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return sendOpenAiError(res, 404, "not_found", `Unknown local API endpoint: ${url.pathname}`);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
context.logger.error("request failed", { requestId, path: url.pathname, error: error.message, durationMs: Date.now() - started });
|
|
146
|
+
if (!res.headersSent) {
|
|
147
|
+
if (error?.code === "BODY_TOO_LARGE") return sendOpenAiError(res, 413, "request_too_large", error.message);
|
|
148
|
+
return sendOpenAiError(res, 502, "upstream_error", error.message || "Local API request failed.");
|
|
149
|
+
}
|
|
150
|
+
res.destroy(error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function proxyRequest(req, res, url, context) {
|
|
155
|
+
const body = await readBody(req, context.maxBodyBytes);
|
|
156
|
+
const upstreamTarget = `${context.upstreamUrl}${url.pathname.slice(3)}${url.search}`;
|
|
157
|
+
const headers = copyProxyHeaders(req.headers, context.upstreamKey);
|
|
158
|
+
const response = await fetch(upstreamTarget, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers,
|
|
161
|
+
body,
|
|
162
|
+
signal: AbortSignal.timeout(10 * 60 * 1000),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
res.statusCode = response.status;
|
|
166
|
+
for (const [key, value] of response.headers) {
|
|
167
|
+
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) res.setHeader(key, value);
|
|
168
|
+
}
|
|
169
|
+
if (!res.hasHeader("content-type")) res.setHeader("content-type", "application/json");
|
|
170
|
+
if (response.body) await pipeline(Readable.fromWeb(response.body), res);
|
|
171
|
+
else res.end();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function copyProxyHeaders(source, upstreamKey) {
|
|
175
|
+
const out = new Headers();
|
|
176
|
+
for (const [key, value] of Object.entries(source)) {
|
|
177
|
+
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
|
178
|
+
if (Array.isArray(value)) out.set(key, value.join(", "));
|
|
179
|
+
else if (value !== undefined) out.set(key, String(value));
|
|
180
|
+
}
|
|
181
|
+
out.set("authorization", `Bearer ${upstreamKey}`);
|
|
182
|
+
if (!out.has("content-type")) out.set("content-type", "application/json");
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readBody(req, maxBytes) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const chunks = [];
|
|
189
|
+
let total = 0;
|
|
190
|
+
req.on("data", chunk => {
|
|
191
|
+
total += chunk.length;
|
|
192
|
+
if (total > maxBytes) {
|
|
193
|
+
const error = new Error(`Request body exceeds ${maxBytes} bytes`);
|
|
194
|
+
error.code = "BODY_TOO_LARGE";
|
|
195
|
+
req.destroy(error);
|
|
196
|
+
reject(error);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
chunks.push(chunk);
|
|
200
|
+
});
|
|
201
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
202
|
+
req.on("error", reject);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isAuthorized(req, expectedKey) {
|
|
207
|
+
const auth = String(req.headers.authorization || "");
|
|
208
|
+
if (auth.startsWith("Bearer ") && auth.slice(7) === expectedKey) return true;
|
|
209
|
+
const apiKey = req.headers["x-api-key"];
|
|
210
|
+
return typeof apiKey === "string" && apiKey === expectedKey;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function modelsPayload(model) {
|
|
214
|
+
return {
|
|
215
|
+
object: "list",
|
|
216
|
+
data: [{
|
|
217
|
+
id: model,
|
|
218
|
+
object: "model",
|
|
219
|
+
created: 0,
|
|
220
|
+
owned_by: "machine-bridge-mcp",
|
|
221
|
+
}],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sendOpenAiError(res, status, code, message) {
|
|
226
|
+
return sendJson(res, status, { error: { message, type: "invalid_request_error", param: null, code } });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function sendJson(res, status, payload) {
|
|
230
|
+
if (!res.headersSent) {
|
|
231
|
+
res.statusCode = status;
|
|
232
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
233
|
+
}
|
|
234
|
+
res.end(`${JSON.stringify(payload)}\n`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sendEmpty(res, status) {
|
|
238
|
+
res.statusCode = status;
|
|
239
|
+
res.end();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function setCorsHeaders(res) {
|
|
243
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
244
|
+
res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
|
|
245
|
+
res.setHeader("access-control-allow-headers", "authorization,content-type,x-api-key,openai-beta,openai-organization,openai-project");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function withPortHint(error, port) {
|
|
249
|
+
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>\`.`;
|
|
251
|
+
}
|
|
252
|
+
if (error?.code === "EACCES") {
|
|
253
|
+
error.message = `Local API port ${port} is not permitted. Re-run with \`machine-mcp api --api-port <free_port>\`.`;
|
|
254
|
+
}
|
|
255
|
+
return error;
|
|
256
|
+
}
|
package/src/local/cli.mjs
CHANGED
|
@@ -4,11 +4,15 @@ import path, { resolve } from "node:path";
|
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import readline from "node:readline/promises";
|
|
6
6
|
import { LocalDaemon } from "./daemon.mjs";
|
|
7
|
+
import { startLocalApiServer, DEFAULT_API_HOST, DEFAULT_API_PORT, DEFAULT_API_MODEL, DEFAULT_UPSTREAM_URL } from "./api-server.mjs";
|
|
8
|
+
import { createLogger, redactSecret } from "./log.mjs";
|
|
7
9
|
import { runWrangler } from "./shell.mjs";
|
|
8
10
|
import {
|
|
11
|
+
acquireDaemonLock,
|
|
9
12
|
appName,
|
|
10
13
|
defaultStateRoot,
|
|
11
14
|
ensureOwnerOnlyDir,
|
|
15
|
+
ensureLocalApiKey,
|
|
12
16
|
ensureWorkerSecrets,
|
|
13
17
|
expandHome,
|
|
14
18
|
loadGlobalConfig,
|
|
@@ -28,11 +32,13 @@ import {
|
|
|
28
32
|
export async function main(argv = process.argv.slice(2)) {
|
|
29
33
|
const [command, rest] = normalizeCommand(argv);
|
|
30
34
|
const args = parseArgs(rest);
|
|
35
|
+
if (command === "api" && args.help) return apiUsage();
|
|
31
36
|
if (args.help || command === "help") return usage();
|
|
32
37
|
if (args.version || command === "version") return version();
|
|
33
38
|
|
|
34
39
|
switch (command) {
|
|
35
40
|
case "start": return startCommand(args);
|
|
41
|
+
case "api": return apiCommand(args);
|
|
36
42
|
case "status": return statusCommand(args);
|
|
37
43
|
case "doctor": return doctorCommand(args);
|
|
38
44
|
case "workspace": return workspaceCommand(args);
|
|
@@ -153,9 +159,11 @@ async function confirm(prompt, assumeYes = false) {
|
|
|
153
159
|
|
|
154
160
|
async function startCommand(args) {
|
|
155
161
|
assertNodeVersion();
|
|
162
|
+
const logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "cli" });
|
|
156
163
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
|
|
157
164
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
158
165
|
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) });
|
|
159
167
|
state.policy = {
|
|
160
168
|
allowWrite: args.noWrite ? false : true,
|
|
161
169
|
allowExec: args.noExec ? false : true,
|
|
@@ -172,40 +180,105 @@ async function startCommand(args) {
|
|
|
172
180
|
await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], policy: state.policy });
|
|
173
181
|
}
|
|
174
182
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
183
|
+
if (!args.daemonOnly) await stopAutostartBestEffort(Boolean(args.quiet));
|
|
184
|
+
|
|
185
|
+
const lock = acquireDaemonLock(state);
|
|
186
|
+
let apiServer = null;
|
|
187
|
+
if (!lock.acquired) {
|
|
188
|
+
if (!args.quiet) {
|
|
189
|
+
const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
|
|
190
|
+
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) });
|
|
192
|
+
}
|
|
193
|
+
if (!args.api) return;
|
|
194
|
+
apiServer = await startConfiguredApiServer(state, args);
|
|
195
|
+
printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
|
|
196
|
+
keepProcessAlive({ apiServer, logger });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const daemon = new LocalDaemon({
|
|
202
|
+
workerUrl: state.worker.url,
|
|
203
|
+
secret: state.worker.daemonSecret,
|
|
204
|
+
workspace,
|
|
205
|
+
policy: state.policy,
|
|
206
|
+
logger: createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "daemon" }),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
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 });
|
|
215
|
+
} catch (error) {
|
|
216
|
+
lock.release();
|
|
217
|
+
if (apiServer) await apiServer.close().catch(() => {});
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function apiCommand(args) {
|
|
223
|
+
assertNodeVersion();
|
|
224
|
+
const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
|
|
225
|
+
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
226
|
+
ensureLocalApiKey(state, { apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY"), rotateApiKey: Boolean(args.rotateApiKey) });
|
|
227
|
+
saveState(state);
|
|
228
|
+
const logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "api" });
|
|
229
|
+
const apiServer = await startConfiguredApiServer(state, args, logger);
|
|
230
|
+
printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
|
|
231
|
+
keepProcessAlive({ apiServer, logger });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function startConfiguredApiServer(state, args, logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "api" })) {
|
|
235
|
+
const apiOptions = apiOptionsFromArgs(state, args);
|
|
236
|
+
return startLocalApiServer({ ...apiOptions, logger });
|
|
237
|
+
}
|
|
182
238
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
239
|
+
function apiOptionsFromArgs(state, args = {}) {
|
|
240
|
+
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);
|
|
242
|
+
const envPort = valueFromArgsEnv(undefined, "MBM_API_PORT", "PORT");
|
|
243
|
+
return {
|
|
244
|
+
host: valueFromArgsEnv(args.apiHost, "MBM_API_HOST") || DEFAULT_API_HOST,
|
|
245
|
+
port: explicitPort ?? envPort ?? DEFAULT_API_PORT,
|
|
246
|
+
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,
|
|
248
|
+
upstreamKey: upstreamKey || "",
|
|
249
|
+
model: valueFromArgsEnv(args.apiModel, "MBM_API_MODEL", "OPENAI_MODEL") || DEFAULT_API_MODEL,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function valueFromArgsEnv(argValue, ...envNames) {
|
|
254
|
+
if (argValue !== undefined && argValue !== null && argValue !== true) return String(argValue);
|
|
255
|
+
for (const name of envNames) {
|
|
256
|
+
if (process.env[name]) return process.env[name];
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
187
259
|
}
|
|
188
260
|
|
|
189
261
|
async function ensureWorker(state, args) {
|
|
262
|
+
const logger = createLogger({ quiet: Boolean(args.quiet), verbose: Boolean(args.verbose), component: "worker" });
|
|
190
263
|
const desiredHash = workerDeployHash(state);
|
|
191
264
|
const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.oauthPassword && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
|
|
192
265
|
if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
|
|
193
266
|
const health = await workerHealth(state.worker.url);
|
|
194
267
|
if (health.ok) {
|
|
195
|
-
|
|
268
|
+
logger.success("Worker unchanged and healthy", { url: state.worker.url });
|
|
196
269
|
return state.worker;
|
|
197
270
|
}
|
|
198
|
-
|
|
271
|
+
logger.warn("Worker health check failed; redeploying", { error: health.error });
|
|
199
272
|
}
|
|
200
273
|
|
|
201
|
-
|
|
274
|
+
logger.info("Checking Cloudflare Wrangler login");
|
|
202
275
|
const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
|
|
203
276
|
if (whoami.code !== 0) {
|
|
204
|
-
|
|
277
|
+
logger.info("Wrangler is not logged in; opening Cloudflare login");
|
|
205
278
|
await runWrangler(["login"]);
|
|
206
279
|
}
|
|
207
280
|
|
|
208
|
-
|
|
281
|
+
logger.info("Deploying Cloudflare Worker", { name: state.worker.name });
|
|
209
282
|
const deploy = await withSecretsFile(state, secretFile => runWrangler([
|
|
210
283
|
"deploy",
|
|
211
284
|
"--name", state.worker.name,
|
|
@@ -223,8 +296,8 @@ async function ensureWorker(state, args) {
|
|
|
223
296
|
saveState(state);
|
|
224
297
|
|
|
225
298
|
const health = await retryHealth(state.worker.url, 8);
|
|
226
|
-
if (!health.ok)
|
|
227
|
-
else
|
|
299
|
+
if (!health.ok) logger.warn("Worker deployed but health check did not pass yet", { error: health.error });
|
|
300
|
+
else logger.success("Worker ready", { url: state.worker.url });
|
|
228
301
|
return state.worker;
|
|
229
302
|
}
|
|
230
303
|
|
|
@@ -330,10 +403,11 @@ async function waitForConnectWithNotice(promise, timeoutMs) {
|
|
|
330
403
|
});
|
|
331
404
|
const result = await Promise.race([promise.then(() => "connected"), timed]);
|
|
332
405
|
clearTimeout(timeout);
|
|
333
|
-
if (result === "timeout")
|
|
406
|
+
if (result === "timeout") createLogger({ component: "daemon" }).warn("Still connecting; credentials are printed now and the process will keep retrying");
|
|
334
407
|
}
|
|
335
408
|
|
|
336
409
|
function printConnection(state, { json = false, noPrintCredentials = false } = {}) {
|
|
410
|
+
const logger = createLogger({ component: "ready" });
|
|
337
411
|
const payload = {
|
|
338
412
|
mcp_server_url: state.worker.mcpServerUrl,
|
|
339
413
|
mcp_connection_password: state.worker.oauthPassword,
|
|
@@ -345,26 +419,40 @@ function printConnection(state, { json = false, noPrintCredentials = false } = {
|
|
|
345
419
|
};
|
|
346
420
|
if (json) {
|
|
347
421
|
const safePayload = noPrintCredentials ? { ...payload, mcp_connection_password: previewSecret(payload.mcp_connection_password) } : payload;
|
|
348
|
-
|
|
422
|
+
logger.json(safePayload);
|
|
349
423
|
return;
|
|
350
424
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (!noPrintCredentials)
|
|
354
|
-
else
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
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)`);
|
|
429
|
+
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"}`);
|
|
431
|
+
logger.plain(` State: ${payload.state_path}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function printApiConnection(apiServer, state, { noPrintCredentials = false } = {}) {
|
|
435
|
+
const logger = createLogger({ component: "ready" });
|
|
436
|
+
logger.success("Local model API provider is ready");
|
|
437
|
+
logger.plain(` API Base URL: ${apiServer.baseUrl}`);
|
|
438
|
+
logger.plain(` API key: ${noPrintCredentials ? `${redactSecret(state.localApi.apiKey)} (redacted)` : state.localApi.apiKey}`);
|
|
439
|
+
logger.plain(" Client type: OpenAI-compatible");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function keepProcessAlive({ daemon = null, lock = null, apiServer = null, logger = createLogger({ component: "cli" }) } = {}) {
|
|
443
|
+
let stopping = false;
|
|
444
|
+
const stop = async () => {
|
|
445
|
+
if (stopping) return;
|
|
446
|
+
stopping = true;
|
|
447
|
+
logger.info("stopping local services");
|
|
448
|
+
try { daemon?.stop?.(); } catch {}
|
|
449
|
+
try { await apiServer?.close?.(); } catch {}
|
|
450
|
+
try { lock?.release?.(); } catch {}
|
|
364
451
|
process.exit(0);
|
|
365
452
|
};
|
|
366
|
-
process.once("SIGINT", stop);
|
|
367
|
-
process.once("SIGTERM", stop);
|
|
453
|
+
process.once("SIGINT", () => void stop());
|
|
454
|
+
process.once("SIGTERM", () => void stop());
|
|
455
|
+
process.once("exit", () => lock?.release?.());
|
|
368
456
|
setInterval(() => {}, 2 ** 31 - 1);
|
|
369
457
|
}
|
|
370
458
|
|
|
@@ -449,6 +537,15 @@ async function installAutostartBestEffort({ workspace, stateRoot, entryScript, p
|
|
|
449
537
|
}
|
|
450
538
|
}
|
|
451
539
|
|
|
540
|
+
async function stopAutostartBestEffort(quiet = false) {
|
|
541
|
+
try {
|
|
542
|
+
const { stopAutostart } = await import("./service.mjs");
|
|
543
|
+
await stopAutostart({ logger: structuredLogger(true) });
|
|
544
|
+
} catch (error) {
|
|
545
|
+
if (!quiet) console.warn(`Autostart stop skipped: ${error.message}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
452
549
|
async function uninstallCommand(args) {
|
|
453
550
|
const stateRoot = stateRootFromArgs(args);
|
|
454
551
|
const deleteRemote = !args.keepWorker;
|
|
@@ -508,12 +605,7 @@ async function removeAutostartBestEffort(stateRoot) {
|
|
|
508
605
|
}
|
|
509
606
|
|
|
510
607
|
function structuredLogger(quiet) {
|
|
511
|
-
|
|
512
|
-
return {
|
|
513
|
-
info: msg => console.log(`[daemon] ${msg}`),
|
|
514
|
-
warn: msg => console.warn(`[daemon] ${msg}`),
|
|
515
|
-
error: msg => console.error(`[daemon] ${msg}`),
|
|
516
|
-
};
|
|
608
|
+
return createLogger({ quiet, component: "daemon" });
|
|
517
609
|
}
|
|
518
610
|
|
|
519
611
|
function sanitizeLines(text) {
|
|
@@ -549,6 +641,7 @@ Usage:
|
|
|
549
641
|
|
|
550
642
|
Commands:
|
|
551
643
|
start Deploy/update Worker, install autostart, start local daemon
|
|
644
|
+
api Start local OpenAI-compatible API provider only
|
|
552
645
|
workspace show Show remembered workspace
|
|
553
646
|
workspace set Re-select workspace; prompts with current/default path
|
|
554
647
|
service status Show autostart status
|
|
@@ -568,11 +661,21 @@ Start options:
|
|
|
568
661
|
--rotate-secrets Rotate secrets before deploying
|
|
569
662
|
--daemon-only Skip deploy and only connect daemon from existing state
|
|
570
663
|
--no-autostart Do not install login autostart during start
|
|
664
|
+
--no-print-credentials Redact the MCP password in console output
|
|
571
665
|
--no-write Disable write_file (default: write enabled)
|
|
572
666
|
--no-exec Disable exec_command (default: exec enabled)
|
|
573
667
|
--full-env Pass full parent environment to exec_command (default: minimal env)
|
|
574
668
|
--state-dir DIR Override state root
|
|
575
669
|
--json Print connection details as JSON
|
|
670
|
+
--api Also start local OpenAI-compatible API provider
|
|
671
|
+
--api-port PORT Local API port (default: 8765)
|
|
672
|
+
--port PORT Alias for --api-port on the api command
|
|
673
|
+
--api-host HOST Local API host (default: 127.0.0.1)
|
|
674
|
+
--api-key KEY Set or replace local API key in state
|
|
675
|
+
--rotate-api-key Rotate local API key
|
|
676
|
+
--api-upstream-url URL Upstream OpenAI-compatible base URL (default: https://api.openai.com/v1)
|
|
677
|
+
--api-upstream-key KEY Upstream provider key; env OPENAI_API_KEY also works
|
|
678
|
+
--api-model MODEL Model id advertised by /v1/models
|
|
576
679
|
|
|
577
680
|
Uninstall options:
|
|
578
681
|
--keep-worker Do not delete deployed Worker(s) during uninstall
|
|
@@ -580,6 +683,38 @@ Uninstall options:
|
|
|
580
683
|
`);
|
|
581
684
|
}
|
|
582
685
|
|
|
686
|
+
function apiUsage() {
|
|
687
|
+
console.log(`machine-bridge-mcp local API provider
|
|
688
|
+
|
|
689
|
+
Usage:
|
|
690
|
+
machine-mcp api
|
|
691
|
+
machine-mcp api --api-port 8766
|
|
692
|
+
machine-mcp start --api --api-port 8766
|
|
693
|
+
|
|
694
|
+
Client settings:
|
|
695
|
+
API Base URL: http://127.0.0.1:<port>/v1
|
|
696
|
+
API key: printed on startup unless --no-print-credentials is set
|
|
697
|
+
Client type: OpenAI-compatible
|
|
698
|
+
|
|
699
|
+
Options:
|
|
700
|
+
--workspace PATH Use and remember this workspace path
|
|
701
|
+
--api-host HOST Local API host (default: 127.0.0.1)
|
|
702
|
+
--api-port PORT Local API port (default: 8765)
|
|
703
|
+
--port PORT Alias for --api-port
|
|
704
|
+
--api-key KEY Set or replace local API key in state
|
|
705
|
+
--rotate-api-key Rotate local API key
|
|
706
|
+
--api-upstream-url URL Upstream OpenAI-compatible base URL
|
|
707
|
+
--api-upstream-key KEY Upstream provider key; env OPENAI_API_KEY also works
|
|
708
|
+
--api-model MODEL Model id advertised by /v1/models
|
|
709
|
+
--no-print-credentials Redact the local API key in console output
|
|
710
|
+
--state-dir DIR Override state root
|
|
711
|
+
|
|
712
|
+
Environment:
|
|
713
|
+
MBM_API_HOST, MBM_API_PORT, MBM_API_KEY, MBM_API_UPSTREAM_URL, MBM_API_UPSTREAM_KEY, MBM_API_MODEL
|
|
714
|
+
OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_API_BASE, OPENAI_MODEL
|
|
715
|
+
`);
|
|
716
|
+
}
|
|
717
|
+
|
|
583
718
|
function version() {
|
|
584
719
|
const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
|
585
720
|
console.log(`${pkg.name} ${pkg.version}`);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
const COLORS = {
|
|
4
|
+
dim: "\x1b[2m",
|
|
5
|
+
reset: "\x1b[0m",
|
|
6
|
+
blue: "\x1b[34m",
|
|
7
|
+
green: "\x1b[32m",
|
|
8
|
+
yellow: "\x1b[33m",
|
|
9
|
+
red: "\x1b[31m",
|
|
10
|
+
gray: "\x1b[90m",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createLogger(options = {}) {
|
|
14
|
+
const quiet = Boolean(options.quiet);
|
|
15
|
+
const verbose = Boolean(options.verbose);
|
|
16
|
+
const component = options.component ? String(options.component) : "cli";
|
|
17
|
+
const useColor = shouldUseColor(options);
|
|
18
|
+
|
|
19
|
+
const write = (stream, level, label, color, message, fields) => {
|
|
20
|
+
if (quiet && level !== "error") return;
|
|
21
|
+
if (level === "debug" && !verbose) return;
|
|
22
|
+
const prefix = useColor ? `${color}${label}${COLORS.reset}` : label;
|
|
23
|
+
const suffix = formatFields(fields);
|
|
24
|
+
stream.write(`${prefix} ${component}: ${String(message)}${suffix}\n`);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
child(childComponent) {
|
|
29
|
+
return createLogger({ ...options, component: childComponent });
|
|
30
|
+
},
|
|
31
|
+
info(message, fields) { write(process.stdout, "info", "[info]", COLORS.blue, message, fields); },
|
|
32
|
+
success(message, fields) { write(process.stdout, "success", "[ok]", COLORS.green, message, fields); },
|
|
33
|
+
warn(message, fields) { write(process.stderr, "warn", "[warn]", COLORS.yellow, message, fields); },
|
|
34
|
+
error(message, fields) { write(process.stderr, "error", "[error]", COLORS.red, message, fields); },
|
|
35
|
+
debug(message, fields) { write(process.stderr, "debug", "[debug]", COLORS.gray, message, fields); },
|
|
36
|
+
plain(message = "") { if (!quiet) process.stdout.write(`${String(message)}\n`); },
|
|
37
|
+
json(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function redactSecret(value) {
|
|
42
|
+
const text = String(value || "");
|
|
43
|
+
if (!text) return "<empty>";
|
|
44
|
+
if (text.length <= 12) return "<redacted>";
|
|
45
|
+
return `${text.slice(0, 6)}...${text.slice(-4)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatFields(fields) {
|
|
49
|
+
if (!fields || typeof fields !== "object" || !Object.keys(fields).length) return "";
|
|
50
|
+
return ` ${JSON.stringify(fields, (_key, value) => {
|
|
51
|
+
if (value instanceof Error) return value.message;
|
|
52
|
+
return value;
|
|
53
|
+
})}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function shouldUseColor(options) {
|
|
57
|
+
if (options.color === false || process.env.NO_COLOR) return false;
|
|
58
|
+
if (options.color === true) return true;
|
|
59
|
+
return Boolean(process.stderr.isTTY || process.stdout.isTTY);
|
|
60
|
+
}
|
package/src/local/self-test.mjs
CHANGED
|
@@ -2,11 +2,14 @@ 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 {
|
|
5
|
+
import { normalizeBaseUrl, startLocalApiServer } from "./api-server.mjs";
|
|
6
|
+
import { createLogger, redactSecret } from "./log.mjs";
|
|
7
|
+
import { acquireDaemonLock, ensureLocalApiKey, ensureWorkerSecrets, loadState, previewSecret, redactState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
|
|
6
8
|
|
|
7
9
|
await daemonSelfTest();
|
|
8
10
|
await stateSelfTest();
|
|
9
|
-
|
|
11
|
+
await apiSelfTest();
|
|
12
|
+
console.log("local daemon/state/api self-test ok");
|
|
10
13
|
|
|
11
14
|
async function stateSelfTest() {
|
|
12
15
|
const stateRoot = await mkdtemp(join(tmpdir(), "mbm-state-test-"));
|
|
@@ -16,13 +19,74 @@ async function stateSelfTest() {
|
|
|
16
19
|
if (selectedWorkspace(stateRoot) !== workspace) throw new Error("selected workspace was not persisted");
|
|
17
20
|
const state = loadState(workspace, { stateDir: stateRoot });
|
|
18
21
|
ensureWorkerSecrets(state, { rotateSecrets: true });
|
|
22
|
+
ensureLocalApiKey(state, { rotateApiKey: true });
|
|
23
|
+
const lock = acquireDaemonLock(state);
|
|
24
|
+
if (!lock.acquired) throw new Error("first daemon lock acquisition failed");
|
|
25
|
+
try {
|
|
26
|
+
const duplicate = acquireDaemonLock(state);
|
|
27
|
+
if (duplicate.acquired) throw new Error("duplicate daemon lock acquisition should fail");
|
|
28
|
+
if (duplicate.owner?.pid !== process.pid) throw new Error("duplicate daemon lock owner was not reported");
|
|
29
|
+
} finally {
|
|
30
|
+
lock.release();
|
|
31
|
+
}
|
|
32
|
+
const relock = acquireDaemonLock(state);
|
|
33
|
+
if (!relock.acquired) throw new Error("daemon lock was not released");
|
|
34
|
+
relock.release();
|
|
35
|
+
|
|
19
36
|
const redacted = redactState(state);
|
|
20
37
|
if (redacted.worker.oauthPassword === state.worker.oauthPassword) throw new Error("oauthPassword was not redacted");
|
|
21
38
|
if (redacted.worker.daemonSecret === state.worker.daemonSecret) throw new Error("daemonSecret was not redacted");
|
|
22
39
|
if (redacted.worker.oauthTokenVersion === state.worker.oauthTokenVersion) throw new Error("oauthTokenVersion was not redacted");
|
|
40
|
+
if (redacted.localApi.apiKey === state.localApi.apiKey) throw new Error("local API key was not redacted");
|
|
23
41
|
if (!previewSecret(state.worker.oauthPassword).includes("...")) throw new Error("previewSecret did not preview long secret");
|
|
42
|
+
if (!redactSecret(state.localApi.apiKey).includes("...")) throw new Error("redactSecret did not preview long secret");
|
|
24
43
|
} finally {
|
|
25
44
|
await rm(stateRoot, { recursive: true, force: true }).catch(() => {});
|
|
26
45
|
await rm(workspace, { recursive: true, force: true }).catch(() => {});
|
|
27
46
|
}
|
|
28
47
|
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async function apiSelfTest() {
|
|
51
|
+
try {
|
|
52
|
+
normalizeBaseUrl("https://user:pass@example.com/v1");
|
|
53
|
+
throw new Error("upstream URL credentials were accepted");
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (!/must not contain credentials/.test(error.message)) throw error;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
normalizeBaseUrl("https://example.com/v1?api_key=secret");
|
|
59
|
+
throw new Error("upstream URL query string was accepted");
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!/without query strings/.test(error.message)) throw error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const logger = createLogger({ quiet: true, component: "api-test" });
|
|
65
|
+
const api = await startLocalApiServer({
|
|
66
|
+
host: "127.0.0.1",
|
|
67
|
+
port: 0,
|
|
68
|
+
apiKey: "local-test-key",
|
|
69
|
+
upstreamKey: "",
|
|
70
|
+
model: "test-model",
|
|
71
|
+
logger,
|
|
72
|
+
});
|
|
73
|
+
const base = `http://${api.host}:${api.port}`;
|
|
74
|
+
try {
|
|
75
|
+
const health = await fetch(`${base}/health`);
|
|
76
|
+
if (health.status !== 200) throw new Error(`health returned ${health.status}`);
|
|
77
|
+
const unauth = await fetch(`${base}/v1/models`);
|
|
78
|
+
if (unauth.status !== 401) throw new Error(`unauthorized models returned ${unauth.status}`);
|
|
79
|
+
const models = await fetch(`${base}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
|
|
80
|
+
if (models.status !== 200) throw new Error(`authorized models returned ${models.status}`);
|
|
81
|
+
const payload = await models.json();
|
|
82
|
+
if (payload?.data?.[0]?.id !== "test-model") throw new Error("model payload did not include configured model");
|
|
83
|
+
const chat = await fetch(`${base}/v1/chat/completions`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ model: "test-model", messages: [] }),
|
|
87
|
+
});
|
|
88
|
+
if (chat.status !== 503) throw new Error(`missing upstream key should return 503, got ${chat.status}`);
|
|
89
|
+
} finally {
|
|
90
|
+
await api.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/local/service.mjs
CHANGED
|
@@ -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
|
|
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=
|
|
185
|
+
Restart=on-failure
|
|
183
186
|
RestartSec=5
|
|
184
187
|
StandardOutput=append:${spec.stdout}
|
|
185
188
|
StandardError=append:${spec.stderr}
|
package/src/local/state.mjs
CHANGED
|
@@ -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 =
|
|
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");
|
|
@@ -120,6 +207,14 @@ export function ensureWorkerSecrets(state, options = {}) {
|
|
|
120
207
|
if (!state.worker.name || options.workerName) state.worker.name = options.workerName || defaultWorkerName(state.workspace.hash);
|
|
121
208
|
}
|
|
122
209
|
|
|
210
|
+
export function ensureLocalApiKey(state, options = {}) {
|
|
211
|
+
state.localApi ||= {};
|
|
212
|
+
if (options.apiKey && options.apiKey !== true) state.localApi.apiKey = String(options.apiKey);
|
|
213
|
+
if (!state.localApi.apiKey || options.rotateApiKey) state.localApi.apiKey = randomToken("local_api_key");
|
|
214
|
+
state.localApi.updatedAt = new Date().toISOString();
|
|
215
|
+
return state.localApi.apiKey;
|
|
216
|
+
}
|
|
217
|
+
|
|
123
218
|
export function defaultWorkerName(hash) {
|
|
124
219
|
return `mbm-${String(hash || "default").slice(0, 12)}`;
|
|
125
220
|
}
|
|
@@ -150,6 +245,7 @@ export function redactState(state) {
|
|
|
150
245
|
if (clone.worker?.oauthPassword) clone.worker.oauthPassword = previewSecret(clone.worker.oauthPassword);
|
|
151
246
|
if (clone.worker?.daemonSecret) clone.worker.daemonSecret = previewSecret(clone.worker.daemonSecret);
|
|
152
247
|
if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = previewSecret(clone.worker.oauthTokenVersion);
|
|
248
|
+
if (clone.localApi?.apiKey) clone.localApi.apiKey = previewSecret(clone.localApi.apiKey);
|
|
153
249
|
return clone;
|
|
154
250
|
}
|
|
155
251
|
|
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.
|
|
4
|
+
const SERVER_VERSION = "0.2.0";
|
|
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))
|
|
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
|
-
|
|
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;
|