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.
- package/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +37 -19
- package/README.md +120 -410
- package/SECURITY.md +2 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +13 -4
- package/docs/AUDIT.md +22 -0
- package/docs/ENGINEERING.md +2 -2
- package/docs/OVERVIEW.md +113 -0
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +78 -33
- package/docs/TESTING.md +14 -7
- package/docs/THREAT_MODEL.md +142 -0
- package/package.json +18 -6
- package/scripts/check-plan.mjs +91 -0
- package/scripts/coverage-check.mjs +22 -0
- package/scripts/github-push.mjs +49 -0
- package/scripts/github-release.mjs +15 -8
- package/scripts/local-release-acceptance.mjs +186 -0
- package/scripts/release-acceptance.mjs +181 -0
- package/scripts/release-impact-check.mjs +19 -3
- package/scripts/release-state.mjs +1 -1
- package/scripts/run-checks.mjs +29 -0
- package/scripts/start-release-candidate.mjs +113 -0
- package/src/local/agent-context-projection.mjs +158 -0
- package/src/local/agent-context.mjs +23 -332
- package/src/local/agent-skill-discovery.mjs +230 -0
- package/src/local/agent-text-file.mjs +41 -0
- package/src/local/browser-bridge-http.mjs +48 -0
- package/src/local/browser-bridge.mjs +48 -222
- package/src/local/browser-broker-routes.mjs +136 -0
- package/src/local/browser-broker-server.mjs +59 -0
- package/src/local/browser-request-registry.mjs +67 -0
- package/src/local/managed-job-lock.mjs +99 -0
- package/src/local/managed-job-projection.mjs +68 -0
- package/src/local/managed-job-runner.mjs +73 -0
- package/src/local/managed-job-storage.mjs +93 -0
- package/src/local/managed-jobs.mjs +12 -297
- package/src/local/runtime-paths.mjs +107 -0
- package/src/local/runtime-relay.mjs +73 -0
- package/src/local/runtime-tool-handlers.mjs +66 -0
- package/src/local/runtime.mjs +22 -204
- package/src/local/windows-launcher.mjs +57 -0
- package/src/local/windows-service.mjs +7 -56
- package/src/worker/index.ts +9 -118
- package/src/worker/mcp-jsonrpc.ts +94 -0
- package/src/worker/oauth-authorization-page.ts +70 -0
- package/src/worker/oauth-controller.ts +9 -58
- package/src/worker/websocket-protocol.ts +24 -0
- package/tsconfig.local.json +6 -1
package/src/worker/index.ts
CHANGED
|
@@ -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.
|
|
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,
|
|
8
|
+
type OAuthClient, type OAuthStore,
|
|
9
9
|
} from "./oauth-state.ts";
|
|
10
10
|
import {
|
|
11
|
-
HttpError, authorizationRedirectLocation,
|
|
12
|
-
normalizeRedirectUri, oauthRedirect, parseRequestBody,
|
|
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
|
|
179
|
+
return authorizationPage({ request, base, serverName: this.serverName, error: validation.error, submitted: body, status: validation.status, allowSubmit: false });
|
|
180
180
|
}
|
|
181
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/tsconfig.local.json
CHANGED
|
@@ -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
|
}
|