@gmickel/gno 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +44 -1
  3. package/assets/skill/mcp-reference.md +21 -0
  4. package/package.json +3 -2
  5. package/spec/AGENTS.md +83 -0
  6. package/spec/CLAUDE.md +83 -0
  7. package/spec/bench-fixture.schema.json +137 -0
  8. package/spec/cli.md +2894 -0
  9. package/spec/db/schema.sql +442 -0
  10. package/spec/evals-agentic.md +510 -0
  11. package/spec/evals.md +1106 -0
  12. package/spec/mcp.md +2229 -0
  13. package/spec/output-schemas/activation-verification.schema.json +515 -0
  14. package/spec/output-schemas/ask.schema.json +366 -0
  15. package/spec/output-schemas/backlinks.schema.json +131 -0
  16. package/spec/output-schemas/bench-result.schema.json +120 -0
  17. package/spec/output-schemas/capture-receipt.schema.json +143 -0
  18. package/spec/output-schemas/collection-list.schema.json +45 -0
  19. package/spec/output-schemas/context-capsule-v1.schema.json +691 -0
  20. package/spec/output-schemas/context-capsule-verification.schema.json +1338 -0
  21. package/spec/output-schemas/context-list.schema.json +21 -0
  22. package/spec/output-schemas/doctor.schema.json +313 -0
  23. package/spec/output-schemas/error.schema.json +30 -0
  24. package/spec/output-schemas/expansion.schema.json +37 -0
  25. package/spec/output-schemas/get.schema.json +140 -0
  26. package/spec/output-schemas/graph-query.schema.json +99 -0
  27. package/spec/output-schemas/graph.schema.json +371 -0
  28. package/spec/output-schemas/links-list.schema.json +186 -0
  29. package/spec/output-schemas/mcp-add-collection-result.schema.json +23 -0
  30. package/spec/output-schemas/mcp-capture-result.schema.json +152 -0
  31. package/spec/output-schemas/mcp-http-error.schema.json +30 -0
  32. package/spec/output-schemas/mcp-job-list.schema.json +58 -0
  33. package/spec/output-schemas/mcp-job-status.schema.json +224 -0
  34. package/spec/output-schemas/mcp-remove-result.schema.json +39 -0
  35. package/spec/output-schemas/mcp-sync-result.schema.json +41 -0
  36. package/spec/output-schemas/mcp-tag-result.schema.json +33 -0
  37. package/spec/output-schemas/models-list.schema.json +93 -0
  38. package/spec/output-schemas/multi-get.schema.json +103 -0
  39. package/spec/output-schemas/process-status.schema.json +119 -0
  40. package/spec/output-schemas/query-diagnose.schema.json +123 -0
  41. package/spec/output-schemas/resident-status.schema.json +154 -0
  42. package/spec/output-schemas/retrieval-trace-common.schema.json +492 -0
  43. package/spec/output-schemas/retrieval-trace-delete.schema.json +16 -0
  44. package/spec/output-schemas/retrieval-trace-export.schema.json +61 -0
  45. package/spec/output-schemas/retrieval-trace-filters.schema.json +139 -0
  46. package/spec/output-schemas/retrieval-trace-judgment.schema.json +15 -0
  47. package/spec/output-schemas/retrieval-trace-list.schema.json +18 -0
  48. package/spec/output-schemas/retrieval-trace-payloads.schema.json +178 -0
  49. package/spec/output-schemas/retrieval-trace-purge.schema.json +31 -0
  50. package/spec/output-schemas/retrieval-trace-qrels.schema.json +303 -0
  51. package/spec/output-schemas/retrieval-trace-replay.schema.json +286 -0
  52. package/spec/output-schemas/retrieval-trace-show.schema.json +69 -0
  53. package/spec/output-schemas/retrieval-trace-summary.schema.json +65 -0
  54. package/spec/output-schemas/search-result.schema.json +154 -0
  55. package/spec/output-schemas/search-results.schema.json +338 -0
  56. package/spec/output-schemas/similar.schema.json +84 -0
  57. package/spec/output-schemas/status.schema.json +676 -0
  58. package/spec/output-schemas/tags-list.schema.json +48 -0
  59. package/src/app/context-runtime-types.ts +3 -0
  60. package/src/app/context-runtime.ts +15 -1
  61. package/src/cli/commands/ask.ts +106 -36
  62. package/src/cli/commands/context-build.ts +56 -9
  63. package/src/cli/commands/daemon.ts +69 -2
  64. package/src/cli/commands/get.ts +64 -3
  65. package/src/cli/commands/models/pull.ts +13 -3
  66. package/src/cli/commands/query.ts +62 -23
  67. package/src/cli/commands/replay.ts +140 -0
  68. package/src/cli/commands/search.ts +48 -3
  69. package/src/cli/commands/shared.ts +3 -1
  70. package/src/cli/commands/status.ts +2 -0
  71. package/src/cli/commands/trace.ts +200 -0
  72. package/src/cli/commands/vsearch.ts +75 -53
  73. package/src/cli/detach.ts +37 -20
  74. package/src/cli/program.ts +329 -27
  75. package/src/config/index.ts +12 -0
  76. package/src/config/retrieval-traces.ts +56 -0
  77. package/src/config/types.ts +41 -0
  78. package/src/core/context-compiler.ts +11 -4
  79. package/src/core/job-manager.ts +19 -0
  80. package/src/core/mutation-generations.ts +33 -0
  81. package/src/core/retrieval-qrels.ts +405 -0
  82. package/src/core/retrieval-replay-candidate.ts +368 -0
  83. package/src/core/retrieval-replay-types.ts +109 -0
  84. package/src/core/retrieval-replay-validation.ts +89 -0
  85. package/src/core/retrieval-replay.ts +441 -0
  86. package/src/core/retrieval-trace-evidence-origin.ts +175 -0
  87. package/src/core/retrieval-trace-export.ts +113 -0
  88. package/src/core/retrieval-trace-filter-normalization.ts +27 -0
  89. package/src/core/retrieval-trace-filters.ts +19 -0
  90. package/src/core/retrieval-trace-management-helpers.ts +247 -0
  91. package/src/core/retrieval-trace-management-types.ts +132 -0
  92. package/src/core/retrieval-trace-management.ts +422 -0
  93. package/src/core/retrieval-trace-request.ts +141 -0
  94. package/src/core/retrieval-trace-session.ts +494 -0
  95. package/src/core/retrieval-trace.ts +472 -0
  96. package/src/llm/cache.ts +13 -3
  97. package/src/llm/nodeLlamaCpp/adapter.ts +10 -1
  98. package/src/llm/nodeLlamaCpp/lifecycle.ts +71 -0
  99. package/src/mcp/context.ts +161 -0
  100. package/src/mcp/http-security.ts +477 -0
  101. package/src/mcp/http-session.ts +272 -0
  102. package/src/mcp/http-transport.ts +370 -0
  103. package/src/mcp/resources/index.ts +141 -134
  104. package/src/mcp/server.ts +19 -79
  105. package/src/mcp/tools/add-collection.ts +3 -1
  106. package/src/mcp/tools/capture.ts +3 -0
  107. package/src/mcp/tools/clear-collection-embeddings.ts +2 -0
  108. package/src/mcp/tools/context.ts +68 -16
  109. package/src/mcp/tools/embed.ts +62 -52
  110. package/src/mcp/tools/get.ts +35 -1
  111. package/src/mcp/tools/index-cmd.ts +88 -74
  112. package/src/mcp/tools/index.ts +96 -2
  113. package/src/mcp/tools/query.ts +95 -64
  114. package/src/mcp/tools/remove-collection.ts +2 -0
  115. package/src/mcp/tools/search.ts +36 -13
  116. package/src/mcp/tools/status.ts +11 -0
  117. package/src/mcp/tools/sync.ts +16 -14
  118. package/src/mcp/tools/trace.ts +143 -0
  119. package/src/mcp/tools/vsearch.ts +71 -38
  120. package/src/mcp/tools/workspace-write.ts +7 -3
  121. package/src/pipeline/answer.ts +167 -26
  122. package/src/pipeline/graph-retrieval.ts +15 -1
  123. package/src/pipeline/hybrid.ts +151 -43
  124. package/src/pipeline/search.ts +36 -3
  125. package/src/pipeline/trace-metadata.ts +47 -0
  126. package/src/pipeline/types.ts +43 -0
  127. package/src/pipeline/vsearch.ts +101 -38
  128. package/src/sdk/client.ts +380 -71
  129. package/src/sdk/documents.ts +48 -1
  130. package/src/sdk/index.ts +17 -0
  131. package/src/sdk/types.ts +28 -0
  132. package/src/serve/background-runtime.ts +12 -212
  133. package/src/serve/context-capsule.ts +67 -8
  134. package/src/serve/embed-scheduler.ts +74 -43
  135. package/src/serve/index.ts +9 -0
  136. package/src/serve/jobs.ts +78 -80
  137. package/src/serve/public/app.tsx +12 -1
  138. package/src/serve/public/components/HealthCenter.tsx +74 -1
  139. package/src/serve/public/globals.built.css +1 -1
  140. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  141. package/src/serve/public/pages/Dashboard.tsx +11 -0
  142. package/src/serve/public/pages/TraceHistory.tsx +478 -0
  143. package/src/serve/public/pages/trace-history-detail.tsx +224 -0
  144. package/src/serve/resident-admission.ts +159 -0
  145. package/src/serve/resident-background-work.ts +39 -0
  146. package/src/serve/resident-request.ts +55 -0
  147. package/src/serve/resident-runtime.ts +490 -0
  148. package/src/serve/resident-status.ts +96 -0
  149. package/src/serve/retrieval-trace.ts +28 -0
  150. package/src/serve/routes/api.ts +629 -239
  151. package/src/serve/routes/mcp.ts +69 -0
  152. package/src/serve/routes/traces.ts +156 -0
  153. package/src/serve/server.ts +276 -37
  154. package/src/serve/status-model.ts +51 -0
  155. package/src/serve/status.ts +5 -0
  156. package/src/store/index.ts +31 -0
  157. package/src/store/migrations/014-retrieval-traces.ts +303 -0
  158. package/src/store/migrations/index.ts +2 -0
  159. package/src/store/retrieval-trace-codec.ts +384 -0
  160. package/src/store/sqlite/adapter.ts +179 -10
  161. package/src/store/sqlite/retrieval-trace-management-store.ts +341 -0
  162. package/src/store/sqlite/retrieval-trace-retention.ts +349 -0
  163. package/src/store/sqlite/retrieval-trace-rows.ts +267 -0
  164. package/src/store/sqlite/retrieval-trace-store.ts +515 -0
  165. package/src/store/types.ts +297 -0
  166. package/src/store/vector/sqlite-vec.ts +76 -1
  167. package/src/store/vector/types.ts +1 -1
