machine-bridge-mcp 0.16.1 → 0.17.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 +34 -0
- package/CONTRIBUTING.md +14 -0
- package/README.md +1 -1
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/service-worker.js +52 -29
- package/docs/ENGINEERING.md +3 -1
- package/docs/PROJECT_STANDARDS.md +143 -0
- package/docs/RELEASING.md +12 -0
- package/docs/TESTING.md +7 -5
- package/docs/TOOL_REFERENCE.md +2836 -0
- package/package.json +14 -5
- package/scripts/commit-message-check.mjs +63 -0
- package/scripts/coverage-check.mjs +9 -1
- package/scripts/generate-policy-reference.mjs +1 -4
- package/scripts/generate-tool-reference.mjs +87 -0
- package/scripts/generate-worker-types.mjs +19 -0
- package/scripts/markdown.mjs +9 -0
- package/src/local/agent-context.mjs +8 -15
- package/src/local/cli.mjs +1 -107
- package/src/local/daemon-process.mjs +16 -4
- package/src/local/default-instructions.mjs +2 -45
- package/src/local/git-service.mjs +4 -9
- package/src/local/managed-job-plan.mjs +2 -7
- package/src/local/managed-jobs.mjs +13 -11
- package/src/local/numbers.mjs +9 -0
- package/src/local/process-execution.mjs +4 -9
- package/src/local/process-sessions.mjs +5 -10
- package/src/local/project-metadata.mjs +50 -0
- package/src/local/project-package.mjs +2 -45
- package/src/local/records.mjs +5 -0
- package/src/local/runtime.mjs +5 -12
- package/src/local/state-inventory.mjs +139 -0
- package/src/local/workspace-file-service.mjs +6 -12
- package/src/worker/errors.ts +41 -0
- package/src/worker/http.ts +252 -0
- package/src/worker/index.ts +33 -17
- package/src/worker/oauth-state.ts +170 -0
- package/src/worker/observability.ts +111 -0
- package/src/worker/pending-calls.ts +109 -0
- package/src/worker/policy.ts +79 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
const UNSAFE_DISPLAY_CONTROLS = /[\u0000-\u001f\u007f\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
|
|
2
|
+
|
|
3
|
+
export class HttpError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly code: string;
|
|
6
|
+
|
|
7
|
+
constructor(status: number, code: string, message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "HttpError";
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function methodNotAllowed(allow: string): Response {
|
|
16
|
+
return new Response(JSON.stringify({ error: "method_not_allowed" }), {
|
|
17
|
+
status: 405,
|
|
18
|
+
headers: {
|
|
19
|
+
allow,
|
|
20
|
+
"cache-control": "no-store",
|
|
21
|
+
"content-type": "application/json; charset=utf-8",
|
|
22
|
+
"x-content-type-options": "nosniff",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function oauthRedirect(location: URL): Response {
|
|
28
|
+
return new Response(null, {
|
|
29
|
+
status: 303,
|
|
30
|
+
headers: {
|
|
31
|
+
location: location.href,
|
|
32
|
+
"cache-control": "no-store",
|
|
33
|
+
"referrer-policy": "no-referrer",
|
|
34
|
+
"x-content-type-options": "nosniff",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function authorizationRedirectLocation(redirectUri: string, code: string, state: string): URL {
|
|
40
|
+
const location = new URL(redirectUri);
|
|
41
|
+
location.searchParams.append("code", code);
|
|
42
|
+
if (state) location.searchParams.append("state", state);
|
|
43
|
+
return location;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function json(value: unknown, status = 200): Response {
|
|
47
|
+
return new Response(JSON.stringify(value), {
|
|
48
|
+
status,
|
|
49
|
+
headers: {
|
|
50
|
+
"content-type": "application/json; charset=utf-8",
|
|
51
|
+
"cache-control": "no-store",
|
|
52
|
+
"x-content-type-options": "nosniff",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function html(value: string, status = 200): Response {
|
|
58
|
+
return new Response(value, {
|
|
59
|
+
status,
|
|
60
|
+
headers: {
|
|
61
|
+
"content-type": "text/html; charset=utf-8",
|
|
62
|
+
"cache-control": "no-store",
|
|
63
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
64
|
+
"referrer-policy": "no-referrer",
|
|
65
|
+
"x-content-type-options": "nosniff",
|
|
66
|
+
"x-frame-options": "DENY",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function baseUrl(request: Request): string {
|
|
72
|
+
return new URL(request.url).origin;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function bearerToken(request: Request): string {
|
|
76
|
+
const match = (request.headers.get("Authorization") ?? "").match(/^Bearer\s+(.+)$/i);
|
|
77
|
+
return match?.[1]?.trim() ?? "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function parseJsonRequest(request: Request, limit: number): Promise<unknown> {
|
|
81
|
+
const text = await readBoundedText(request, limit);
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new HttpError(400, "invalid_json", "Request body is not valid JSON");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function parseRequestBody(request: Request, limit: number): Promise<Record<string, unknown>> {
|
|
90
|
+
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
91
|
+
const text = await readBoundedText(request, limit);
|
|
92
|
+
if (contentType.includes("application/json") || text.trim().startsWith("{")) {
|
|
93
|
+
let parsed: unknown;
|
|
94
|
+
try {
|
|
95
|
+
parsed = text.trim() ? JSON.parse(text) : {};
|
|
96
|
+
} catch {
|
|
97
|
+
throw new HttpError(400, "invalid_json", "Request body is not valid JSON");
|
|
98
|
+
}
|
|
99
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
100
|
+
throw new HttpError(400, "bad_request", "JSON body must be an object");
|
|
101
|
+
}
|
|
102
|
+
return parsed as Record<string, unknown>;
|
|
103
|
+
}
|
|
104
|
+
return searchParamsObject(new URLSearchParams(text));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function readBoundedText(request: Request, limit: number): Promise<string> {
|
|
108
|
+
const length = Number(request.headers.get("content-length") ?? "0");
|
|
109
|
+
if (Number.isFinite(length) && length > limit) {
|
|
110
|
+
throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
|
|
111
|
+
}
|
|
112
|
+
if (!request.body) return "";
|
|
113
|
+
const reader = request.body.getReader();
|
|
114
|
+
const chunks: Uint8Array[] = [];
|
|
115
|
+
let total = 0;
|
|
116
|
+
try {
|
|
117
|
+
for (;;) {
|
|
118
|
+
const { done, value } = await reader.read();
|
|
119
|
+
if (done) break;
|
|
120
|
+
if (!value) continue;
|
|
121
|
+
total += value.byteLength;
|
|
122
|
+
if (total > limit) {
|
|
123
|
+
await reader.cancel();
|
|
124
|
+
throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
|
|
125
|
+
}
|
|
126
|
+
chunks.push(value);
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
reader.releaseLock();
|
|
130
|
+
}
|
|
131
|
+
const combined = new Uint8Array(total);
|
|
132
|
+
let offset = 0;
|
|
133
|
+
for (const chunk of chunks) {
|
|
134
|
+
combined.set(chunk, offset);
|
|
135
|
+
offset += chunk.byteLength;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(combined);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new HttpError(400, "invalid_encoding", "Request body must be valid UTF-8");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function normalizeRedirectUri(value: string): string | null {
|
|
145
|
+
try {
|
|
146
|
+
const url = new URL(value);
|
|
147
|
+
if (url.username || url.password || url.hash) return null;
|
|
148
|
+
if (url.protocol === "https:" && url.hostname) return url.toString();
|
|
149
|
+
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url.toString();
|
|
150
|
+
return null;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function corsPreflight(request: Request, base: string, configured: string): Response {
|
|
157
|
+
const origin = request.headers.get("Origin") ?? "";
|
|
158
|
+
if (!isConfiguredOrSameOrigin(origin, base, configured)) return json({ error: "origin_not_allowed" }, 403);
|
|
159
|
+
const requestedMethod = (request.headers.get("Access-Control-Request-Method") ?? "").toUpperCase();
|
|
160
|
+
if (requestedMethod && !["GET", "POST"].includes(requestedMethod)) return methodNotAllowed("GET, POST, OPTIONS");
|
|
161
|
+
return new Response(null, {
|
|
162
|
+
status: 204,
|
|
163
|
+
headers: {
|
|
164
|
+
"access-control-allow-origin": origin,
|
|
165
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
166
|
+
"access-control-allow-headers": "authorization, content-type, mcp-protocol-version, mcp-session-id",
|
|
167
|
+
"access-control-max-age": "600",
|
|
168
|
+
"cache-control": "no-store",
|
|
169
|
+
"vary": "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function applyCors(response: Response, request: Request, base: string, configured: string): Response {
|
|
175
|
+
if (response.status === 101) return response;
|
|
176
|
+
const origin = request.headers.get("Origin") ?? "";
|
|
177
|
+
if (!origin || !isConfiguredOrSameOrigin(origin, base, configured)) return response;
|
|
178
|
+
const headers = new Headers(response.headers);
|
|
179
|
+
headers.set("access-control-allow-origin", origin);
|
|
180
|
+
headers.set("access-control-expose-headers", "www-authenticate, mcp-session-id");
|
|
181
|
+
appendVary(headers, "Origin");
|
|
182
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function validateOrigin(request: Request, base: string, configured = ""): boolean {
|
|
186
|
+
const origin = request.headers.get("Origin");
|
|
187
|
+
return !origin || isConfiguredOrSameOrigin(origin, base, configured);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function searchParamsEntries(params: URLSearchParams): Array<[string, string]> {
|
|
191
|
+
const entries: Array<[string, string]> = [];
|
|
192
|
+
params.forEach((value, key) => entries.push([key, value]));
|
|
193
|
+
return entries;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
|
|
197
|
+
const out: Record<string, unknown> = {};
|
|
198
|
+
params.forEach((value, key) => {
|
|
199
|
+
if (out[key] === undefined) out[key] = value;
|
|
200
|
+
else if (Array.isArray(out[key])) (out[key] as string[]).push(value);
|
|
201
|
+
else out[key] = [out[key] as string, value];
|
|
202
|
+
});
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function normalizeDisplayText(value: string, maxLength: number, fallback = "MCP Client"): string {
|
|
207
|
+
const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
|
|
208
|
+
return (normalized || fallback).slice(0, maxLength);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function sanitizeMetadataText(value: unknown, maxLength: number): string | undefined {
|
|
212
|
+
if (typeof value !== "string") return undefined;
|
|
213
|
+
const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
|
|
214
|
+
return normalized ? normalized.slice(0, maxLength) : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function escapeHtml(value: string): string {
|
|
218
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function workerErrorClass(error: unknown): string {
|
|
222
|
+
if (error instanceof HttpError) return error.code;
|
|
223
|
+
if (error instanceof TypeError) return "type_error";
|
|
224
|
+
if (error instanceof RangeError) return "range_error";
|
|
225
|
+
if (error instanceof Error) return error.name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase().slice(0, 64) || "error";
|
|
226
|
+
return "unknown_error";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function appendVary(headers: Headers, value: string): void {
|
|
230
|
+
const existing = headers.get("vary");
|
|
231
|
+
const values = new Set((existing ?? "").split(",").map((item) => item.trim()).filter(Boolean));
|
|
232
|
+
values.add(value);
|
|
233
|
+
headers.set("vary", [...values].join(", "));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isConfiguredOrSameOrigin(origin: string, base: string, configured: string): boolean {
|
|
237
|
+
if (isDefaultAllowedOrigin(origin, base)) return true;
|
|
238
|
+
const allowed = configured.split(",").map((item) => item.trim()).filter((item) => item && item !== "null");
|
|
239
|
+
return allowed.includes(origin);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isDefaultAllowedOrigin(origin: string, base: string): boolean {
|
|
243
|
+
try {
|
|
244
|
+
return new URL(origin).origin === new URL(base).origin;
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
251
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
252
|
+
}
|
package/src/worker/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./http";
|
|
18
18
|
|
|
19
19
|
const SERVER_NAME = String(serverMetadata.name);
|
|
20
|
-
const SERVER_VERSION = "0.
|
|
20
|
+
const SERVER_VERSION = "0.17.0";
|
|
21
21
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
22
22
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
23
23
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -162,14 +162,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
162
162
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
163
163
|
const size = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
|
|
164
164
|
if (size > MAX_DAEMON_MESSAGE_BYTES) {
|
|
165
|
-
|
|
165
|
+
closeWebSocketQuietly(ws, 1009, "message too large");
|
|
166
166
|
return;
|
|
167
167
|
}
|
|
168
168
|
let text: string;
|
|
169
169
|
try {
|
|
170
170
|
text = typeof message === "string" ? message : new TextDecoder("utf-8", { fatal: true }).decode(message);
|
|
171
171
|
} catch {
|
|
172
|
-
|
|
172
|
+
closeWebSocketQuietly(ws, 1007, "invalid UTF-8");
|
|
173
173
|
return;
|
|
174
174
|
}
|
|
175
175
|
let parsed: unknown;
|
|
@@ -187,12 +187,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
187
187
|
|
|
188
188
|
const socketAttachment = this.socketAttachment(ws);
|
|
189
189
|
if (!socketAttachment) {
|
|
190
|
-
|
|
190
|
+
closeWebSocketQuietly(ws, 1008, "missing daemon attachment");
|
|
191
191
|
return;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
if (socketAttachment.role === "expired") {
|
|
195
|
-
|
|
195
|
+
closeWebSocketQuietly(ws, 1008, "expired daemon candidate");
|
|
196
196
|
return;
|
|
197
197
|
}
|
|
198
198
|
|
|
@@ -204,7 +204,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
204
204
|
const previousDaemons = this.daemonSockets().filter((socket) => socket !== ws);
|
|
205
205
|
if (socketAttachment.role === "candidate") {
|
|
206
206
|
if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
|
|
207
|
-
|
|
207
|
+
closeWebSocketQuietly(ws, 1008, "stale daemon candidate");
|
|
208
208
|
await this.scheduleCandidateAlarm();
|
|
209
209
|
return;
|
|
210
210
|
}
|
|
@@ -225,17 +225,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
225
225
|
role: "expired",
|
|
226
226
|
connectedAt: socketAttachment.connectedAt,
|
|
227
227
|
} satisfies DaemonAttachment);
|
|
228
|
-
|
|
228
|
+
closeWebSocketQuietly(ws, 1011, "daemon hello acknowledgement failed");
|
|
229
229
|
return;
|
|
230
230
|
}
|
|
231
231
|
for (const socket of previousDaemons) {
|
|
232
|
-
|
|
232
|
+
closeWebSocketQuietly(socket, 1012, "replaced by authenticated daemon");
|
|
233
233
|
}
|
|
234
234
|
return;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
if (socketAttachment.role !== "daemon") {
|
|
238
|
-
|
|
238
|
+
closeWebSocketQuietly(ws, 1008, "daemon hello required");
|
|
239
239
|
return;
|
|
240
240
|
}
|
|
241
241
|
|
|
@@ -387,7 +387,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
387
387
|
clientRequestKey: requestKey,
|
|
388
388
|
timeoutMs,
|
|
389
389
|
onTimeout: (record) => {
|
|
390
|
-
|
|
390
|
+
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
391
391
|
return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
|
|
392
392
|
},
|
|
393
393
|
});
|
|
@@ -410,7 +410,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
410
410
|
private cancelClientRequest(requestKey?: string): void {
|
|
411
411
|
if (!requestKey) return;
|
|
412
412
|
this.pending.cancelRequest(requestKey, (record) => {
|
|
413
|
-
|
|
413
|
+
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
414
414
|
return new WorkerToolError("cancelled", "tool call cancelled by client");
|
|
415
415
|
});
|
|
416
416
|
}
|
|
@@ -422,7 +422,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
422
422
|
if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
|
|
423
423
|
|
|
424
424
|
for (const socket of this.nonDaemonSockets()) {
|
|
425
|
-
|
|
425
|
+
closeWebSocketQuietly(socket, 1012, "replaced by newer daemon candidate");
|
|
426
426
|
}
|
|
427
427
|
|
|
428
428
|
const pair = new WebSocketPair();
|
|
@@ -508,8 +508,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
508
508
|
role: "expired",
|
|
509
509
|
connectedAt: attachment?.connectedAt ?? new Date(0).toISOString(),
|
|
510
510
|
} satisfies DaemonAttachment);
|
|
511
|
-
|
|
512
|
-
|
|
511
|
+
sendWebSocketQuietly(socket, { type: "error", error: "daemon_hello_timeout" });
|
|
512
|
+
closeWebSocketQuietly(socket, 1008, "daemon hello timeout");
|
|
513
513
|
continue;
|
|
514
514
|
}
|
|
515
515
|
nextDeadline = Math.min(nextDeadline, deadline);
|
|
@@ -524,7 +524,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
524
524
|
const attachment = this.socketAttachment(socket);
|
|
525
525
|
const connectedAt = Date.parse(attachment?.connectedAt ?? "");
|
|
526
526
|
if (!Number.isFinite(connectedAt)) {
|
|
527
|
-
|
|
527
|
+
closeWebSocketQuietly(socket, 1008, "invalid daemon candidate timestamp");
|
|
528
528
|
continue;
|
|
529
529
|
}
|
|
530
530
|
nextDeadline = Math.min(nextDeadline, connectedAt + DAEMON_HELLO_TIMEOUT_MS);
|
|
@@ -885,8 +885,24 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
|
885
885
|
}
|
|
886
886
|
|
|
887
887
|
function rejectDaemonMessage(ws: WebSocket, error: string, closeCode: number, closeReason: string): void {
|
|
888
|
-
|
|
889
|
-
|
|
888
|
+
sendWebSocketQuietly(ws, { type: "error", error });
|
|
889
|
+
closeWebSocketQuietly(ws, closeCode, closeReason);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function sendWebSocketQuietly(ws: WebSocket, value: unknown): void {
|
|
893
|
+
try {
|
|
894
|
+
ws.send(typeof value === "string" ? value : JSON.stringify(value));
|
|
895
|
+
} catch {
|
|
896
|
+
// Best-effort protocol cleanup must not replace the primary timeout or rejection.
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function closeWebSocketQuietly(ws: WebSocket, code?: number, reason?: string): void {
|
|
901
|
+
try {
|
|
902
|
+
ws.close(code, reason);
|
|
903
|
+
} catch {
|
|
904
|
+
// The socket may already be closed or detached; no recovery remains at this boundary.
|
|
905
|
+
}
|
|
890
906
|
}
|
|
891
907
|
|
|
892
908
|
function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export interface OAuthClient {
|
|
2
|
+
client_id: string;
|
|
3
|
+
client_name: string;
|
|
4
|
+
redirect_uris: string[];
|
|
5
|
+
created_at: number;
|
|
6
|
+
last_used_at: number;
|
|
7
|
+
registration_identity?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OAuthCode {
|
|
11
|
+
client_id: string;
|
|
12
|
+
redirect_uri: string;
|
|
13
|
+
code_challenge: string;
|
|
14
|
+
scope: string;
|
|
15
|
+
resource: string;
|
|
16
|
+
expires_at: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OAuthToken {
|
|
20
|
+
client_id: string;
|
|
21
|
+
scope: string;
|
|
22
|
+
resource: string;
|
|
23
|
+
version: string;
|
|
24
|
+
expires_at: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface OAuthFailure {
|
|
28
|
+
count: number;
|
|
29
|
+
window_started: number;
|
|
30
|
+
blocked_until: number;
|
|
31
|
+
last_attempt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OAuthStore {
|
|
35
|
+
clients: Record<string, OAuthClient>;
|
|
36
|
+
codes: Record<string, OAuthCode>;
|
|
37
|
+
tokens: Record<string, OAuthToken>;
|
|
38
|
+
auth_failures: Record<string, OAuthFailure>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ValidatedAuthorization {
|
|
42
|
+
client: OAuthClient;
|
|
43
|
+
clientId: string;
|
|
44
|
+
redirectUri: string;
|
|
45
|
+
codeChallenge: string;
|
|
46
|
+
requestedResource: string;
|
|
47
|
+
scope: string;
|
|
48
|
+
state: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const AUTH_FAILURE_WINDOW_SECONDS = 10 * 60;
|
|
52
|
+
export const AUTH_BLOCK_SECONDS = 15 * 60;
|
|
53
|
+
const AUTH_FAILURE_LIMIT = 10;
|
|
54
|
+
|
|
55
|
+
export function validateAuthorizationRequest(
|
|
56
|
+
body: Record<string, unknown>,
|
|
57
|
+
base: string,
|
|
58
|
+
serverName: string,
|
|
59
|
+
store: OAuthStore,
|
|
60
|
+
): { value: ValidatedAuthorization } | { error: string; status: number } {
|
|
61
|
+
const responseType = String(body.response_type ?? "");
|
|
62
|
+
const clientId = String(body.client_id ?? "");
|
|
63
|
+
const redirectUri = String(body.redirect_uri ?? "");
|
|
64
|
+
const codeChallenge = String(body.code_challenge ?? "");
|
|
65
|
+
const codeChallengeMethod = String(body.code_challenge_method ?? "");
|
|
66
|
+
const requestedResource = String(body.resource ?? `${base}/mcp`);
|
|
67
|
+
const scope = String(body.scope ?? serverName).trim();
|
|
68
|
+
const state = body.state === undefined ? "" : typeof body.state === "string" ? body.state : "";
|
|
69
|
+
|
|
70
|
+
if (responseType !== "code") return { error: "response_type must be code.", status: 400 };
|
|
71
|
+
if (requestedResource !== `${base}/mcp`) return { error: "resource mismatch.", status: 400 };
|
|
72
|
+
if (scope !== serverName) return { error: "unsupported scope.", status: 400 };
|
|
73
|
+
if (body.state !== undefined && typeof body.state !== "string") return { error: "state must be a string.", status: 400 };
|
|
74
|
+
if (state.length > 1024) return { error: "state is too long.", status: 400 };
|
|
75
|
+
if (codeChallengeMethod !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) {
|
|
76
|
+
return { error: "A valid PKCE S256 challenge is required.", status: 400 };
|
|
77
|
+
}
|
|
78
|
+
const client = store.clients[clientId];
|
|
79
|
+
if (!client) return { error: "Unknown OAuth client.", status: 400 };
|
|
80
|
+
if (!client.redirect_uris.includes(redirectUri)) return { error: "redirect_uri is not registered.", status: 400 };
|
|
81
|
+
return { value: { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function pruneClientRecordByExpiry<T extends { client_id: string; expires_at: number }>(record: Record<string, T>, clientId: string, keep: number): void {
|
|
85
|
+
const allowed = new Set(Object.entries(record)
|
|
86
|
+
.filter(([, value]) => value.client_id === clientId)
|
|
87
|
+
.sort((left, right) => right[1].expires_at - left[1].expires_at)
|
|
88
|
+
.slice(0, keep)
|
|
89
|
+
.map(([key]) => key));
|
|
90
|
+
for (const [key, value] of Object.entries(record)) {
|
|
91
|
+
if (value.client_id === clientId && !allowed.has(key)) delete record[key];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function pruneRecordByExpiry<T extends { expires_at: number }>(record: Record<string, T>, keep: number): void {
|
|
96
|
+
const allowed = new Set(Object.entries(record)
|
|
97
|
+
.sort((left, right) => right[1].expires_at - left[1].expires_at)
|
|
98
|
+
.slice(0, keep)
|
|
99
|
+
.map(([key]) => key));
|
|
100
|
+
for (const key of Object.keys(record)) if (!allowed.has(key)) delete record[key];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function authorizationIdentity(request: Request, keyMaterial: string): Promise<string> {
|
|
104
|
+
const source = (request.headers.get("CF-Connecting-IP") || "unknown").slice(0, 128);
|
|
105
|
+
const encoder = new TextEncoder();
|
|
106
|
+
const key = await crypto.subtle.importKey(
|
|
107
|
+
"raw",
|
|
108
|
+
encoder.encode(keyMaterial),
|
|
109
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
110
|
+
false,
|
|
111
|
+
["sign"],
|
|
112
|
+
);
|
|
113
|
+
const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(source));
|
|
114
|
+
return `hmac-sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function recordAuthorizationFailure(store: OAuthStore, identity: string, now: number): void {
|
|
118
|
+
const current = store.auth_failures[identity];
|
|
119
|
+
const activeWindow = current && current.window_started + AUTH_FAILURE_WINDOW_SECONDS > now;
|
|
120
|
+
const count = activeWindow ? current.count + 1 : 1;
|
|
121
|
+
store.auth_failures[identity] = {
|
|
122
|
+
count,
|
|
123
|
+
window_started: activeWindow ? current.window_started : now,
|
|
124
|
+
blocked_until: count >= AUTH_FAILURE_LIMIT ? now + AUTH_BLOCK_SECONDS : 0,
|
|
125
|
+
last_attempt: now,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function pruneAuthFailures(store: OAuthStore, keep: number): void {
|
|
130
|
+
const allowed = new Set(Object.entries(store.auth_failures)
|
|
131
|
+
.sort((left, right) => right[1].last_attempt - left[1].last_attempt)
|
|
132
|
+
.slice(0, keep)
|
|
133
|
+
.map(([key]) => key));
|
|
134
|
+
for (const key of Object.keys(store.auth_failures)) if (!allowed.has(key)) delete store.auth_failures[key];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function randomToken(prefix: string): string {
|
|
138
|
+
const bytes = new Uint8Array(32);
|
|
139
|
+
crypto.getRandomValues(bytes);
|
|
140
|
+
return `${prefix}_${base64Url(bytes)}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function sha256Hex(value: string): Promise<string> {
|
|
144
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
145
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function safeEqual(left: string, right: string): Promise<boolean> {
|
|
149
|
+
const encoder = new TextEncoder();
|
|
150
|
+
const [leftHash, rightHash] = await Promise.all([
|
|
151
|
+
crypto.subtle.digest("SHA-256", encoder.encode(left)),
|
|
152
|
+
crypto.subtle.digest("SHA-256", encoder.encode(right)),
|
|
153
|
+
]);
|
|
154
|
+
const leftBytes = new Uint8Array(leftHash);
|
|
155
|
+
const rightBytes = new Uint8Array(rightHash);
|
|
156
|
+
let diff = leftBytes.length ^ rightBytes.length;
|
|
157
|
+
for (let index = 0; index < Math.max(leftBytes.length, rightBytes.length); index += 1) {
|
|
158
|
+
diff |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
|
|
159
|
+
}
|
|
160
|
+
return diff === 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function pkceS256(verifier: string): Promise<string> {
|
|
164
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
165
|
+
return base64Url(new Uint8Array(digest));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function base64Url(bytes: Uint8Array): string {
|
|
169
|
+
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
170
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const MAX_ERROR_CODES = 64;
|
|
2
|
+
const MAX_TOOLS = 128;
|
|
3
|
+
const SENSITIVE_FIELD = /(?:authorization|cookie|credential|password|secret|token|verifier|private[_-]?key)/i;
|
|
4
|
+
|
|
5
|
+
export class WorkerObservability {
|
|
6
|
+
private readonly startedAt = Date.now();
|
|
7
|
+
private readonly requests = { total: 0, successful: 0, client_error: 0, server_error: 0 };
|
|
8
|
+
private readonly calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
9
|
+
private readonly sockets = { candidates: 0, authenticated: 0, disconnected: 0, protocol_errors: 0 };
|
|
10
|
+
private readonly errors = new Map<string, number>();
|
|
11
|
+
private readonly tools = new Map<string, { started: number; completed: number; failed: number; active: number }>();
|
|
12
|
+
|
|
13
|
+
requestFinished(status: number): void {
|
|
14
|
+
this.requests.total += 1;
|
|
15
|
+
if (status >= 500) this.requests.server_error += 1;
|
|
16
|
+
else if (status >= 400) this.requests.client_error += 1;
|
|
17
|
+
else this.requests.successful += 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
callStarted(tool: string): void {
|
|
21
|
+
this.calls.started += 1;
|
|
22
|
+
const metric = this.toolMetric(tool);
|
|
23
|
+
metric.started += 1;
|
|
24
|
+
metric.active += 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
callFinished(tool: string, code = ""): void {
|
|
28
|
+
const metric = this.toolMetric(tool);
|
|
29
|
+
metric.active = Math.max(0, metric.active - 1);
|
|
30
|
+
if (!code) {
|
|
31
|
+
this.calls.completed += 1;
|
|
32
|
+
metric.completed += 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.calls.failed += 1;
|
|
36
|
+
metric.failed += 1;
|
|
37
|
+
if (code === "cancelled") this.calls.cancelled += 1;
|
|
38
|
+
if (code === "timeout") this.calls.timed_out += 1;
|
|
39
|
+
this.incrementError(code);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
socketCandidate(): void { this.sockets.candidates += 1; }
|
|
43
|
+
socketAuthenticated(): void { this.sockets.authenticated += 1; }
|
|
44
|
+
socketDisconnected(): void { this.sockets.disconnected += 1; }
|
|
45
|
+
socketProtocolError(code: string): void {
|
|
46
|
+
this.sockets.protocol_errors += 1;
|
|
47
|
+
this.incrementError(code || "protocol_error");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
snapshot(): Record<string, unknown> {
|
|
51
|
+
return {
|
|
52
|
+
uptime_ms: Math.max(0, Date.now() - this.startedAt),
|
|
53
|
+
requests: { ...this.requests },
|
|
54
|
+
calls: { ...this.calls },
|
|
55
|
+
sockets: { ...this.sockets },
|
|
56
|
+
errors: Object.fromEntries([...this.errors.entries()].sort(([left], [right]) => left.localeCompare(right))),
|
|
57
|
+
tools: Object.fromEntries([...this.tools.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, metric]) => [name, { ...metric }])),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
event(level: "info" | "warn" | "error", event: string, fields: Record<string, unknown> = {}): void {
|
|
62
|
+
const entry = {
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
level,
|
|
65
|
+
component: "worker",
|
|
66
|
+
event: sanitizeName(event),
|
|
67
|
+
...sanitizeFields(fields),
|
|
68
|
+
};
|
|
69
|
+
const text = JSON.stringify(entry);
|
|
70
|
+
if (level === "error") console.error(text);
|
|
71
|
+
else if (level === "warn") console.warn(text);
|
|
72
|
+
else console.log(text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private toolMetric(tool: string): { started: number; completed: number; failed: number; active: number } {
|
|
76
|
+
const name = sanitizeName(tool) || "unknown";
|
|
77
|
+
if (!this.tools.has(name)) {
|
|
78
|
+
if (this.tools.size >= MAX_TOOLS) return this.toolMetric("other");
|
|
79
|
+
this.tools.set(name, { started: 0, completed: 0, failed: 0, active: 0 });
|
|
80
|
+
}
|
|
81
|
+
return this.tools.get(name)!;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private incrementError(code: string): void {
|
|
85
|
+
const key = sanitizeName(code) || "execution_failed";
|
|
86
|
+
if (!this.errors.has(key) && this.errors.size >= MAX_ERROR_CODES) {
|
|
87
|
+
this.errors.set("other", (this.errors.get("other") ?? 0) + 1);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.errors.set(key, (this.errors.get(key) ?? 0) + 1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sanitizeName(value: unknown): string {
|
|
95
|
+
return String(value || "").toLowerCase().replace(/[^a-z0-9._-]/g, "_").slice(0, 128);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sanitizeFields(fields: Record<string, unknown>): Record<string, unknown> {
|
|
99
|
+
const out: Record<string, unknown> = {};
|
|
100
|
+
for (const [key, value] of Object.entries(fields).slice(0, 32)) {
|
|
101
|
+
const safeKey = sanitizeName(key);
|
|
102
|
+
if (!safeKey) continue;
|
|
103
|
+
if (SENSITIVE_FIELD.test(safeKey)) {
|
|
104
|
+
out[safeKey] = "<redacted>";
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (typeof value === "boolean" || typeof value === "number" || value === null) out[safeKey] = value;
|
|
108
|
+
else if (typeof value === "string") out[safeKey] = value.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 256);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|