machine-bridge-mcp 0.2.0 → 0.2.4
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 +43 -38
- package/package.json +1 -1
- package/src/local/api-server.mjs +206 -94
- package/src/local/cli.mjs +214 -60
- package/src/local/self-test.mjs +168 -20
- package/src/local/service.mjs +2 -0
- package/src/local/state.mjs +8 -0
- package/src/worker/index.ts +259 -16
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.
|
|
32
|
+
7. Starts the local daemon and local OpenAI-compatible API.
|
|
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,43 +47,54 @@ 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 local API 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.
|
|
53
|
+
|
|
54
|
+
## Local OpenAI-compatible API
|
|
55
|
+
|
|
56
|
+
The project exposes two connected integration surfaces:
|
|
48
57
|
|
|
49
|
-
|
|
58
|
+
- **ChatGPT web / ChatGPT apps:** use the Remote MCP Server URL and MCP connection password printed by `machine-mcp`. In this mode, ChatGPT calls tools on your machine through the Worker + local daemon bridge.
|
|
59
|
+
- **Desktop clients such as Cherry Studio, Chatbox, or Continue:** use the optional local OpenAI-compatible `/v1` API. `POST /v1/chat/completions` is backed by MCP sampling: the local API asks the hosted Worker to send `sampling/createMessage` to the already-connected ChatGPT MCP client, then wraps the MCP sampling result as an OpenAI-compatible chat completion response.
|
|
50
60
|
|
|
51
|
-
|
|
61
|
+
No separate model API setup is required or used in this path; the local API never asks for a model base URL or model API key. Generation depends on the ChatGPT-side MCP client actually being connected and able to receive server-to-client sampling requests. If ChatGPT is not connected, has no open MCP stream for server-to-client messages, or did not advertise the MCP `sampling` capability, generation returns an explicit OpenAI-shaped error saying that the missing piece is the MCP client stream or sampling capability.
|
|
52
62
|
|
|
53
|
-
Start
|
|
63
|
+
Start the normal daemon and local API:
|
|
64
|
+
|
|
65
|
+
```zsh
|
|
66
|
+
machine-mcp
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Start only the local API from remembered state:
|
|
54
70
|
|
|
55
71
|
```zsh
|
|
56
72
|
machine-mcp api
|
|
57
73
|
```
|
|
58
74
|
|
|
59
|
-
|
|
75
|
+
Disable the default local API for a daemon run:
|
|
60
76
|
|
|
61
77
|
```zsh
|
|
62
|
-
machine-mcp
|
|
78
|
+
machine-mcp --no-api
|
|
63
79
|
```
|
|
64
80
|
|
|
65
|
-
|
|
81
|
+
When the API is running, the CLI prints client settings like:
|
|
66
82
|
|
|
67
83
|
```text
|
|
68
84
|
API Base URL: http://127.0.0.1:8765/v1
|
|
69
85
|
API key: local_api_key_...
|
|
70
86
|
Client type: OpenAI-compatible
|
|
87
|
+
Model: chatgpt-mcp
|
|
88
|
+
Backend: ChatGPT MCP sampling via the connected ChatGPT app
|
|
71
89
|
```
|
|
72
90
|
|
|
73
|
-
Use
|
|
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`
|
|
91
|
+
Use the API Base URL, API key, and model in your desktop client. Separately, connect ChatGPT to the printed MCP Server URL/password so the Worker has an MCP client stream that can receive `sampling/createMessage`.
|
|
78
92
|
|
|
79
93
|
If port `8765` conflicts with another local app, choose a different port explicitly:
|
|
80
94
|
|
|
81
95
|
```zsh
|
|
96
|
+
machine-mcp --api-port 8766
|
|
82
97
|
machine-mcp api --api-port 8766
|
|
83
|
-
machine-mcp start --api --api-port 8766
|
|
84
98
|
```
|
|
85
99
|
|
|
86
100
|
`--port` is also accepted on the `api` command:
|
|
@@ -89,34 +103,23 @@ machine-mcp start --api --api-port 8766
|
|
|
89
103
|
machine-mcp api --port 8766
|
|
90
104
|
```
|
|
91
105
|
|
|
92
|
-
By default, the local API binds to `127.0.0.1
|
|
93
|
-
|
|
94
|
-
```zsh
|
|
95
|
-
machine-mcp api --rotate-api-key
|
|
96
|
-
```
|
|
106
|
+
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`, and `--api-model` values are persisted for the workspace so autostart uses the same API settings. `--api-model` only controls the local model id advertised by `GET /v1/models`; the actual model is chosen by the connected MCP client, and any different `model` value in a chat-completions request is passed as an MCP model preference hint.
|
|
97
107
|
|
|
98
|
-
|
|
108
|
+
Rotate the local desktop-client API key with:
|
|
99
109
|
|
|
100
110
|
```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
|
|
111
|
+
machine-mcp api --rotate-api-key
|
|
105
112
|
```
|
|
106
113
|
|
|
107
|
-
Environment variables
|
|
114
|
+
Environment variables supported for the current process: `MBM_API_HOST`, `MBM_API_PORT`, `MBM_API_KEY`, and `MBM_API_MODEL`. Environment overrides are not persisted to state; use `--api-host`, `--api-port`, `--api-key`, or `--api-model` when you want a setting saved for future runs and autostart.
|
|
108
115
|
|
|
109
116
|
Supported local API routes:
|
|
110
117
|
|
|
111
118
|
- `GET /health` without authentication
|
|
112
119
|
- `GET /v1/models` with `Authorization: Bearer <local_api_key>` or `x-api-key`
|
|
113
120
|
- `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
121
|
|
|
122
|
+
`POST /v1/responses`, `POST /v1/completions`, and `POST /v1/embeddings` return `501 unsupported_endpoint`; MCP sampling is a chat-message path and does not expose embeddings or the full Responses API. Logs record route, status, latency, and safe configuration metadata; request and response bodies and API keys are not logged.
|
|
120
123
|
|
|
121
124
|
## Re-select workspace
|
|
122
125
|
|
|
@@ -237,7 +240,7 @@ Default state roots:
|
|
|
237
240
|
- Linux with `XDG_STATE_HOME`: `$XDG_STATE_HOME/machine-bridge-mcp`
|
|
238
241
|
- Windows: `%APPDATA%\machine-bridge-mcp`
|
|
239
242
|
|
|
240
|
-
State contains the MCP password, daemon secret, and local API key
|
|
243
|
+
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
244
|
|
|
242
245
|
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
246
|
|
|
@@ -251,13 +254,15 @@ machine-mcp --state-dir /path/to/state
|
|
|
251
254
|
|
|
252
255
|
```mermaid
|
|
253
256
|
flowchart LR
|
|
254
|
-
C["
|
|
257
|
+
C["ChatGPT / MCP client"] -- "HTTPS /mcp + OAuth" --> W["Hosted Worker relay"]
|
|
258
|
+
C -- "GET SSE stream for server-to-client MCP" --> W
|
|
255
259
|
W --> DO["Durable Object broker"]
|
|
256
260
|
D["Local daemon"] -- "outbound WebSocket" --> W
|
|
257
261
|
D --> M["Local filesystem and shell"]
|
|
258
|
-
API["
|
|
262
|
+
API["Local /v1/chat/completions"] -- "POST /api/mcp/sampling" --> W
|
|
263
|
+
DO -- "sampling/createMessage" --> C
|
|
259
264
|
CLI["machine-mcp CLI"] --> API
|
|
260
|
-
CLI
|
|
265
|
+
CLI --> W
|
|
261
266
|
CLI --> D
|
|
262
267
|
CLI --> S["Autostart service"]
|
|
263
268
|
```
|
|
@@ -269,8 +274,8 @@ Why this architecture:
|
|
|
269
274
|
- The public MCP URL is stable after deployment.
|
|
270
275
|
- The Worker stores OAuth client/code/token metadata and relays tool calls.
|
|
271
276
|
- 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.
|
|
277
|
+
- The local `/v1` API binds to loopback by default, starts automatically with the daemon, and can be disabled with `--no-api`.
|
|
278
|
+
- Autostart keeps the daemon and local API alive across logins without requiring MCP clients to change URLs.
|
|
274
279
|
|
|
275
280
|
## Development
|
|
276
281
|
|
package/package.json
CHANGED
package/src/local/api-server.mjs
CHANGED
|
@@ -1,36 +1,14 @@
|
|
|
1
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";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
3
|
import { createLogger } from "./log.mjs";
|
|
6
4
|
|
|
7
5
|
export const DEFAULT_API_HOST = "127.0.0.1";
|
|
8
6
|
export const DEFAULT_API_PORT = 8765;
|
|
9
|
-
export const
|
|
10
|
-
export const DEFAULT_API_MODEL = "machine-bridge-mcp";
|
|
7
|
+
export const DEFAULT_API_MODEL = "chatgpt-mcp";
|
|
11
8
|
export const DEFAULT_API_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
export const DEFAULT_SAMPLING_TIMEOUT_MS = 180_000;
|
|
12
10
|
|
|
13
|
-
const
|
|
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
|
-
]);
|
|
11
|
+
const CHAT_COMPLETIONS_PATH = "/v1/chat/completions";
|
|
34
12
|
|
|
35
13
|
export function parseApiPort(value, fallback = DEFAULT_API_PORT) {
|
|
36
14
|
if (value === undefined || value === null || value === true || value === "") return fallback;
|
|
@@ -43,38 +21,25 @@ export function normalizeApiHost(value, fallback = DEFAULT_API_HOST) {
|
|
|
43
21
|
if (value === undefined || value === null || value === true || value === "") return fallback;
|
|
44
22
|
const host = String(value).trim();
|
|
45
23
|
if (!host) return fallback;
|
|
46
|
-
if (/[
|
|
24
|
+
if (/[\\/\s]/.test(host)) throw new Error(`Invalid API host: ${value}`);
|
|
47
25
|
return host;
|
|
48
26
|
}
|
|
49
27
|
|
|
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
28
|
export async function startLocalApiServer(options = {}) {
|
|
65
29
|
const logger = options.logger || createLogger({ component: "api", quiet: options.quiet });
|
|
66
30
|
const host = normalizeApiHost(options.host);
|
|
67
31
|
const port = parseApiPort(options.port);
|
|
68
32
|
const apiKey = String(options.apiKey || "");
|
|
69
|
-
const upstreamUrl = normalizeBaseUrl(options.upstreamUrl);
|
|
70
|
-
const upstreamKey = String(options.upstreamKey || "");
|
|
71
33
|
const model = String(options.model || DEFAULT_API_MODEL);
|
|
34
|
+
const workerUrl = String(options.workerUrl || "").replace(/\/+$/, "");
|
|
35
|
+
const daemonSecret = String(options.daemonSecret || "");
|
|
72
36
|
const maxBodyBytes = Number(options.maxBodyBytes || DEFAULT_API_MAX_BODY_BYTES);
|
|
37
|
+
const samplingTimeoutMs = Number(options.samplingTimeoutMs || DEFAULT_SAMPLING_TIMEOUT_MS);
|
|
73
38
|
|
|
74
39
|
if (!apiKey) throw new Error("Local API key is missing");
|
|
75
40
|
|
|
76
41
|
const server = http.createServer((req, res) => {
|
|
77
|
-
void handleRequest(req, res, { logger, apiKey,
|
|
42
|
+
void handleRequest(req, res, { logger, apiKey, model, workerUrl, daemonSecret, maxBodyBytes, samplingTimeoutMs });
|
|
78
43
|
});
|
|
79
44
|
|
|
80
45
|
server.keepAliveTimeout = 65_000;
|
|
@@ -99,7 +64,7 @@ export async function startLocalApiServer(options = {}) {
|
|
|
99
64
|
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
100
65
|
const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
101
66
|
const baseUrl = `http://${urlHost}:${actualPort}/v1`;
|
|
102
|
-
logger.success("local OpenAI-compatible API started", { baseUrl, model,
|
|
67
|
+
logger.success("local OpenAI-compatible API started", { baseUrl, model, backend: "chatgpt-mcp", mcpBridgeConfigured: Boolean(workerUrl && daemonSecret) });
|
|
103
68
|
|
|
104
69
|
return {
|
|
105
70
|
server,
|
|
@@ -107,6 +72,8 @@ export async function startLocalApiServer(options = {}) {
|
|
|
107
72
|
port: actualPort,
|
|
108
73
|
baseUrl,
|
|
109
74
|
url: `http://${urlHost}:${actualPort}`,
|
|
75
|
+
apiKey,
|
|
76
|
+
model,
|
|
110
77
|
close() {
|
|
111
78
|
return new Promise(resolve => server.close(() => resolve()));
|
|
112
79
|
},
|
|
@@ -121,66 +88,199 @@ async function handleRequest(req, res, context) {
|
|
|
121
88
|
|
|
122
89
|
try {
|
|
123
90
|
if (req.method === "OPTIONS") return sendEmpty(res, 204);
|
|
124
|
-
if (req.method === "GET" && url.pathname === "/health")
|
|
91
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
92
|
+
return sendJson(res, 200, {
|
|
93
|
+
ok: true,
|
|
94
|
+
service: "machine-bridge-mcp-local-api",
|
|
95
|
+
backend: "chatgpt-mcp",
|
|
96
|
+
api_key_sha256: sha256String(context.apiKey),
|
|
97
|
+
mcp_bridge_configured: Boolean(context.workerUrl && context.daemonSecret),
|
|
98
|
+
model: context.model,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
125
101
|
|
|
126
102
|
if (!isAuthorized(req, context.apiKey)) return sendOpenAiError(res, 401, "invalid_api_key", "Missing or invalid local API key.");
|
|
127
103
|
|
|
128
104
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
129
105
|
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
|
|
106
|
+
return sendJson(res, 200, modelsPayload(context));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (req.method === "POST" && ["/v1/responses", "/v1/completions", "/v1/embeddings"].includes(url.pathname)) {
|
|
110
|
+
return sendOpenAiError(res, 501, "unsupported_endpoint", "Only /v1/chat/completions is available through the ChatGPT MCP-backed local API. Embeddings, legacy completions, and Responses API are not exposed by MCP sampling.");
|
|
131
111
|
}
|
|
132
112
|
|
|
133
|
-
if (req.method === "POST" &&
|
|
134
|
-
if (!context.
|
|
135
|
-
return sendOpenAiError(res, 503, "
|
|
113
|
+
if (req.method === "POST" && url.pathname === CHAT_COMPLETIONS_PATH) {
|
|
114
|
+
if (!context.workerUrl || !context.daemonSecret) {
|
|
115
|
+
return sendOpenAiError(res, 503, "mcp_bridge_not_configured", "Local API is not connected to a Remote MCP bridge yet. Run `machine-mcp` normally so the Worker URL and MCP daemon secret exist, then reconnect ChatGPT to the printed MCP Server URL.");
|
|
136
116
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
context.logger.info("
|
|
117
|
+
const payload = await readJsonBody(req, context.maxBodyBytes);
|
|
118
|
+
const { params: samplingRequest, modelHint } = samplingRequestFromOpenAiChat(payload, context.model);
|
|
119
|
+
context.logger.info("MCP sampling request started", { requestId, path: url.pathname, modelHint: modelHint || null });
|
|
120
|
+
const result = await requestMcpSampling(context, samplingRequest);
|
|
121
|
+
const text = extractSamplingText(result);
|
|
122
|
+
const model = String(result?.model || modelHint || context.model);
|
|
123
|
+
const finishReason = finishReasonFromSampling(result);
|
|
124
|
+
if (payload.stream === true) sendChatCompletionStream(res, { text, model, finishReason });
|
|
125
|
+
else sendJson(res, 200, chatCompletionPayload({ text, model, finishReason }));
|
|
126
|
+
context.logger.info("MCP sampling request completed", { requestId, path: url.pathname, status: res.statusCode, durationMs: Date.now() - started });
|
|
140
127
|
return;
|
|
141
128
|
}
|
|
142
129
|
|
|
143
130
|
return sendOpenAiError(res, 404, "not_found", `Unknown local API endpoint: ${url.pathname}`);
|
|
144
131
|
} catch (error) {
|
|
145
|
-
context.logger.error("request failed", { requestId, path: url.pathname,
|
|
132
|
+
context.logger.error("request failed", safeErrorLogFields(error, { requestId, path: url.pathname, durationMs: Date.now() - started }));
|
|
146
133
|
if (!res.headersSent) {
|
|
147
134
|
if (error?.code === "BODY_TOO_LARGE") return sendOpenAiError(res, 413, "request_too_large", error.message);
|
|
148
|
-
return sendOpenAiError(res,
|
|
135
|
+
if (error instanceof ApiError) return sendOpenAiError(res, error.status, error.code, error.message);
|
|
136
|
+
return sendOpenAiError(res, 502, "mcp_sampling_error", error.message || "Local API request failed.");
|
|
149
137
|
}
|
|
150
138
|
res.destroy(error);
|
|
151
139
|
}
|
|
152
140
|
}
|
|
153
141
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
142
|
+
function samplingRequestFromOpenAiChat(payload, advertisedModel) {
|
|
143
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new ApiError(400, "invalid_request_error", "Request body must be a JSON object.");
|
|
144
|
+
if (!Array.isArray(payload.messages)) throw new ApiError(400, "invalid_request_error", "messages must be an array.");
|
|
145
|
+
const requestedModel = typeof payload.model === "string" && payload.model.trim() ? payload.model.trim() : "";
|
|
146
|
+
const modelHint = requestedModel && requestedModel !== advertisedModel ? requestedModel : "";
|
|
147
|
+
const messages = [];
|
|
148
|
+
const systemParts = [];
|
|
149
|
+
for (const message of payload.messages) {
|
|
150
|
+
const role = normalizeRole(message?.role);
|
|
151
|
+
const text = contentToText(message?.content);
|
|
152
|
+
if (!text) continue;
|
|
153
|
+
if (role === "system") systemParts.push(text);
|
|
154
|
+
else messages.push({ role, content: { type: "text", text } });
|
|
155
|
+
}
|
|
156
|
+
if (!messages.length) throw new ApiError(400, "invalid_request_error", "No user/assistant message content was provided.");
|
|
157
|
+
const params = {
|
|
158
|
+
messages,
|
|
159
|
+
systemPrompt: systemParts.join("\n\n") || undefined,
|
|
160
|
+
maxTokens: clampInt(payload.max_tokens ?? payload.max_completion_tokens ?? payload.max_output_tokens, 1024, 1, 128000),
|
|
161
|
+
stopSequences: Array.isArray(payload.stop) ? payload.stop.map(String) : typeof payload.stop === "string" ? [payload.stop] : undefined,
|
|
162
|
+
};
|
|
163
|
+
if (typeof payload.temperature === "number" && Number.isFinite(payload.temperature)) params.temperature = payload.temperature;
|
|
164
|
+
if (modelHint) params.modelPreferences = { hints: [{ name: modelHint }] };
|
|
165
|
+
return { params: removeUndefined(params), modelHint };
|
|
166
|
+
}
|
|
164
167
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
async function requestMcpSampling(context, samplingRequest) {
|
|
169
|
+
let response;
|
|
170
|
+
try {
|
|
171
|
+
response = await fetch(`${context.workerUrl}/api/mcp/sampling`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: {
|
|
174
|
+
"content-type": "application/json",
|
|
175
|
+
"X-Bridge-Token": context.daemonSecret,
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({ ...samplingRequest, timeout_ms: context.samplingTimeoutMs }),
|
|
178
|
+
signal: AbortSignal.timeout(context.samplingTimeoutMs + 5_000),
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
182
|
+
throw new ApiError(504, "mcp_sampling_timeout", "Timed out waiting for the Worker and connected MCP client to complete sampling/createMessage.");
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
168
185
|
}
|
|
169
|
-
|
|
170
|
-
if (response.
|
|
171
|
-
|
|
186
|
+
const body = await response.json().catch(() => null);
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
throw new ApiError(response.status, String(body?.error || "mcp_sampling_error"), String(body?.message || `MCP sampling request failed with HTTP ${response.status}`));
|
|
189
|
+
}
|
|
190
|
+
return body?.result ?? body;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function extractSamplingText(result) {
|
|
194
|
+
const content = result?.content;
|
|
195
|
+
if (typeof content === "string") return content;
|
|
196
|
+
if (content?.type === "text" && typeof content.text === "string") return content.text;
|
|
197
|
+
if (Array.isArray(content)) return content.map(item => item?.type === "text" ? item.text : "").filter(Boolean).join("\n");
|
|
198
|
+
if (typeof result?.text === "string") return result.text;
|
|
199
|
+
return JSON.stringify(result ?? {});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function finishReasonFromSampling(result) {
|
|
203
|
+
const reason = String(result?.stopReason || "").toLowerCase();
|
|
204
|
+
if (reason === "maxtokens" || reason === "max_tokens" || reason === "length") return "length";
|
|
205
|
+
if (reason === "tooluse" || reason === "tool_use") return "tool_calls";
|
|
206
|
+
return "stop";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function chatCompletionPayload({ text, model, finishReason }) {
|
|
210
|
+
return {
|
|
211
|
+
id: `chatcmpl-${randomUUID()}`,
|
|
212
|
+
object: "chat.completion",
|
|
213
|
+
created: Math.floor(Date.now() / 1000),
|
|
214
|
+
model,
|
|
215
|
+
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: finishReason }],
|
|
216
|
+
usage: null,
|
|
217
|
+
};
|
|
172
218
|
}
|
|
173
219
|
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
220
|
+
function sendChatCompletionStream(res, { text, model, finishReason }) {
|
|
221
|
+
res.statusCode = 200;
|
|
222
|
+
res.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
223
|
+
res.setHeader("cache-control", "no-cache");
|
|
224
|
+
const id = `chatcmpl-${randomUUID()}`;
|
|
225
|
+
const created = Math.floor(Date.now() / 1000);
|
|
226
|
+
const first = { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] };
|
|
227
|
+
const done = { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: finishReason }] };
|
|
228
|
+
res.write(`data: ${JSON.stringify(first)}\n\n`);
|
|
229
|
+
res.write(`data: ${JSON.stringify(done)}\n\n`);
|
|
230
|
+
res.write("data: [DONE]\n\n");
|
|
231
|
+
res.end();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function modelsPayload(context) {
|
|
235
|
+
return {
|
|
236
|
+
object: "list",
|
|
237
|
+
data: [{ id: context.model, object: "model", created: 0, owned_by: "chatgpt-mcp" }],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function normalizeRole(role) {
|
|
242
|
+
if (role === "assistant") return "assistant";
|
|
243
|
+
if (role === "system" || role === "developer") return "system";
|
|
244
|
+
return "user";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function contentToText(content) {
|
|
248
|
+
if (content === undefined || content === null) return "";
|
|
249
|
+
if (typeof content === "string") return content;
|
|
250
|
+
if (Array.isArray(content)) return content.map(contentPartToText).filter(Boolean).join("\n");
|
|
251
|
+
return contentPartToText(content);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function contentPartToText(content) {
|
|
255
|
+
if (content === undefined || content === null) return "";
|
|
256
|
+
if (typeof content === "string") return content;
|
|
257
|
+
if (Array.isArray(content)) return content.map(contentPartToText).filter(Boolean).join("\n");
|
|
258
|
+
if (typeof content === "object") {
|
|
259
|
+
if ((content.type === "text" || content.type === "input_text") && typeof content.text === "string") return content.text;
|
|
260
|
+
if (typeof content.text === "string") return content.text;
|
|
261
|
+
if (typeof content.content === "string") return content.content;
|
|
262
|
+
if (content.type === "text" && typeof content.value === "string") return content.value;
|
|
263
|
+
if (typeof content.type === "string" && content.type) {
|
|
264
|
+
throw new ApiError(400, "unsupported_content", `Only text message content is supported by this MCP-backed local chat API; unsupported content part: ${content.type}.`);
|
|
265
|
+
}
|
|
266
|
+
return "";
|
|
180
267
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
268
|
+
return String(content);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function removeUndefined(value) {
|
|
272
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function readJsonBody(req, maxBytes) {
|
|
276
|
+
return readBody(req, maxBytes).then(buffer => {
|
|
277
|
+
try {
|
|
278
|
+
const text = buffer.toString("utf8");
|
|
279
|
+
return text.trim() ? JSON.parse(text) : {};
|
|
280
|
+
} catch {
|
|
281
|
+
throw new ApiError(400, "invalid_json", "Request body is not valid JSON.");
|
|
282
|
+
}
|
|
283
|
+
});
|
|
184
284
|
}
|
|
185
285
|
|
|
186
286
|
function readBody(req, maxBytes) {
|
|
@@ -203,6 +303,10 @@ function readBody(req, maxBytes) {
|
|
|
203
303
|
});
|
|
204
304
|
}
|
|
205
305
|
|
|
306
|
+
function sha256String(value) {
|
|
307
|
+
return createHash("sha256").update(String(value)).digest("hex");
|
|
308
|
+
}
|
|
309
|
+
|
|
206
310
|
function isAuthorized(req, expectedKey) {
|
|
207
311
|
const auth = String(req.headers.authorization || "");
|
|
208
312
|
if (auth.startsWith("Bearer ") && auth.slice(7) === expectedKey) return true;
|
|
@@ -210,18 +314,6 @@ function isAuthorized(req, expectedKey) {
|
|
|
210
314
|
return typeof apiKey === "string" && apiKey === expectedKey;
|
|
211
315
|
}
|
|
212
316
|
|
|
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
317
|
function sendOpenAiError(res, status, code, message) {
|
|
226
318
|
return sendJson(res, status, { error: { message, type: "invalid_request_error", param: null, code } });
|
|
227
319
|
}
|
|
@@ -245,12 +337,32 @@ function setCorsHeaders(res) {
|
|
|
245
337
|
res.setHeader("access-control-allow-headers", "authorization,content-type,x-api-key,openai-beta,openai-organization,openai-project");
|
|
246
338
|
}
|
|
247
339
|
|
|
340
|
+
function safeErrorLogFields(error, fields) {
|
|
341
|
+
if (error instanceof ApiError) return { ...fields, status: error.status, code: error.code };
|
|
342
|
+
if (error?.code === "BODY_TOO_LARGE") return { ...fields, status: 413, code: "request_too_large" };
|
|
343
|
+
return { ...fields, error: error?.name || "Error" };
|
|
344
|
+
}
|
|
345
|
+
|
|
248
346
|
function withPortHint(error, port) {
|
|
249
347
|
if (error?.code === "EADDRINUSE") {
|
|
250
|
-
error.message = `Local API port ${port} is already in use. Re-run with \`machine-mcp
|
|
348
|
+
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
349
|
}
|
|
252
350
|
if (error?.code === "EACCES") {
|
|
253
|
-
error.message = `Local API port ${port} is not permitted. Re-run with \`machine-mcp api --api-port <free_port>\`.`;
|
|
351
|
+
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
352
|
}
|
|
255
353
|
return error;
|
|
256
354
|
}
|
|
355
|
+
|
|
356
|
+
function clampInt(value, fallback, min, max) {
|
|
357
|
+
const number = Number(value);
|
|
358
|
+
if (!Number.isFinite(number)) return fallback;
|
|
359
|
+
return Math.min(Math.max(Math.floor(number), min), max);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
class ApiError extends Error {
|
|
363
|
+
constructor(status, code, message) {
|
|
364
|
+
super(message);
|
|
365
|
+
this.status = status;
|
|
366
|
+
this.code = code;
|
|
367
|
+
}
|
|
368
|
+
}
|