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 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 prints:
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
- ## Local OpenAI-compatible API provider
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
- `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.
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 only the local API service:
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
- Or run it together with the Remote MCP daemon:
75
+ Disable the default local API for a daemon run:
60
76
 
61
77
  ```zsh
62
- machine-mcp start --api
78
+ machine-mcp --no-api
63
79
  ```
64
80
 
65
- The CLI prints client settings like:
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 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`
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` 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
- ```
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
- Configure an upstream OpenAI-compatible provider:
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 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`.
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 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.
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["Remote MCP client"] -- "HTTPS /mcp + OAuth" --> W["Hosted Worker relay"]
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["Optional local /v1 API"] -- "OpenAI-compatible HTTP" --> U["Configured upstream provider"]
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["machine-mcp CLI"] --> W
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 optional local `/v1` API binds to loopback by default and starts only when requested.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "description": "One-command hosted Remote MCP bridge to a local machine daemon.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 DEFAULT_UPSTREAM_URL = "https://api.openai.com/v1";
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 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
- ]);
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 (/[/\\\s]/.test(host)) throw new Error(`Invalid API host: ${value}`);
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, upstreamUrl, upstreamKey, model, maxBodyBytes });
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, upstream: upstreamUrl, upstreamConfigured: Boolean(upstreamKey) });
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") return sendJson(res, 200, { ok: true, service: "machine-bridge-mcp-local-api" });
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.model));
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" && 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.");
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
- 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 });
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, error: error.message, durationMs: Date.now() - started });
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, 502, "upstream_error", error.message || "Local API request failed.");
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
- 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
- });
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
- 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
+ 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
- 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();
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 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));
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
- out.set("authorization", `Bearer ${upstreamKey}`);
182
- if (!out.has("content-type")) out.set("content-type", "application/json");
183
- return out;
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 api --api-port <free_port>\` or \`machine-mcp start --api --api-port <free_port>\`.`;
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
+ }