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 +27 -33
- package/package.json +1 -1
- package/src/local/api-server.mjs +199 -91
- package/src/local/cli.mjs +40 -27
- package/src/local/self-test.mjs +164 -20
- package/src/local/state.mjs +8 -0
- package/src/worker/index.ts +259 -16
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
|
|
32
|
-
7. Starts the local daemon and local OpenAI-compatible API
|
|
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
|
|
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
|
|
54
|
+
## Local OpenAI-compatible API
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
The project exposes two connected integration surfaces:
|
|
57
57
|
|
|
58
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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`,
|
|
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
|
-
|
|
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
|
|
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["
|
|
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["
|
|
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
|
|
265
|
+
CLI --> W
|
|
272
266
|
CLI --> D
|
|
273
267
|
CLI --> S["Autostart service"]
|
|
274
268
|
```
|
package/package.json
CHANGED
package/src/local/api-server.mjs
CHANGED
|
@@ -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
|
|
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.");
|
|
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;
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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) {
|
|
@@ -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
|
+
}
|