machine-bridge-mcp 0.16.1 → 0.16.2
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 +7 -0
- package/browser-extension/manifest.json +2 -2
- package/package.json +2 -2
- package/src/local/managed-jobs.mjs +11 -4
- package/src/worker/errors.ts +41 -0
- package/src/worker/http.ts +252 -0
- package/src/worker/index.ts +1 -1
- 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/src/worker/worker-configuration.d.ts +14716 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.2 - 2026-07-13
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Package the entire `src/worker` directory (instead of just `src/worker/index.ts`) in the published npm package so that global installations have all files required to compile/deploy the Worker.
|
|
8
|
+
- Add regression coverage in packaging tests to verify all worker modules are present.
|
|
9
|
+
|
|
3
10
|
## 0.16.1 - 2026-07-13
|
|
4
11
|
|
|
5
12
|
### Fixed
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Machine Bridge Browser",
|
|
4
|
-
"version": "0.16.
|
|
4
|
+
"version": "0.16.2",
|
|
5
5
|
"description": "Connects the current Chromium browser profile to the local Machine Bridge runtime for user-authorized automation.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"tabs",
|
|
@@ -30,5 +30,5 @@
|
|
|
30
30
|
"action": {
|
|
31
31
|
"default_title": "Machine Bridge Browser"
|
|
32
32
|
},
|
|
33
|
-
"version_name": "0.16.
|
|
33
|
+
"version_name": "0.16.2"
|
|
34
34
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.2",
|
|
4
4
|
"description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"browser-extension",
|
|
21
21
|
"src/local",
|
|
22
22
|
"src/shared",
|
|
23
|
-
"src/worker
|
|
23
|
+
"src/worker",
|
|
24
24
|
"scripts",
|
|
25
25
|
"wrangler.jsonc",
|
|
26
26
|
"tsconfig.json",
|
|
@@ -697,10 +697,17 @@ function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
|
|
|
697
697
|
|
|
698
698
|
function scrubFinishedPlan(dir, status) {
|
|
699
699
|
if (PLAN_RETAINING_STATES.has(status.status)) return;
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
700
|
+
const safeRm = (path) => {
|
|
701
|
+
try {
|
|
702
|
+
rmSync(path, { force: true });
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (!["ENOENT", "EPERM", "EACCES"].includes(error.code)) throw error;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
safeRm(join(dir, "plan.json"));
|
|
708
|
+
safeRm(join(dir, "runner.pid"));
|
|
709
|
+
safeRm(join(dir, "recovery.lock"));
|
|
710
|
+
safeRm(join(dir, "transition.lock"));
|
|
704
711
|
}
|
|
705
712
|
|
|
706
713
|
function reviewablePlan(plan) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const WORKER_ERROR_CODES = new Set([
|
|
2
|
+
"cancelled", "timeout", "authentication_failed", "authorization_denied", "policy_denied",
|
|
3
|
+
"invalid_request", "not_found", "conflict", "limit_exceeded", "permission_denied",
|
|
4
|
+
"path_boundary", "network_error", "protocol_error", "unavailable", "integrity_error",
|
|
5
|
+
"execution_failed", "internal_error",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export class WorkerToolError extends Error {
|
|
9
|
+
readonly code: string;
|
|
10
|
+
readonly retryable: boolean;
|
|
11
|
+
|
|
12
|
+
constructor(code: string, message: string, retryable = false) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "WorkerToolError";
|
|
15
|
+
this.code = normalizeCode(code);
|
|
16
|
+
this.retryable = retryable;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function daemonToolError(value: unknown): WorkerToolError {
|
|
21
|
+
const input = asObject(value);
|
|
22
|
+
const code = normalizeCode(input.code);
|
|
23
|
+
const message = typeof input.message === "string" && input.message
|
|
24
|
+
? input.message.slice(0, 2000)
|
|
25
|
+
: "daemon tool failed";
|
|
26
|
+
return new WorkerToolError(code, message, input.retryable === true);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function publicWorkerToolError(error: unknown): { code: string; message: string; retryable: boolean } {
|
|
30
|
+
if (error instanceof WorkerToolError) return { code: error.code, message: error.message, retryable: error.retryable };
|
|
31
|
+
return { code: "execution_failed", message: "tool execution failed", retryable: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeCode(value: unknown): string {
|
|
35
|
+
const code = String(value || "execution_failed").toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 64);
|
|
36
|
+
return WORKER_ERROR_CODES.has(code) ? code : "execution_failed";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function asObject(value: unknown): Record<string, unknown> {
|
|
40
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
41
|
+
}
|
|
@@ -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.16.
|
|
20
|
+
const SERVER_VERSION = "0.16.2";
|
|
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";
|
|
@@ -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
|
+
}
|