machine-bridge-mcp 1.2.8 → 1.2.10
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 +20 -0
- package/CONTRIBUTING.md +16 -5
- package/README.md +115 -416
- package/SECURITY.md +2 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +19 -9
- package/docs/AUDIT.md +20 -0
- package/docs/ENGINEERING.md +2 -2
- package/docs/LOGGING.md +1 -1
- package/docs/MULTI_ACCOUNT.md +2 -2
- package/docs/OPERATIONS.md +2 -2
- package/docs/OVERVIEW.md +113 -0
- package/docs/PROJECT_STANDARDS.md +4 -4
- package/docs/RELEASING.md +25 -12
- package/docs/TESTING.md +12 -8
- package/docs/THREAT_MODEL.md +142 -0
- package/package.json +10 -3
- package/scripts/check-plan.mjs +91 -0
- package/scripts/coverage-check.mjs +22 -0
- package/scripts/github-push.mjs +1 -1
- package/scripts/github-release.mjs +1 -1
- package/scripts/local-release-acceptance.mjs +56 -6
- package/scripts/release-acceptance.mjs +15 -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/relay-call-recovery.mjs +148 -0
- package/src/local/relay-connection.mjs +5 -0
- package/src/local/runtime-paths.mjs +107 -0
- package/src/local/runtime-relay.mjs +89 -0
- package/src/local/runtime-tool-handlers.mjs +66 -0
- package/src/local/runtime.mjs +80 -242
- package/src/local/windows-launcher.mjs +57 -0
- package/src/local/windows-service.mjs +7 -56
- package/src/worker/daemon-sockets.ts +10 -1
- package/src/worker/index.ts +51 -124
- 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/pending-call-contract.ts +26 -0
- package/src/worker/pending-calls.ts +41 -25
- package/src/worker/websocket-protocol.ts +24 -0
- package/tsconfig.local.json +7 -1
package/src/worker/index.ts
CHANGED
|
@@ -25,16 +25,23 @@ 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.10";
|
|
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;
|
|
37
43
|
const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
44
|
+
const DAEMON_RECONNECT_GRACE_MS = 30_000;
|
|
38
45
|
|
|
39
46
|
interface BridgeEnv extends OAuthControllerEnv {
|
|
40
47
|
BRIDGE: DurableObjectNamespace<BridgeRoom>;
|
|
@@ -45,15 +52,6 @@ interface BridgeEnv extends OAuthControllerEnv {
|
|
|
45
52
|
MBM_ALLOWED_ORIGINS?: string;
|
|
46
53
|
}
|
|
47
54
|
|
|
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
55
|
const MCP_INSTRUCTIONS = serverMetadata.instructions.map((value) => String(value)).join("\n");
|
|
58
56
|
|
|
59
57
|
export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
@@ -182,11 +180,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
182
180
|
return;
|
|
183
181
|
}
|
|
184
182
|
const daemonPolicy = sanitizeDaemonPolicy(body.policy);
|
|
183
|
+
const instanceId = daemonInstanceId(body.instance_id);
|
|
184
|
+
if (!instanceId) {
|
|
185
|
+
rejectDaemonMessage(ws, "invalid_daemon_instance", 1002, "daemon instance id required");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
185
188
|
const authenticatedAt = new Date().toISOString();
|
|
186
189
|
const probeId = randomToken("probe");
|
|
187
190
|
this.daemonRegistry.beginProbe(ws, {
|
|
188
191
|
connectedAt: authenticatedAt,
|
|
189
192
|
probeId,
|
|
193
|
+
instanceId,
|
|
190
194
|
policy: daemonPolicy,
|
|
191
195
|
tools: sanitizeDaemonTools(body.tools, daemonPolicy),
|
|
192
196
|
});
|
|
@@ -221,11 +225,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
221
225
|
return;
|
|
222
226
|
}
|
|
223
227
|
const readyAt = new Date().toISOString();
|
|
224
|
-
|
|
228
|
+
const readyAttachment = this.daemonRegistry.promote(ws, readyAt);
|
|
229
|
+
if (!readyAttachment) {
|
|
225
230
|
rejectDaemonMessage(ws, "invalid_relay_readiness_state", 1002, "invalid daemon readiness state");
|
|
226
231
|
return;
|
|
227
232
|
}
|
|
233
|
+
const reboundCallIds = this.pending.rebindInstance(readyAttachment.instanceId ?? "", ws);
|
|
234
|
+
if (reboundCallIds.length > 0) {
|
|
235
|
+
this.observability.event("info", "daemon.calls.rebound", { rebound_calls: reboundCallIds.length });
|
|
236
|
+
}
|
|
228
237
|
try {
|
|
238
|
+
ws.send(JSON.stringify({ type: "resume_calls", ids: reboundCallIds }));
|
|
229
239
|
ws.send(JSON.stringify({ type: "ready_ack", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
230
240
|
} catch {
|
|
231
241
|
this.invalidateDaemonSocket(ws, "daemon readiness acknowledgement failed", "daemon ready timeout", "daemon_ready_timeout");
|
|
@@ -266,7 +276,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
266
276
|
|
|
267
277
|
private async cleanupDaemonSocket(ws: WebSocket, message: string): Promise<void> {
|
|
268
278
|
this.observability.socketDisconnected();
|
|
269
|
-
this.
|
|
279
|
+
this.detachDaemonSocketCalls(ws, message);
|
|
270
280
|
await this.scheduleSocketAlarms();
|
|
271
281
|
}
|
|
272
282
|
|
|
@@ -295,7 +305,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
295
305
|
const body = await parseJsonRequest(request, this.bodyLimitBytes());
|
|
296
306
|
if (isJsonRpcResponse(body)) return new Response(null, { status: 202 });
|
|
297
307
|
if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
|
|
298
|
-
const protocolError = validateProtocolVersionHeader(request, body);
|
|
308
|
+
const protocolError = validateProtocolVersionHeader(request, body, MCP_SUPPORTED_PROTOCOL_VERSIONS);
|
|
299
309
|
if (protocolError) return json(protocolError, 400);
|
|
300
310
|
|
|
301
311
|
const session = await resolveMcpSession(request, body.method, this.oauth.identityKey(), authorized.tokenKey);
|
|
@@ -431,6 +441,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
431
441
|
this.reclaimStaleDaemonSockets();
|
|
432
442
|
const socket = this.daemonRegistry.readySockets()[0];
|
|
433
443
|
if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
|
|
444
|
+
const daemonAttachment = this.daemonRegistry.readyAttachment(socket);
|
|
445
|
+
const daemonInstanceId = daemonAttachment?.instanceId ?? "";
|
|
446
|
+
if (!daemonInstanceId) throw new WorkerToolError("unavailable", "local daemon connection is missing its instance identity", true);
|
|
434
447
|
const id = randomToken("call");
|
|
435
448
|
const timeoutMs = daemonToolTimeoutMs(name, args);
|
|
436
449
|
let result: Promise<unknown>;
|
|
@@ -438,20 +451,23 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
438
451
|
result = this.pending.register({
|
|
439
452
|
id,
|
|
440
453
|
socket,
|
|
454
|
+
daemonInstanceId,
|
|
441
455
|
clientRequestKey: requestKey,
|
|
442
456
|
tool: name,
|
|
443
457
|
timeoutMs,
|
|
444
458
|
onTimeout: (record) => {
|
|
445
|
-
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
446
|
-
const silentForMs =
|
|
447
|
-
|
|
459
|
+
if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
460
|
+
const silentForMs = record.socket
|
|
461
|
+
? Date.now() - daemonLastSeenMs(this.daemonRegistry.readyAttachment(record.socket))
|
|
462
|
+
: 0;
|
|
463
|
+
if (record.socket && (!Number.isFinite(silentForMs) || silentForMs > 45_000)) {
|
|
448
464
|
this.invalidateDaemonSocket(record.socket, "daemon became unresponsive", "daemon liveness timeout");
|
|
449
465
|
}
|
|
450
466
|
return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
|
|
451
467
|
},
|
|
452
468
|
signal,
|
|
453
469
|
onAbort: (record) => {
|
|
454
|
-
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
470
|
+
if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
455
471
|
return new WorkerToolError("cancelled", "MCP client stopped waiting for the tool result");
|
|
456
472
|
},
|
|
457
473
|
});
|
|
@@ -485,7 +501,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
485
501
|
private cancelClientRequest(requestKey?: string): void {
|
|
486
502
|
if (!requestKey) return;
|
|
487
503
|
this.pending.cancelRequest(requestKey, (record) => {
|
|
488
|
-
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
504
|
+
if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
489
505
|
return new WorkerToolError("cancelled", "tool call cancelled by client");
|
|
490
506
|
});
|
|
491
507
|
}
|
|
@@ -545,12 +561,24 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
545
561
|
closeReason: string,
|
|
546
562
|
errorCode = "daemon_liveness_timeout",
|
|
547
563
|
): void {
|
|
564
|
+
this.detachDaemonSocketCalls(ws, message);
|
|
548
565
|
this.daemonRegistry.expire(ws);
|
|
549
|
-
this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
|
|
550
566
|
sendWebSocketQuietly(ws, { type: "error", error: errorCode });
|
|
551
567
|
closeWebSocketQuietly(ws, 1008, closeReason);
|
|
552
568
|
}
|
|
553
569
|
|
|
570
|
+
private detachDaemonSocketCalls(ws: WebSocket, message: string): number {
|
|
571
|
+
const attachment = this.daemonRegistry.attachment(ws);
|
|
572
|
+
if (!attachment?.instanceId) {
|
|
573
|
+
return this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
|
|
574
|
+
}
|
|
575
|
+
return this.pending.detachSocket(
|
|
576
|
+
ws,
|
|
577
|
+
DAEMON_RECONNECT_GRACE_MS,
|
|
578
|
+
() => new WorkerToolError("unavailable", `${message}; reconnect grace expired`, true),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
554
582
|
private reclaimStaleDaemonSockets(now = Date.now()): void {
|
|
555
583
|
for (const socket of this.daemonRegistry.readyRoleSockets()) {
|
|
556
584
|
const deadline = daemonLivenessDeadlineMs(this.daemonRegistry.readyAttachment(socket));
|
|
@@ -701,108 +729,7 @@ export default {
|
|
|
701
729
|
},
|
|
702
730
|
} satisfies ExportedHandler<BridgeEnv>;
|
|
703
731
|
|
|
704
|
-
function
|
|
705
|
-
|
|
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
|
-
});
|
|
732
|
+
function daemonInstanceId(value: unknown): string {
|
|
733
|
+
if (typeof value !== "string" || !/^daemon_[A-Za-z0-9_-]{16,96}$/.test(value)) return "";
|
|
734
|
+
return value;
|
|
808
735
|
}
|
|
@@ -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,26 @@
|
|
|
1
|
+
export interface PendingCallRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
socket?: WebSocket;
|
|
4
|
+
daemonInstanceId?: string;
|
|
5
|
+
reconnectTimeout?: ReturnType<typeof setTimeout>;
|
|
6
|
+
clientRequestKey?: string;
|
|
7
|
+
tool: string;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
10
|
+
resolve: (value: unknown) => void;
|
|
11
|
+
reject: (error: Error) => void;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
abortHandler?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RegisterPendingCall {
|
|
17
|
+
id: string;
|
|
18
|
+
socket: WebSocket;
|
|
19
|
+
daemonInstanceId?: string;
|
|
20
|
+
clientRequestKey?: string;
|
|
21
|
+
tool: string;
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
onTimeout: (record: PendingCallRecord) => Error;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
onAbort?: (record: PendingCallRecord) => Error;
|
|
26
|
+
}
|