machine-bridge-mcp 1.2.7 → 1.2.9

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CONTRIBUTING.md +37 -19
  3. package/README.md +120 -410
  4. package/SECURITY.md +2 -0
  5. package/browser-extension/manifest.json +2 -2
  6. package/docs/ARCHITECTURE.md +13 -4
  7. package/docs/AUDIT.md +22 -0
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/OVERVIEW.md +113 -0
  10. package/docs/PROJECT_STANDARDS.md +9 -5
  11. package/docs/RELEASING.md +78 -33
  12. package/docs/TESTING.md +14 -7
  13. package/docs/THREAT_MODEL.md +142 -0
  14. package/package.json +18 -6
  15. package/scripts/check-plan.mjs +91 -0
  16. package/scripts/coverage-check.mjs +22 -0
  17. package/scripts/github-push.mjs +49 -0
  18. package/scripts/github-release.mjs +15 -8
  19. package/scripts/local-release-acceptance.mjs +186 -0
  20. package/scripts/release-acceptance.mjs +181 -0
  21. package/scripts/release-impact-check.mjs +19 -3
  22. package/scripts/release-state.mjs +1 -1
  23. package/scripts/run-checks.mjs +29 -0
  24. package/scripts/start-release-candidate.mjs +113 -0
  25. package/src/local/agent-context-projection.mjs +158 -0
  26. package/src/local/agent-context.mjs +23 -332
  27. package/src/local/agent-skill-discovery.mjs +230 -0
  28. package/src/local/agent-text-file.mjs +41 -0
  29. package/src/local/browser-bridge-http.mjs +48 -0
  30. package/src/local/browser-bridge.mjs +48 -222
  31. package/src/local/browser-broker-routes.mjs +136 -0
  32. package/src/local/browser-broker-server.mjs +59 -0
  33. package/src/local/browser-request-registry.mjs +67 -0
  34. package/src/local/managed-job-lock.mjs +99 -0
  35. package/src/local/managed-job-projection.mjs +68 -0
  36. package/src/local/managed-job-runner.mjs +73 -0
  37. package/src/local/managed-job-storage.mjs +93 -0
  38. package/src/local/managed-jobs.mjs +12 -297
  39. package/src/local/runtime-paths.mjs +107 -0
  40. package/src/local/runtime-relay.mjs +73 -0
  41. package/src/local/runtime-tool-handlers.mjs +66 -0
  42. package/src/local/runtime.mjs +22 -204
  43. package/src/local/windows-launcher.mjs +57 -0
  44. package/src/local/windows-service.mjs +7 -56
  45. package/src/worker/index.ts +9 -118
  46. package/src/worker/mcp-jsonrpc.ts +94 -0
  47. package/src/worker/oauth-authorization-page.ts +70 -0
  48. package/src/worker/oauth-controller.ts +9 -58
  49. package/src/worker/websocket-protocol.ts +24 -0
  50. package/tsconfig.local.json +6 -1
@@ -25,12 +25,18 @@ import {
25
25
  HttpError, applyCors, baseUrl, bearerToken, corsPreflight, json, methodNotAllowed,
26
26
  parseJsonRequest, workerErrorClass,
27
27
  } from "./http.ts";
28
+ import {
29
+ asObject, isJsonRpcRequest, isJsonRpcResponse, requiredString, rpcError, rpcResult,
30
+ sessionInstructionText, textToolResult, validateProtocolVersionHeader, type JsonRpcRequest,
31
+ } from "./mcp-jsonrpc.ts";
32
+ import {
33
+ closeWebSocketQuietly, isObjectRecord, rejectDaemonMessage, sendWebSocketQuietly,
34
+ } from "./websocket-protocol.ts";
28
35
 
29
36
  const SERVER_NAME = String(serverMetadata.name);
30
- const SERVER_VERSION = "1.2.7";
37
+ const SERVER_VERSION = "1.2.9";
31
38
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
32
39
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
33
- const JSONRPC_VERSION = "2.0";
34
40
  const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
35
41
  const MAX_BODY_BYTES = 16 * 1024 * 1024;
36
42
  const MAX_PENDING_CALLS = 32;
@@ -45,15 +51,6 @@ interface BridgeEnv extends OAuthControllerEnv {
45
51
  MBM_ALLOWED_ORIGINS?: string;
46
52
  }
