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.
- package/README.md +43 -38
- package/package.json +1 -1
- package/src/local/api-server.mjs +206 -94
- package/src/local/cli.mjs +214 -60
- package/src/local/self-test.mjs +168 -20
- package/src/local/service.mjs +2 -0
- package/src/local/state.mjs +8 -0
- package/src/worker/index.ts +259 -16
package/src/local/self-test.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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]
|
|
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: "
|
|
89
|
+
body: JSON.stringify({ model: "chatgpt-mcp", messages: [{ role: "user", content: "hello" }] }),
|
|
87
90
|
});
|
|
88
|
-
if (chat.status !== 503) throw new Error(`missing
|
|
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
|
}
|
package/src/local/service.mjs
CHANGED
|
@@ -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
|
|
package/src/local/state.mjs
CHANGED
|
@@ -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");
|
package/src/worker/index.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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))
|
|
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<
|
|
656
|
-
if (!token) return
|
|
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
|
|
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
|
|
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
|
|
668
|
-
|
|
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
|
-
|
|
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;
|