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.
@@ -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.1";
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;