47
53
 
48
- type JsonRpcId = string | number | null;
49
-
50
- interface JsonRpcRequest {
51
- jsonrpc: "2.0";
52
- id?: JsonRpcId;
53
- method: string;
54
- params?: unknown;
55
- }
56
-
57
54
  const MCP_INSTRUCTIONS = serverMetadata.instructions.map((value) => String(value)).join("\n");
58
55
 
59
56
  export class BridgeRoom extends DurableObject<BridgeEnv> {
@@ -295,7 +292,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
295
292
  const body = await parseJsonRequest(request, this.bodyLimitBytes());
296
293
  if (isJsonRpcResponse(body)) return new Response(null, { status: 202 });
297
294
  if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
298
- const protocolError = validateProtocolVersionHeader(request, body);
295
+ const protocolError = validateProtocolVersionHeader(request, body, MCP_SUPPORTED_PROTOCOL_VERSIONS);
299
296
  if (protocolError) return json(protocolError, 400);
300
297
 
301
298
  const session = await resolveMcpSession(request, body.method, this.oauth.identityKey(), authorized.tokenKey);
@@ -700,109 +697,3 @@ export default {
700
697
  return stub.fetch(request);
701
698
  },
702
699
  } satisfies ExportedHandler<BridgeEnv>;
703
-
704
- function isObjectRecord(value: unknown): value is Record<string, unknown> {
705
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
706
- }
707
-
708
- function rejectDaemonMessage(ws: WebSocket, error: string, closeCode: number, closeReason: string): void {
709
- sendWebSocketQuietly(ws, { type: "error", error });
710
- closeWebSocketQuietly(ws, closeCode, closeReason);
711
- }
712
-
713
- function sendWebSocketQuietly(ws: WebSocket, value: unknown): void {
714
- try {
715
- ws.send(typeof value === "string" ? value : JSON.stringify(value));
716
- } catch {
717
- // Best-effort protocol cleanup must not replace the primary timeout or rejection.
718
- }
719
- }
720
-
721
- function closeWebSocketQuietly(ws: WebSocket, code?: number, reason?: string): void {
722
- try {
723
- ws.close(code, reason);
724
- } catch {
725
- // The socket may already be closed or detached; no recovery remains at this boundary.
726
- }
727
- }
728
-
729
- function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
730
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
731
- const candidate = value as Record<string, unknown>;
732
- if (candidate.jsonrpc !== JSONRPC_VERSION || typeof candidate.method !== "string" || !candidate.method.trim() || candidate.method.length > 256) return false;
733
- if ("id" in candidate && !isJsonRpcId(candidate.id)) return false;
734
- return true;
735
- }
736
-
737
- function isJsonRpcId(value: unknown): value is JsonRpcId {
738
- return value === null || typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
739
- }
740
-
741
- function isJsonRpcResponse(value: unknown): boolean {
742
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
743
- const candidate = value as Record<string, unknown>;
744
- if (candidate.jsonrpc !== JSONRPC_VERSION || !("id" in candidate) || !isJsonRpcId(candidate.id) || typeof candidate.method === "string") return false;
745
- return ("result" in candidate) !== ("error" in candidate);
746
- }
747
-
748
- function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, unknown> | null {
749
- if (id === undefined) return null;
750
- return { jsonrpc: JSONRPC_VERSION, id, result };
751
- }
752
-
753
- function rpcError(id: JsonRpcId | undefined, code: number, message: string, data?: unknown): Record<string, unknown> {
754
- const error: Record<string, unknown> = { code, message };
755
- if (data !== undefined) error.data = data;
756
- return { jsonrpc: JSONRPC_VERSION, id: id ?? null, error };
757
- }
758
-
759
- function textToolResult(value: unknown, isError = false): Record<string, unknown> {
760
- const special = asObject(value).$mcp;
761
- if (special && typeof special === "object" && !Array.isArray(special)) {
762
- const specialObject = special as Record<string, unknown>;
763
- if (Array.isArray(specialObject.content)) {
764
- const result: Record<string, unknown> = { content: specialObject.content, isError };
765
- if (specialObject.structuredContent && typeof specialObject.structuredContent === "object" && !Array.isArray(specialObject.structuredContent)) {
766
- result.structuredContent = specialObject.structuredContent;
767
- }
768
- return result;
769
- }
770
- }
771
- const result: Record<string, unknown> = {
772
- content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
773
- isError,
774
- };
775
- if (value && typeof value === "object" && !Array.isArray(value)) result.structuredContent = value;
776
- return result;
777
- }
778
-
779
- function asObject(value: unknown): Record<string, unknown> {
780
- if (!value || typeof value !== "object" || Array.isArray(value)) return {};
781
- return value as Record<string, unknown>;
782
- }
783
-
784
- function requiredString(value: Record<string, unknown>, key: string): string {
785
- const field = value[key];
786
- if (typeof field !== "string" || !field.trim()) throw new Error(`${key} must be a non-empty string`);
787
- return field.trim();
788
- }
789
-
790
- function sessionInstructionText(value: unknown): string {
791
- const object = asObject(value);
792
- const instructions = typeof object.instructions === "string" ? object.instructions : "";
793
- if (!instructions) return "";
794
- const bytes = new TextEncoder().encode(instructions);
795
- if (bytes.byteLength > 3 * 1024 * 1024) return "";
796
- return instructions;
797
- }
798
-
799
- function validateProtocolVersionHeader(request: Request, body: JsonRpcRequest): Record<string, unknown> | null {
800
- if (body.method === "initialize") return null;
801
- const version = request.headers.get("MCP-Protocol-Version");
802
- if (!version) return null;
803
- if (MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(version as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])) return null;
804
- return rpcError(body.id, -32602, "Unsupported MCP protocol version", {
805
- requested: version,
806
- supported: [...MCP_SUPPORTED_PROTOCOL_VERSIONS],
807
- });
808
- }
@@ -0,0 +1,94 @@
1
+ const JSONRPC_VERSION = "2.0";
2
+ const MAX_SESSION_INSTRUCTION_BYTES = 3 * 1024 * 1024;
3
+
4
+ export type JsonRpcId = string | number | null;
5
+
6
+ export interface JsonRpcRequest {
7
+ jsonrpc: "2.0";
8
+ id?: JsonRpcId;
9
+ method: string;
10
+ params?: unknown;
11
+ }
12
+
13
+ export function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
15
+ const candidate = value as Record<string, unknown>;
16
+ if (candidate.jsonrpc !== JSONRPC_VERSION || typeof candidate.method !== "string" || !candidate.method.trim() || candidate.method.length > 256) return false;
17
+ if ("id" in candidate && !isJsonRpcId(candidate.id)) return false;
18
+ return true;
19
+ }
20
+
21
+ export function isJsonRpcResponse(value: unknown): boolean {
22
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
23
+ const candidate = value as Record<string, unknown>;
24
+ if (candidate.jsonrpc !== JSONRPC_VERSION || !("id" in candidate) || !isJsonRpcId(candidate.id) || typeof candidate.method === "string") return false;
25
+ return ("result" in candidate) !== ("error" in candidate);
26
+ }
27
+
28
+ export function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, unknown> | null {
29
+ if (id === undefined) return null;
30
+ return { jsonrpc: JSONRPC_VERSION, id, result };
31
+ }
32
+
33
+ export function rpcError(id: JsonRpcId | undefined, code: number, message: string, data?: unknown): Record<string, unknown> {
34
+ const error: Record<string, unknown> = { code, message };
35
+ if (data !== undefined) error.data = data;
36
+ return { jsonrpc: JSONRPC_VERSION, id: id ?? null, error };
37
+ }
38
+
39
+ export function textToolResult(value: unknown, isError = false): Record<string, unknown> {
40
+ const special = asObject(value).$mcp;
41
+ if (special && typeof special === "object" && !Array.isArray(special)) {
42
+ const specialObject = special as Record<string, unknown>;
43
+ if (Array.isArray(specialObject.content)) {
44
+ const result: Record<string, unknown> = { content: specialObject.content, isError };
45
+ if (specialObject.structuredContent && typeof specialObject.structuredContent === "object" && !Array.isArray(specialObject.structuredContent)) {
46
+ result.structuredContent = specialObject.structuredContent;
47
+ }
48
+ return result;
49
+ }
50
+ }
51
+ const result: Record<string, unknown> = {
52
+ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
53
+ isError,
54
+ };
55
+ if (value && typeof value === "object" && !Array.isArray(value)) result.structuredContent = value;
56
+ return result;
57
+ }
58
+
59
+ export function asObject(value: unknown): Record<string, unknown> {
60
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
61
+ return value as Record<string, unknown>;
62
+ }
63
+
64
+ export function requiredString(value: Record<string, unknown>, key: string): string {
65
+ const field = value[key];
66
+ if (typeof field !== "string" || !field.trim()) throw new Error(`${key} must be a non-empty string`);
67
+ return field.trim();
68
+ }
69
+
70
+ export function sessionInstructionText(value: unknown): string {
71
+ const object = asObject(value);
72
+ const instructions = typeof object.instructions === "string" ? object.instructions : "";
73
+ if (!instructions) return "";
74
+ if (new TextEncoder().encode(instructions).byteLength > MAX_SESSION_INSTRUCTION_BYTES) return "";
75
+ return instructions;
76
+ }
77
+
78
+ export function validateProtocolVersionHeader(
79
+ request: Request,
80
+ body: JsonRpcRequest,
81
+ supportedVersions: readonly string[],
82
+ ): Record<string, unknown> | null {
83
+ if (body.method === "initialize") return null;
84
+ const version = request.headers.get("MCP-Protocol-Version");
85
+ if (!version || supportedVersions.includes(version)) return null;
86
+ return rpcError(body.id, -32602, "Unsupported MCP protocol version", {
87
+ requested: version,
88
+ supported: [...supportedVersions],
89
+ });
90
+ }
91
+
92
+ function isJsonRpcId(value: unknown): value is JsonRpcId {
93
+ return value === null || typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
94
+ }
@@ -0,0 +1,70 @@
1
+ import type { ValidatedAuthorization } from "./oauth-state.ts";
2
+ import {
3
+ escapeHtml, html, normalizeDisplayText, searchParamsEntries,
4
+ } from "./http.ts";
5
+
6
+ const AUTHORIZATION_FIELDS = new Set([
7
+ "response_type", "client_id", "redirect_uri", "code_challenge",
8
+ "code_challenge_method", "scope", "resource", "state",
9
+ ]);
10
+
11
+ export interface AuthorizationPageOptions {
12
+ request: Request;
13
+ base: string;
14
+ serverName: string;
15
+ error?: string;
16
+ submitted?: Record<string, unknown>;
17
+ status?: number;
18
+ authorization?: ValidatedAuthorization;
19
+ allowSubmit?: boolean;
20
+ }
21
+
22
+ export function authorizationPage({
23
+ request,
24
+ base,
25
+ serverName,
26
+ error = "",
27
+ submitted,
28
+ status = 200,
29
+ authorization,
30
+ allowSubmit = true,
31
+ }: AuthorizationPageOptions): Response {
32
+ const url = new URL(request.url);
33
+ const sourceEntries = submitted ? Object.entries(submitted) : searchParamsEntries(url.searchParams);
34
+ const hidden = sourceEntries
35
+ .filter(([key]) => AUTHORIZATION_FIELDS.has(key))
36
+ .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
37
+ .join("\n");
38
+ const resource = normalizeDisplayText(
39
+ authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`),
40
+ 1024,
41
+ `${base}/mcp`,
42
+ );
43
+ const clientBlock = authorization
44
+ ? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
45
+ <p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
46
+ : "";
47
+ const errorBlock = error ? `<p role="alert" aria-live="assertive" style="color:#b91c1c; font-weight:600">${escapeHtml(error)}</p>` : "";
48
+ const accountName = normalizeDisplayText(String(submitted?.account_name ?? ""), 64, "");
49
+ const form = allowSubmit
50
+ ? `<form method="post" action="/oauth/authorize">
51
+ ${hidden}
52
+ <label>Account name<br><input name="account_name" value="${escapeHtml(accountName)}" autocomplete="username" autofocus required style="width: 100%; box-sizing: border-box; padding: 8px;"></label>
53
+ <p><label>Account password<br><input name="account_password" type="password" autocomplete="current-password" required style="width: 100%; box-sizing: border-box; padding: 8px;"></label></p>
54
+ <p><button type="submit">Authorize</button></p>
55
+ </form>`
56
+ : "<p>Authorization cannot continue. Return to the MCP client and start the connection again.</p>";
57
+ const redirectOrigin = authorization ? new URL(authorization.redirectUri).origin : "";
58
+ return html(`<!doctype html>
59
+ <html>
60
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize ${serverName}</title></head>
61
+ <body style="font-family: system-ui, sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5; padding: 0 16px;">
62
+ <h1>Authorize ${serverName}</h1>
63
+ <p>Only continue if you initiated this MCP connection and recognize the client and redirect URI below.</p>
64
+ ${clientBlock}
65
+ <p><strong>Resource:</strong> <code>${escapeHtml(resource)}</code></p>
66
+ ${errorBlock}
67
+ ${form}
68
+ </body>
69
+ </html>`, status, redirectOrigin);
70
+ }
@@ -5,12 +5,13 @@ import {
5
5
  AUTH_BLOCK_SECONDS, accountByName, authorizationIdentity, emptyOAuthStore,
6
6
  isCurrentOAuthStore, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
7
7
  recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest, verifyAccountPassword,
8
- type OAuthClient, type OAuthStore, type ValidatedAuthorization,
8
+ type OAuthClient, type OAuthStore,
9
9
  } from "./oauth-state.ts";
10
10
  import {
11
- HttpError, authorizationRedirectLocation, escapeHtml, html, json, normalizeDisplayText,
12
- normalizeRedirectUri, oauthRedirect, parseRequestBody, searchParamsEntries, searchParamsObject,
11
+ HttpError, authorizationRedirectLocation, json, normalizeDisplayText,
12
+ normalizeRedirectUri, oauthRedirect, parseRequestBody, searchParamsObject,
13
13
  } from "./http.ts";
14
+ import { authorizationPage } from "./oauth-authorization-page.ts";
14
15
 
15
16
  const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
16
17
  const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
@@ -20,7 +21,6 @@ const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
20
21
  const MAX_CODES_PER_CLIENT = 10;
21
22
  const MAX_OAUTH_CODES = 200;
22
23
  const MAX_AUTH_FAILURE_IDENTITIES = 200;
23
- const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
24
24
 
25
25
  export interface OAuthControllerEnv {
26
26
  ACCOUNT_ADMIN_SECRET: string;
@@ -176,75 +176,26 @@ export class OAuthController {
176
176
  const store = await this.oauthStore();
177
177
  const validation = validateAuthorizationRequest(body, base, this.serverName, store);
178
178
  if ("error" in validation) {
179
- return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
179
+ return authorizationPage({ request, base, serverName: this.serverName, error: validation.error, submitted: body, status: validation.status, allowSubmit: false });
180
180
  }
181
- return this.authorizePage(request, base, "", body, 200, validation.value, true);
181
+ return authorizationPage({ request, base, serverName: this.serverName, submitted: body, authorization: validation.value });
182
182
  });
183
183
  }
184
184
 
185
- private authorizePage(
186
- request: Request,
187
- base: string,
188
- error = "",
189
- submitted?: Record<string, unknown>,
190
- status = 200,
191
- authorization?: ValidatedAuthorization,
192
- allowSubmit = true,
193
- ): Response {
194
- const url = new URL(request.url);
195
- const sourceEntries = submitted ? Object.entries(submitted) : searchParamsEntries(url.searchParams);
196
- const hidden = sourceEntries
197
- .filter(([key]) => AUTHORIZATION_FIELDS.has(key))
198
- .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
199
- .join("\n");
200
- const resource = normalizeDisplayText(
201
- authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`),
202
- 1024,
203
- `${base}/mcp`,
204
- );
205
- const clientBlock = authorization
206
- ? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
207
- <p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
208
- : "";
209
- const errorBlock = error ? `<p role="alert" aria-live="assertive" style="color:#b91c1c; font-weight:600">${escapeHtml(error)}</p>` : "";
210
- const accountName = normalizeDisplayText(String(submitted?.account_name ?? ""), 64, "");
211
- const form = allowSubmit
212
- ? `<form method="post" action="/oauth/authorize">
213
- ${hidden}
214
- <label>Account name<br><input name="account_name" value="${escapeHtml(accountName)}" autocomplete="username" autofocus required style="width: 100%; box-sizing: border-box; padding: 8px;"></label>
215
- <p><label>Account password<br><input name="account_password" type="password" autocomplete="current-password" required style="width: 100%; box-sizing: border-box; padding: 8px;"></label></p>
216
- <p><button type="submit">Authorize</button></p>
217
- </form>`
218
- : "<p>Authorization cannot continue. Return to the MCP client and start the connection again.</p>";
219
- const redirectOrigin = authorization ? new URL(authorization.redirectUri).origin : "";
220
- return html(`<!doctype html>
221
- <html>
222
- <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize ${this.serverName}</title></head>
223
- <body style="font-family: system-ui, sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5; padding: 0 16px;">
224
- <h1>Authorize ${this.serverName}</h1>
225
- <p>Only continue if you initiated this MCP connection and recognize the client and redirect URI below.</p>
226
- ${clientBlock}
227
- <p><strong>Resource:</strong> <code>${escapeHtml(resource)}</code></p>
228
- ${errorBlock}
229
- ${form}
230
- </body>
231
- </html>`, status, redirectOrigin);
232
- }
233
-
234
185
  async authorizeSubmit(request: Request, base: string): Promise<Response> {
235
186
  const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
236
187
  return this.withOAuthLock(async () => {
237
188
  const store = await this.oauthStore();
238
189
  const validation = validateAuthorizationRequest(body, base, this.serverName, store);
239
190
  if ("error" in validation) {
240
- return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
191
+ return authorizationPage({ request, base, serverName: this.serverName, error: validation.error, submitted: body, status: validation.status, allowSubmit: false });
241
192
  }
242
193
  const { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } = validation.value;
243
194
  const now = Math.floor(Date.now() / 1000);
244
195
  const identity = await authorizationIdentity(request, this.identityKey());
245
196
  const failure = store.auth_failures[identity];
246
197
  if (failure?.blocked_until > now) {
247
- return this.authorizePage(request, base, "Too many failed attempts. Try again later.", body, 429, validation.value);
198
+ return authorizationPage({ request, base, serverName: this.serverName, error: "Too many failed attempts. Try again later.", submitted: body, status: 429, authorization: validation.value });
248
199
  }
249
200
 
250
201
  const account = accountByName(store, body.account_name);
@@ -254,7 +205,7 @@ export class OAuthController {
254
205
  pruneAuthFailures(store, MAX_AUTH_FAILURE_IDENTITIES);
255
206
  await this.ctx.storage.put("oauth", store);
256
207
  const status = store.auth_failures[identity]?.blocked_until > now ? 429 : 401;
257
- return this.authorizePage(request, base, "Invalid account credentials.", body, status, validation.value);
208
+ return authorizationPage({ request, base, serverName: this.serverName, error: "Invalid account credentials.", submitted: body, status, authorization: validation.value });
258
209
  }
259
210
  delete store.auth_failures[identity];
260
211
  client.last_used_at = now;
@@ -0,0 +1,24 @@
1
+ export function isObjectRecord(value: unknown): value is Record<string, unknown> {
2
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+
5
+ export function rejectDaemonMessage(ws: WebSocket, error: string, closeCode: number, closeReason: string): void {
6
+ sendWebSocketQuietly(ws, { type: "error", error });
7
+ closeWebSocketQuietly(ws, closeCode, closeReason);
8
+ }
9
+
10
+ export function sendWebSocketQuietly(ws: WebSocket, value: unknown): void {
11
+ try {
12
+ ws.send(typeof value === "string" ? value : JSON.stringify(value));
13
+ } catch {
14
+ // Best-effort protocol cleanup must not replace the primary timeout or rejection.
15
+ }
16
+ }
17
+
18
+ export function closeWebSocketQuietly(ws: WebSocket, code?: number, reason?: string): void {
19
+ try {
20
+ ws.close(code, reason);
21
+ } catch {
22
+ // The socket may already be closed or detached; no recovery remains at this boundary.
23
+ }
24
+ }
@@ -22,6 +22,11 @@
22
22
  "src/local/agent-contract.mjs",
23
23
  "src/local/browser-extension-protocol.mjs",
24
24
  "src/local/call-registry.mjs",
25
- "src/local/policy.mjs"
25
+ "src/local/policy.mjs",
26
+ "src/local/runtime-paths.mjs",
27
+ "src/local/agent-context-projection.mjs",
28
+ "src/local/agent-skill-discovery.mjs",
29
+ "src/local/agent-text-file.mjs",
30
+ "src/local/managed-job-projection.mjs"
26
31
  ]
27
32
  }