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.
@@ -1,10 +1,12 @@
1
+ import { createHash } from "node:crypto";
2
+ import http from "node:http";
1
3
  import { mkdtemp, rm } from "node:fs/promises";
2
4
  import { tmpdir } from "node:os";
3
5
  import { join } from "node:path";
4
6
  import { daemonSelfTest } from "./daemon.mjs";
5
- import { normalizeBaseUrl, startLocalApiServer } from "./api-server.mjs";
7
+ import { startLocalApiServer } from "./api-server.mjs";
6
8
  import { createLogger, redactSecret } from "./log.mjs";
7
- import { acquireDaemonLock, ensureLocalApiKey, ensureWorkerSecrets, loadState, previewSecret, redactState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
9
+ import { acquireDaemonLock, ensureLocalApiKey, ensureWorkerSecrets, loadState, previewSecret, redactState, saveState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
8
10
 
9
11
  await daemonSelfTest();
10
12
  await stateSelfTest();
@@ -40,6 +42,15 @@ async function stateSelfTest() {
40
42
  if (redacted.localApi.apiKey === state.localApi.apiKey) throw new Error("local API key was not redacted");
41
43
  if (!previewSecret(state.worker.oauthPassword).includes("...")) throw new Error("previewSecret did not preview long secret");
42
44
  if (!redactSecret(state.localApi.apiKey).includes("...")) throw new Error("redactSecret did not preview long secret");
45
+
46
+ state.localApi.upstreamUrl = "https://api.example.test/v1";
47
+ state.localApi.upstreamKey = "old-upstream-key";
48
+ state.localApi.upstreamModel = "old-upstream-model";
49
+ saveState(state);
50
+ const migrated = loadState(workspace, { stateDir: stateRoot });
51
+ if ("upstreamUrl" in migrated.localApi || "upstreamKey" in migrated.localApi || "upstreamModel" in migrated.localApi) {
52
+ throw new Error("legacy upstream local API state was not migrated away");
53
+ }
43
54
  } finally {
44
55
  await rm(stateRoot, { recursive: true, force: true }).catch(() => {});
45
56
  await rm(workspace, { recursive: true, force: true }).catch(() => {});
@@ -48,45 +59,182 @@ async function stateSelfTest() {
48
59
 
49
60
 
50
61
  async function apiSelfTest() {
51
- try {
52
- normalizeBaseUrl("https://user:pass@example.com/v1");
53
- throw new Error("upstream URL credentials were accepted");
54
- } catch (error) {
55
- if (!/must not contain credentials/.test(error.message)) throw error;
56
- }
57
- try {
58
- normalizeBaseUrl("https://example.com/v1?api_key=secret");
59
- throw new Error("upstream URL query string was accepted");
60
- } catch (error) {
61
- if (!/without query strings/.test(error.message)) throw error;
62
- }
63
-
64
62
  const logger = createLogger({ quiet: true, component: "api-test" });
65
63
  const api = await startLocalApiServer({
66
64
  host: "127.0.0.1",
67
65
  port: 0,
68
66
  apiKey: "local-test-key",
69
- upstreamKey: "",
70
- model: "test-model",
67
+ model: "chatgpt-mcp",
71
68
  logger,
72
69
  });
73
70
  const base = `http://${api.host}:${api.port}`;
74
71
  try {
72
+ if (api.apiKey !== "local-test-key") throw new Error("local API server did not expose runtime API key for CLI printing");
75
73
  const health = await fetch(`${base}/health`);
76
74
  if (health.status !== 200) throw new Error(`health returned ${health.status}`);
75
+ const healthPayload = await health.json();
76
+ const expectedHash = createHash("sha256").update("local-test-key").digest("hex");
77
+ if (healthPayload.api_key_sha256 !== expectedHash) throw new Error("health did not expose expected API key hash");
78
+ if (healthPayload.backend !== "chatgpt-mcp") throw new Error("health did not report MCP-backed backend");
79
+ if (healthPayload.mcp_bridge_configured !== false) throw new Error("health should report missing MCP bridge");
77
80
  const unauth = await fetch(`${base}/v1/models`);
78
81
  if (unauth.status !== 401) throw new Error(`unauthorized models returned ${unauth.status}`);
79
82
  const models = await fetch(`${base}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
80
83
  if (models.status !== 200) throw new Error(`authorized models returned ${models.status}`);
81
84
  const payload = await models.json();
82
- if (payload?.data?.[0]?.id !== "test-model") throw new Error("model payload did not include configured model");
85
+ if (payload?.data?.length !== 1 || payload.data[0].id !== "chatgpt-mcp") throw new Error("model payload should expose local MCP model");
83
86
  const chat = await fetch(`${base}/v1/chat/completions`, {
84
87
  method: "POST",
85
88
  headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
86
- body: JSON.stringify({ model: "test-model", messages: [] }),
89
+ body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
87
90
  });
88
- if (chat.status !== 503) throw new Error(`missing upstream key should return 503, got ${chat.status}`);
91
+ if (chat.status !== 503) throw new Error(`missing MCP bridge should return 503, got ${chat.status}`);
92
+ const chatPayload = await chat.json();
93
+ if (!/Remote MCP bridge/.test(chatPayload?.error?.message || "")) throw new Error("missing bridge error did not clarify MCP bridge requirement");
94
+
95
+ const unsupported = await fetch(`${base}/v1/responses`, {
96
+ method: "POST",
97
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
98
+ body: JSON.stringify({ input: "hello" }),
99
+ });
100
+ if (unsupported.status !== 501) throw new Error(`unsupported Responses endpoint returned ${unsupported.status}`);
101
+ const unsupportedPayload = await unsupported.json();
102
+ if (unsupportedPayload?.error?.code !== "unsupported_endpoint") throw new Error("unsupported endpoint did not return explicit error code");
103
+
104
+ } finally {
105
+ await api.close();
106
+ }
107
+
108
+ await mcpSamplingSelfTest(logger);
109
+ await mcpSamplingErrorSelfTest(logger);
110
+ }
111
+
112
+ async function mcpSamplingSelfTest(logger) {
113
+ let captured = null;
114
+ const expectedErrorLogger = { ...logger, error() {} };
115
+ const worker = http.createServer((req, res) => {
116
+ const chunks = [];
117
+ req.on("data", chunk => chunks.push(chunk));
118
+ req.on("end", () => {
119
+ captured = { bridgeToken: req.headers["x-bridge-token"], url: req.url, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
120
+ res.setHeader("content-type", "application/json");
121
+ res.end(JSON.stringify({ ok: true, result: { role: "assistant", content: { type: "text", text: "hello from ChatGPT MCP" }, model: "chatgpt-client-model", stopReason: "endTurn" } }));
122
+ });
123
+ });
124
+ await new Promise(resolve => worker.listen({ host: "127.0.0.1", port: 0 }, resolve));
125
+ const workerPort = worker.address().port;
126
+ const api = await startLocalApiServer({
127
+ host: "127.0.0.1",
128
+ port: 0,
129
+ apiKey: "local-test-key",
130
+ workerUrl: `http://127.0.0.1:${workerPort}`,
131
+ daemonSecret: "daemon-test-secret",
132
+ model: "chatgpt-mcp",
133
+ logger: expectedErrorLogger,
134
+ });
135
+ try {
136
+ const models = await fetch(`http://${api.host}:${api.port}/v1/models`, { headers: { authorization: "Bearer local-test-key" } });
137
+ if (models.status !== 200) throw new Error(`configured models returned ${models.status}`);
138
+ const modelsPayload = await models.json();
139
+ if (modelsPayload?.data?.length !== 1 || modelsPayload.data[0].id !== "chatgpt-mcp") throw new Error("model payload did not expose local MCP model");
140
+
141
+ const image = await fetch(`http://${api.host}:${api.port}/v1/chat/completions`, {
142
+ method: "POST",
143
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
144
+ body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.test/image.png" } }] }] }),
145
+ });
146
+ if (image.status !== 400) throw new Error(`unsupported non-text content returned ${image.status}`);
147
+ const imagePayload = await image.json();
148
+ if (imagePayload?.error?.code !== "unsupported_content") throw new Error("unsupported non-text content did not return explicit error code");
149
+
150
+ const response = await fetch(`http://${api.host}:${api.port}/v1/chat/completions`, {
151
+ method: "POST",
152
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
153
+ body: JSON.stringify({
154
+ model: "gpt-5-hint",
155
+ messages: [
156
+ { role: "system", content: "Be concise." },
157
+ { role: "developer", content: "Use plain text." },
158
+ { role: "user", content: [{ type: "text", text: "Say hello" }] },
159
+ ],
160
+ max_completion_tokens: 77,
161
+ }),
162
+ });
163
+ if (response.status !== 200) throw new Error(`MCP sampling rewrite returned ${response.status}`);
164
+ if (captured?.url !== "/api/mcp/sampling") throw new Error("sampling request did not target Worker sampling endpoint");
165
+ if (captured?.bridgeToken !== "daemon-test-secret") throw new Error("bridge token was not set");
166
+ if (captured?.body?.messages?.[0]?.content?.text !== "Say hello") throw new Error("chat message was not converted to MCP sampling message");
167
+ if (captured?.body?.systemPrompt !== "Be concise.\n\nUse plain text.") throw new Error("system/developer prompt was not forwarded");
168
+ if (captured?.body?.maxTokens !== 77) throw new Error("max_completion_tokens was not converted to maxTokens");
169
+ if (captured?.body?.modelPreferences?.hints?.[0]?.name !== "gpt-5-hint") throw new Error("model hint was not forwarded");
170
+ const payload = await response.json();
171
+ if (payload?.choices?.[0]?.message?.content !== "hello from ChatGPT MCP") throw new Error("MCP sampling result was not wrapped as chat completion");
172
+ if (payload?.model !== "chatgpt-client-model") throw new Error("MCP sampling result model was not preserved");
173
+ } finally {
174
+ await api.close();
175
+ await new Promise(resolve => worker.close(resolve));
176
+ }
177
+ }
178
+
179
+ async function mcpSamplingErrorSelfTest(logger) {
180
+ await withMockWorkerError(
181
+ logger,
182
+ 409,
183
+ { error: "mcp_client_stream_missing", message: "No MCP client has an open server-to-client stream for sampling/createMessage." },
184
+ async ({ base }) => {
185
+ const response = await fetch(`${base}/v1/chat/completions`, {
186
+ method: "POST",
187
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
188
+ body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
189
+ });
190
+ if (response.status !== 409) throw new Error(`missing MCP stream should return 409, got ${response.status}`);
191
+ const payload = await response.json();
192
+ if (payload?.error?.code !== "mcp_client_stream_missing") throw new Error("missing MCP stream error code was not preserved");
193
+ if (!/MCP client|server-to-client stream/.test(payload?.error?.message || "")) throw new Error("missing MCP stream error message was not explicit");
194
+ }
195
+ );
196
+
197
+ await withMockWorkerError(
198
+ logger,
199
+ 501,
200
+ { error: "mcp_sampling_not_supported", message: "A connected MCP client did not advertise the MCP sampling capability." },
201
+ async ({ base }) => {
202
+ const response = await fetch(`${base}/v1/chat/completions`, {
203
+ method: "POST",
204
+ headers: { authorization: "Bearer local-test-key", "content-type": "application/json" },
205
+ body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
206
+ });
207
+ if (response.status !== 501) throw new Error(`missing sampling capability should return 501, got ${response.status}`);
208
+ const payload = await response.json();
209
+ if (payload?.error?.code !== "mcp_sampling_not_supported") throw new Error("missing sampling capability error code was not preserved");
210
+ if (!/sampling capability/.test(payload?.error?.message || "")) throw new Error("missing sampling capability message was not explicit");
211
+ }
212
+ );
213
+ }
214
+
215
+ async function withMockWorkerError(logger, status, payload, callback) {
216
+ const expectedErrorLogger = { ...logger, error() {} };
217
+ const worker = http.createServer((req, res) => {
218
+ req.resume();
219
+ res.statusCode = status;
220
+ res.setHeader("content-type", "application/json");
221
+ res.end(JSON.stringify(payload));
222
+ });
223
+ await new Promise(resolve => worker.listen({ host: "127.0.0.1", port: 0 }, resolve));
224
+ const workerPort = worker.address().port;
225
+ const api = await startLocalApiServer({
226
+ host: "127.0.0.1",
227
+ port: 0,
228
+ apiKey: "local-test-key",
229
+ workerUrl: `http://127.0.0.1:${workerPort}`,
230
+ daemonSecret: "daemon-test-secret",
231
+ model: "chatgpt-mcp",
232
+ logger: expectedErrorLogger,
233
+ });
234
+ try {
235
+ await callback({ base: `http://${api.host}:${api.port}` });
89
236
  } finally {
90
237
  await api.close();
238
+ await new Promise(resolve => worker.close(resolve));
91
239
  }
92
240
  }
@@ -54,6 +54,7 @@ function serviceSpec({ workspace, stateRoot, entryScript, policy = {} }) {
54
54
  allowWrite: policy.allowWrite !== false,
55
55
  allowExec: policy.allowExec !== false,
56
56
  minimalEnv: policy.minimalEnv !== false,
57
+ apiEnabled: policy.apiEnabled !== false,
57
58
  },
58
59
  };
59
60
  }
@@ -71,6 +72,7 @@ function daemonArgs(spec) {
71
72
  if (spec.policy.allowWrite === false) args.push("--no-write");
72
73
  if (spec.policy.allowExec === false) args.push("--no-exec");
73
74
  if (spec.policy.minimalEnv === false) args.push("--full-env");
75
+ if (spec.policy.apiEnabled === false) args.push("--no-api");
74
76
  return args;
75
77
  }
76
78
 
@@ -100,9 +100,17 @@ export function loadState(workspace, options = {}) {
100
100
  state.paths = { stateRoot, profileDir, statePath };
101
101
  state.worker ||= {};
102
102
  state.policy ||= {};
103
+ migrateLocalApiState(state);
103
104
  return state;
104
105
  }
105
106
 
107
+ function migrateLocalApiState(state) {
108
+ if (!state.localApi || typeof state.localApi !== "object" || Array.isArray(state.localApi)) return;
109
+ delete state.localApi.upstreamUrl;
110
+ delete state.localApi.upstreamKey;
111
+ delete state.localApi.upstreamModel;
112
+ }
113
+
106
114
  export function saveState(state) {
107
115
  const statePath = state?.paths?.statePath;
108
116
  if (!statePath) throw new Error("state path is missing");
@@ -1,7 +1,7 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
 
3
3
  const SERVER_NAME = "machine-bridge-mcp";
4
- const SERVER_VERSION = "0.2.0";
4
+ const SERVER_VERSION = "0.2.4";
5
5
  const MCP_PROTOCOL_VERSION = "2025-06-18";
6
6
  const JSONRPC_VERSION = "2.0";
7
7
  const DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024;
@@ -50,6 +50,12 @@ interface OAuthToken {
50
50
  expires_at: number;
51
51
  }
52
52
 
53
+ interface AuthenticatedClient {
54
+ clientId: string;
55
+ scope: string;
56
+ resource: string;
57
+ }
58
+
53
59
  interface OAuthStore {
54
60
  clients: Record<string, OAuthClient>;
55
61
  codes: Record<string, OAuthCode>;
@@ -70,6 +76,22 @@ interface PendingCall {
70
76
  resolve: (value: unknown) => void;
71
77
  reject: (error: Error) => void;
72
78
  timeout: ReturnType<typeof setTimeout>;
79
+ streamId?: string;
80
+ }
81
+
82
+ interface McpClientState {
83
+ clientId: string;
84
+ initializedAt: string;
85
+ capabilities: Record<string, unknown>;
86
+ clientInfo?: Record<string, unknown>;
87
+ }
88
+
89
+ interface McpClientStream {
90
+ id: string;
91
+ clientId: string;
92
+ connectedAt: number;
93
+ writer: WritableStreamDefaultWriter<Uint8Array>;
94
+ heartbeat: ReturnType<typeof setInterval>;
73
95
  }
74
96
 
75
97
  const serverInfoTool = {
@@ -195,6 +217,9 @@ const MCP_INSTRUCTIONS = [
195
217
 
196
218
  export class BridgeRoom extends DurableObject<BridgeEnv> {
197
219
  private readonly pending = new Map<string, PendingCall>();
220
+ private readonly pendingClientRequests = new Map<string, PendingCall>();
221
+ private readonly mcpClients = new Map<string, McpClientState>();
222
+ private readonly mcpClientStreams = new Map<string, McpClientStream>();
198
223
 
199
224
  constructor(ctx: DurableObjectState, env: BridgeEnv) {
200
225
  super(ctx, env);
@@ -209,10 +234,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
209
234
  }
210
235
 
211
236
  if (url.pathname === "/" && request.method === "GET") {
212
- return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, mcp: `${base}/mcp`, daemon: this.daemonStatus(false) });
237
+ return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, mcp: `${base}/mcp`, daemon: this.daemonStatus(false), mcp_clients: this.mcpClientStatus(false) });
213
238
  }
214
239
  if (url.pathname === "/healthz") {
215
- return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, daemon: this.daemonStatus(false) });
240
+ return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, daemon: this.daemonStatus(false), mcp_clients: this.mcpClientStatus(false) });
216
241
  }