@@ -0,0 +1,161 @@
1
+ /** Shared MCP surface and request-scoped runtime context. */
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ // node:async_hooks provides async-local request context; Bun has no separate native equivalent.
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+
7
+ import type { Collection, Config } from "../config/types";
8
+ import type { JobManager } from "../core/job-manager";
9
+ import type { ModelLease } from "../llm/nodeLlamaCpp/lifecycle";
10
+ import type { ResidentStatus } from "../serve/status-model";
11
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
12
+
13
+ import { MCP_SERVER_NAME, VERSION } from "../app/constants";
14
+ import { createStandaloneResidentStatus } from "../serve/resident-status";
15
+ import { registerResources } from "./resources/index";
16
+ import { registerTools } from "./tools/index";
17
+
18
+ export interface AsyncMutex {
19
+ acquire(): Promise<() => void>;
20
+ }
21
+
22
+ export class Mutex implements AsyncMutex {
23
+ #locked = false;
24
+ readonly #queue: Array<() => void> = [];
25
+
26
+ acquire(): Promise<() => void> {
27
+ return new Promise((resolve) => {
28
+ const tryAcquire = (): void => {
29
+ if (this.#locked) {
30
+ this.#queue.push(tryAcquire);
31
+ return;
32
+ }
33
+ this.#locked = true;
34
+ resolve(() => this.#release());
35
+ };
36
+ tryAcquire();
37
+ });
38
+ }
39
+
40
+ #release(): void {
41
+ this.#locked = false;
42
+ this.#queue.shift()?.();
43
+ }
44
+ }
45
+
46
+ export interface ToolContextSnapshot {
47
+ config: Config;
48
+ collections: Collection[];
49
+ }
50
+
51
+ export interface ToolContext {
52
+ store: SqliteAdapter;
53
+ config: Config;
54
+ collections: Collection[];
55
+ actualConfigPath: string;
56
+ indexName: string;
57
+ toolMutex: AsyncMutex;
58
+ jobManager: JobManager;
59
+ serverInstanceId: string;
60
+ writeLockPath: string;
61
+ enableWrite: boolean;
62
+ isShuttingDown: () => boolean;
63
+ getResidentStatus?: () => ResidentStatus;
64
+ acquireModelLease?: () => ModelLease;
65
+ markContentMutation?: () => void;
66
+ markIndexMutation?: () => void;
67
+ runWithSnapshot?<T>(operation: () => Promise<T>): Promise<T>;
68
+ }
69
+
70
+ export interface CreateToolContextOptions {
71
+ store: SqliteAdapter;
72
+ getConfig: () => Config;
73
+ setConfig?: (config: Config) => void;
74
+ actualConfigPath: string;
75
+ indexName: string;
76
+ toolMutex: AsyncMutex;
77
+ jobManager: JobManager;
78
+ serverInstanceId: string;
79
+ writeLockPath: string;
80
+ enableWrite: boolean;
81
+ isShuttingDown: () => boolean;
82
+ getResidentStatus?: () => ResidentStatus;
83
+ acquireModelLease?: () => ModelLease;
84
+ markContentMutation?: () => void;
85
+ markIndexMutation?: () => void;
86
+ }
87
+
88
+ /**
89
+ * Create a transport-neutral MCP context.
90
+ *
91
+ * Config and collection getters resolve from one snapshot captured at the
92
+ * request boundary, so a hot reload cannot mix old and new values mid-call.
93
+ */
94
+ export function createToolContext(
95
+ options: CreateToolContextOptions
96
+ ): ToolContext {
97
+ const requestSnapshot = new AsyncLocalStorage<ToolContextSnapshot>();
98
+ const currentSnapshot = (): ToolContextSnapshot =>
99
+ requestSnapshot.getStore() ??
100
+ (() => {
101
+ const config = options.getConfig();
102
+ return { config, collections: config.collections };
103
+ })();
104
+
105
+ return {
106
+ store: options.store,
107
+ get config() {
108
+ return currentSnapshot().config;
109
+ },
110
+ set config(config: Config) {
111
+ options.setConfig?.(config);
112
+ },
113
+ get collections() {
114
+ return currentSnapshot().collections;
115
+ },
116
+ set collections(_collections: Collection[]) {
117
+ // Collections are derived from config. Existing write handlers assign
118
+ // both for backwards compatibility; the config setter is authoritative.
119
+ },
120
+ actualConfigPath: options.actualConfigPath,
121
+ indexName: options.indexName,
122
+ toolMutex: options.toolMutex,
123
+ jobManager: options.jobManager,
124
+ serverInstanceId: options.serverInstanceId,
125
+ writeLockPath: options.writeLockPath,
126
+ enableWrite: options.enableWrite,
127
+ isShuttingDown: options.isShuttingDown,
128
+ getResidentStatus:
129
+ options.getResidentStatus ??
130
+ (() => createStandaloneResidentStatus("stdio")),
131
+ acquireModelLease: options.acquireModelLease,
132
+ markContentMutation: options.markContentMutation,
133
+ markIndexMutation: options.markIndexMutation,
134
+ runWithSnapshot<T>(operation: () => Promise<T>): Promise<T> {
135
+ const config = options.getConfig();
136
+ return requestSnapshot.run(
137
+ { config, collections: config.collections },
138
+ operation
139
+ );
140
+ },
141
+ };
142
+ }
143
+
144
+ /** Build the contract-identical MCP tool/resource surface for any transport. */
145
+ export function createMcpServerSurface(
146
+ context: ToolContext,
147
+ identity: { name: string; version: string } = {
148
+ name: MCP_SERVER_NAME,
149
+ version: VERSION,
150
+ }
151
+ ): McpServer {
152
+ const server = new McpServer(identity, {
153
+ capabilities: {
154
+ tools: { listChanged: false },
155
+ resources: { subscribe: false, listChanged: false },
156
+ },
157
+ });
158
+ registerTools(server, context);
159
+ registerResources(server, context);
160
+ return server;
161
+ }
@@ -0,0 +1,477 @@
1
+ /** Fail-closed network boundary for the resident Streamable HTTP transport. */
2
+
3
+ // node:fs/promises exposes atomic create modes and POSIX metadata; Bun.file does not.
4
+ import { lstat, open } from "node:fs/promises";
5
+
6
+ import type { HttpGatewayConfig } from "../config/types";
7
+
8
+ import { expandPath } from "../config/paths";
9
+
10
+ export const DEFAULT_HTTP_GATEWAY_HOST = "127.0.0.1";
11
+ export const DEFAULT_HTTP_GATEWAY_PORT = 3000;
12
+ export const DEFAULT_HTTP_MCP_MAX_BODY_BYTES = 1024 * 1024;
13
+ export const DEFAULT_HTTP_MCP_MAX_REQUESTS_PER_MINUTE = 120;
14
+ export const DEFAULT_HTTP_MCP_MAX_CONCURRENT_REQUESTS = 64;
15
+ export const DEFAULT_HTTP_MCP_MAX_QUEUED_REQUESTS = 16;
16
+ export const DEFAULT_HTTP_MCP_MAX_SESSIONS = 32;
17
+ export const DEFAULT_HTTP_MCP_SESSION_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
18
+
19
+ const MAX_RATE_KEYS = 4096;
20
+ const RATE_WINDOW_MS = 60_000;
21
+ const TOKEN_BYTES = 32;
22
+ const TOKEN_FILE_FORBIDDEN_MODE = 0o077;
23
+ const VISIBLE_ASCII = /^[\x21-\x7e]+$/;
24
+
25
+ export interface HttpMcpPeerServer {
26
+ requestIP(request: Request): { address: string; port: number } | null;
27
+ timeout(request: Request, seconds: number): void;
28
+ }
29
+
30
+ export interface ResolvedHttpGatewayConfig {
31
+ host: string;
32
+ port: number;
33
+ tokenFile?: string;
34
+ allowedHosts: readonly string[];
35
+ allowedOrigins: readonly string[];
36
+ enableWrite: boolean;
37
+ limits: {
38
+ maxBodyBytes: number;
39
+ maxRequestsPerMinute: number;
40
+ maxConcurrentRequests: number;
41
+ maxQueuedRequests: number;
42
+ maxSessions: number;
43
+ sessionIdleTimeoutMs: number;
44
+ };
45
+ }
46
+
47
+ export interface HttpGatewayOverrides {
48
+ host?: string;
49
+ port?: number;
50
+ tokenFile?: string;
51
+ allowedHosts?: string[];
52
+ allowedOrigins?: string[];
53
+ enableWrite?: boolean;
54
+ }
55
+
56
+ export interface AuthorizedHttpMcpRequest {
57
+ identity: string;
58
+ parsedBody?: unknown;
59
+ request: Request;
60
+ }
61
+
62
+ export type HttpMcpAuthorizationResult =
63
+ | { ok: true; value: AuthorizedHttpMcpRequest }
64
+ | { ok: false; response: Response };
65
+
66
+ interface TokenState {
67
+ digest: string;
68
+ revision: string;
69
+ }
70
+
71
+ interface RateState {
72
+ count: number;
73
+ windowStartedAt: number;
74
+ }
75
+
76
+ export interface HttpMcpSecurityOptions {
77
+ now?: () => number;
78
+ onCredentialsChanged?: () => Promise<void> | void;
79
+ }
80
+
81
+ function securityError(status: 401 | 403 | 413 | 429 | 503): Response {
82
+ const messages = {
83
+ 401: "Unauthorized",
84
+ 403: "Forbidden",
85
+ 413: "Request body too large",
86
+ 429: "Too many requests",
87
+ 503: "Resident runtime unavailable",
88
+ } as const;
89
+ const headers = status === 401 ? { "WWW-Authenticate": "Bearer" } : undefined;
90
+ return Response.json(
91
+ {
92
+ jsonrpc: "2.0",
93
+ error: { code: -32_000, message: messages[status] },
94
+ id: null,
95
+ },
96
+ { status, headers }
97
+ );
98
+ }
99
+
100
+ function isLoopbackAddress(address: string): boolean {
101
+ const normalized = address.toLowerCase();
102
+ if (normalized === "::1") return true;
103
+ const mapped = normalized.startsWith("::ffff:")
104
+ ? normalized.slice("::ffff:".length)
105
+ : normalized;
106
+ const octets = mapped.split(".");
107
+ return (
108
+ octets.length === 4 &&
109
+ octets.every((octet) => /^\d{1,3}$/.test(octet)) &&
110
+ Number(octets[0]) === 127 &&
111
+ octets.every((octet) => Number(octet) <= 255)
112
+ );
113
+ }
114
+
115
+ export function isHttpGatewayLoopbackBind(host: string): boolean {
116
+ return host === "127.0.0.1" || host === "::1" || host === "[::1]";
117
+ }
118
+
119
+ function defaultAllowedHosts(host: string, port: number): string[] {
120
+ if (host === "127.0.0.1") return [`127.0.0.1:${port}`, `localhost:${port}`];
121
+ if (host === "::1" || host === "[::1]") return [`[::1]:${port}`];
122
+ return [];
123
+ }
124
+
125
+ function defaultAllowedOrigins(host: string, port: number): string[] {
126
+ return defaultAllowedHosts(host, port).map(
127
+ (allowedHost) => `http://${allowedHost}`
128
+ );
129
+ }
130
+
131
+ function validateHostEntry(host: string): boolean {
132
+ if (host !== host.trim() || host.length === 0) return false;
133
+ if (host.includes("*") || host.includes(",")) return false;
134
+ try {
135
+ const url = new URL(`http://${host}`);
136
+ return (
137
+ url.host.toLowerCase() === host.toLowerCase() && url.pathname === "/"
138
+ );
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ function validateOriginEntry(origin: string): boolean {
145
+ if (origin !== origin.trim() || origin.length === 0) return false;
146
+ if (origin.includes("*")) return false;
147
+ try {
148
+ const url = new URL(origin);
149
+ return (
150
+ (url.protocol === "http:" || url.protocol === "https:") &&
151
+ url.username === "" &&
152
+ url.password === "" &&
153
+ url.pathname === "/" &&
154
+ url.search === "" &&
155
+ url.hash === "" &&
156
+ url.origin === origin
157
+ );
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ export function resolveHttpGatewayConfig(
164
+ config: HttpGatewayConfig | undefined,
165
+ overrides: HttpGatewayOverrides = {}
166
+ ): ResolvedHttpGatewayConfig {
167
+ const host = overrides.host ?? config?.host ?? DEFAULT_HTTP_GATEWAY_HOST;
168
+ const port = overrides.port ?? DEFAULT_HTTP_GATEWAY_PORT;
169
+ return {
170
+ host,
171
+ port,
172
+ tokenFile: overrides.tokenFile ?? config?.tokenFile,
173
+ allowedHosts:
174
+ overrides.allowedHosts ??
175
+ config?.allowedHosts ??
176
+ defaultAllowedHosts(host, port),
177
+ allowedOrigins:
178
+ overrides.allowedOrigins ??
179
+ config?.allowedOrigins ??
180
+ defaultAllowedOrigins(host, port),
181
+ enableWrite: overrides.enableWrite ?? config?.enableWrite ?? false,
182
+ limits: {
183
+ maxBodyBytes:
184
+ config?.limits?.maxBodyBytes ?? DEFAULT_HTTP_MCP_MAX_BODY_BYTES,
185
+ maxRequestsPerMinute:
186
+ config?.limits?.maxRequestsPerMinute ??
187
+ DEFAULT_HTTP_MCP_MAX_REQUESTS_PER_MINUTE,
188
+ maxConcurrentRequests:
189
+ config?.limits?.maxConcurrentRequests ??
190
+ DEFAULT_HTTP_MCP_MAX_CONCURRENT_REQUESTS,
191
+ maxQueuedRequests:
192
+ config?.limits?.maxQueuedRequests ??
193
+ DEFAULT_HTTP_MCP_MAX_QUEUED_REQUESTS,
194
+ maxSessions: config?.limits?.maxSessions ?? DEFAULT_HTTP_MCP_MAX_SESSIONS,
195
+ sessionIdleTimeoutMs:
196
+ config?.limits?.sessionIdleTimeoutMs ??
197
+ DEFAULT_HTTP_MCP_SESSION_IDLE_TIMEOUT_MS,
198
+ },
199
+ };
200
+ }
201
+
202
+ async function sha256(value: string): Promise<string> {
203
+ const bytes = new TextEncoder().encode(value);
204
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
205
+ return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
206
+ }
207
+
208
+ function constantTimeEqual(left: string, right: string): boolean {
209
+ if (left.length !== right.length) return false;
210
+ let difference = 0;
211
+ for (let index = 0; index < left.length; index += 1) {
212
+ difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
213
+ }
214
+ return difference === 0;
215
+ }
216
+
217
+ async function createTokenFile(path: string): Promise<void> {
218
+ const token = [...crypto.getRandomValues(new Uint8Array(TOKEN_BYTES))]
219
+ .map((byte) => byte.toString(16).padStart(2, "0"))
220
+ .join("");
221
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
222
+ try {
223
+ handle = await open(path, "wx", 0o600);
224
+ await handle.writeFile(`${token}\n`, { encoding: "utf8" });
225
+ } catch (error) {
226
+ const code = (error as { code?: string }).code;
227
+ if (code !== "EEXIST") throw error;
228
+ } finally {
229
+ await handle?.close();
230
+ }
231
+ }
232
+
233
+ async function loadTokenFile(path: string): Promise<TokenState> {
234
+ const expandedPath = expandPath(path);
235
+ const pathMetadata = await lstat(expandedPath);
236
+ if (!pathMetadata.isFile()) throw new Error("MCP token path must be a file");
237
+ const handle = await open(expandedPath, "r");
238
+ let metadata: Awaited<ReturnType<typeof handle.stat>>;
239
+ let raw: string;
240
+ try {
241
+ metadata = await handle.stat();
242
+ if (
243
+ metadata.dev !== pathMetadata.dev ||
244
+ metadata.ino !== pathMetadata.ino ||
245
+ !metadata.isFile()
246
+ )
247
+ throw new Error("MCP token file changed while opening");
248
+ if (
249
+ process.platform !== "win32" &&
250
+ (metadata.mode & TOKEN_FILE_FORBIDDEN_MODE) !== 0
251
+ )
252
+ throw new Error("MCP token file permissions must be 0600 or stricter");
253
+ raw = await handle.readFile({ encoding: "utf8" });
254
+ } finally {
255
+ await handle.close();
256
+ }
257
+ const token = raw.trim();
258
+ if (token.length < 32 || token.length > 512 || !VISIBLE_ASCII.test(token))
259
+ throw new Error("MCP token file contains an invalid token");
260
+ const digest = await sha256(token);
261
+ return {
262
+ digest,
263
+ revision: `${metadata.dev}:${metadata.ino}:${metadata.size}:${metadata.mtimeMs}:${digest}`,
264
+ };
265
+ }
266
+
267
+ /** Create the explicitly requested token file, then validate restrictive mode. */
268
+ export async function ensureHttpMcpTokenFile(path: string): Promise<void> {
269
+ const expandedPath = expandPath(path);
270
+ await createTokenFile(expandedPath);
271
+ await loadTokenFile(expandedPath);
272
+ }
273
+
274
+ async function readBoundedJson(
275
+ request: Request,
276
+ maxBytes: number
277
+ ): Promise<{ ok: true; value: unknown } | { ok: false; response: Response }> {
278
+ const declaredLength = request.headers.get("content-length");
279
+ if (declaredLength !== null) {
280
+ if (!/^\d+$/.test(declaredLength))
281
+ return { ok: false, response: securityError(413) };
282
+ if (Number(declaredLength) > maxBytes)
283
+ return { ok: false, response: securityError(413) };
284
+ }
285
+
286
+ const reader = request.body?.getReader();
287
+ if (!reader) return { ok: true, value: undefined };
288
+ const chunks: Uint8Array[] = [];
289
+ let total = 0;
290
+ while (true) {
291
+ const result = await reader.read();
292
+ if (result.done) break;
293
+ total += result.value.byteLength;
294
+ if (total > maxBytes) {
295
+ await reader.cancel();
296
+ return { ok: false, response: securityError(413) };
297
+ }
298
+ chunks.push(result.value);
299
+ }
300
+ const body = new Uint8Array(total);
301
+ let offset = 0;
302
+ for (const chunk of chunks) {
303
+ body.set(chunk, offset);
304
+ offset += chunk.byteLength;
305
+ }
306
+ try {
307
+ return { ok: true, value: JSON.parse(new TextDecoder().decode(body)) };
308
+ } catch {
309
+ return {
310
+ ok: false,
311
+ response: Response.json(
312
+ {
313
+ jsonrpc: "2.0",
314
+ error: { code: -32_700, message: "Parse error: Invalid JSON" },
315
+ id: null,
316
+ },
317
+ { status: 400 }
318
+ ),
319
+ };
320
+ }
321
+ }
322
+
323
+ export class HttpMcpSecurity {
324
+ readonly #config: ResolvedHttpGatewayConfig;
325
+ readonly #now: () => number;
326
+ readonly #onCredentialsChanged: () => Promise<void> | void;
327
+ readonly #rates = new Map<string, RateState>();
328
+ #tokenState?: TokenState;
329
+
330
+ constructor(
331
+ config: ResolvedHttpGatewayConfig,
332
+ options: HttpMcpSecurityOptions = {}
333
+ ) {
334
+ this.#config = config;
335
+ this.#now = options.now ?? Date.now;
336
+ this.#onCredentialsChanged = options.onCredentialsChanged ?? (() => {});
337
+ }
338
+
339
+ get config(): ResolvedHttpGatewayConfig {
340
+ return this.#config;
341
+ }
342
+
343
+ async initialize(): Promise<void> {
344
+ if (!this.#config.allowedHosts.every(validateHostEntry))
345
+ throw new Error("MCP allowedHosts must contain exact Host values");
346
+ if (!this.#config.allowedOrigins.every(validateOriginEntry))
347
+ throw new Error("MCP allowedOrigins must contain exact HTTP(S) origins");
348
+
349
+ const loopback = isHttpGatewayLoopbackBind(this.#config.host);
350
+ if (
351
+ !loopback &&
352
+ (!this.#config.tokenFile ||
353
+ this.#config.allowedHosts.length === 0 ||
354
+ this.#config.allowedOrigins.length === 0)
355
+ ) {
356
+ throw new Error(
357
+ "Wildcard/non-loopback MCP binding requires tokenFile plus exact allowedHosts and allowedOrigins"
358
+ );
359
+ }
360
+
361
+ if (this.#config.tokenFile) {
362
+ await ensureHttpMcpTokenFile(this.#config.tokenFile);
363
+ this.#tokenState = await loadTokenFile(this.#config.tokenFile);
364
+ }
365
+ }
366
+
367
+ async authorize(
368
+ request: Request,
369
+ server: HttpMcpPeerServer
370
+ ): Promise<HttpMcpAuthorizationResult> {
371
+ const peer = server.requestIP(request);
372
+ if (!peer) return { ok: false, response: securityError(403) };
373
+ if (
374
+ isHttpGatewayLoopbackBind(this.#config.host) &&
375
+ !isLoopbackAddress(peer.address)
376
+ )
377
+ return { ok: false, response: securityError(403) };
378
+
379
+ const host = request.headers.get("host");
380
+ if (
381
+ !host ||
382
+ !this.#config.allowedHosts.some(
383
+ (allowedHost) => allowedHost.toLowerCase() === host.toLowerCase()
384
+ )
385
+ )
386
+ return { ok: false, response: securityError(403) };
387
+
388
+ const origin = request.headers.get("origin");
389
+ if (origin && !this.#config.allowedOrigins.includes(origin))
390
+ return { ok: false, response: securityError(403) };
391
+
392
+ if (!this.#allowRate(peer.address))
393
+ return { ok: false, response: securityError(429) };
394
+
395
+ const requiresAuth =
396
+ !isHttpGatewayLoopbackBind(this.#config.host) ||
397
+ this.#config.tokenFile !== undefined;
398
+ const tokenState = await this.#refreshTokenState();
399
+ let identity = "loopback";
400
+ if (requiresAuth) {
401
+ if (!tokenState) return { ok: false, response: securityError(503) };
402
+ const authorization = request.headers.get("authorization");
403
+ const match = authorization?.match(/^Bearer ([\x21-\x7e]+)$/);
404
+ if (!match) return { ok: false, response: securityError(401) };
405
+ const presentedDigest = await sha256(match[1] ?? "");
406
+ if (!constantTimeEqual(presentedDigest, tokenState.digest))
407
+ return { ok: false, response: securityError(401) };
408
+ identity = tokenState.digest;
409
+ }
410
+
411
+ let parsedBody: unknown;
412
+ if (request.method === "POST") {
413
+ const body = await readBoundedJson(
414
+ request,
415
+ this.#config.limits.maxBodyBytes
416
+ );
417
+ if (!body.ok) return body;
418
+ parsedBody = body.value;
419
+ }
420
+
421
+ const sanitizedHeaders = new Headers(request.headers);
422
+ sanitizedHeaders.delete("authorization");
423
+ sanitizedHeaders.delete("forwarded");
424
+ sanitizedHeaders.delete("content-length");
425
+ sanitizedHeaders.delete("transfer-encoding");
426
+ const forwardedHeaderNames: string[] = [];
427
+ for (const name of sanitizedHeaders.keys())
428
+ if (name.startsWith("x-forwarded-")) forwardedHeaderNames.push(name);
429
+ for (const name of forwardedHeaderNames) sanitizedHeaders.delete(name);
430
+ const sanitizedRequest = new Request(request.url, {
431
+ method: request.method,
432
+ headers: sanitizedHeaders,
433
+ signal: request.signal,
434
+ });
435
+
436
+ return {
437
+ ok: true,
438
+ value: { identity, parsedBody, request: sanitizedRequest },
439
+ };
440
+ }
441
+
442
+ #allowRate(key: string): boolean {
443
+ const now = this.#now();
444
+ let state = this.#rates.get(key);
445
+ if (!state || now - state.windowStartedAt >= RATE_WINDOW_MS) {
446
+ state = { count: 0, windowStartedAt: now };
447
+ this.#rates.set(key, state);
448
+ }
449
+ state.count += 1;
450
+ if (this.#rates.size > MAX_RATE_KEYS) {
451
+ for (const [rateKey, candidate] of this.#rates) {
452
+ if (now - candidate.windowStartedAt >= RATE_WINDOW_MS)
453
+ this.#rates.delete(rateKey);
454
+ }
455
+ if (this.#rates.size > MAX_RATE_KEYS) this.#rates.clear();
456
+ }
457
+ return state.count <= this.#config.limits.maxRequestsPerMinute;
458
+ }
459
+
460
+ async #refreshTokenState(): Promise<TokenState | undefined> {
461
+ if (!this.#config.tokenFile) return undefined;
462
+ let next: TokenState | undefined;
463
+ try {
464
+ next = await loadTokenFile(this.#config.tokenFile);
465
+ } catch {
466
+ if (this.#tokenState) {
467
+ this.#tokenState = undefined;
468
+ await this.#onCredentialsChanged();
469
+ }
470
+ return undefined;
471
+ }
472
+ if (this.#tokenState && this.#tokenState.revision !== next.revision)
473
+ await this.#onCredentialsChanged();
474
+ this.#tokenState = next;
475
+ return next;
476
+ }
477
+ }