machine-bridge-mcp 0.2.1 → 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,8 +28,8 @@ 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 and default local API provider.
32
- 7. Starts the local daemon and local OpenAI-compatible API provider.
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
33
  8. Prints MCP connection details on first run, plus local API settings:
34
34
 
35
35
  ```text
@@ -47,45 +47,48 @@ The command is safe to run repeatedly:
47
47
  npm install -g machine-bridge-mcp@latest && machine-mcp
48
48
  ```
49
49
 
50
- On repeat runs, the CLI reuses existing state and secrets unless you request rotation, skips Worker redeploys when the deployed Worker is healthy and Worker source/config/secrets are unchanged, refreshes the autostart entry, stops any currently loaded autostart daemon before starting the foreground daemon, and refuses to start a second daemon for the same workspace if another foreground instance is already running. Local-only package, CLI, logging, and API-provider changes do not by themselves force a Worker redeploy.
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
51
 
52
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
53
 
54
- ## Local OpenAI-compatible API provider
54
+ ## Local OpenAI-compatible API
55
55
 
56
- `machine-bridge-mcp` exposes a local OpenAI-compatible API provider by default for desktop AI clients such as Cherry Studio, Chatbox, Continue, or other apps that accept an OpenAI-style base URL and API key. No extra command is needed; the recommended `machine-mcp` command starts both the Remote MCP daemon and the local API provider.
56
+ The project exposes two connected integration surfaces:
57
57
 
58
- Start the normal daemon and 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.
60
+
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.
62
+
63
+ Start the normal daemon and local API:
59
64
 
60
65
  ```zsh
61
66
  machine-mcp
62
67
  ```
63
68
 
64
- Start only the local API service, without the Remote MCP daemon:
69
+ Start only the local API from remembered state:
65
70
 
66
71
  ```zsh
67
72
  machine-mcp api
68
73
  ```
69
74
 
70
- Disable the default local API provider for a daemon run:
75
+ Disable the default local API for a daemon run:
71
76
 
72
77
  ```zsh
73
78
  machine-mcp --no-api
74
79
  ```
75
80
 
76
- The CLI prints client settings like:
81
+ When the API is running, the CLI prints client settings like:
77
82
 
78
83
  ```text
79
84
  API Base URL: http://127.0.0.1:8765/v1
80
85
  API key: local_api_key_...
81
86
  Client type: OpenAI-compatible
87
+ Model: chatgpt-mcp
88
+ Backend: ChatGPT MCP sampling via the connected ChatGPT app
82
89
  ```
83
90
 
84
- Use these values in the local AI client:
85
-
86
- - Base URL: `http://127.0.0.1:8765/v1`
87
- - API key: the `local_api_key_...` printed by the CLI
88
- - 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`.
89
92
 
90
93
  If port `8765` conflicts with another local app, choose a different port explicitly:
91
94
 
@@ -100,34 +103,23 @@ machine-mcp api --api-port 8766
100
103
  machine-mcp api --port 8766
101
104
  ```
102
105
 
103
- By default, the local API binds to `127.0.0.1`, starts with `machine-mcp`, and stores a per-workspace local API key in the same owner-only state profile used by the MCP credentials. Explicit `--api-host`, `--api-port`, `--api-model`, and `--api-upstream-url` values are persisted for the workspace so autostart uses the same non-secret API settings; upstream API keys are not written to state. Rotate the local API key with:
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.
104
107
 
105
- ```zsh
106
- machine-mcp api --rotate-api-key
107
- ```
108
-
109
- Configure an upstream OpenAI-compatible provider:
108
+ Rotate the local desktop-client API key with:
110
109
 
111
110
  ```zsh
112
- machine-mcp api \
113
- --api-upstream-url https://api.openai.com/v1 \
114
- --api-upstream-key "$OPENAI_API_KEY" \
115
- --api-model gpt-4.1
111
+ machine-mcp api --rotate-api-key
116
112
  ```
117
113
 
118
- 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.
119
115
 
120
116
  Supported local API routes:
121
117
 
122
118
  - `GET /health` without authentication
123
119
  - `GET /v1/models` with `Authorization: Bearer <local_api_key>` or `x-api-key`
124
120
  - `POST /v1/chat/completions`
125
- - `POST /v1/responses`
126
- - `POST /v1/embeddings`
127
- - `POST /v1/completions`
128
-
129
- 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.
130
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.
131
123
 
132
124
  ## Re-select workspace
133
125
 
@@ -262,13 +254,15 @@ machine-mcp --state-dir /path/to/state
262
254
 
263
255
  ```mermaid
264
256
  flowchart LR
265
- 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
266
259
  W --> DO["Durable Object broker"]
267
260
  D["Local daemon"] -- "outbound WebSocket" --> W
268
261
  D --> M["Local filesystem and shell"]
269
- API["Default 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
270
264
  CLI["machine-mcp CLI"] --> API
271
- CLI["machine-mcp CLI"] --> W
265
+ CLI --> W
272
266
  CLI --> D
273
267
  CLI --> S["Autostart service"]
274
268
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.2.1",
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
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { Readable } from "node:stream";
4
- import { pipeline } from "node:stream/promises";
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.");
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", api_key_sha256: sha256String(context.apiKey) });
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;
185
+ }
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}`));
168
189
  }
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();
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) {
@@ -214,18 +314,6 @@ function isAuthorized(req, expectedKey) {
214
314
  return typeof apiKey === "string" && apiKey === expectedKey;
215
315
  }
216
316
 
217
- function modelsPayload(model) {
218
- return {
219
- object: "list",
220
- data: [{
221
- id: model,
222
- object: "model",
223
- created: 0,
224
- owned_by: "machine-bridge-mcp",
225
- }],
226
- };
227
- }
228
-
229
317
  function sendOpenAiError(res, status, code, message) {
230
318
  return sendJson(res, status, { error: { message, type: "invalid_request_error", param: null, code } });
231
319
  }
@@ -249,6 +337,12 @@ function setCorsHeaders(res) {
249
337
  res.setHeader("access-control-allow-headers", "authorization,content-type,x-api-key,openai-beta,openai-organization,openai-project");
250
338
  }
251
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
+
252
346
  function withPortHint(error, port) {
253
347
  if (error?.code === "EADDRINUSE") {
254
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>\`.`;
@@ -258,3 +352,17 @@ function withPortHint(error, port) {
258
352
  }
259
353
  return error;
260
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
+ }