217
242
  if (url.pathname === "/.well-known/mcp.json") {
218
243
  return json(this.mcpMetadata(base));
@@ -235,6 +260,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
235
260
  if (url.pathname === "/daemon/ws") return this.acceptDaemonWebSocket(request);
236
261
  if (url.pathname === "/mcp") return this.handleMcp(request, base);
237
262
  if (url.pathname === "/api/daemon/status") return json(this.daemonStatus(false));
263
+ if (url.pathname === "/api/mcp/sampling") return this.handleSamplingApi(request);
238
264
  return json({ error: "not_found" }, 404);
239
265
  } catch (error) {
240
266
  if (error instanceof HttpError) return json({ error: error.code, message: error.message }, error.status);
@@ -297,27 +323,33 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
297
323
  }
298
324
 
299
325
  private async handleMcp(request: Request, base: string): Promise<Response> {
300
- if (request.method === "GET") return new Response("GET SSE stream is not implemented; use POST Streamable HTTP.", { status: 405 });
301
326
  if (request.method === "DELETE") return new Response(null, { status: 405 });
302
- if (request.method !== "POST") return json({ error: "mcp endpoint expects POST JSON-RPC" }, 405);
327
+ if (request.method !== "POST" && request.method !== "GET") return json({ error: "mcp endpoint expects POST JSON-RPC or GET SSE" }, 405);
303
328
 
304
- if (!(await this.verifyAccessToken(bearerToken(request), base))) {
329
+ const auth = await this.verifyAccessToken(bearerToken(request), base);
330
+ if (!auth) {
305
331
  return new Response("OAuth bearer token required", {
306
332
  status: 401,
307
333
  headers: { "WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"` },
308
334
  });
309
335
  }
310
336
 
337
+ if (request.method === "GET") return this.openMcpSseStream(request, auth);
338
+
311
339
  const body = await parseJsonRequest(request, this.bodyLimitBytes());
312
- if (isJsonRpcResponse(body)) return new Response(null, { status: 202 });
340
+ if (isJsonRpcResponse(body)) {
341
+ this.handleClientJsonRpcResponse(body);
342
+ return new Response(null, { status: 202 });
343
+ }
313
344
  if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
314
- const response = await this.dispatchJsonRpc(body, base);
345
+ const response = await this.dispatchJsonRpc(body, base, auth);
315
346
  if (response === null) return new Response(null, { status: 202 });
316
347
  return json(response);
317
348
  }
318
349
 
319
- private async dispatchJsonRpc(request: JsonRpcRequest, base: string): Promise<Record<string, unknown> | null> {
350
+ private async dispatchJsonRpc(request: JsonRpcRequest, base: string, auth: AuthenticatedClient): Promise<Record<string, unknown> | null> {
320
351
  if (request.method === "initialize") {
352
+ this.recordClientInitialize(auth, request.params);
321
353
  return rpcResult(request.id, {
322
354
  protocolVersion: MCP_PROTOCOL_VERSION,
323
355
  capabilities: { tools: { listChanged: false } },
@@ -350,6 +382,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
350
382
  mcp_url: `${base}/mcp`,
351
383
  oauth: this.authorizationServerMetadata(base),
352
384
  daemon: this.daemonStatus(true),
385
+ mcp_clients: this.mcpClientStatus(true),
353
386
  tools: this.allTools().map((tool) => tool.name),
354
387
  };
355
388
  }
@@ -401,6 +434,172 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
401
434
  return new Response(null, { status: 101, webSocket: client });
402
435
  }
403
436
 
437
+ private async handleSamplingApi(request: Request): Promise<Response> {
438
+ if (request.method !== "POST") return json({ error: "method_not_allowed", message: "POST required" }, 405);
439
+ const expected = this.env.DAEMON_SHARED_SECRET ?? "";
440
+ const supplied = request.headers.get("X-Bridge-Token") ?? "";
441
+ if (!expected || !(await safeEqual(supplied, expected))) return json({ error: "unauthorized", message: "Unauthorized local API bridge request" }, 401);
442
+
443
+ const body = await parseRequestBody(request, this.bodyLimitBytes());
444
+ const timeoutMs = clampNumber(body.timeout_ms ?? body.timeoutMs, 180_000, 1_000, 600_000);
445
+ const params = samplingParamsFromApiBody(body);
446
+ const result = await this.requestClientSampling(params, timeoutMs);
447
+ return json({ ok: true, result });
448
+ }
449
+
450
+ private openMcpSseStream(request: Request, auth: AuthenticatedClient): Response {
451
+ const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
452
+ const writer = writable.getWriter();
453
+ const streamId = randomToken("mcp_stream");
454
+ const stream: McpClientStream = {
455
+ id: streamId,
456
+ clientId: auth.clientId,
457
+ connectedAt: Date.now(),
458
+ writer,
459
+ heartbeat: setInterval(() => {
460
+ void writeSseComment(writer, `keepalive ${Date.now()}`).catch(() => this.closeMcpClientStream(streamId));
461
+ }, 25_000),
462
+ };
463
+ this.mcpClientStreams.set(streamId, stream);
464
+ request.signal.addEventListener("abort", () => this.closeMcpClientStream(streamId), { once: true });
465
+ void writeSseComment(writer, `${SERVER_NAME} connected`).catch(() => this.closeMcpClientStream(streamId));
466
+ return new Response(readable, {
467
+ status: 200,
468
+ headers: {
469
+ "content-type": "text/event-stream; charset=utf-8",
470
+ "cache-control": "no-cache, no-transform",
471
+ },
472
+ });
473
+ }
474
+
475
+ private closeMcpClientStream(streamId: string): void {
476
+ const stream = this.mcpClientStreams.get(streamId);
477
+ if (!stream) return;
478
+ this.mcpClientStreams.delete(streamId);
479
+ clearInterval(stream.heartbeat);
480
+ for (const [id, pending] of this.pendingClientRequests) {
481
+ if (pending.streamId !== streamId) continue;
482
+ clearTimeout(pending.timeout);
483
+ this.pendingClientRequests.delete(id);
484
+ pending.reject(new HttpError(
485
+ 409,
486
+ "mcp_client_stream_closed",
487
+ "The MCP client server-to-client stream closed before it answered sampling/createMessage. Reconnect ChatGPT to the MCP Server URL and retry."
488
+ ));
489
+ }
490
+ try {
491
+ void stream.writer.close();
492
+ } catch {
493
+ // Ignore already-closed streams.
494
+ }
495
+ }
496
+
497
+ private async requestClientSampling(params: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
498
+ const streams = [...this.mcpClientStreams.values()].sort((left, right) => right.connectedAt - left.connectedAt);
499
+ if (!streams.length) {
500
+ throw new HttpError(
501
+ 409,
502
+ "mcp_client_stream_missing",
503
+ "No MCP client has an open server-to-client stream. Connect ChatGPT to the printed MCP Server URL and keep a client stream open so this bridge can send sampling/createMessage requests."
504
+ );
505
+ }
506
+
507
+ const capableStreams = streams.filter((stream) => this.clientSupportsSampling(stream.clientId));
508
+ if (!capableStreams.length) {
509
+ throw new HttpError(
510
+ 501,
511
+ "mcp_sampling_not_supported",
512
+ "A ChatGPT MCP client stream is connected, but the client did not advertise the MCP sampling capability. This local /v1 API requires a client that can receive sampling/createMessage requests."
513
+ );
514
+ }
515
+
516
+ const request: JsonRpcRequest = {
517
+ jsonrpc: JSONRPC_VERSION,
518
+ id: randomToken("sampling"),
519
+ method: "sampling/createMessage",
520
+ params,
521
+ };
522
+ return this.sendClientRequest(capableStreams[0], request, timeoutMs);
523
+ }
524
+
525
+ private async sendClientRequest(stream: McpClientStream, request: JsonRpcRequest, timeoutMs: number): Promise<unknown> {
526
+ const id = String(request.id);
527
+ return await new Promise((resolve, reject) => {
528
+ const timeout = setTimeout(() => {
529
+ this.pendingClientRequests.delete(id);
530
+ reject(new HttpError(
531
+ 504,
532
+ "mcp_sampling_timeout",
533
+ "Timed out waiting for the MCP client to answer sampling/createMessage. Check that ChatGPT is still connected and that the sampling request was approved."
534
+ ));
535
+ }, timeoutMs);
536
+ this.pendingClientRequests.set(id, { resolve, reject, timeout, streamId: stream.id });
537
+ void writeSseJson(stream.writer, request, id).catch((error) => {
538
+ clearTimeout(timeout);
539
+ this.pendingClientRequests.delete(id);
540
+ this.closeMcpClientStream(stream.id);
541
+ reject(new HttpError(409, "mcp_client_stream_unavailable", `MCP client stream is not writable: ${errorMessage(error)}`));
542
+ });
543
+ });
544
+ }
545
+
546
+ private handleClientJsonRpcResponse(response: unknown): void {
547
+ const candidate = response as Record<string, unknown>;
548
+ const id = String(candidate.id ?? "");
549
+ if (!id) return;
550
+ const pending = this.pendingClientRequests.get(id);
551
+ if (!pending) return;
552
+ clearTimeout(pending.timeout);
553
+ this.pendingClientRequests.delete(id);
554
+ if ("error" in candidate) {
555
+ pending.reject(new HttpError(
556
+ 502,
557
+ "mcp_sampling_client_error",
558
+ `MCP client returned an error for sampling/createMessage: ${jsonRpcErrorMessage(candidate.error)}`
559
+ ));
560
+ }
561
+ else pending.resolve(candidate.result);
562
+ }
563
+
564
+ private recordClientInitialize(auth: AuthenticatedClient, params: unknown): void {
565
+ const body = asObject(params);
566
+ const meta = asObject(body._meta);
567
+ const directCapabilities = asObject(body.capabilities);
568
+ const metaCapabilities = asObject(meta["io.modelcontextprotocol/clientCapabilities"]);
569
+ const capabilities = Object.keys(directCapabilities).length ? directCapabilities : metaCapabilities;
570
+ this.mcpClients.set(auth.clientId, {
571
+ clientId: auth.clientId,
572
+ initializedAt: new Date().toISOString(),
573
+ capabilities,
574
+ clientInfo: asObject(body.clientInfo),
575
+ });
576
+ }
577
+
578
+ private clientSupportsSampling(clientId: string): boolean {
579
+ const capabilities = this.mcpClients.get(clientId)?.capabilities;
580
+ return Boolean(capabilities && Object.prototype.hasOwnProperty.call(capabilities, "sampling"));
581
+ }
582
+
583
+ private mcpClientStatus(detail: boolean): Record<string, unknown> {
584
+ const streams = [...this.mcpClientStreams.values()];
585
+ const samplingCapableClientIds = new Set([...this.mcpClients.values()].filter((client) => Object.prototype.hasOwnProperty.call(client.capabilities, "sampling")).map((client) => client.clientId));
586
+ const base = {
587
+ stream_count: streams.length,
588
+ initialized_count: this.mcpClients.size,
589
+ sampling_capable_count: samplingCapableClientIds.size,
590
+ };
591
+ if (!detail) return base;
592
+ return {
593
+ ...base,
594
+ streams: streams.map((stream) => ({
595
+ id: stream.id,
596
+ client_id: stream.clientId,
597
+ connected_at: new Date(stream.connectedAt).toISOString(),
598
+ sampling_capable: samplingCapableClientIds.has(stream.clientId),
599
+ })),
600
+ };
601
+ }
602
+
404
603
  private allTools(): Array<Record<string, unknown>> {
405
604
  const advertised = this.daemonAdvertisedTools();
406
605
  const localTools = advertised
@@ -652,20 +851,21 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
652
851
  return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
653
852
  }
654
853
 
655
- private async verifyAccessToken(token: string, base: string): Promise<boolean> {
656
- if (!token) return false;
854
+ private async verifyAccessToken(token: string, base: string): Promise<AuthenticatedClient | null> {
855
+ if (!token) return null;
657
856
  const store = await this.oauthStore();
658
857
  const key = `sha256:${await sha256Hex(token)}`;
659
858
  const record = store.tokens[key];
660
- if (!record) return false;
859
+ if (!record) return null;
661
860
  if (record.expires_at <= Math.floor(Date.now() / 1000)) {
662
861
  delete store.tokens[key];
663
862
  await this.ctx.storage.put("oauth", store);
664
- return false;
863
+ return null;
665
864
  }
666
865
  const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
667
- if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return false;
668
- return record.resource === `${base}/mcp`;
866
+ if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
867
+ if (record.resource !== `${base}/mcp`) return null;
868
+ return { clientId: record.client_id, scope: record.scope, resource: record.resource };
669
869
  }
670
870
 
671
871
  private bodyLimitBytes(): number {
@@ -708,6 +908,45 @@ function textToolResult(value: unknown, isError = false): Record<string, unknown
708
908
  return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }], isError };
709
909
  }
710
910
 
911
+ function samplingParamsFromApiBody(body: Record<string, unknown>): Record<string, unknown> {
912
+ const messages = body.messages;
913
+ if (!Array.isArray(messages) || messages.length === 0) {
914
+ throw new HttpError(400, "invalid_sampling_request", "sampling/createMessage requires a non-empty messages array");
915
+ }
916
+ const maxTokens = Number(body.maxTokens);
917
+ if (!Number.isFinite(maxTokens) || maxTokens <= 0) {
918
+ throw new HttpError(400, "invalid_sampling_request", "sampling/createMessage requires maxTokens");
919
+ }
920
+ const params = { ...body };
921
+ delete params.timeout_ms;
922
+ delete params.timeoutMs;
923
+ return params;
924
+ }
925
+
926
+ async function writeSseJson(writer: WritableStreamDefaultWriter<Uint8Array>, value: unknown, id?: string): Promise<void> {
927
+ const lines = [];
928
+ if (id) lines.push(`id: ${sseLine(id)}`);
929
+ lines.push("event: message");
930
+ for (const line of JSON.stringify(value).split(/\r?\n/)) lines.push(`data: ${line}`);
931
+ lines.push("", "");
932
+ await writer.write(new TextEncoder().encode(lines.join("\n")));
933
+ }
934
+
935
+ async function writeSseComment(writer: WritableStreamDefaultWriter<Uint8Array>, value: string): Promise<void> {
936
+ await writer.write(new TextEncoder().encode(`: ${sseLine(value)}\n\n`));
937
+ }
938
+
939
+ function sseLine(value: string): string {
940
+ return value.replaceAll("\r", " ").replaceAll("\n", " ");
941
+ }
942
+
943
+ function jsonRpcErrorMessage(error: unknown): string {
944
+ const value = asObject(error);
945
+ const message = typeof value.message === "string" && value.message ? value.message : "MCP client returned an error";
946
+ const code = value.code === undefined ? "" : ` (${String(value.code)})`;
947
+ return `${message}${code}`;
948
+ }
949
+
711
950
  function json(value: unknown, status = 200): Response {
712
951
  return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json; charset=utf-8" } });
713
952
  }
@@ -861,8 +1100,12 @@ function isAllowedRedirectUri(value: string): boolean {
861
1100
  function validateOrigin(request: Request, base: string, configured = ""): boolean {
862
1101
  const origin = request.headers.get("Origin");
863
1102
  if (!origin) return true;
1103
+ if (isDefaultAllowedOrigin(origin, base)) return true;
864
1104
  const allowed = configured.split(",").map((item) => item.trim()).filter(Boolean);
865
- if (allowed.length > 0) return allowed.includes(origin);
1105
+ return allowed.includes(origin);
1106
+ }
1107
+
1108
+ function isDefaultAllowedOrigin(origin: string, base: string): boolean {
866
1109
  try {
867
1110
  const parsed = new URL(origin);
868
1111
  if (origin === base) return true;