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/CHANGELOG.md +94 -0
- package/README.md +159 -201
- package/SECURITY.md +134 -0
- package/docs/ARCHITECTURE.md +169 -0
- package/docs/CLIENTS.md +72 -0
- package/docs/OPERATIONS.md +81 -0
- package/docs/RELEASING.md +62 -0
- package/docs/TESTING.md +51 -0
- package/docs/examples/github-actions-ci.yml +53 -0
- package/package.json +34 -13
- package/src/local/cli.mjs +391 -299
- package/src/local/daemon.mjs +783 -195
- package/src/local/log.mjs +33 -11
- package/src/local/patch.mjs +140 -0
- package/src/local/process-sessions.mjs +352 -0
- package/src/local/service.mjs +46 -19
- package/src/local/shell.mjs +90 -19
- package/src/local/state.mjs +240 -51
- package/src/local/stdio.mjs +194 -0
- package/src/local/tools.mjs +96 -0
- package/src/shared/tool-catalog.json +638 -0
- package/src/worker/index.ts +778 -549
- package/tsconfig.json +4 -2
- package/wrangler.jsonc +2 -2
- package/src/local/api-server.mjs +0 -368
- package/src/local/self-test.mjs +0 -256
package/src/worker/index.ts
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import toolCatalog from "../shared/tool-catalog.json";
|
|
2
3
|
|
|
3
4
|
const SERVER_NAME = "machine-bridge-mcp";
|
|
4
|
-
const SERVER_VERSION = "0.
|
|
5
|
-
const MCP_PROTOCOL_VERSION = "2025-
|
|
5
|
+
const SERVER_VERSION = "0.4.0";
|
|
6
|
+
const MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
7
|
+
const MCP_SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"] as const;
|
|
6
8
|
const JSONRPC_VERSION = "2.0";
|
|
7
|
-
const DEFAULT_MAX_BODY_BYTES =
|
|
8
|
-
const MAX_BODY_BYTES =
|
|
9
|
+
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
9
11
|
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
12
|
+
const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
|
|
13
|
+
const MAX_PENDING_CALLS = 32;
|
|
14
|
+
const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
15
|
+
const DAEMON_HELLO_TIMEOUT_MS = 10_000;
|
|
16
|
+
const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
|
|
17
|
+
const MAX_OAUTH_CLIENTS = 50;
|
|
18
|
+
const MAX_OAUTH_CLIENTS_PER_IDENTITY = 5;
|
|
19
|
+
const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
|
|
20
|
+
const MAX_TOKENS_PER_CLIENT = 20;
|
|
21
|
+
const MAX_CODES_PER_CLIENT = 10;
|
|
22
|
+
const MAX_OAUTH_CODES = 200;
|
|
23
|
+
const MAX_OAUTH_TOKENS = 500;
|
|
24
|
+
const MAX_AUTH_FAILURE_IDENTITIES = 200;
|
|
25
|
+
const AUTH_FAILURE_WINDOW_SECONDS = 10 * 60;
|
|
26
|
+
const AUTH_BLOCK_SECONDS = 15 * 60;
|
|
27
|
+
const AUTH_FAILURE_LIMIT = 10;
|
|
28
|
+
const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
|
|
10
29
|
|
|
11
30
|
interface BridgeEnv {
|
|
12
31
|
BRIDGE: DurableObjectNamespace<BridgeRoom>;
|
|
@@ -31,6 +50,8 @@ interface OAuthClient {
|
|
|
31
50
|
client_name: string;
|
|
32
51
|
redirect_uris: string[];
|
|
33
52
|
created_at: number;
|
|
53
|
+
last_used_at: number;
|
|
54
|
+
registration_identity?: string;
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
interface OAuthCode {
|
|
@@ -50,24 +71,33 @@ interface OAuthToken {
|
|
|
50
71
|
expires_at: number;
|
|
51
72
|
}
|
|
52
73
|
|
|
53
|
-
interface
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
74
|
+
interface OAuthFailure {
|
|
75
|
+
count: number;
|
|
76
|
+
window_started: number;
|
|
77
|
+
blocked_until: number;
|
|
78
|
+
last_attempt: number;
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
interface OAuthStore {
|
|
60
82
|
clients: Record<string, OAuthClient>;
|
|
61
83
|
codes: Record<string, OAuthCode>;
|
|
62
84
|
tokens: Record<string, OAuthToken>;
|
|
85
|
+
auth_failures: Record<string, OAuthFailure>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface ValidatedAuthorization {
|
|
89
|
+
client: OAuthClient;
|
|
90
|
+
clientId: string;
|
|
91
|
+
redirectUri: string;
|
|
92
|
+
codeChallenge: string;
|
|
93
|
+
requestedResource: string;
|
|
94
|
+
scope: string;
|
|
95
|
+
state: string;
|
|
63
96
|
}
|
|
64
97
|
|
|
65
98
|
interface DaemonAttachment {
|
|
66
|
-
role: "daemon";
|
|
99
|
+
role: "candidate" | "expired" | "daemon";
|
|
67
100
|
connectedAt: string;
|
|
68
|
-
daemonId: string;
|
|
69
|
-
workspaceHash?: string;
|
|
70
|
-
workspaceName?: string;
|
|
71
101
|
policy?: Record<string, unknown>;
|
|
72
102
|
tools?: string[];
|
|
73
103
|
}
|
|
@@ -76,170 +106,66 @@ interface PendingCall {
|
|
|
76
106
|
resolve: (value: unknown) => void;
|
|
77
107
|
reject: (error: Error) => void;
|
|
78
108
|
timeout: ReturnType<typeof setTimeout>;
|
|
79
|
-
|
|
109
|
+
socket: WebSocket;
|
|
110
|
+
clientRequestKey?: string;
|
|
80
111
|
}
|
|
81
112
|
|
|
82
|
-
interface
|
|
113
|
+
interface AuthorizedToken {
|
|
114
|
+
tokenKey: string;
|
|
83
115
|
clientId: string;
|
|
84
|
-
initializedAt: string;
|
|
85
|
-
capabilities: Record<string, unknown>;
|
|
86
|
-
clientInfo?: Record<string, unknown>;
|
|
87
116
|
}
|
|
88
117
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
heartbeat: ReturnType<typeof setInterval>;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const serverInfoTool = {
|
|
98
|
-
name: "server_info",
|
|
99
|
-
description: "Return bridge metadata, OAuth endpoint details, daemon connection status, and available tools.",
|
|
100
|
-
inputSchema: { type: "object", additionalProperties: true },
|
|
101
|
-
} as const;
|
|
102
|
-
|
|
103
|
-
const workspaceTools = [
|
|
104
|
-
{
|
|
105
|
-
name: "project_overview",
|
|
106
|
-
description: "Summarize the connected local workspace and daemon policy.",
|
|
107
|
-
inputSchema: { type: "object", additionalProperties: true },
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
name: "list_roots",
|
|
111
|
-
description: "List workspace roots exposed by the local daemon.",
|
|
112
|
-
inputSchema: { type: "object", additionalProperties: true },
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
name: "list_dir",
|
|
116
|
-
description: "List direct children of a workspace directory.",
|
|
117
|
-
inputSchema: {
|
|
118
|
-
type: "object",
|
|
119
|
-
properties: { path: { type: "string", default: "." } },
|
|
120
|
-
additionalProperties: true,
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
{
|
|
124
|
-
name: "list_files",
|
|
125
|
-
description: "Recursively list files under a workspace path.",
|
|
126
|
-
inputSchema: {
|
|
127
|
-
type: "object",
|
|
128
|
-
properties: {
|
|
129
|
-
path: { type: "string", default: "." },
|
|
130
|
-
max_files: { type: "integer", minimum: 1, maximum: 10000, default: 1000 },
|
|
131
|
-
},
|
|
132
|
-
additionalProperties: true,
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
name: "read_file",
|
|
137
|
-
description: "Read a UTF-8 file. Relative paths use the daemon workspace; absolute paths and parent-directory paths are allowed. Sensitive files are not hidden by default.",
|
|
138
|
-
inputSchema: {
|
|
139
|
-
type: "object",
|
|
140
|
-
properties: {
|
|
141
|
-
path: { type: "string" },
|
|
142
|
-
max_bytes: { type: "integer", minimum: 1, maximum: 5242880, default: 1048576 },
|
|
143
|
-
},
|
|
144
|
-
required: ["path"],
|
|
145
|
-
additionalProperties: true,
|
|
146
|
-
},
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
name: "write_file",
|
|
150
|
-
description: "Write a UTF-8 file. Relative paths use the daemon workspace; absolute paths and parent-directory paths are allowed. Enabled by default.",
|
|
151
|
-
inputSchema: {
|
|
152
|
-
type: "object",
|
|
153
|
-
properties: {
|
|
154
|
-
path: { type: "string" },
|
|
155
|
-
content: { type: "string" },
|
|
156
|
-
create_only: { type: "boolean", default: false },
|
|
157
|
-
expected_sha256: { type: "string" },
|
|
158
|
-
},
|
|
159
|
-
required: ["path", "content"],
|
|
160
|
-
additionalProperties: true,
|
|
161
|
-
},
|
|
162
|
-
},
|
|
163
|
-
{
|
|
164
|
-
name: "search_text",
|
|
165
|
-
description: "Search plain text files under a workspace path for a substring.",
|
|
166
|
-
inputSchema: {
|
|
167
|
-
type: "object",
|
|
168
|
-
properties: {
|
|
169
|
-
query: { type: "string" },
|
|
170
|
-
path: { type: "string", default: "." },
|
|
171
|
-
max_matches: { type: "integer", minimum: 1, maximum: 1000, default: 100 },
|
|
172
|
-
max_files: { type: "integer", minimum: 1, maximum: 100000, default: 10000 },
|
|
173
|
-
},
|
|
174
|
-
required: ["query"],
|
|
175
|
-
additionalProperties: true,
|
|
176
|
-
},
|
|
177
|
-
},
|
|
178
|
-
{
|
|
179
|
-
name: "git_status",
|
|
180
|
-
description: "Run git status --short in the local workspace.",
|
|
181
|
-
inputSchema: { type: "object", additionalProperties: true },
|
|
182
|
-
},
|
|
183
|
-
{
|
|
184
|
-
name: "git_diff",
|
|
185
|
-
description: "Run git diff for the local workspace and return bounded output.",
|
|
186
|
-
inputSchema: {
|
|
187
|
-
type: "object",
|
|
188
|
-
properties: {
|
|
189
|
-
path: { type: "string", default: "." },
|
|
190
|
-
max_bytes: { type: "integer", minimum: 1, maximum: 5242880, default: 1048576 },
|
|
191
|
-
},
|
|
192
|
-
additionalProperties: true,
|
|
193
|
-
},
|
|
194
|
-
},
|
|
195
|
-
{
|
|
196
|
-
name: "exec_command",
|
|
197
|
-
description: "Execute a shell command with cwd set to the daemon workspace. Enabled by default; environment is intentionally minimal unless the daemon is started with full env.",
|
|
198
|
-
inputSchema: {
|
|
199
|
-
type: "object",
|
|
200
|
-
properties: {
|
|
201
|
-
command: { type: "string" },
|
|
202
|
-
timeout_seconds: { type: "integer", minimum: 1, maximum: 600, default: 120 },
|
|
203
|
-
},
|
|
204
|
-
required: ["command"],
|
|
205
|
-
additionalProperties: true,
|
|
206
|
-
},
|
|
207
|
-
},
|
|
208
|
-
] as const;
|
|
118
|
+
type ToolDefinition = Record<string, unknown> & { name: string; availability?: string };
|
|
119
|
+
|
|
120
|
+
const allCatalogTools = toolCatalog as ToolDefinition[];
|
|
121
|
+
const serverInfoTool = publicTool(allCatalogTools.find((tool) => tool.name === "server_info")!);
|
|
122
|
+
const workspaceTools = allCatalogTools.filter((tool) => tool.name !== "server_info").map(publicTool);
|
|
209
123
|
|
|
210
124
|
const MCP_INSTRUCTIONS = [
|
|
211
125
|
"You are connected to a local workspace through machine-bridge-mcp.",
|
|
212
|
-
"The Worker
|
|
213
|
-
"Relative paths use the configured workspace
|
|
214
|
-
"
|
|
215
|
-
"Prefer
|
|
126
|
+
"The Cloudflare Worker authenticates and relays calls; file and command operations execute on the user's local runtime.",
|
|
127
|
+
"Relative paths use the configured workspace. Direct filesystem tools are workspace-scoped unless unrestricted paths are explicitly enabled.",
|
|
128
|
+
"run_process avoids shell parsing but is not an OS sandbox. exec_command is only exposed in shell mode and has the local user's authority.",
|
|
129
|
+
"Prefer read_file line ranges, edit_file, or apply_patch over whole-file replacement, and report commands that were run.",
|
|
216
130
|
].join("\n");
|
|
217
131
|
|
|
132
|
+
function publicTool(tool: ToolDefinition): ToolDefinition {
|
|
133
|
+
const { availability: _availability, ...definition } = tool;
|
|
134
|
+
return structuredClone(definition) as ToolDefinition;
|
|
135
|
+
}
|
|
136
|
+
|
|
218
137
|
export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
219
138
|
private readonly pending = new Map<string, PendingCall>();
|
|
220
|
-
private
|
|
221
|
-
private readonly mcpClients = new Map<string, McpClientState>();
|
|
222
|
-
private readonly mcpClientStreams = new Map<string, McpClientStream>();
|
|
139
|
+
private oauthQueue: Promise<void> = Promise.resolve();
|
|
223
140
|
|
|
224
141
|
constructor(ctx: DurableObjectState, env: BridgeEnv) {
|
|
225
142
|
super(ctx, env);
|
|
226
143
|
}
|
|
227
144
|
|
|
228
145
|
async fetch(request: Request): Promise<Response> {
|
|
229
|
-
const url = new URL(request.url);
|
|
230
146
|
const base = baseUrl(request);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
147
|
+
const configuredOrigins = this.env.MBM_ALLOWED_ORIGINS ?? "";
|
|
148
|
+
if (!validateOrigin(request, base, configuredOrigins)) return json({ error: "origin_not_allowed" }, 403);
|
|
149
|
+
if (request.method === "OPTIONS" && request.headers.has("Origin")) {
|
|
150
|
+
return corsPreflight(request, base, configuredOrigins);
|
|
151
|
+
}
|
|
152
|
+
const response = await this.handleRequest(request, base);
|
|
153
|
+
return applyCors(response, request, base, configuredOrigins);
|
|
154
|
+
}
|
|
235
155
|
|
|
236
|
-
|
|
237
|
-
|
|
156
|
+
private async handleRequest(request: Request, base: string): Promise<Response> {
|
|
157
|
+
const url = new URL(request.url);
|
|
158
|
+
try {
|
|
159
|
+
if (url.pathname === "/") {
|
|
160
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
161
|
+
return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, mcp: `${base}/mcp` });
|
|
238
162
|
}
|
|
239
163
|
if (url.pathname === "/healthz") {
|
|
240
|
-
|
|
164
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
165
|
+
return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION });
|
|
241
166
|
}
|
|
242
167
|
if (url.pathname === "/.well-known/mcp.json") {
|
|
168
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
243
169
|
return json(this.mcpMetadata(base));
|
|
244
170
|
}
|
|
245
171
|
if (
|
|
@@ -248,29 +174,52 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
248
174
|
url.pathname === "/.well-known/openid-configuration" ||
|
|
249
175
|
url.pathname === "/.well-known/openid-configuration/mcp"
|
|
250
176
|
) {
|
|
177
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
251
178
|
return json(this.authorizationServerMetadata(base));
|
|
252
179
|
}
|
|
253
180
|
if (url.pathname === "/.well-known/oauth-protected-resource" || url.pathname === "/.well-known/oauth-protected-resource/mcp") {
|
|
181
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
254
182
|
return json(this.protectedResourceMetadata(base));
|
|
255
183
|
}
|
|
256
|
-
if (url.pathname === "/oauth/register"
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (url.pathname === "/
|
|
184
|
+
if (url.pathname === "/oauth/register") {
|
|
185
|
+
if (request.method !== "POST") return methodNotAllowed("POST");
|
|
186
|
+
return await this.registerClient(request);
|
|
187
|
+
}
|
|
188
|
+
if (url.pathname === "/oauth/authorize") {
|
|
189
|
+
if (request.method === "GET") return await this.authorizeGet(request, base);
|
|
190
|
+
if (request.method === "POST") return await this.authorizeSubmit(request, base);
|
|
191
|
+
return methodNotAllowed("GET, POST");
|
|
192
|
+
}
|
|
193
|
+
if (url.pathname === "/oauth/token") {
|
|
194
|
+
if (request.method !== "POST") return methodNotAllowed("POST");
|
|
195
|
+
return await this.exchangeToken(request, base);
|
|
196
|
+
}
|
|
197
|
+
if (url.pathname === "/daemon/ws") {
|
|
198
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
199
|
+
return await this.acceptDaemonWebSocket(request);
|
|
200
|
+
}
|
|
261
201
|
if (url.pathname === "/mcp") return await this.handleMcp(request, base);
|
|
262
|
-
if (url.pathname === "/api/daemon/status") return json(this.daemonStatus(false));
|
|
263
|
-
if (url.pathname === "/api/mcp/sampling") return await this.handleSamplingApi(request);
|
|
264
202
|
return json({ error: "not_found" }, 404);
|
|
265
203
|
} catch (error) {
|
|
266
204
|
if (error instanceof HttpError) return json({ error: error.code, message: error.message }, error.status);
|
|
267
|
-
console.error(JSON.stringify({ level: "error", message: "request_failed", path: url.pathname,
|
|
205
|
+
console.error(JSON.stringify({ level: "error", message: "request_failed", path: url.pathname, error_class: workerErrorClass(error) }));
|
|
268
206
|
return json({ error: "internal_server_error" }, 500);
|
|
269
207
|
}
|
|
270
208
|
}
|
|
271
209
|
|
|
272
210
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
273
|
-
const
|
|
211
|
+
const size = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
|
|
212
|
+
if (size > MAX_DAEMON_MESSAGE_BYTES) {
|
|
213
|
+
try { ws.close(1009, "message too large"); } catch {}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
let text: string;
|
|
217
|
+
try {
|
|
218
|
+
text = typeof message === "string" ? message : new TextDecoder("utf-8", { fatal: true }).decode(message);
|
|
219
|
+
} catch {
|
|
220
|
+
try { ws.close(1007, "invalid UTF-8"); } catch {}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
274
223
|
let body: Record<string, unknown>;
|
|
275
224
|
try {
|
|
276
225
|
body = JSON.parse(text) as Record<string, unknown>;
|
|
@@ -279,18 +228,52 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
279
228
|
return;
|
|
280
229
|
}
|
|
281
230
|
|
|
231
|
+
const socketAttachment = this.socketAttachment(ws);
|
|
232
|
+
if (!socketAttachment) {
|
|
233
|
+
try { ws.close(1008, "missing daemon attachment"); } catch {}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (socketAttachment.role === "expired") {
|
|
238
|
+
try { ws.close(1008, "expired daemon candidate"); } catch {}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
282
242
|
if (body.type === "hello") {
|
|
283
|
-
const
|
|
284
|
-
if (
|
|
243
|
+
const previousDaemons = this.daemonSockets().filter((socket) => socket !== ws);
|
|
244
|
+
if (socketAttachment.role === "candidate") {
|
|
245
|
+
if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
|
|
246
|
+
try { ws.close(1008, "stale daemon candidate"); } catch {}
|
|
247
|
+
await this.scheduleCandidateAlarm();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const daemonPolicy = sanitizeDaemonPolicy(body.policy);
|
|
252
|
+
ws.serializeAttachment({
|
|
253
|
+
role: "daemon",
|
|
254
|
+
connectedAt: new Date().toISOString(),
|
|
255
|
+
policy: daemonPolicy,
|
|
256
|
+
tools: sanitizeDaemonTools(body.tools, daemonPolicy),
|
|
257
|
+
} satisfies DaemonAttachment);
|
|
258
|
+
await this.scheduleCandidateAlarm();
|
|
259
|
+
try {
|
|
260
|
+
ws.send(JSON.stringify({ type: "hello_ack", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
261
|
+
} catch {
|
|
285
262
|
ws.serializeAttachment({
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
});
|
|
263
|
+
role: "expired",
|
|
264
|
+
connectedAt: socketAttachment.connectedAt,
|
|
265
|
+
} satisfies DaemonAttachment);
|
|
266
|
+
try { ws.close(1011, "daemon hello acknowledgement failed"); } catch {}
|
|
267
|
+
return;
|
|
292
268
|
}
|
|
293
|
-
|
|
269
|
+
for (const socket of previousDaemons) {
|
|
270
|
+
try { socket.close(1012, "replaced by authenticated daemon"); } catch {}
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (socketAttachment.role !== "daemon") {
|
|
276
|
+
try { ws.close(1008, "daemon hello required"); } catch {}
|
|
294
277
|
return;
|
|
295
278
|
}
|
|
296
279
|
|
|
@@ -305,7 +288,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
305
288
|
}
|
|
306
289
|
|
|
307
290
|
const pending = this.pending.get(body.id);
|
|
308
|
-
if (!pending) return;
|
|
291
|
+
if (!pending || pending.socket !== ws) return;
|
|
309
292
|
clearTimeout(pending.timeout);
|
|
310
293
|
this.pending.delete(body.id);
|
|
311
294
|
if (body.ok === false) pending.reject(new Error(errorMessage(body.error)));
|
|
@@ -313,9 +296,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
313
296
|
}
|
|
314
297
|
|
|
315
298
|
async webSocketClose(ws: WebSocket): Promise<void> {
|
|
299
|
+
await this.scheduleCandidateAlarm();
|
|
316
300
|
const attachment = this.daemonAttachment(ws);
|
|
317
|
-
if (attachment
|
|
301
|
+
if (!attachment) return;
|
|
318
302
|
for (const [id, pending] of this.pending) {
|
|
303
|
+
if (pending.socket !== ws) continue;
|
|
319
304
|
clearTimeout(pending.timeout);
|
|
320
305
|
pending.reject(new Error("daemon disconnected"));
|
|
321
306
|
this.pending.delete(id);
|
|
@@ -323,41 +308,60 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
323
308
|
}
|
|
324
309
|
|
|
325
310
|
private async handleMcp(request: Request, base: string): Promise<Response> {
|
|
326
|
-
if (request.method
|
|
327
|
-
|
|
311
|
+
if (request.method !== "POST") {
|
|
312
|
+
return new Response(request.method === "HEAD" ? null : JSON.stringify({ error: "mcp endpoint expects POST JSON-RPC" }), {
|
|
313
|
+
status: 405,
|
|
314
|
+
headers: { "content-type": "application/json; charset=utf-8", "allow": "POST", "cache-control": "no-store" },
|
|
315
|
+
});
|
|
316
|
+
}
|
|
328
317
|
|
|
329
|
-
const
|
|
330
|
-
if (!
|
|
318
|
+
const authorized = await this.verifyAccessToken(bearerToken(request), base);
|
|
319
|
+
if (!authorized) {
|
|
331
320
|
return new Response("OAuth bearer token required", {
|
|
332
321
|
status: 401,
|
|
333
|
-
headers: {
|
|
322
|
+
headers: {
|
|
323
|
+
"WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`,
|
|
324
|
+
"cache-control": "no-store",
|
|
325
|
+
"content-type": "text/plain; charset=utf-8",
|
|
326
|
+
"x-content-type-options": "nosniff",
|
|
327
|
+
},
|
|
334
328
|
});
|
|
335
329
|
}
|
|
336
330
|
|
|
337
|
-
if (request.method === "GET") return this.openMcpSseStream(request, auth);
|
|
338
|
-
|
|
339
331
|
const body = await parseJsonRequest(request, this.bodyLimitBytes());
|
|
340
|
-
if (isJsonRpcResponse(body)) {
|
|
341
|
-
this.handleClientJsonRpcResponse(body);
|
|
342
|
-
return new Response(null, { status: 202 });
|
|
343
|
-
}
|
|
332
|
+
if (isJsonRpcResponse(body)) return new Response(null, { status: 202 });
|
|
344
333
|
if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
|
|
345
|
-
const
|
|
334
|
+
const protocolError = validateProtocolVersionHeader(request, body);
|
|
335
|
+
if (protocolError) return json(protocolError, 400);
|
|
336
|
+
const response = await this.dispatchJsonRpc(body, base, authorized);
|
|
346
337
|
if (response === null) return new Response(null, { status: 202 });
|
|
347
338
|
return json(response);
|
|
348
339
|
}
|
|
349
340
|
|
|
350
|
-
private async dispatchJsonRpc(request: JsonRpcRequest, base: string,
|
|
341
|
+
private async dispatchJsonRpc(request: JsonRpcRequest, base: string, authorized: AuthorizedToken): Promise<Record<string, unknown> | null> {
|
|
351
342
|
if (request.method === "initialize") {
|
|
352
|
-
|
|
343
|
+
const requested = asObject(request.params).protocolVersion;
|
|
344
|
+
const protocolVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])
|
|
345
|
+
? requested
|
|
346
|
+
: MCP_PROTOCOL_VERSION;
|
|
353
347
|
return rpcResult(request.id, {
|
|
354
|
-
protocolVersion
|
|
355
|
-
capabilities: { tools: { listChanged: false } },
|
|
356
|
-
serverInfo: {
|
|
348
|
+
protocolVersion,
|
|
349
|
+
capabilities: { tools: { listChanged: false }, logging: {} },
|
|
350
|
+
serverInfo: {
|
|
351
|
+
name: SERVER_NAME,
|
|
352
|
+
title: "Machine Bridge MCP",
|
|
353
|
+
version: SERVER_VERSION,
|
|
354
|
+
description: "Workspace-scoped local coding tools over authenticated remote relay.",
|
|
355
|
+
},
|
|
357
356
|
instructions: MCP_INSTRUCTIONS,
|
|
358
357
|
});
|
|
359
358
|
}
|
|
360
359
|
if (request.method === "notifications/initialized") return null;
|
|
360
|
+
if (request.method === "notifications/cancelled") {
|
|
361
|
+
this.cancelClientRequest(clientRequestKey(authorized, asObject(request.params).requestId));
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
if (request.method === "logging/setLevel") return rpcResult(request.id, {});
|
|
361
365
|
if (request.method === "ping") return rpcResult(request.id, {});
|
|
362
366
|
if (request.method === "tools/list") return rpcResult(request.id, { tools: this.allTools() });
|
|
363
367
|
if (request.method === "tools/call") {
|
|
@@ -365,7 +369,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
365
369
|
const name = requiredString(params, "name");
|
|
366
370
|
const args = asObject(params.arguments);
|
|
367
371
|
try {
|
|
368
|
-
const result = await this.callTool(name, args, base);
|
|
372
|
+
const result = await this.callTool(name, args, base, clientRequestKey(authorized, request.id));
|
|
369
373
|
return rpcResult(request.id, textToolResult(result));
|
|
370
374
|
} catch (error) {
|
|
371
375
|
return rpcResult(request.id, textToolResult({ error: errorMessage(error) }, true));
|
|
@@ -374,7 +378,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
374
378
|
return rpcError(request.id, -32601, `Method not found: ${request.method}`);
|
|
375
379
|
}
|
|
376
380
|
|
|
377
|
-
private async callTool(name: string, args: Record<string, unknown>, base: string): Promise<unknown> {
|
|
381
|
+
private async callTool(name: string, args: Record<string, unknown>, base: string, requestKey?: string): Promise<unknown> {
|
|
378
382
|
if (name === "server_info") {
|
|
379
383
|
return {
|
|
380
384
|
name: SERVER_NAME,
|
|
@@ -382,256 +386,168 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
382
386
|
mcp_url: `${base}/mcp`,
|
|
383
387
|
oauth: this.authorizationServerMetadata(base),
|
|
384
388
|
daemon: this.daemonStatus(true),
|
|
385
|
-
mcp_clients: this.mcpClientStatus(true),
|
|
386
389
|
tools: this.allTools().map((tool) => tool.name),
|
|
387
390
|
};
|
|
388
391
|
}
|
|
389
392
|
if (workspaceTools.some((tool) => tool.name === name)) {
|
|
390
393
|
if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
|
|
391
|
-
return this.callDaemonTool(name, args);
|
|
394
|
+
return this.callDaemonTool(name, args, requestKey);
|
|
392
395
|
}
|
|
393
396
|
throw new Error(`unknown tool: ${name}`);
|
|
394
397
|
}
|
|
395
398
|
|
|
396
|
-
private async callDaemonTool(name: string, args: Record<string, unknown
|
|
399
|
+
private async callDaemonTool(name: string, args: Record<string, unknown>, requestKey?: string): Promise<unknown> {
|
|
397
400
|
const socket = this.daemonSockets()[0];
|
|
398
401
|
if (!socket) throw new Error("local daemon is not connected; keep the CLI start command running");
|
|
402
|
+
if (this.pending.size >= MAX_PENDING_CALLS) throw new Error("too many concurrent daemon tool calls");
|
|
403
|
+
if (requestKey && [...this.pending.values()].some((pending) => pending.clientRequestKey === requestKey)) {
|
|
404
|
+
throw new Error("duplicate in-flight JSON-RPC request id for this access token");
|
|
405
|
+
}
|
|
399
406
|
const id = randomToken("call");
|
|
400
407
|
const timeoutMs = daemonToolTimeoutMs(name, args);
|
|
401
408
|
return await new Promise((resolve, reject) => {
|
|
402
409
|
const timeout = setTimeout(() => {
|
|
403
410
|
this.pending.delete(id);
|
|
411
|
+
try { socket.send(JSON.stringify({ type: "cancel_call", id })); } catch {}
|
|
404
412
|
reject(new Error(`daemon tool timed out: ${name}`));
|
|
405
413
|
}, timeoutMs);
|
|
406
|
-
this.pending.set(id, { resolve, reject, timeout });
|
|
407
|
-
|
|
414
|
+
this.pending.set(id, { resolve, reject, timeout, socket, clientRequestKey: requestKey });
|
|
415
|
+
try {
|
|
416
|
+
socket.send(JSON.stringify({ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs }));
|
|
417
|
+
} catch (error) {
|
|
418
|
+
clearTimeout(timeout);
|
|
419
|
+
this.pending.delete(id);
|
|
420
|
+
reject(new Error(`failed to send daemon tool call: ${errorMessage(error)}`));
|
|
421
|
+
}
|
|
408
422
|
});
|
|
409
423
|
}
|
|
410
424
|
|
|
425
|
+
private cancelClientRequest(requestKey?: string): void {
|
|
426
|
+
if (!requestKey) return;
|
|
427
|
+
for (const [id, pending] of this.pending) {
|
|
428
|
+
if (pending.clientRequestKey !== requestKey) continue;
|
|
429
|
+
clearTimeout(pending.timeout);
|
|
430
|
+
this.pending.delete(id);
|
|
431
|
+
try { pending.socket.send(JSON.stringify({ type: "cancel_call", id })); } catch {}
|
|
432
|
+
pending.reject(new Error("tool call cancelled by client"));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
411
436
|
private async acceptDaemonWebSocket(request: Request): Promise<Response> {
|
|
412
437
|
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return new Response("Expected Upgrade: websocket", { status: 426 });
|
|
413
438
|
const expected = this.env.DAEMON_SHARED_SECRET ?? "";
|
|
414
439
|
const supplied = request.headers.get("X-Bridge-Token") ?? "";
|
|
415
440
|
if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
|
|
416
441
|
|
|
417
|
-
for (const socket of this.
|
|
418
|
-
try {
|
|
419
|
-
socket.close(1012, "replaced by newer daemon");
|
|
420
|
-
} catch {
|
|
421
|
-
// Ignore stale sockets.
|
|
422
|
-
}
|
|
442
|
+
for (const socket of this.nonDaemonSockets()) {
|
|
443
|
+
try { socket.close(1012, "replaced by newer daemon candidate"); } catch {}
|
|
423
444
|
}
|
|
424
445
|
|
|
425
446
|
const pair = new WebSocketPair();
|
|
426
447
|
const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
|
|
427
448
|
this.ctx.acceptWebSocket(server);
|
|
428
449
|
server.serializeAttachment({
|
|
429
|
-
role: "
|
|
450
|
+
role: "candidate",
|
|
430
451
|
connectedAt: new Date().toISOString(),
|
|
431
|
-
daemonId: request.headers.get("X-Daemon-Id") || randomToken("daemon"),
|
|
432
452
|
} satisfies DaemonAttachment);
|
|
453
|
+
await this.ctx.storage.setAlarm(Date.now() + DAEMON_HELLO_TIMEOUT_MS);
|
|
433
454
|
server.send(JSON.stringify({ type: "welcome", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
434
455
|
return new Response(null, { status: 101, webSocket: client });
|
|
435
456
|
}
|
|
436
457
|
|
|
437
|
-
private
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
|
|
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
|
-
});
|
|
458
|
+
private allTools(): Array<Record<string, unknown>> {
|
|
459
|
+
const advertised = this.daemonAdvertisedTools();
|
|
460
|
+
const localTools = workspaceTools.filter((tool) => advertised.has(tool.name));
|
|
461
|
+
return [serverInfoTool, ...localTools].map((tool) => structuredClone(tool));
|
|
473
462
|
}
|
|
474
463
|
|
|
475
|
-
private
|
|
476
|
-
|
|
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
|
-
}
|
|
464
|
+
private daemonToolEnabled(name: string): boolean {
|
|
465
|
+
return this.daemonAdvertisedTools().has(name);
|
|
495
466
|
}
|
|
496
467
|
|
|
497
|
-
private
|
|
498
|
-
const
|
|
499
|
-
if (!
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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);
|
|
468
|
+
private daemonAdvertisedTools(): Set<string> {
|
|
469
|
+
const socket = this.daemonSockets()[0];
|
|
470
|
+
if (!socket) return new Set();
|
|
471
|
+
const attachment = this.daemonAttachment(socket);
|
|
472
|
+
if (!attachment?.tools) return new Set();
|
|
473
|
+
return new Set(attachment.tools);
|
|
523
474
|
}
|
|
524
475
|
|
|
525
|
-
private
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
this.
|
|
530
|
-
|
|
531
|
-
|
|
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)}`));
|
|
476
|
+
private daemonSockets(): WebSocket[] {
|
|
477
|
+
return this.ctx.getWebSockets()
|
|
478
|
+
.filter((socket) => this.daemonAttachment(socket) && socket.readyState === WebSocket.OPEN)
|
|
479
|
+
.sort((left, right) => {
|
|
480
|
+
const leftTime = Date.parse(this.daemonAttachment(left)?.connectedAt ?? "") || 0;
|
|
481
|
+
const rightTime = Date.parse(this.daemonAttachment(right)?.connectedAt ?? "") || 0;
|
|
482
|
+
return rightTime - leftTime;
|
|
542
483
|
});
|
|
543
|
-
});
|
|
544
484
|
}
|
|
545
485
|
|
|
546
|
-
private
|
|
547
|
-
|
|
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);
|
|
486
|
+
private candidateSockets(): WebSocket[] {
|
|
487
|
+
return this.ctx.getWebSockets().filter((socket) => this.socketAttachment(socket)?.role === "candidate" && socket.readyState === WebSocket.OPEN);
|
|
562
488
|
}
|
|
563
489
|
|
|
564
|
-
private
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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),
|
|
490
|
+
private nonDaemonSockets(): WebSocket[] {
|
|
491
|
+
return this.ctx.getWebSockets().filter((socket) => {
|
|
492
|
+
const role = this.socketAttachment(socket)?.role;
|
|
493
|
+
return role !== "daemon" && socket.readyState === WebSocket.OPEN;
|
|
575
494
|
});
|
|
576
495
|
}
|
|
577
496
|
|
|
578
|
-
private
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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;
|
|
497
|
+
private socketAttachment(socket: WebSocket): DaemonAttachment | undefined {
|
|
498
|
+
const raw = socket.deserializeAttachment();
|
|
499
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
500
|
+
const candidate = raw as Partial<DaemonAttachment>;
|
|
501
|
+
if (candidate.role !== "candidate" && candidate.role !== "expired" && candidate.role !== "daemon") return undefined;
|
|
502
|
+
const policy = sanitizeDaemonPolicy(candidate.policy);
|
|
592
503
|
return {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
connected_at: new Date(stream.connectedAt).toISOString(),
|
|
598
|
-
sampling_capable: samplingCapableClientIds.has(stream.clientId),
|
|
599
|
-
})),
|
|
504
|
+
role: candidate.role,
|
|
505
|
+
connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
|
|
506
|
+
policy,
|
|
507
|
+
tools: sanitizeDaemonTools(candidate.tools, policy),
|
|
600
508
|
};
|
|
601
509
|
}
|
|
602
510
|
|
|
603
|
-
private
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
? workspaceTools.filter((tool) => advertised.has(tool.name))
|
|
607
|
-
: workspaceTools;
|
|
608
|
-
return [serverInfoTool, ...localTools].map((tool) => ({ ...tool }));
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
private daemonToolEnabled(name: string): boolean {
|
|
612
|
-
const advertised = this.daemonAdvertisedTools();
|
|
613
|
-
return !advertised || advertised.has(name);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
private daemonAdvertisedTools(): Set<string> | null {
|
|
617
|
-
const socket = this.daemonSockets()[0];
|
|
618
|
-
const attachment = socket ? this.daemonAttachment(socket) : undefined;
|
|
619
|
-
if (!attachment?.tools?.length) return null;
|
|
620
|
-
return new Set(attachment.tools);
|
|
511
|
+
private daemonAttachment(socket: WebSocket): DaemonAttachment | undefined {
|
|
512
|
+
const attachment = this.socketAttachment(socket);
|
|
513
|
+
return attachment?.role === "daemon" ? attachment : undefined;
|
|
621
514
|
}
|
|
622
515
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
516
|
+
async alarm(): Promise<void> {
|
|
517
|
+
const now = Date.now();
|
|
518
|
+
let nextDeadline = Number.POSITIVE_INFINITY;
|
|
519
|
+
for (const socket of this.candidateSockets()) {
|
|
520
|
+
const attachment = this.socketAttachment(socket);
|
|
521
|
+
const connectedAt = Date.parse(attachment?.connectedAt ?? "");
|
|
522
|
+
const deadline = connectedAt + DAEMON_HELLO_TIMEOUT_MS;
|
|
523
|
+
if (!Number.isFinite(connectedAt) || deadline <= now) {
|
|
524
|
+
socket.serializeAttachment({
|
|
525
|
+
role: "expired",
|
|
526
|
+
connectedAt: attachment?.connectedAt ?? new Date(0).toISOString(),
|
|
527
|
+
} satisfies DaemonAttachment);
|
|
528
|
+
try { socket.send(JSON.stringify({ type: "error", error: "daemon_hello_timeout" })); } catch {}
|
|
529
|
+
try { socket.close(1008, "daemon hello timeout"); } catch {}
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
nextDeadline = Math.min(nextDeadline, deadline);
|
|
533
|
+
}
|
|
534
|
+
if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(nextDeadline);
|
|
535
|
+
else await this.ctx.storage.deleteAlarm();
|
|
628
536
|
}
|
|
629
537
|
|
|
630
|
-
private
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
538
|
+
private async scheduleCandidateAlarm(): Promise<void> {
|
|
539
|
+
let nextDeadline = Number.POSITIVE_INFINITY;
|
|
540
|
+
for (const socket of this.candidateSockets()) {
|
|
541
|
+
const attachment = this.socketAttachment(socket);
|
|
542
|
+
const connectedAt = Date.parse(attachment?.connectedAt ?? "");
|
|
543
|
+
if (!Number.isFinite(connectedAt)) {
|
|
544
|
+
try { socket.close(1008, "invalid daemon candidate timestamp"); } catch {}
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
nextDeadline = Math.min(nextDeadline, connectedAt + DAEMON_HELLO_TIMEOUT_MS);
|
|
548
|
+
}
|
|
549
|
+
if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(Math.max(Date.now(), nextDeadline));
|
|
550
|
+
else await this.ctx.storage.deleteAlarm();
|
|
635
551
|
}
|
|
636
552
|
|
|
637
553
|
private daemonStatus(detail: boolean): Record<string, unknown> {
|
|
@@ -647,8 +563,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
647
563
|
if (!detail) return base;
|
|
648
564
|
return {
|
|
649
565
|
...base,
|
|
650
|
-
workspace_hash: attachment?.workspaceHash ?? null,
|
|
651
|
-
workspace_name: attachment?.workspaceName ?? null,
|
|
652
566
|
policy: attachment?.policy ?? null,
|
|
653
567
|
tools,
|
|
654
568
|
};
|
|
@@ -659,10 +573,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
659
573
|
name: SERVER_NAME,
|
|
660
574
|
version: SERVER_VERSION,
|
|
661
575
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
576
|
+
protocolVersions: [...MCP_SUPPORTED_PROTOCOL_VERSIONS],
|
|
662
577
|
transport: { type: "streamable-http", url: `${base}/mcp` },
|
|
663
|
-
auth: { type: "oauth" },
|
|
664
|
-
tools: this.allTools().map((tool) => tool.name),
|
|
665
|
-
instructions: MCP_INSTRUCTIONS,
|
|
578
|
+
auth: { type: "oauth", authorization_servers: [base] },
|
|
666
579
|
};
|
|
667
580
|
}
|
|
668
581
|
|
|
@@ -691,7 +604,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
691
604
|
}
|
|
692
605
|
|
|
693
606
|
private async oauthStore(): Promise<OAuthStore> {
|
|
694
|
-
const store = (await this.ctx.storage.get<OAuthStore>("oauth")) ?? { clients: {}, codes: {}, tokens: {} };
|
|
607
|
+
const store = (await this.ctx.storage.get<OAuthStore>("oauth")) ?? { clients: {}, codes: {}, tokens: {}, auth_failures: {} };
|
|
608
|
+
store.clients ||= {};
|
|
609
|
+
store.codes ||= {};
|
|
610
|
+
store.tokens ||= {};
|
|
611
|
+
store.auth_failures ||= {};
|
|
695
612
|
const now = Math.floor(Date.now() / 1000);
|
|
696
613
|
let changed = false;
|
|
697
614
|
for (const [code, value] of Object.entries(store.codes)) {
|
|
@@ -706,166 +623,259 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
706
623
|
changed = true;
|
|
707
624
|
}
|
|
708
625
|
}
|
|
626
|
+
for (const [identity, value] of Object.entries(store.auth_failures)) {
|
|
627
|
+
if (!identity.startsWith("hmac-sha256:") || value.last_attempt + AUTH_BLOCK_SECONDS <= now) {
|
|
628
|
+
delete store.auth_failures[identity];
|
|
629
|
+
changed = true;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const activeClientIds = new Set([
|
|
633
|
+
...Object.values(store.codes).map((value) => value.client_id),
|
|
634
|
+
...Object.values(store.tokens).map((value) => value.client_id),
|
|
635
|
+
]);
|
|
636
|
+
for (const [clientId, client] of Object.entries(store.clients)) {
|
|
637
|
+
if (client.registration_identity && !client.registration_identity.startsWith("hmac-sha256:")) {
|
|
638
|
+
delete client.registration_identity;
|
|
639
|
+
changed = true;
|
|
640
|
+
}
|
|
641
|
+
if (!client.last_used_at) {
|
|
642
|
+
client.last_used_at = client.created_at;
|
|
643
|
+
changed = true;
|
|
644
|
+
}
|
|
645
|
+
const ttl = client.last_used_at === client.created_at ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
|
|
646
|
+
if (!activeClientIds.has(clientId) && client.last_used_at + ttl <= now) {
|
|
647
|
+
delete store.clients[clientId];
|
|
648
|
+
changed = true;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
709
651
|
if (changed) await this.ctx.storage.put("oauth", store);
|
|
710
652
|
return store;
|
|
711
653
|
}
|
|
712
654
|
|
|
713
655
|
private async registerClient(request: Request): Promise<Response> {
|
|
714
|
-
const body = await parseRequestBody(request,
|
|
656
|
+
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
715
657
|
const redirectUris = body.redirect_uris;
|
|
716
658
|
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
|
|
717
659
|
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be a non-empty array" }, 400);
|
|
718
660
|
}
|
|
719
|
-
if (redirectUris.length >
|
|
720
|
-
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most
|
|
661
|
+
if (redirectUris.length > 5) {
|
|
662
|
+
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most 5 entries" }, 400);
|
|
721
663
|
}
|
|
722
|
-
const normalized = redirectUris.map((item) => String(item));
|
|
723
|
-
if (normalized.some((item) => item.length >
|
|
664
|
+
const normalized = [...new Set(redirectUris.map((item) => String(item)))];
|
|
665
|
+
if (normalized.some((item) => item.length > 1024)) {
|
|
724
666
|
return json({ error: "invalid_client_metadata", error_description: "redirect_uri is too long" }, 400);
|
|
725
667
|
}
|
|
726
668
|
if (!normalized.every(isAllowedRedirectUri)) {
|
|
727
669
|
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be https or local http" }, 400);
|
|
728
670
|
}
|
|
729
671
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
672
|
+
return this.withOAuthLock(async () => {
|
|
673
|
+
const store = await this.oauthStore();
|
|
674
|
+
const registrationIdentity = await authorizationIdentity(request, this.identityKey());
|
|
675
|
+
const identityClientCount = Object.values(store.clients).filter((client) => client.registration_identity === registrationIdentity).length;
|
|
676
|
+
if (identityClientCount >= MAX_OAUTH_CLIENTS_PER_IDENTITY) {
|
|
677
|
+
return json({ error: "too_many_requests", error_description: "client registration limit reached for this source" }, 429);
|
|
678
|
+
}
|
|
679
|
+
if (Object.keys(store.clients).length >= MAX_OAUTH_CLIENTS) {
|
|
680
|
+
return json({ error: "temporarily_unavailable", error_description: "client registry is full; remove stale state or retry after inactive clients expire" }, 503);
|
|
681
|
+
}
|
|
682
|
+
const now = Math.floor(Date.now() / 1000);
|
|
683
|
+
const client: OAuthClient = {
|
|
684
|
+
client_id: randomToken("mcp_client"),
|
|
685
|
+
client_name: normalizeDisplayText(stringOrUndefined(body.client_name) ?? "MCP Client", 128),
|
|
686
|
+
redirect_uris: normalized,
|
|
687
|
+
created_at: now,
|
|
688
|
+
last_used_at: now,
|
|
689
|
+
registration_identity: registrationIdentity,
|
|
690
|
+
};
|
|
691
|
+
store.clients[client.client_id] = client;
|
|
692
|
+
await this.ctx.storage.put("oauth", store);
|
|
693
|
+
return json({
|
|
694
|
+
client_id: client.client_id,
|
|
695
|
+
client_name: client.client_name,
|
|
696
|
+
redirect_uris: client.redirect_uris,
|
|
697
|
+
grant_types: ["authorization_code"],
|
|
698
|
+
response_types: ["code"],
|
|
699
|
+
token_endpoint_auth_method: "none",
|
|
700
|
+
client_id_issued_at: client.created_at,
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private async authorizeGet(request: Request, base: string): Promise<Response> {
|
|
706
|
+
const body = searchParamsObject(new URL(request.url).searchParams);
|
|
707
|
+
return this.withOAuthLock(async () => {
|
|
708
|
+
const store = await this.oauthStore();
|
|
709
|
+
const validation = validateAuthorizationRequest(body, base, store);
|
|
710
|
+
if ("error" in validation) {
|
|
711
|
+
return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
|
|
712
|
+
}
|
|
713
|
+
return this.authorizePage(request, base, "", body, 200, validation.value, true);
|
|
748
714
|
});
|
|
749
715
|
}
|
|
750
716
|
|
|
751
|
-
private authorizePage(
|
|
717
|
+
private authorizePage(
|
|
718
|
+
request: Request,
|
|
719
|
+
base: string,
|
|
720
|
+
error = "",
|
|
721
|
+
submitted?: Record<string, unknown>,
|
|
722
|
+
status = 200,
|
|
723
|
+
authorization?: ValidatedAuthorization,
|
|
724
|
+
allowSubmit = true,
|
|
725
|
+
): Response {
|
|
752
726
|
const url = new URL(request.url);
|
|
753
727
|
const sourceEntries = submitted ? Object.entries(submitted) : searchParamsEntries(url.searchParams);
|
|
754
728
|
const hidden = sourceEntries
|
|
755
|
-
.filter(([key]) => key
|
|
729
|
+
.filter(([key]) => AUTHORIZATION_FIELDS.has(key))
|
|
756
730
|
.map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
|
|
757
731
|
.join("\n");
|
|
758
|
-
const resource = String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`);
|
|
759
|
-
const
|
|
732
|
+
const resource = authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`);
|
|
733
|
+
const clientBlock = authorization
|
|
734
|
+
? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
|
|
735
|
+
<p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
|
|
736
|
+
: "";
|
|
737
|
+
const errorBlock = error ? `<p role="alert" style="color:#b91c1c">${escapeHtml(error)}</p>` : "";
|
|
738
|
+
const form = allowSubmit
|
|
739
|
+
? `<form method="post" action="/oauth/authorize">
|
|
740
|
+
${hidden}
|
|
741
|
+
<label>Connection password<br><input name="login_token" type="password" autocomplete="current-password" autofocus required style="width: 100%; box-sizing: border-box; padding: 8px;"></label>
|
|
742
|
+
<p><button type="submit">Authorize</button></p>
|
|
743
|
+
</form>`
|
|
744
|
+
: "<p>Authorization cannot continue. Return to the MCP client and start the connection again.</p>";
|
|
760
745
|
return html(`<!doctype html>
|
|
761
746
|
<html>
|
|
762
|
-
<head><meta charset="utf-8"><title>Authorize ${SERVER_NAME}</title></head>
|
|
747
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize ${SERVER_NAME}</title></head>
|
|
763
748
|
<body style="font-family: system-ui, sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5; padding: 0 16px;">
|
|
764
749
|
<h1>Authorize ${SERVER_NAME}</h1>
|
|
765
|
-
<p>
|
|
750
|
+
<p>Only continue if you initiated this MCP connection and recognize the client and redirect URI below.</p>
|
|
751
|
+
${clientBlock}
|
|
766
752
|
<p><strong>Resource:</strong> <code>${escapeHtml(resource)}</code></p>
|
|
767
753
|
${errorBlock}
|
|
768
|
-
|
|
769
|
-
${hidden}
|
|
770
|
-
<label>Connection password<br><input name="login_token" type="password" autofocus style="width: 100%; box-sizing: border-box; padding: 8px;"></label>
|
|
771
|
-
<p><button type="submit">Authorize</button></p>
|
|
772
|
-
</form>
|
|
754
|
+
${form}
|
|
773
755
|
</body>
|
|
774
|
-
</html
|
|
756
|
+
</html>`, status);
|
|
775
757
|
}
|
|
776
758
|
|
|
777
759
|
private async authorizeSubmit(request: Request, base: string): Promise<Response> {
|
|
778
|
-
const body = await parseRequestBody(request,
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
760
|
+
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
761
|
+
return this.withOAuthLock(async () => {
|
|
762
|
+
const store = await this.oauthStore();
|
|
763
|
+
const validation = validateAuthorizationRequest(body, base, store);
|
|
764
|
+
if ("error" in validation) {
|
|
765
|
+
return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
|
|
766
|
+
}
|
|
767
|
+
const { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } = validation.value;
|
|
768
|
+
const now = Math.floor(Date.now() / 1000);
|
|
769
|
+
const identity = await authorizationIdentity(request, this.identityKey());
|
|
770
|
+
const failure = store.auth_failures[identity];
|
|
771
|
+
if (failure?.blocked_until > now) {
|
|
772
|
+
return this.authorizePage(request, base, "Too many failed attempts. Try again later.", body, 429, validation.value);
|
|
773
|
+
}
|
|
783
774
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
expires_at: Math.floor(Date.now() / 1000) + 300,
|
|
808
|
-
};
|
|
809
|
-
await this.ctx.storage.put("oauth", store);
|
|
775
|
+
const expectedLogin = this.env.MCP_OAUTH_PASSWORD ?? "";
|
|
776
|
+
if (!expectedLogin || !(await safeEqual(String(body.login_token ?? ""), expectedLogin))) {
|
|
777
|
+
recordAuthorizationFailure(store, identity, now);
|
|
778
|
+
pruneAuthFailures(store, MAX_AUTH_FAILURE_IDENTITIES);
|
|
779
|
+
await this.ctx.storage.put("oauth", store);
|
|
780
|
+
const status = store.auth_failures[identity]?.blocked_until > now ? 429 : 401;
|
|
781
|
+
return this.authorizePage(request, base, "Invalid connection password.", body, status, validation.value);
|
|
782
|
+
}
|
|
783
|
+
delete store.auth_failures[identity];
|
|
784
|
+
client.last_used_at = now;
|
|
785
|
+
|
|
786
|
+
const code = randomToken("mcp_code");
|
|
787
|
+
store.codes[code] = {
|
|
788
|
+
client_id: clientId,
|
|
789
|
+
redirect_uri: redirectUri,
|
|
790
|
+
code_challenge: codeChallenge,
|
|
791
|
+
scope,
|
|
792
|
+
resource: requestedResource,
|
|
793
|
+
expires_at: now + 300,
|
|
794
|
+
};
|
|
795
|
+
pruneClientRecordByExpiry(store.codes, clientId, MAX_CODES_PER_CLIENT);
|
|
796
|
+
pruneRecordByExpiry(store.codes, MAX_OAUTH_CODES);
|
|
797
|
+
await this.ctx.storage.put("oauth", store);
|
|
810
798
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
799
|
+
const params = new URLSearchParams({ code });
|
|
800
|
+
if (state) params.set("state", state);
|
|
801
|
+
return oauthRedirect(`${redirectUri}${redirectUri.includes("?") ? "&" : "?"}${params.toString()}`);
|
|
802
|
+
});
|
|
814
803
|
}
|
|
815
804
|
|
|
816
805
|
private async exchangeToken(request: Request, base: string): Promise<Response> {
|
|
817
|
-
const body = await parseRequestBody(request,
|
|
806
|
+
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
818
807
|
if (String(body.grant_type ?? "") !== "authorization_code") return json({ error: "unsupported_grant_type" }, 400);
|
|
819
808
|
|
|
820
809
|
const code = String(body.code ?? "");
|
|
821
810
|
const verifier = String(body.code_verifier ?? "");
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
811
|
+
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
|
|
812
|
+
return this.withOAuthLock(async () => {
|
|
813
|
+
const store = await this.oauthStore();
|
|
814
|
+
const record = store.codes[code];
|
|
815
|
+
if (!record) return json({ error: "invalid_grant" }, 400);
|
|
816
|
+
if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
|
|
817
|
+
return json({ error: "invalid_grant", error_description: "client or redirect mismatch" }, 400);
|
|
818
|
+
}
|
|
819
|
+
if (String(body.resource ?? record.resource) !== record.resource) {
|
|
820
|
+
return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
|
|
821
|
+
}
|
|
822
|
+
if (!(await safeEqual(await pkceS256(verifier), record.code_challenge))) {
|
|
823
|
+
return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const tokenVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
|
|
827
|
+
if (!tokenVersion) return json({ error: "server_error", error_description: "OAuth token version is not configured" }, 503);
|
|
828
|
+
delete store.codes[code];
|
|
829
|
+
const accessToken = randomToken("mcp_at");
|
|
830
|
+
store.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
|
|
831
|
+
client_id: record.client_id,
|
|
832
|
+
scope: record.scope,
|
|
833
|
+
resource: `${base}/mcp`,
|
|
834
|
+
version: tokenVersion,
|
|
835
|
+
expires_at: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
|
|
836
|
+
};
|
|
837
|
+
pruneClientRecordByExpiry(store.tokens, record.client_id, MAX_TOKENS_PER_CLIENT);
|
|
838
|
+
pruneRecordByExpiry(store.tokens, MAX_OAUTH_TOKENS);
|
|
838
839
|
await this.ctx.storage.put("oauth", store);
|
|
839
|
-
return json({
|
|
840
|
-
}
|
|
840
|
+
return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
|
|
841
|
+
});
|
|
842
|
+
}
|
|
841
843
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
844
|
+
private async verifyAccessToken(token: string, base: string): Promise<AuthorizedToken | null> {
|
|
845
|
+
return this.withOAuthLock(async () => {
|
|
846
|
+
if (!token) return null;
|
|
847
|
+
const store = await this.oauthStore();
|
|
848
|
+
const key = `sha256:${await sha256Hex(token)}`;
|
|
849
|
+
const record = store.tokens[key];
|
|
850
|
+
if (!record) return null;
|
|
851
|
+
if (record.expires_at <= Math.floor(Date.now() / 1000)) {
|
|
852
|
+
delete store.tokens[key];
|
|
853
|
+
await this.ctx.storage.put("oauth", store);
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
|
|
857
|
+
if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
|
|
858
|
+
if (record.resource !== `${base}/mcp`) return null;
|
|
859
|
+
return { tokenKey: key, clientId: record.client_id };
|
|
860
|
+
});
|
|
852
861
|
}
|
|
853
862
|
|
|
854
|
-
private async
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
return null;
|
|
863
|
+
private async withOAuthLock<T>(callback: () => Promise<T>): Promise<T> {
|
|
864
|
+
const previous = this.oauthQueue;
|
|
865
|
+
let release = () => {};
|
|
866
|
+
this.oauthQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
867
|
+
await previous;
|
|
868
|
+
try {
|
|
869
|
+
return await callback();
|
|
870
|
+
} finally {
|
|
871
|
+
release();
|
|
864
872
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private identityKey(): string {
|
|
876
|
+
const key = this.env.OAUTH_TOKEN_VERSION || this.env.DAEMON_SHARED_SECRET || this.env.MCP_OAUTH_PASSWORD;
|
|
877
|
+
if (!key) throw new HttpError(503, "server_not_configured", "OAuth identity key is not configured");
|
|
878
|
+
return key;
|
|
869
879
|
}
|
|
870
880
|
|
|
871
881
|
private bodyLimitBytes(): number {
|
|
@@ -885,14 +895,20 @@ export default {
|
|
|
885
895
|
function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
|
|
886
896
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
887
897
|
const candidate = value as Record<string, unknown>;
|
|
888
|
-
|
|
898
|
+
if (candidate.jsonrpc !== JSONRPC_VERSION || typeof candidate.method !== "string" || !candidate.method.trim() || candidate.method.length > 256) return false;
|
|
899
|
+
if ("id" in candidate && !isJsonRpcId(candidate.id)) return false;
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function isJsonRpcId(value: unknown): value is JsonRpcId {
|
|
904
|
+
return value === null || typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
|
|
889
905
|
}
|
|
890
906
|
|
|
891
907
|
function isJsonRpcResponse(value: unknown): boolean {
|
|
892
908
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
893
909
|
const candidate = value as Record<string, unknown>;
|
|
894
|
-
if (candidate.jsonrpc !== JSONRPC_VERSION || !("id" in candidate) || typeof candidate.method === "string") return false;
|
|
895
|
-
return "result" in candidate
|
|
910
|
+
if (candidate.jsonrpc !== JSONRPC_VERSION || !("id" in candidate) || !isJsonRpcId(candidate.id) || typeof candidate.method === "string") return false;
|
|
911
|
+
return ("result" in candidate) !== ("error" in candidate);
|
|
896
912
|
}
|
|
897
913
|
|
|
898
914
|
function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, unknown> | null {
|
|
@@ -900,59 +916,125 @@ function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, u
|
|
|
900
916
|
return { jsonrpc: JSONRPC_VERSION, id, result };
|
|
901
917
|
}
|
|
902
918
|
|
|
903
|
-
function rpcError(id: JsonRpcId | undefined, code: number, message: string): Record<string, unknown> {
|
|
904
|
-
|
|
919
|
+
function rpcError(id: JsonRpcId | undefined, code: number, message: string, data?: unknown): Record<string, unknown> {
|
|
920
|
+
const error: Record<string, unknown> = { code, message };
|
|
921
|
+
if (data !== undefined) error.data = data;
|
|
922
|
+
return { jsonrpc: JSONRPC_VERSION, id: id ?? null, error };
|
|
905
923
|
}
|
|
906
924
|
|
|
907
925
|
function textToolResult(value: unknown, isError = false): Record<string, unknown> {
|
|
908
|
-
|
|
926
|
+
const special = asObject(value).$mcp;
|
|
927
|
+
if (special && typeof special === "object" && !Array.isArray(special)) {
|
|
928
|
+
const specialObject = special as Record<string, unknown>;
|
|
929
|
+
if (Array.isArray(specialObject.content)) {
|
|
930
|
+
const result: Record<string, unknown> = { content: specialObject.content, isError };
|
|
931
|
+
if (specialObject.structuredContent && typeof specialObject.structuredContent === "object" && !Array.isArray(specialObject.structuredContent)) {
|
|
932
|
+
result.structuredContent = specialObject.structuredContent;
|
|
933
|
+
}
|
|
934
|
+
return result;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const result: Record<string, unknown> = {
|
|
938
|
+
content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
|
|
939
|
+
isError,
|
|
940
|
+
};
|
|
941
|
+
if (value && typeof value === "object" && !Array.isArray(value)) result.structuredContent = value;
|
|
942
|
+
return result;
|
|
909
943
|
}
|
|
910
944
|
|
|
911
|
-
function
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
945
|
+
function methodNotAllowed(allow: string): Response {
|
|
946
|
+
return new Response(JSON.stringify({ error: "method_not_allowed" }), {
|
|
947
|
+
status: 405,
|
|
948
|
+
headers: {
|
|
949
|
+
allow,
|
|
950
|
+
"cache-control": "no-store",
|
|
951
|
+
"content-type": "application/json; charset=utf-8",
|
|
952
|
+
"x-content-type-options": "nosniff",
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function oauthRedirect(location: string): Response {
|
|
958
|
+
return new Response(null, {
|
|
959
|
+
status: 302,
|
|
960
|
+
headers: {
|
|
961
|
+
location,
|
|
962
|
+
"cache-control": "no-store",
|
|
963
|
+
"referrer-policy": "no-referrer",
|
|
964
|
+
"x-content-type-options": "nosniff",
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function isFreshDaemonCandidate(connectedAt: string): boolean {
|
|
970
|
+
const timestamp = Date.parse(connectedAt);
|
|
971
|
+
if (!Number.isFinite(timestamp)) return false;
|
|
972
|
+
const age = Date.now() - timestamp;
|
|
973
|
+
return age >= 0 && age <= DAEMON_HELLO_TIMEOUT_MS;
|
|
924
974
|
}
|
|
925
975
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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")));
|
|
976
|
+
function sanitizeMetadataText(value: unknown, maxLength: number): string | undefined {
|
|
977
|
+
if (typeof value !== "string") return undefined;
|
|
978
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
979
|
+
return normalized ? normalized.slice(0, maxLength) : undefined;
|
|
933
980
|
}
|
|
934
981
|
|
|
935
|
-
|
|
936
|
-
|
|
982
|
+
|
|
983
|
+
function sanitizeDaemonTools(value: unknown, policy: Record<string, unknown>): string[] {
|
|
984
|
+
if (!Array.isArray(value)) return [];
|
|
985
|
+
const definitions = new Map(allCatalogTools.map((tool) => [tool.name, tool]));
|
|
986
|
+
return [...new Set(value.filter((item): item is string => {
|
|
987
|
+
if (typeof item !== "string" || item === "server_info") return false;
|
|
988
|
+
const definition = definitions.get(item);
|
|
989
|
+
return Boolean(definition && daemonPolicyAllows(definition.availability, policy));
|
|
990
|
+
}))];
|
|
937
991
|
}
|
|
938
992
|
|
|
939
|
-
function
|
|
940
|
-
|
|
993
|
+
function daemonPolicyAllows(availability: unknown, policy: Record<string, unknown>): boolean {
|
|
994
|
+
if (availability === "always") return true;
|
|
995
|
+
if (availability === "write") return policy.allowWrite === true;
|
|
996
|
+
if (availability === "direct-exec") return policy.execMode === "direct" || policy.execMode === "shell";
|
|
997
|
+
if (availability === "shell-exec") return policy.execMode === "shell";
|
|
998
|
+
return false;
|
|
941
999
|
}
|
|
942
1000
|
|
|
943
|
-
function
|
|
944
|
-
const
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
1001
|
+
function sanitizeDaemonPolicy(value: unknown): Record<string, unknown> {
|
|
1002
|
+
const policy = asObject(value);
|
|
1003
|
+
const execMode = policy.execMode === "shell" || policy.execMode === "direct" ? policy.execMode : "off";
|
|
1004
|
+
return {
|
|
1005
|
+
profile: sanitizeMetadataText(policy.profile, 32) ?? "custom",
|
|
1006
|
+
allowWrite: policy.allowWrite === true,
|
|
1007
|
+
allowExec: execMode !== "off",
|
|
1008
|
+
execMode,
|
|
1009
|
+
unrestrictedPaths: policy.unrestrictedPaths === true,
|
|
1010
|
+
minimalEnv: policy.minimalEnv !== false,
|
|
1011
|
+
exposeAbsolutePaths: policy.exposeAbsolutePaths === true,
|
|
1012
|
+
};
|
|
948
1013
|
}
|
|
949
1014
|
|
|
950
1015
|
function json(value: unknown, status = 200): Response {
|
|
951
|
-
return new Response(JSON.stringify(value), {
|
|
1016
|
+
return new Response(JSON.stringify(value), {
|
|
1017
|
+
status,
|
|
1018
|
+
headers: {
|
|
1019
|
+
"content-type": "application/json; charset=utf-8",
|
|
1020
|
+
"cache-control": "no-store",
|
|
1021
|
+
"x-content-type-options": "nosniff",
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
952
1024
|
}
|
|
953
1025
|
|
|
954
1026
|
function html(value: string, status = 200): Response {
|
|
955
|
-
return new Response(value, {
|
|
1027
|
+
return new Response(value, {
|
|
1028
|
+
status,
|
|
1029
|
+
headers: {
|
|
1030
|
+
"content-type": "text/html; charset=utf-8",
|
|
1031
|
+
"cache-control": "no-store",
|
|
1032
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
1033
|
+
"referrer-policy": "no-referrer",
|
|
1034
|
+
"x-content-type-options": "nosniff",
|
|
1035
|
+
"x-frame-options": "DENY",
|
|
1036
|
+
},
|
|
1037
|
+
});
|
|
956
1038
|
}
|
|
957
1039
|
|
|
958
1040
|
function baseUrl(request: Request): string {
|
|
@@ -989,6 +1071,7 @@ async function parseRequestBody(request: Request, limit: number): Promise<Record
|
|
|
989
1071
|
return searchParamsObject(new URLSearchParams(text));
|
|
990
1072
|
}
|
|
991
1073
|
|
|
1074
|
+
|
|
992
1075
|
async function readBoundedText(request: Request, limit: number): Promise<string> {
|
|
993
1076
|
const length = Number(request.headers.get("content-length") ?? "0");
|
|
994
1077
|
if (Number.isFinite(length) && length > limit) throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
|
|
@@ -1017,7 +1100,11 @@ async function readBoundedText(request: Request, limit: number): Promise<string>
|
|
|
1017
1100
|
combined.set(chunk, offset);
|
|
1018
1101
|
offset += chunk.byteLength;
|
|
1019
1102
|
}
|
|
1020
|
-
|
|
1103
|
+
try {
|
|
1104
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(combined);
|
|
1105
|
+
} catch {
|
|
1106
|
+
throw new HttpError(400, "invalid_encoding", "Request body must be valid UTF-8");
|
|
1107
|
+
}
|
|
1021
1108
|
}
|
|
1022
1109
|
|
|
1023
1110
|
function asObject(value: unknown): Record<string, unknown> {
|
|
@@ -1042,15 +1129,106 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number)
|
|
|
1042
1129
|
}
|
|
1043
1130
|
|
|
1044
1131
|
function daemonToolTimeoutMs(name: string, args: Record<string, unknown>): number {
|
|
1045
|
-
if (name !== "exec_command") return 60_000;
|
|
1132
|
+
if (name !== "exec_command" && name !== "run_process") return 60_000;
|
|
1046
1133
|
const seconds = clampNumber(args.timeout_seconds, 120, 1, 600);
|
|
1047
1134
|
return Math.min((seconds + 5) * 1000, 610_000);
|
|
1048
1135
|
}
|
|
1049
1136
|
|
|
1050
|
-
function
|
|
1051
|
-
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1137
|
+
function validateProtocolVersionHeader(request: Request, body: JsonRpcRequest): Record<string, unknown> | null {
|
|
1138
|
+
if (body.method === "initialize") return null;
|
|
1139
|
+
const version = request.headers.get("MCP-Protocol-Version");
|
|
1140
|
+
if (!version) return null;
|
|
1141
|
+
if (MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(version as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])) return null;
|
|
1142
|
+
return rpcError(body.id, -32602, "Unsupported MCP protocol version", {
|
|
1143
|
+
requested: version,
|
|
1144
|
+
supported: [...MCP_SUPPORTED_PROTOCOL_VERSIONS],
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function clientRequestKey(authorized: AuthorizedToken, requestId: unknown): string | undefined {
|
|
1149
|
+
if (requestId === null || (typeof requestId !== "string" && typeof requestId !== "number")) return undefined;
|
|
1150
|
+
return `${authorized.tokenKey}:${typeof requestId}:${String(requestId)}`;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function validateAuthorizationRequest(
|
|
1154
|
+
body: Record<string, unknown>,
|
|
1155
|
+
base: string,
|
|
1156
|
+
store: OAuthStore,
|
|
1157
|
+
): { value: ValidatedAuthorization } | { error: string; status: number } {
|
|
1158
|
+
const responseType = String(body.response_type ?? "");
|
|
1159
|
+
const clientId = String(body.client_id ?? "");
|
|
1160
|
+
const redirectUri = String(body.redirect_uri ?? "");
|
|
1161
|
+
const codeChallenge = String(body.code_challenge ?? "");
|
|
1162
|
+
const codeChallengeMethod = String(body.code_challenge_method ?? "");
|
|
1163
|
+
const requestedResource = String(body.resource ?? `${base}/mcp`);
|
|
1164
|
+
const scope = String(body.scope ?? SERVER_NAME).trim();
|
|
1165
|
+
const state = body.state === undefined ? "" : typeof body.state === "string" ? body.state : "";
|
|
1166
|
+
|
|
1167
|
+
if (responseType !== "code") return { error: "response_type must be code.", status: 400 };
|
|
1168
|
+
if (requestedResource !== `${base}/mcp`) return { error: "resource mismatch.", status: 400 };
|
|
1169
|
+
if (scope !== SERVER_NAME) return { error: "unsupported scope.", status: 400 };
|
|
1170
|
+
if (body.state !== undefined && typeof body.state !== "string") return { error: "state must be a string.", status: 400 };
|
|
1171
|
+
if (state.length > 1024) return { error: "state is too long.", status: 400 };
|
|
1172
|
+
if (codeChallengeMethod !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) {
|
|
1173
|
+
return { error: "A valid PKCE S256 challenge is required.", status: 400 };
|
|
1174
|
+
}
|
|
1175
|
+
const client = store.clients[clientId];
|
|
1176
|
+
if (!client) return { error: "Unknown OAuth client.", status: 400 };
|
|
1177
|
+
if (!client.redirect_uris.includes(redirectUri)) return { error: "redirect_uri is not registered.", status: 400 };
|
|
1178
|
+
return { value: { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } };
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function pruneClientRecordByExpiry<T extends { client_id: string; expires_at: number }>(record: Record<string, T>, clientId: string, keep: number): void {
|
|
1182
|
+
const allowed = new Set(Object.entries(record)
|
|
1183
|
+
.filter(([, value]) => value.client_id === clientId)
|
|
1184
|
+
.sort((left, right) => right[1].expires_at - left[1].expires_at)
|
|
1185
|
+
.slice(0, keep)
|
|
1186
|
+
.map(([key]) => key));
|
|
1187
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1188
|
+
if (value.client_id === clientId && !allowed.has(key)) delete record[key];
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function pruneRecordByExpiry<T extends { expires_at: number }>(record: Record<string, T>, keep: number): void {
|
|
1193
|
+
const allowed = new Set(Object.entries(record)
|
|
1194
|
+
.sort((left, right) => right[1].expires_at - left[1].expires_at)
|
|
1195
|
+
.slice(0, keep)
|
|
1196
|
+
.map(([key]) => key));
|
|
1197
|
+
for (const key of Object.keys(record)) if (!allowed.has(key)) delete record[key];
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async function authorizationIdentity(request: Request, keyMaterial: string): Promise<string> {
|
|
1201
|
+
const source = (request.headers.get("CF-Connecting-IP") || "unknown").slice(0, 128);
|
|
1202
|
+
const encoder = new TextEncoder();
|
|
1203
|
+
const key = await crypto.subtle.importKey(
|
|
1204
|
+
"raw",
|
|
1205
|
+
encoder.encode(keyMaterial),
|
|
1206
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1207
|
+
false,
|
|
1208
|
+
["sign"],
|
|
1209
|
+
);
|
|
1210
|
+
const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(source));
|
|
1211
|
+
return `hmac-sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function recordAuthorizationFailure(store: OAuthStore, identity: string, now: number): void {
|
|
1215
|
+
const current = store.auth_failures[identity];
|
|
1216
|
+
const activeWindow = current && current.window_started + AUTH_FAILURE_WINDOW_SECONDS > now;
|
|
1217
|
+
const count = activeWindow ? current.count + 1 : 1;
|
|
1218
|
+
store.auth_failures[identity] = {
|
|
1219
|
+
count,
|
|
1220
|
+
window_started: activeWindow ? current.window_started : now,
|
|
1221
|
+
blocked_until: count >= AUTH_FAILURE_LIMIT ? now + AUTH_BLOCK_SECONDS : 0,
|
|
1222
|
+
last_attempt: now,
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function pruneAuthFailures(store: OAuthStore, keep: number): void {
|
|
1227
|
+
const allowed = new Set(Object.entries(store.auth_failures)
|
|
1228
|
+
.sort((left, right) => right[1].last_attempt - left[1].last_attempt)
|
|
1229
|
+
.slice(0, keep)
|
|
1230
|
+
.map(([key]) => key));
|
|
1231
|
+
for (const key of Object.keys(store.auth_failures)) if (!allowed.has(key)) delete store.auth_failures[key];
|
|
1054
1232
|
}
|
|
1055
1233
|
|
|
1056
1234
|
function randomToken(prefix: string): string {
|
|
@@ -1089,7 +1267,8 @@ async function pkceS256(verifier: string): Promise<string> {
|
|
|
1089
1267
|
function isAllowedRedirectUri(value: string): boolean {
|
|
1090
1268
|
try {
|
|
1091
1269
|
const url = new URL(value);
|
|
1092
|
-
if (url.
|
|
1270
|
+
if (url.username || url.password || url.hash) return false;
|
|
1271
|
+
if (url.protocol === "https:" && url.hostname) return true;
|
|
1093
1272
|
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return true;
|
|
1094
1273
|
return false;
|
|
1095
1274
|
} catch {
|
|
@@ -1097,19 +1276,56 @@ function isAllowedRedirectUri(value: string): boolean {
|
|
|
1097
1276
|
}
|
|
1098
1277
|
}
|
|
1099
1278
|
|
|
1100
|
-
function
|
|
1101
|
-
const origin = request.headers.get("Origin");
|
|
1102
|
-
if (!origin) return
|
|
1279
|
+
function corsPreflight(request: Request, base: string, configured: string): Response {
|
|
1280
|
+
const origin = request.headers.get("Origin") ?? "";
|
|
1281
|
+
if (!isConfiguredOrSameOrigin(origin, base, configured)) return json({ error: "origin_not_allowed" }, 403);
|
|
1282
|
+
const requestedMethod = (request.headers.get("Access-Control-Request-Method") ?? "").toUpperCase();
|
|
1283
|
+
if (requestedMethod && !["GET", "POST"].includes(requestedMethod)) return methodNotAllowed("GET, POST, OPTIONS");
|
|
1284
|
+
return new Response(null, {
|
|
1285
|
+
status: 204,
|
|
1286
|
+
headers: {
|
|
1287
|
+
"access-control-allow-origin": origin,
|
|
1288
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
1289
|
+
"access-control-allow-headers": "authorization, content-type, mcp-protocol-version, mcp-session-id",
|
|
1290
|
+
"access-control-max-age": "600",
|
|
1291
|
+
"cache-control": "no-store",
|
|
1292
|
+
"vary": "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
|
|
1293
|
+
},
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function applyCors(response: Response, request: Request, base: string, configured: string): Response {
|
|
1298
|
+
if (response.status === 101) return response;
|
|
1299
|
+
const origin = request.headers.get("Origin") ?? "";
|
|
1300
|
+
if (!origin || !isConfiguredOrSameOrigin(origin, base, configured)) return response;
|
|
1301
|
+
const headers = new Headers(response.headers);
|
|
1302
|
+
headers.set("access-control-allow-origin", origin);
|
|
1303
|
+
headers.set("access-control-expose-headers", "www-authenticate, mcp-session-id");
|
|
1304
|
+
appendVary(headers, "Origin");
|
|
1305
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function appendVary(headers: Headers, value: string): void {
|
|
1309
|
+
const existing = headers.get("vary");
|
|
1310
|
+
const values = new Set((existing ?? "").split(",").map((item) => item.trim()).filter(Boolean));
|
|
1311
|
+
values.add(value);
|
|
1312
|
+
headers.set("vary", [...values].join(", "));
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function isConfiguredOrSameOrigin(origin: string, base: string, configured: string): boolean {
|
|
1103
1316
|
if (isDefaultAllowedOrigin(origin, base)) return true;
|
|
1104
|
-
const allowed = configured.split(",").map((item) => item.trim()).filter(
|
|
1317
|
+
const allowed = configured.split(",").map((item) => item.trim()).filter((item) => item && item !== "null");
|
|
1105
1318
|
return allowed.includes(origin);
|
|
1106
1319
|
}
|
|
1107
1320
|
|
|
1321
|
+
function validateOrigin(request: Request, base: string, configured = ""): boolean {
|
|
1322
|
+
const origin = request.headers.get("Origin");
|
|
1323
|
+
return !origin || isConfiguredOrSameOrigin(origin, base, configured);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1108
1326
|
function isDefaultAllowedOrigin(origin: string, base: string): boolean {
|
|
1109
1327
|
try {
|
|
1110
|
-
|
|
1111
|
-
if (origin === base) return true;
|
|
1112
|
-
return parsed.protocol === "http:" && isLoopbackHost(parsed.hostname);
|
|
1328
|
+
return new URL(origin).origin === new URL(base).origin;
|
|
1113
1329
|
} catch {
|
|
1114
1330
|
return false;
|
|
1115
1331
|
}
|
|
@@ -1135,10 +1351,23 @@ function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
|
|
|
1135
1351
|
return out;
|
|
1136
1352
|
}
|
|
1137
1353
|
|
|
1354
|
+
function normalizeDisplayText(value: string, maxLength: number): string {
|
|
1355
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
1356
|
+
return (normalized || "MCP Client").slice(0, maxLength);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1138
1359
|
function escapeHtml(value: string): string {
|
|
1139
1360
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
1140
1361
|
}
|
|
1141
1362
|
|
|
1363
|
+
function workerErrorClass(error: unknown): string {
|
|
1364
|
+
if (error instanceof HttpError) return error.code;
|
|
1365
|
+
if (error instanceof TypeError) return "type_error";
|
|
1366
|
+
if (error instanceof RangeError) return "range_error";
|
|
1367
|
+
if (error instanceof Error) return error.name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase().slice(0, 64) || "error";
|
|
1368
|
+
return "unknown_error";
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1142
1371
|
function errorMessage(error: unknown): string {
|
|
1143
1372
|
if (error instanceof Error) return error.message;
|
|
1144
1373
|
if (typeof error === "string") return error;
|