machine-bridge-mcp 0.2.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tsconfig.json CHANGED
@@ -10,10 +10,12 @@
10
10
  "strict": true,
11
11
  "noEmit": true,
12
12
  "skipLibCheck": true,
13
- "forceConsistentCasingInFileNames": true
13
+ "forceConsistentCasingInFileNames": true,
14
+ "resolveJsonModule": true
14
15
  },
15
16
  "include": [
16
17
  "src/worker/**/*.ts",
17
- "src/worker/**/*.d.ts"
18
+ "src/worker/**/*.d.ts",
19
+ "src/shared/**/*.json"
18
20
  ]
19
21
  }
package/wrangler.jsonc CHANGED
@@ -21,10 +21,10 @@
21
21
  ],
22
22
  "observability": {
23
23
  "enabled": true,
24
- "head_sampling_rate": 1
24
+ "head_sampling_rate": 0.1
25
25
  },
26
26
  "vars": {
27
- "MBM_WORKER_MAX_BODY_BYTES": "33554432",
27
+ "MBM_WORKER_MAX_BODY_BYTES": "8388608",
28
28
  "MBM_ALLOWED_ORIGINS": ""
29
29
  }
30
30
  }
@@ -1,368 +0,0 @@
1
- import http from "node:http";
2
- import { createHash, randomUUID } from "node:crypto";
3
- import { createLogger } from "./log.mjs";
4
-
5
- export const DEFAULT_API_HOST = "127.0.0.1";
6
- export const DEFAULT_API_PORT = 8765;
7
- export const DEFAULT_API_MODEL = "chatgpt-mcp";
8
- export const DEFAULT_API_MAX_BODY_BYTES = 32 * 1024 * 1024;
9
- export const DEFAULT_SAMPLING_TIMEOUT_MS = 180_000;
10
-
11
- const CHAT_COMPLETIONS_PATH = "/v1/chat/completions";
12
-
13
- export function parseApiPort(value, fallback = DEFAULT_API_PORT) {
14
- if (value === undefined || value === null || value === true || value === "") return fallback;
15
- const port = Number(value);
16
- if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid API port: ${value}`);
17
- return port;
18
- }
19
-
20
- export function normalizeApiHost(value, fallback = DEFAULT_API_HOST) {
21
- if (value === undefined || value === null || value === true || value === "") return fallback;
22
- const host = String(value).trim();
23
- if (!host) return fallback;
24
- if (/[\\/\s]/.test(host)) throw new Error(`Invalid API host: ${value}`);
25
- return host;
26
- }
27
-
28
- export async function startLocalApiServer(options = {}) {
29
- const logger = options.logger || createLogger({ component: "api", quiet: options.quiet });
30
- const host = normalizeApiHost(options.host);
31
- const port = parseApiPort(options.port);
32
- const apiKey = String(options.apiKey || "");
33
- const model = String(options.model || DEFAULT_API_MODEL);
34
- const workerUrl = String(options.workerUrl || "").replace(/\/+$/, "");
35
- const daemonSecret = String(options.daemonSecret || "");
36
- const maxBodyBytes = Number(options.maxBodyBytes || DEFAULT_API_MAX_BODY_BYTES);
37
- const samplingTimeoutMs = Number(options.samplingTimeoutMs || DEFAULT_SAMPLING_TIMEOUT_MS);
38
-
39
- if (!apiKey) throw new Error("Local API key is missing");
40
-
41
- const server = http.createServer((req, res) => {
42
- void handleRequest(req, res, { logger, apiKey, model, workerUrl, daemonSecret, maxBodyBytes, samplingTimeoutMs });
43
- });
44
-
45
- server.keepAliveTimeout = 65_000;
46
- server.headersTimeout = 70_000;
47
- server.requestTimeout = 0;
48
-
49
- await new Promise((resolve, reject) => {
50
- const onError = error => {
51
- server.off("listening", onListening);
52
- reject(withPortHint(error, port));
53
- };
54
- const onListening = () => {
55
- server.off("error", onError);
56
- resolve();
57
- };
58
- server.once("error", onError);
59
- server.once("listening", onListening);
60
- server.listen({ host, port });
61
- });
62
-
63
- const address = server.address();
64
- const actualPort = typeof address === "object" && address ? address.port : port;
65
- const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
66
- const baseUrl = `http://${urlHost}:${actualPort}/v1`;
67
- logger.success("local OpenAI-compatible API started", { baseUrl, model, backend: "chatgpt-mcp", mcpBridgeConfigured: Boolean(workerUrl && daemonSecret) });
68
-
69
- return {
70
- server,
71
- host,
72
- port: actualPort,
73
- baseUrl,
74
- url: `http://${urlHost}:${actualPort}`,
75
- apiKey,
76
- model,
77
- close() {
78
- return new Promise(resolve => server.close(() => resolve()));
79
- },
80
- };
81
- }
82
-
83
- async function handleRequest(req, res, context) {
84
- const requestId = randomUUID().slice(0, 8);
85
- const started = Date.now();
86
- const url = new URL(req.url || "/", "http://127.0.0.1");
87
- setCorsHeaders(res);
88
-
89
- try {
90
- if (req.method === "OPTIONS") return sendEmpty(res, 204);
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
- }
101
-
102
- if (!isAuthorized(req, context.apiKey)) return sendOpenAiError(res, 401, "invalid_api_key", "Missing or invalid local API key.");
103
-
104
- if (req.method === "GET" && url.pathname === "/v1/models") {
105
- context.logger.info("request completed", { requestId, method: req.method, path: url.pathname, status: 200, durationMs: Date.now() - started });
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.");
111
- }
112
-
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.");
116
- }
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 });
127
- return;
128
- }
129
-
130
- return sendOpenAiError(res, 404, "not_found", `Unknown local API endpoint: ${url.pathname}`);
131
- } catch (error) {
132
- context.logger.error("request failed", safeErrorLogFields(error, { requestId, path: url.pathname, durationMs: Date.now() - started }));
133
- if (!res.headersSent) {
134
- if (error?.code === "BODY_TOO_LARGE") return sendOpenAiError(res, 413, "request_too_large", error.message);
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.");
137
- }
138
- res.destroy(error);
139
- }
140
- }
141
-
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
- }
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}`));
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
- };
218
- }
219
-
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 "";
267
- }
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
- });
284
- }
285
-
286
- function readBody(req, maxBytes) {
287
- return new Promise((resolve, reject) => {
288
- const chunks = [];
289
- let total = 0;
290
- req.on("data", chunk => {
291
- total += chunk.length;
292
- if (total > maxBytes) {
293
- const error = new Error(`Request body exceeds ${maxBytes} bytes`);
294
- error.code = "BODY_TOO_LARGE";
295
- req.destroy(error);
296
- reject(error);
297
- return;
298
- }
299
- chunks.push(chunk);
300
- });
301
- req.on("end", () => resolve(Buffer.concat(chunks)));
302
- req.on("error", reject);
303
- });
304
- }
305
-
306
- function sha256String(value) {
307
- return createHash("sha256").update(String(value)).digest("hex");
308
- }
309
-
310
- function isAuthorized(req, expectedKey) {
311
- const auth = String(req.headers.authorization || "");
312
- if (auth.startsWith("Bearer ") && auth.slice(7) === expectedKey) return true;
313
- const apiKey = req.headers["x-api-key"];
314
- return typeof apiKey === "string" && apiKey === expectedKey;
315
- }
316
-
317
- function sendOpenAiError(res, status, code, message) {
318
- return sendJson(res, status, { error: { message, type: "invalid_request_error", param: null, code } });
319
- }
320
-
321
- function sendJson(res, status, payload) {
322
- if (!res.headersSent) {
323
- res.statusCode = status;
324
- res.setHeader("content-type", "application/json; charset=utf-8");
325
- }
326
- res.end(`${JSON.stringify(payload)}\n`);
327
- }
328
-
329
- function sendEmpty(res, status) {
330
- res.statusCode = status;
331
- res.end();
332
- }
333
-
334
- function setCorsHeaders(res) {
335
- res.setHeader("access-control-allow-origin", "*");
336
- res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
337
- res.setHeader("access-control-allow-headers", "authorization,content-type,x-api-key,openai-beta,openai-organization,openai-project");
338
- }
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
-
346
- function withPortHint(error, port) {
347
- if (error?.code === "EADDRINUSE") {
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>\`.`;
349
- }
350
- if (error?.code === "EACCES") {
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>\`.`;
352
- }
353
- return error;
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
- }
@@ -1,256 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import http from "node:http";
3
- import { mkdtemp, readFile, rm } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { daemonSelfTest } from "./daemon.mjs";
7
- import { startLocalApiServer } from "./api-server.mjs";
8
- import { createLogger, redactSecret } from "./log.mjs";
9
- import { acquireDaemonLock, ensureLocalApiKey, ensureWorkerSecrets, loadState, previewSecret, redactState, saveState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
10
-
11
- await daemonSelfTest();
12
- await stateSelfTest();
13
- await workerSourceSelfTest();
14
- await apiSelfTest();
15
- console.log("local daemon/state/api self-test ok");
16
-
17
- async function stateSelfTest() {
18
- const stateRoot = await mkdtemp(join(tmpdir(), "mbm-state-test-"));
19
- const workspace = await mkdtemp(join(tmpdir(), "mbm-state-workspace-"));
20
- try {
21
- setSelectedWorkspace(workspace, stateRoot);
22
- if (selectedWorkspace(stateRoot) !== workspace) throw new Error("selected workspace was not persisted");
23
- const state = loadState(workspace, { stateDir: stateRoot });
24
- ensureWorkerSecrets(state, { rotateSecrets: true });
25
- ensureLocalApiKey(state, { rotateApiKey: true });
26
- const lock = acquireDaemonLock(state);
27
- if (!lock.acquired) throw new Error("first daemon lock acquisition failed");
28
- try {
29
- const duplicate = acquireDaemonLock(state);
30
- if (duplicate.acquired) throw new Error("duplicate daemon lock acquisition should fail");
31
- if (duplicate.owner?.pid !== process.pid) throw new Error("duplicate daemon lock owner was not reported");
32
- } finally {
33
- lock.release();
34
- }
35
- const relock = acquireDaemonLock(state);
36
- if (!relock.acquired) throw new Error("daemon lock was not released");
37
- relock.release();
38
-
39
- const redacted = redactState(state);
40
- if (redacted.worker.oauthPassword === state.worker.oauthPassword) throw new Error("oauthPassword was not redacted");
41
- if (redacted.worker.daemonSecret === state.worker.daemonSecret) throw new Error("daemonSecret was not redacted");
42
- if (redacted.worker.oauthTokenVersion === state.worker.oauthTokenVersion) throw new Error("oauthTokenVersion was not redacted");
43
- if (redacted.localApi.apiKey === state.localApi.apiKey) throw new Error("local API key was not redacted");
44
- if (!previewSecret(state.worker.oauthPassword).includes("...")) throw new Error("previewSecret did not preview long secret");
45
- if (!redactSecret(state.localApi.apiKey).includes("...")) throw new Error("redactSecret did not preview long secret");
46
-
47
- state.localApi.upstreamUrl = "https://api.example.test/v1";
48
- state.localApi.upstreamKey = "old-upstream-key";
49
- state.localApi.upstreamModel = "old-upstream-model";
50
- saveState(state);
51
- const migrated = loadState(workspace, { stateDir: stateRoot });
52
- if ("upstreamUrl" in migrated.localApi || "upstreamKey" in migrated.localApi || "upstreamModel" in migrated.localApi) {
53
- throw new Error("legacy upstream local API state was not migrated away");
54
- }
55
- } finally {
56
- await rm(stateRoot, { recursive: true, force: true }).catch(() => {});
57
- await rm(workspace, { recursive: true, force: true }).catch(() => {});
58
- }
59
- }
60
-
61
- async function workerSourceSelfTest() {
62
- const source = await readFile(new URL("../worker/index.ts", import.meta.url), "utf8");
63
- const unawaitedAsyncRoutes = [
64
- "return this.registerClient(request);",
65
- "return this.authorizeSubmit(request, base);",
66
- "return this.exchangeToken(request, base);",
67
- "return this.acceptDaemonWebSocket(request);",
68
- "return this.handleMcp(request, base);",
69
- "return this.handleSamplingApi(request);",
70
- ].filter((snippet) => source.includes(snippet));
71
- if (unawaitedAsyncRoutes.length) {
72
- throw new Error(`Worker async routes must be awaited so HttpError is caught: ${unawaitedAsyncRoutes.join(", ")}`);
73
- }
74
- }
75
-
76
-
77
- async function apiSelfTest() {
78
- const logger = createLogger({ quiet: true, component: "api-test" });
79
- const api = await startLocalApiServer({
80
- host: "127.0.0.1",
81
- port: 0,
82
- apiKey: "local-test-key",
83
- model: "chatgpt-mcp",
84
- logger,
85
- });
86
- const base = `http://${api.host}:${api.port}`;
87
- try {
88
- if (api.apiKey !== "local-test-key") throw new Error("local API server did not expose runtime API key for CLI printing");
89
- const health = await fetch(`${base}/health`);
90
- if (health.status !== 200) throw new Error(`health returned ${health.status}`);
91
- const healthPayload = await health.json();
92
- const expectedHash = createHash("sha256").update("local-test-key").digest("hex");
93
- if (healthPayload.api_key_sha256 !== expectedHash) throw new Error("health did not expose expected API key hash");
94
- if (healthPayload.backend !== "chatgpt-mcp") throw new Error("health did not report MCP-backed backend");
95
- if (healthPayload.mcp_bridge_configured !== false) throw new Error("health should report missing MCP bridge");
96
- const unauth = await fetch(`${base}/v1/models`);
97
- if (unauth.status !== 401) throw new Error(`unauthorized models returned ${unauth.status}`);
98
- const models = await fetch(`${base}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
99
- if (models.status !== 200) throw new Error(`authorized models returned ${models.status}`);
100
- const payload = await models.json();
101
- if (payload?.data?.length !== 1 || payload.data[0].id !== "chatgpt-mcp") throw new Error("model payload should expose local MCP model");
102
- const chat = await fetch(`${base}/v1/chat/completions`, {
103
- method: "POST",
104
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
105
- body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
106
- });
107
- if (chat.status !== 503) throw new Error(`missing MCP bridge should return 503, got ${chat.status}`);
108
- const chatPayload = await chat.json();
109
- if (!/Remote MCP bridge/.test(chatPayload?.error?.message || "")) throw new Error("missing bridge error did not clarify MCP bridge requirement");
110
-
111
- const unsupported = await fetch(`${base}/v1/responses`, {
112
- method: "POST",
113
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
114
- body: JSON.stringify({ input: "hello" }),
115
- });
116
- if (unsupported.status !== 501) throw new Error(`unsupported Responses endpoint returned ${unsupported.status}`);
117
- const unsupportedPayload = await unsupported.json();
118
- if (unsupportedPayload?.error?.code !== "unsupported_endpoint") throw new Error("unsupported endpoint did not return explicit error code");
119
-
120
- } finally {
121
- await api.close();
122
- }
123
-
124
- await mcpSamplingSelfTest(logger);
125
- await mcpSamplingErrorSelfTest(logger);
126
- }
127
-
128
- async function mcpSamplingSelfTest(logger) {
129
- let captured = null;
130
- const expectedErrorLogger = { ...logger, error() {} };
131
- const worker = http.createServer((req, res) => {
132
- const chunks = [];
133
- req.on("data", chunk => chunks.push(chunk));
134
- req.on("end", () => {
135
- captured = { bridgeToken: req.headers["x-bridge-token"], url: req.url, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
136
- res.setHeader("content-type", "application/json");
137
- res.end(JSON.stringify({ ok: true, result: { role: "assistant", content: { type: "text", text: "hello from ChatGPT MCP" }, model: "chatgpt-client-model", stopReason: "endTurn" } }));
138
- });
139
- });
140
- await new Promise(resolve => worker.listen({ host: "127.0.0.1", port: 0 }, resolve));
141
- const workerPort = worker.address().port;
142
- const api = await startLocalApiServer({
143
- host: "127.0.0.1",
144
- port: 0,
145
- apiKey: "local-test-key",
146
- workerUrl: `http://127.0.0.1:${workerPort}`,
147
- daemonSecret: "daemon-test-secret",
148
- model: "chatgpt-mcp",
149
- logger: expectedErrorLogger,
150
- });
151
- try {
152
- const models = await fetch(`http://${api.host}:${api.port}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
153
- if (models.status !== 200) throw new Error(`configured models returned ${models.status}`);
154
- const modelsPayload = await models.json();
155
- if (modelsPayload?.data?.length !== 1 || modelsPayload.data[0].id !== "chatgpt-mcp") throw new Error("model payload did not expose local MCP model");
156
-
157
- const image = await fetch(`http://${api.host}:${api.port}/v1/chat/completions`, {
158
- method: "POST",
159
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
160
- body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.test/image.png" } }] }] }),
161
- });
162
- if (image.status !== 400) throw new Error(`unsupported non-text content returned ${image.status}`);
163
- const imagePayload = await image.json();
164
- if (imagePayload?.error?.code !== "unsupported_content") throw new Error("unsupported non-text content did not return explicit error code");
165
-
166
- const response = await fetch(`http://${api.host}:${api.port}/v1/chat/completions`, {
167
- method: "POST",
168
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
169
- body: JSON.stringify({
170
- model: "gpt-5-hint",
171
- messages: [
172
- { role: "system", content: "Be concise." },
173
- { role: "developer", content: "Use plain text." },
174
- { role: "user", content: [{ type: "text", text: "Say hello" }] },
175
- ],
176
- max_completion_tokens: 77,
177
- }),
178
- });
179
- if (response.status !== 200) throw new Error(`MCP sampling rewrite returned ${response.status}`);
180
- if (captured?.url !== "/api/mcp/sampling") throw new Error("sampling request did not target Worker sampling endpoint");
181
- if (captured?.bridgeToken !== "daemon-test-secret") throw new Error("bridge token was not set");
182
- if (captured?.body?.messages?.[0]?.content?.text !== "Say hello") throw new Error("chat message was not converted to MCP sampling message");
183
- if (captured?.body?.systemPrompt !== "Be concise.\n\nUse plain text.") throw new Error("system/developer prompt was not forwarded");
184
- if (captured?.body?.maxTokens !== 77) throw new Error("max_completion_tokens was not converted to maxTokens");
185
- if (captured?.body?.modelPreferences?.hints?.[0]?.name !== "gpt-5-hint") throw new Error("model hint was not forwarded");
186
- const payload = await response.json();
187
- if (payload?.choices?.[0]?.message?.content !== "hello from ChatGPT MCP") throw new Error("MCP sampling result was not wrapped as chat completion");
188
- if (payload?.model !== "chatgpt-client-model") throw new Error("MCP sampling result model was not preserved");
189
- } finally {
190
- await api.close();
191
- await new Promise(resolve => worker.close(resolve));
192
- }
193
- }
194
-
195
- async function mcpSamplingErrorSelfTest(logger) {
196
- await withMockWorkerError(
197
- logger,
198
- 409,
199
- { error: "mcp_client_stream_missing", message: "No MCP client has an open server-to-client stream for sampling/createMessage." },
200
- async ({ base }) => {
201
- const response = await fetch(`${base}/v1/chat/completions`, {
202
- method: "POST",
203
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
204
- body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
205
- });
206
- if (response.status !== 409) throw new Error(`missing MCP stream should return 409, got ${response.status}`);
207
- const payload = await response.json();
208
- if (payload?.error?.code !== "mcp_client_stream_missing") throw new Error("missing MCP stream error code was not preserved");
209
- if (!/MCP client|server-to-client stream/.test(payload?.error?.message || "")) throw new Error("missing MCP stream error message was not explicit");
210
- }
211
- );
212
-
213
- await withMockWorkerError(
214
- logger,
215
- 501,
216
- { error: "mcp_sampling_not_supported", message: "A connected MCP client did not advertise the MCP sampling capability." },
217
- async ({ base }) => {
218
- const response = await fetch(`${base}/v1/chat/completions`, {
219
- method: "POST",
220
- headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
221
- body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
222
- });
223
- if (response.status !== 501) throw new Error(`missing sampling capability should return 501, got ${response.status}`);
224
- const payload = await response.json();
225
- if (payload?.error?.code !== "mcp_sampling_not_supported") throw new Error("missing sampling capability error code was not preserved");
226
- if (!/sampling capability/.test(payload?.error?.message || "")) throw new Error("missing sampling capability message was not explicit");
227
- }
228
- );
229
- }
230
-
231
- async function withMockWorkerError(logger, status, payload, callback) {
232
- const expectedErrorLogger = { ...logger, error() {} };
233
- const worker = http.createServer((req, res) => {
234
- req.resume();
235
- res.statusCode = status;
236
- res.setHeader("content-type", "application/json");
237
- res.end(JSON.stringify(payload));
238
- });
239
- await new Promise(resolve => worker.listen({ host: "127.0.0.1", port: 0 }, resolve));
240
- const workerPort = worker.address().port;
241
- const api = await startLocalApiServer({
242
- host: "127.0.0.1",
243
- port: 0,
244
- apiKey: "local-test-key",
245
- workerUrl: `http://127.0.0.1:${workerPort}`,
246
- daemonSecret: "daemon-test-secret",
247
- model: "chatgpt-mcp",
248
- logger: expectedErrorLogger,
249
- });
250
- try {
251
- await callback({ base: `http://${api.host}:${api.port}` });
252
- } finally {
253
- await api.close();
254
- await new Promise(resolve => worker.close(resolve));
255
- }
256
- }