@gmickel/gno 1.27.1 → 1.29.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 (195) hide show
  1. package/README.md +46 -20
  2. package/assets/skill/SKILL.md +59 -1
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/browser-extension/dist/preview.html +1 -1
  8. package/browser-extension/dist/service-worker.js +6 -6
  9. package/package.json +2 -2
  10. package/spec/cli.md +82 -5
  11. package/spec/db/schema.sql +31 -0
  12. package/spec/mcp.md +71 -0
  13. package/spec/output-schemas/ask.schema.json +156 -1
  14. package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
  15. package/spec/output-schemas/collection-egress-check.schema.json +91 -0
  16. package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
  17. package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
  18. package/spec/output-schemas/context-capsule-v1.schema.json +183 -3
  19. package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
  20. package/spec/output-schemas/egress-audit-management.schema.json +88 -0
  21. package/spec/output-schemas/get.schema.json +94 -0
  22. package/spec/output-schemas/mcp-http-error.schema.json +113 -2
  23. package/spec/output-schemas/mcp-job-status.schema.json +28 -0
  24. package/spec/output-schemas/multi-get.schema.json +128 -0
  25. package/spec/output-schemas/publish-artifact.schema.json +32 -0
  26. package/spec/output-schemas/record-import.schema.json +223 -0
  27. package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
  28. package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
  29. package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
  30. package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
  31. package/spec/output-schemas/search-result.schema.json +94 -0
  32. package/spec/output-schemas/search-results.schema.json +155 -0
  33. package/spec/project-profile.schema.json +66 -0
  34. package/src/app/context-format.ts +1 -1
  35. package/src/app/context-runtime-contract.ts +16 -7
  36. package/src/cli/commands/ask.ts +7 -0
  37. package/src/cli/commands/collection/index.ts +5 -0
  38. package/src/cli/commands/collection/policy.ts +126 -0
  39. package/src/cli/commands/context-build.ts +5 -0
  40. package/src/cli/commands/daemon.ts +45 -3
  41. package/src/cli/commands/egress-audit.ts +68 -0
  42. package/src/cli/commands/embed.ts +2 -0
  43. package/src/cli/commands/get.ts +12 -2
  44. package/src/cli/commands/index-cmd.ts +13 -0
  45. package/src/cli/commands/multi-get.ts +9 -2
  46. package/src/cli/commands/query.ts +12 -0
  47. package/src/cli/commands/replay.ts +13 -3
  48. package/src/cli/commands/shared.ts +28 -0
  49. package/src/cli/commands/update.ts +5 -0
  50. package/src/cli/commands/vsearch.ts +3 -1
  51. package/src/cli/program.ts +127 -0
  52. package/src/config/index.ts +9 -0
  53. package/src/config/project-profile.ts +2 -0
  54. package/src/config/types.ts +66 -0
  55. package/src/converters/adapters/browser-export/adapter.ts +199 -0
  56. package/src/converters/adapters/browser-export/formats.ts +358 -0
  57. package/src/converters/adapters/email/adapter.ts +429 -0
  58. package/src/converters/adapters/email/html.ts +162 -0
  59. package/src/converters/adapters/email/mime.ts +454 -0
  60. package/src/converters/adapters/email/parameters.ts +77 -0
  61. package/src/converters/adapters/ical/adapter.ts +475 -0
  62. package/src/converters/adapters/ical/recurrence.ts +186 -0
  63. package/src/converters/adapters/jsonl/adapter.ts +238 -0
  64. package/src/converters/adapters/jsonl/config.ts +105 -0
  65. package/src/converters/adapters/shared/html-text.ts +165 -0
  66. package/src/converters/adapters/shared/record-utils.ts +79 -0
  67. package/src/converters/adapters/shared/utf8-lines.ts +141 -0
  68. package/src/converters/adapters/transcript/adapter.ts +295 -0
  69. package/src/converters/adapters/transcript/json.ts +184 -0
  70. package/src/converters/adapters/transcript/model.ts +171 -0
  71. package/src/converters/adapters/transcript/text.ts +58 -0
  72. package/src/converters/adapters/transcript/timed.ts +152 -0
  73. package/src/converters/index.ts +11 -1
  74. package/src/converters/mime.ts +8 -0
  75. package/src/converters/pipeline.ts +15 -1
  76. package/src/converters/registry.ts +37 -1
  77. package/src/converters/types.ts +136 -0
  78. package/src/core/browser-clip.ts +16 -1
  79. package/src/core/collection-egress-policy-projection.ts +28 -0
  80. package/src/core/collection-egress-policy-service.ts +443 -0
  81. package/src/core/collection-egress-policy-validation.ts +242 -0
  82. package/src/core/config-mutation.ts +31 -19
  83. package/src/core/context-capsule-schema.ts +198 -1
  84. package/src/core/context-capsule-validation.ts +4 -4
  85. package/src/core/context-capsule-verification.ts +5 -1
  86. package/src/core/context-capsule.ts +177 -112
  87. package/src/core/context-evidence.ts +93 -15
  88. package/src/core/destination-classifier.ts +402 -0
  89. package/src/core/document-capabilities.ts +12 -0
  90. package/src/core/egress-audit.ts +239 -0
  91. package/src/core/egress-authorization.ts +50 -0
  92. package/src/core/egress-enforcement.ts +264 -0
  93. package/src/core/egress-policy.ts +440 -0
  94. package/src/core/egress-provenance.ts +336 -0
  95. package/src/core/job-manager.ts +37 -0
  96. package/src/core/network-boundary-inventory.ts +261 -0
  97. package/src/core/project-profile-apply-state.ts +5 -0
  98. package/src/core/project-profile.ts +4 -0
  99. package/src/core/record-metadata.ts +49 -0
  100. package/src/core/retrieval-qrels.ts +6 -0
  101. package/src/core/retrieval-replay.ts +18 -4
  102. package/src/core/retrieval-trace-export.ts +23 -4
  103. package/src/core/retrieval-trace-management-helpers.ts +1 -0
  104. package/src/core/retrieval-trace-management-types.ts +3 -0
  105. package/src/core/retrieval-trace-management.ts +9 -1
  106. package/src/core/retrieval-trace-session.ts +94 -0
  107. package/src/core/retrieval-trace.ts +22 -0
  108. package/src/ingestion/record-adapter-canonical.ts +433 -0
  109. package/src/ingestion/record-adapter.ts +448 -0
  110. package/src/ingestion/record-container.ts +704 -0
  111. package/src/ingestion/record-path.ts +20 -0
  112. package/src/ingestion/record-sync.ts +70 -0
  113. package/src/ingestion/sync.ts +243 -36
  114. package/src/ingestion/types.ts +71 -1
  115. package/src/ingestion/walker.ts +31 -11
  116. package/src/llm/errors.ts +10 -0
  117. package/src/llm/http-inference.ts +175 -0
  118. package/src/llm/http-policy.ts +537 -0
  119. package/src/llm/httpEmbedding.ts +47 -28
  120. package/src/llm/httpGeneration.ts +31 -18
  121. package/src/llm/httpRerank.ts +30 -18
  122. package/src/llm/index.ts +1 -0
  123. package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
  124. package/src/llm/pinned-http-connection.ts +177 -0
  125. package/src/mcp/context.ts +53 -1
  126. package/src/mcp/http-egress.ts +220 -0
  127. package/src/mcp/http-security.ts +32 -21
  128. package/src/mcp/http-session.ts +13 -2
  129. package/src/mcp/http-transport.ts +96 -6
  130. package/src/mcp/sync-egress.ts +24 -0
  131. package/src/mcp/tools/add-collection.ts +4 -0
  132. package/src/mcp/tools/ask.ts +1 -1
  133. package/src/mcp/tools/context.ts +9 -2
  134. package/src/mcp/tools/egress.ts +247 -0
  135. package/src/mcp/tools/embed.ts +1 -0
  136. package/src/mcp/tools/get.ts +10 -2
  137. package/src/mcp/tools/index-cmd.ts +7 -0
  138. package/src/mcp/tools/index.ts +98 -0
  139. package/src/mcp/tools/multi-get.ts +9 -2
  140. package/src/mcp/tools/query.ts +10 -0
  141. package/src/mcp/tools/remove-collection.ts +4 -0
  142. package/src/mcp/tools/sync.ts +6 -0
  143. package/src/mcp/tools/trace.ts +7 -1
  144. package/src/mcp/tools/vsearch.ts +1 -0
  145. package/src/mcp/tools/workspace-write.ts +2 -0
  146. package/src/pipeline/egress-lineage.ts +124 -0
  147. package/src/pipeline/filters.ts +1 -1
  148. package/src/pipeline/graph-retrieval.ts +7 -4
  149. package/src/pipeline/hybrid.ts +21 -4
  150. package/src/pipeline/result-context.ts +12 -4
  151. package/src/pipeline/search.ts +23 -4
  152. package/src/pipeline/types.ts +6 -0
  153. package/src/pipeline/vsearch.ts +67 -28
  154. package/src/publish/artifact.ts +31 -3
  155. package/src/publish/export-service.ts +25 -0
  156. package/src/sdk/client.ts +119 -2
  157. package/src/sdk/documents.ts +13 -5
  158. package/src/sdk/embed.ts +1 -0
  159. package/src/sdk/types.ts +33 -0
  160. package/src/serve/browse-tree.ts +4 -2
  161. package/src/serve/clipper-capture.ts +5 -1
  162. package/src/serve/closed-json.ts +61 -0
  163. package/src/serve/config-sync.ts +4 -1
  164. package/src/serve/context.ts +1 -0
  165. package/src/serve/doc-events.ts +110 -39
  166. package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
  167. package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
  168. package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
  169. package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
  170. package/src/serve/public/globals.built.css +1 -1
  171. package/src/serve/public/pages/Collections.tsx +3 -0
  172. package/src/serve/resident-request.ts +80 -3
  173. package/src/serve/resident-runtime.ts +63 -1
  174. package/src/serve/routes/api.ts +237 -65
  175. package/src/serve/routes/clipper.ts +64 -1
  176. package/src/serve/routes/mcp.ts +6 -0
  177. package/src/serve/routes/traces.ts +27 -4
  178. package/src/serve/server.ts +118 -7
  179. package/src/store/migrations/022-record-export-lineage.ts +42 -0
  180. package/src/store/migrations/023-collection-egress-policy.ts +61 -0
  181. package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
  182. package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
  183. package/src/store/migrations/index.ts +8 -0
  184. package/src/store/retrieval-trace-codec.ts +15 -0
  185. package/src/store/sqlite/adapter.ts +284 -14
  186. package/src/store/sqlite/change-journal-store.ts +41 -2
  187. package/src/store/sqlite/egress-audit-store.ts +485 -0
  188. package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
  189. package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
  190. package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
  191. package/src/store/sqlite/retrieval-trace-store.ts +104 -5
  192. package/src/store/types.ts +166 -1
  193. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip +0 -0
  194. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip.sha256 +0 -1
  195. package/browser-extension/dist/chunk-vn5f663b.js +0 -50
@@ -0,0 +1,220 @@
1
+ /** Per-request egress authorization for the resident HTTP MCP boundary. */
2
+
3
+ import type { Collection } from "../config/types";
4
+ import type {
5
+ EgressContentClass,
6
+ EgressDestinationZone,
7
+ } from "../core/egress-policy";
8
+
9
+ import { parseUri } from "../app/constants";
10
+ import {
11
+ collectionEgressStates,
12
+ EGRESS_DENIED_MESSAGE,
13
+ EgressDeniedError,
14
+ } from "../core/egress-enforcement";
15
+ import { evaluateEgressPolicy } from "../core/egress-policy";
16
+
17
+ export const MCP_HTTP_EGRESS_TOOLS = {
18
+ gno_add_collection: "metadata",
19
+ gno_ask: "capsule",
20
+ gno_backlinks: "metadata",
21
+ gno_capture: "metadata",
22
+ gno_changes: "metadata",
23
+ gno_clear_collection_embeddings: "metadata",
24
+ gno_context: "capsule",
25
+ gno_context_verify: "capsule",
26
+ gno_create_folder: "metadata",
27
+ gno_diff: "metadata",
28
+ gno_duplicate_note: "metadata",
29
+ gno_embed: "metadata",
30
+ gno_egress_audit_delete: "audit_log",
31
+ gno_egress_audit_list: "audit_log",
32
+ gno_egress_audit_purge: "audit_log",
33
+ gno_egress_audit_show: "audit_log",
34
+ gno_egress_audit_status: "audit_log",
35
+ gno_egress_check: "audit_log",
36
+ gno_egress_policy_get: "metadata",
37
+ gno_egress_policy_set: "metadata",
38
+ gno_get: "source",
39
+ gno_graph: "metadata",
40
+ gno_graph_neighbors: "metadata",
41
+ gno_graph_path: "metadata",
42
+ gno_graph_query: "metadata",
43
+ gno_impact: "metadata",
44
+ gno_index: "metadata",
45
+ gno_job_status: "metadata",
46
+ gno_links: "metadata",
47
+ gno_list_jobs: "metadata",
48
+ gno_list_tags: "metadata",
49
+ gno_move_note: "metadata",
50
+ gno_multi_get: "source",
51
+ gno_query: "snippet",
52
+ gno_query_diagnose: "metadata",
53
+ gno_remove_collection: "metadata",
54
+ gno_rename_note: "metadata",
55
+ gno_search: "snippet",
56
+ gno_similar: "snippet",
57
+ gno_status: "metadata",
58
+ gno_sync: "metadata",
59
+ gno_trace_delete: "retrieval_trace",
60
+ gno_trace_export: "retrieval_trace",
61
+ gno_trace_label: "retrieval_trace",
62
+ gno_trace_list: "retrieval_trace",
63
+ gno_trace_purge: "retrieval_trace",
64
+ gno_trace_show: "retrieval_trace",
65
+ gno_vsearch: "snippet",
66
+ } as const satisfies Record<string, EgressContentClass>;
67
+
68
+ export const MCP_HTTP_EGRESS_METHODS = {
69
+ "resources/list": "metadata",
70
+ "resources/read": "source",
71
+ "resources/templates/list": "metadata",
72
+ } as const satisfies Record<string, EgressContentClass>;
73
+
74
+ export interface HttpMcpEgressContext {
75
+ authenticated: boolean;
76
+ destinationZone: EgressDestinationZone;
77
+ operationAuthorized: boolean;
78
+ }
79
+
80
+ interface JsonRpcMessage {
81
+ id?: unknown;
82
+ method?: unknown;
83
+ params?: unknown;
84
+ }
85
+
86
+ export class HttpMcpEgressDeniedError extends EgressDeniedError {
87
+ readonly requestId: unknown;
88
+
89
+ constructor(error: EgressDeniedError, requestId: unknown) {
90
+ super(error.decision);
91
+ this.name = "HttpMcpEgressDeniedError";
92
+ this.requestId = requestId;
93
+ }
94
+ }
95
+
96
+ const SYSTEM_EGRESS_STATE = {
97
+ collection: "system",
98
+ policy: "local_only",
99
+ source: "config_default",
100
+ } as const;
101
+
102
+ const asRecord = (value: unknown): Record<string, unknown> | null =>
103
+ typeof value === "object" && value !== null
104
+ ? (value as Record<string, unknown>)
105
+ : null;
106
+
107
+ const collectionFromRef = (value: unknown): string | null => {
108
+ if (typeof value !== "string") return null;
109
+ const parsed = parseUri(value);
110
+ if (parsed) return parsed.collection;
111
+ const slash = value.indexOf("/");
112
+ if (slash <= 0 || value.startsWith("#")) return null;
113
+ return value.slice(0, slash).trim().toLowerCase() || null;
114
+ };
115
+
116
+ const requestedCollections = (
117
+ params: unknown,
118
+ collections: readonly Collection[]
119
+ ): string[] => {
120
+ const record = asRecord(params);
121
+ const args = asRecord(record?.arguments ?? params);
122
+ if (!args) return collections.map(({ name }) => name);
123
+
124
+ const names = new Set<string>();
125
+ const direct = args.collection;
126
+ if (typeof direct === "string") names.add(direct.trim().toLowerCase());
127
+ for (const key of ["ref", "target", "from", "to", "root", "uri"]) {
128
+ const collection = collectionFromRef(args[key]);
129
+ if (collection) names.add(collection);
130
+ }
131
+ if (Array.isArray(args.refs)) {
132
+ for (const ref of args.refs) {
133
+ const collection = collectionFromRef(ref);
134
+ if (collection) names.add(collection);
135
+ }
136
+ }
137
+ return names.size > 0 ? [...names] : collections.map(({ name }) => name);
138
+ };
139
+
140
+ const enforceMessage = (
141
+ message: JsonRpcMessage,
142
+ collections: readonly Collection[],
143
+ context: HttpMcpEgressContext
144
+ ): void => {
145
+ let contentClass: EgressContentClass | undefined;
146
+ if (message.method === "tools/call") {
147
+ const params = asRecord(message.params);
148
+ const name = params?.name;
149
+ if (typeof name !== "string" || !(name in MCP_HTTP_EGRESS_TOOLS)) return;
150
+ // Trace export is authorized inside RetrievalTraceManagementService after
151
+ // exact trace IDs resolve to their immutable collection lineage.
152
+ if (name === "gno_trace_export") return;
153
+ contentClass =
154
+ MCP_HTTP_EGRESS_TOOLS[name as keyof typeof MCP_HTTP_EGRESS_TOOLS];
155
+ } else if (
156
+ typeof message.method === "string" &&
157
+ message.method in MCP_HTTP_EGRESS_METHODS
158
+ ) {
159
+ contentClass =
160
+ MCP_HTTP_EGRESS_METHODS[
161
+ message.method as keyof typeof MCP_HTTP_EGRESS_METHODS
162
+ ];
163
+ } else {
164
+ return;
165
+ }
166
+
167
+ const names = requestedCollections(message.params, collections);
168
+ const scoped = collectionEgressStates(collections, names);
169
+ const decision = evaluateEgressPolicy({
170
+ collections: scoped.length > 0 ? scoped : [SYSTEM_EGRESS_STATE],
171
+ action: "serve",
172
+ destination: { zone: context.destinationZone },
173
+ caller: {
174
+ authenticated: context.authenticated,
175
+ operationAuthorized: context.operationAuthorized,
176
+ },
177
+ contentClass,
178
+ });
179
+ if (!decision.allowed) {
180
+ throw new HttpMcpEgressDeniedError(
181
+ new EgressDeniedError(decision),
182
+ message.id
183
+ );
184
+ }
185
+ };
186
+
187
+ export const enforceHttpMcpEgress = (
188
+ payload: unknown,
189
+ collections: readonly Collection[],
190
+ context: HttpMcpEgressContext
191
+ ): void => {
192
+ const messages = Array.isArray(payload) ? payload : [payload];
193
+ for (const value of messages) {
194
+ const message = asRecord(value);
195
+ if (message) enforceMessage(message, collections, context);
196
+ }
197
+ };
198
+
199
+ export const httpMcpEgressDeniedResponse = (
200
+ error: EgressDeniedError,
201
+ payload: unknown
202
+ ): Response => {
203
+ const first = Array.isArray(payload) ? payload[0] : payload;
204
+ const id =
205
+ error instanceof HttpMcpEgressDeniedError
206
+ ? (error.requestId ?? null)
207
+ : (asRecord(first)?.id ?? null);
208
+ return Response.json(
209
+ {
210
+ jsonrpc: "2.0",
211
+ error: {
212
+ code: -32_003,
213
+ message: EGRESS_DENIED_MESSAGE,
214
+ data: error.toJSON(),
215
+ },
216
+ id,
217
+ },
218
+ { status: 403 }
219
+ );
220
+ };
@@ -4,8 +4,13 @@
4
4
  import { lstat, open } from "node:fs/promises";
5
5
 
6
6
  import type { HttpGatewayConfig } from "../config/types";
7
+ import type { DestinationClassification } from "../core/destination-classifier";
7
8
 
8
9
  import { expandPath } from "../config/paths";
10
+ import {
11
+ classifyBindDestination,
12
+ classifyDestination,
13
+ } from "../core/destination-classifier";
9
14
 
10
15
  export const DEFAULT_HTTP_GATEWAY_HOST = "127.0.0.1";
11
16
  export const DEFAULT_HTTP_GATEWAY_PORT = 3000;
@@ -54,7 +59,9 @@ export interface HttpGatewayOverrides {
54
59
  }
55
60
 
56
61
  export interface AuthorizedHttpMcpRequest {
62
+ authenticated: boolean;
57
63
  identity: string;
64
+ peerClassification: DestinationClassification;
58
65
  parsedBody?: unknown;
59
66
  request: Request;
60
67
  }
@@ -97,29 +104,21 @@ function securityError(status: 401 | 403 | 413 | 429 | 503): Response {
97
104
  );
98
105
  }
99
106
 
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
107
  export function isHttpGatewayLoopbackBind(host: string): boolean {
116
- return host === "127.0.0.1" || host === "::1" || host === "[::1]";
108
+ return classifyBindDestination(host).zone === "loopback";
117
109
  }
118
110
 
119
111
  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 [];
112
+ if (!isHttpGatewayLoopbackBind(host)) return [];
113
+ const normalized =
114
+ host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
115
+ const rawAuthority = normalized.includes(":")
116
+ ? `[${normalized}]:${port}`
117
+ : `${normalized}:${port}`;
118
+ const authority = new URL(`http://${rawAuthority}`).host;
119
+ return normalized === "127.0.0.1"
120
+ ? [authority, `localhost:${port}`]
121
+ : [authority];
123
122
  }
124
123
 
125
124
  function defaultAllowedOrigins(host: string, port: number): string[] {
@@ -370,9 +369,13 @@ export class HttpMcpSecurity {
370
369
  ): Promise<HttpMcpAuthorizationResult> {
371
370
  const peer = server.requestIP(request);
372
371
  if (!peer) return { ok: false, response: securityError(403) };
372
+ const peerClassification = classifyDestination({
373
+ kind: "network",
374
+ hostname: peer.address,
375
+ });
373
376
  if (
374
377
  isHttpGatewayLoopbackBind(this.#config.host) &&
375
- !isLoopbackAddress(peer.address)
378
+ peerClassification.zone !== "loopback"
376
379
  )
377
380
  return { ok: false, response: securityError(403) };
378
381
 
@@ -397,6 +400,7 @@ export class HttpMcpSecurity {
397
400
  this.#config.tokenFile !== undefined;
398
401
  const tokenState = await this.#refreshTokenState();
399
402
  let identity = "loopback";
403
+ let authenticated = false;
400
404
  if (requiresAuth) {
401
405
  if (!tokenState) return { ok: false, response: securityError(503) };
402
406
  const authorization = request.headers.get("authorization");
@@ -406,6 +410,7 @@ export class HttpMcpSecurity {
406
410
  if (!constantTimeEqual(presentedDigest, tokenState.digest))
407
411
  return { ok: false, response: securityError(401) };
408
412
  identity = tokenState.digest;
413
+ authenticated = true;
409
414
  }
410
415
 
411
416
  let parsedBody: unknown;
@@ -435,7 +440,13 @@ export class HttpMcpSecurity {
435
440
 
436
441
  return {
437
442
  ok: true,
438
- value: { identity, parsedBody, request: sanitizedRequest },
443
+ value: {
444
+ authenticated,
445
+ identity,
446
+ peerClassification,
447
+ parsedBody,
448
+ request: sanitizedRequest,
449
+ },
439
450
  };
440
451
  }
441
452
 
@@ -43,6 +43,7 @@ export interface PendingHttpMcpSession {
43
43
  }
44
44
 
45
45
  interface OwnedHttpMcpSession extends HttpMcpSession {
46
+ invalidated: boolean;
46
47
  releaseRuntimeSession(): void;
47
48
  }
48
49
 
@@ -133,6 +134,10 @@ export class HttpMcpSessionStore {
133
134
  finishRequest(session: HttpMcpSession): void {
134
135
  session.activeRequests = Math.max(0, session.activeRequests - 1);
135
136
  session.lastActivityAt = this.#now();
137
+ const owned = session as OwnedHttpMcpSession;
138
+ if (owned.invalidated && owned.activeRequests === 0) {
139
+ void owned.server.close().catch(() => undefined);
140
+ }
136
141
  }
137
142
 
138
143
  async createPendingSession(
@@ -168,6 +173,7 @@ export class HttpMcpSessionStore {
168
173
  transport,
169
174
  lastActivityAt: this.#now(),
170
175
  activeRequests: 0,
176
+ invalidated: false,
171
177
  releaseRuntimeSession,
172
178
  };
173
179
  this.#sessions.set(sessionId, host.session);
@@ -250,14 +256,19 @@ export class HttpMcpSessionStore {
250
256
  async closeSessions(): Promise<void> {
251
257
  const sessions = [...this.#sessions.values()];
252
258
  this.#sessions.clear();
253
- for (const session of sessions) session.releaseRuntimeSession();
259
+ for (const session of sessions) {
260
+ session.invalidated = true;
261
+ session.releaseRuntimeSession();
262
+ }
254
263
 
255
264
  const pending = [...this.#pending];
256
265
  this.#pending.clear();
257
266
  for (const host of pending) host.discarded = true;
258
267
 
259
268
  await Promise.allSettled([
260
- ...sessions.map((session) => session.server.close()),
269
+ ...sessions
270
+ .filter((session) => session.activeRequests === 0)
271
+ .map((session) => session.server.close()),
261
272
  ...pending.map((host) => host.server.close()),
262
273
  ]);
263
274
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
4
4
 
5
+ import type { DestinationClassification } from "../core/destination-classifier";
5
6
  import type { ResidentRequestHandle } from "../serve/resident-runtime";
6
7
  import type {
7
8
  HttpMcpSession,
@@ -10,6 +11,11 @@ import type {
10
11
  PendingHttpMcpSession,
11
12
  } from "./http-session";
12
13
 
14
+ import { EgressDeniedError } from "../core/egress-enforcement";
15
+ import {
16
+ enforceHttpMcpEgress,
17
+ httpMcpEgressDeniedResponse,
18
+ } from "./http-egress";
13
19
  import { HttpMcpSessionStore } from "./http-session";
14
20
  import { MCP_WRITE_TOOL_NAMES } from "./tools/index";
15
21
 
@@ -17,8 +23,12 @@ const DEFAULT_MAX_CONCURRENT_REQUESTS = 64;
17
23
  const DEFAULT_MAX_QUEUED_REQUESTS = 0;
18
24
  const MCP_HTTP_METHODS = new Set(["DELETE", "GET", "POST"]);
19
25
  const MCP_SESSION_HEADER = "mcp-session-id";
26
+ const POLICY_CHANGED_SSE = new TextEncoder().encode(
27
+ 'event: message\ndata: {"jsonrpc":"2.0","error":{"code":-32000,"message":"EGRESS_POLICY_CHANGED: Collection policy changed; retry"},"id":null}\n\n'
28
+ );
20
29
 
21
30
  export interface HttpMcpTransportRuntime extends HttpMcpSessionRuntime {
31
+ readonly authorizationEpoch?: string;
22
32
  readonly isShuttingDown: boolean;
23
33
  admitRequest(signal?: AbortSignal): ResidentRequestHandle | null;
24
34
  }
@@ -30,8 +40,10 @@ export interface HttpMcpTransportOptions extends HttpMcpSessionStoreOptions {
30
40
  }
31
41
 
32
42
  export interface HttpMcpRequestContext {
43
+ authenticated?: boolean;
33
44
  identity: string;
34
45
  parsedBody?: unknown;
46
+ peerClassification?: DestinationClassification;
35
47
  }
36
48
 
37
49
  export interface HttpMcpTransportStatus {
@@ -101,7 +113,8 @@ function validateInitializeHeaders(request: Request): Response | undefined {
101
113
 
102
114
  function wrapStreamingResponse(
103
115
  response: Response,
104
- finish: () => void
116
+ finish: () => void,
117
+ isAuthorizationEpochCurrent: () => boolean
105
118
  ): Response {
106
119
  if (!response.body) {
107
120
  finish();
@@ -117,7 +130,21 @@ function wrapStreamingResponse(
117
130
  const body = new ReadableStream<Uint8Array>({
118
131
  async pull(controller) {
119
132
  try {
133
+ if (!isAuthorizationEpochCurrent()) {
134
+ await reader.cancel("EGRESS_POLICY_CHANGED");
135
+ controller.enqueue(POLICY_CHANGED_SSE);
136
+ controller.close();
137
+ finishOnce();
138
+ return;
139
+ }
120
140
  const result = await reader.read();
141
+ if (!isAuthorizationEpochCurrent()) {
142
+ await reader.cancel("EGRESS_POLICY_CHANGED");
143
+ controller.enqueue(POLICY_CHANGED_SSE);
144
+ controller.close();
145
+ finishOnce();
146
+ return;
147
+ }
121
148
  if (result.done) {
122
149
  controller.close();
123
150
  finishOnce();
@@ -137,6 +164,13 @@ function wrapStreamingResponse(
137
164
  return new Response(body, response);
138
165
  }
139
166
 
167
+ const policyChangedResponse = (): Response =>
168
+ jsonRpcError(
169
+ 409,
170
+ -32_000,
171
+ "EGRESS_POLICY_CHANGED: Collection policy changed; retry"
172
+ );
173
+
140
174
  /** Stateful session gateway used by the production `/mcp` route. */
141
175
  export class HttpMcpTransport {
142
176
  readonly #runtime: HttpMcpTransportRuntime;
@@ -212,6 +246,15 @@ export class HttpMcpTransport {
212
246
  let session: HttpMcpSession | undefined;
213
247
  let pending: PendingHttpMcpSession | undefined;
214
248
  let finished = false;
249
+ const authorizationEpoch = {
250
+ value:
251
+ admission.authorizationEpoch ??
252
+ this.#runtime.authorizationEpoch ??
253
+ "egress-epoch-unavailable",
254
+ };
255
+ const isAuthorizationEpochCurrent = (): boolean =>
256
+ this.#runtime.authorizationEpoch === undefined ||
257
+ authorizationEpoch.value === this.#runtime.authorizationEpoch;
215
258
  const finish = (): void => {
216
259
  if (finished) return;
217
260
  finished = true;
@@ -293,10 +336,53 @@ export class HttpMcpTransport {
293
336
  finish();
294
337
  return jsonRpcError(403, -32_000, "Forbidden");
295
338
  }
296
- const response = await transport.handleRequest(
297
- request,
298
- requestBody === undefined ? undefined : { parsedBody: requestBody }
299
- );
339
+ try {
340
+ enforceHttpMcpEgress(
341
+ requestBody,
342
+ this.#runtime.mcpContext.collections,
343
+ {
344
+ authenticated: context.authenticated ?? false,
345
+ destinationZone:
346
+ context.peerClassification?.zone ??
347
+ (context.identity === "loopback" ? "loopback" : "remote"),
348
+ operationAuthorized: true,
349
+ }
350
+ );
351
+ } catch (error) {
352
+ if (error instanceof EgressDeniedError) {
353
+ await pending?.discard();
354
+ finish();
355
+ return httpMcpEgressDeniedResponse(error, requestBody);
356
+ }
357
+ throw error;
358
+ }
359
+ const handle = () =>
360
+ transport.handleRequest(
361
+ request,
362
+ requestBody === undefined ? undefined : { parsedBody: requestBody }
363
+ );
364
+ const destinationZone =
365
+ context.peerClassification?.zone ??
366
+ (context.identity === "loopback" ? "loopback" : "remote");
367
+ const response = this.#runtime.mcpContext.runWithEgressContext
368
+ ? await this.#runtime.mcpContext.runWithEgressContext(
369
+ {
370
+ destinationZone,
371
+ caller: {
372
+ authenticated: context.authenticated ?? false,
373
+ operationAuthorized: true,
374
+ },
375
+ authorizationEpoch,
376
+ },
377
+ handle
378
+ )
379
+ : await handle();
380
+
381
+ if (!isAuthorizationEpochCurrent()) {
382
+ await pending?.discard();
383
+ finish();
384
+ return policyChangedResponse();
385
+ }
300
386
 
301
387
  if (pending) {
302
388
  session = pending.session;
@@ -305,7 +391,11 @@ export class HttpMcpTransport {
305
391
  }
306
392
 
307
393
  if (response.headers.get("content-type")?.includes("text/event-stream")) {
308
- return wrapStreamingResponse(response, finish);
394
+ return wrapStreamingResponse(
395
+ response,
396
+ finish,
397
+ isAuthorizationEpochCurrent
398
+ );
309
399
  }
310
400
  finish();
311
401
  return response;
@@ -0,0 +1,24 @@
1
+ /** Fail-closed guard for sync modes that can spawn network-capable commands. */
2
+
3
+ import type { ToolContext } from "./context";
4
+
5
+ import { enforceCollectionEgress } from "../core/egress-enforcement";
6
+
7
+ export const enforceSyncCommandEgress = (
8
+ ctx: ToolContext,
9
+ input: {
10
+ collectionNames?: readonly string[];
11
+ gitPull?: boolean;
12
+ runUpdateCmd?: boolean;
13
+ }
14
+ ): void => {
15
+ if (!(input.gitPull || input.runUpdateCmd)) return;
16
+ enforceCollectionEgress({
17
+ collections: ctx.collections,
18
+ collectionNames: input.collectionNames,
19
+ action: "export",
20
+ destinationZone: "remote",
21
+ caller: { authenticated: true, operationAuthorized: ctx.enableWrite },
22
+ contentClass: "source",
23
+ });
24
+ };
@@ -140,6 +140,10 @@ export function handleAddCollection(
140
140
  if (!collection) {
141
141
  throw new Error("RUNTIME: Collection missing after add");
142
142
  }
143
+ const invalidation = await ctx.invalidateEgressPolicy?.();
144
+ if (invalidation) {
145
+ ctx.advanceRequestAuthorizationEpoch?.(invalidation.policyEpoch);
146
+ }
143
147
 
144
148
  const jobId = await ctx.jobManager.startJobWithLock(
145
149
  "add",
@@ -202,7 +202,7 @@ export const handleAsk = (
202
202
  traceSession = started.value ?? undefined;
203
203
  modelPorts = await createMcpModelPorts(
204
204
  context,
205
- args.collection,
205
+ args.collection ? [args.collection] : undefined,
206
206
  dependencies.modelPortFactory,
207
207
  { generation: true }
208
208
  );
@@ -119,7 +119,7 @@ export const disposeContextModelOwners = async (
119
119
 
120
120
  export const createMcpModelPorts = async (
121
121
  context: ToolContext,
122
- collection?: string,
122
+ collections?: readonly string[],
123
123
  factoryOverride?: McpModelPortFactory,
124
124
  options: { generation?: boolean } = {}
125
125
  ): Promise<McpModelPorts> => {
@@ -128,6 +128,10 @@ export const createMcpModelPorts = async (
128
128
  const lease = factory.acquireModelLease?.();
129
129
  const policy = resolveDownloadPolicy(process.env, {});
130
130
  const progress = createNonTtyProgressRenderer();
131
+ const egressCollections = collections?.length
132
+ ? collections
133
+ : ("all" as const);
134
+ const collection = collections?.length === 1 ? collections[0] : undefined;
131
135
  const embedUri = resolveModelUri(
132
136
  context.config,
133
137
  "embed",
@@ -141,6 +145,7 @@ export const createMcpModelPorts = async (
141
145
  let vectorIndex: VectorIndexPort | null = null;
142
146
  try {
143
147
  const embedResult = await factory.createEmbeddingPort(embedUri, {
148
+ egressCollections,
144
149
  policy,
145
150
  onProgress: (value) => progress("embed", value),
146
151
  });
@@ -160,6 +165,7 @@ export const createMcpModelPorts = async (
160
165
  const rerankResult = await factory.createRerankPort(
161
166
  resolveModelUri(context.config, "rerank", undefined, collection),
162
167
  {
168
+ egressCollections,
163
169
  policy,
164
170
  onProgress: (value) => progress("rerank", value),
165
171
  }
@@ -169,6 +175,7 @@ export const createMcpModelPorts = async (
169
175
  const genResult = await factory.createGenerationPort(
170
176
  resolveModelUri(context.config, "gen", undefined, collection),
171
177
  {
178
+ egressCollections,
172
179
  policy,
173
180
  onProgress: (value) => progress("gen", value),
174
181
  }
@@ -269,7 +276,7 @@ export const handleContext = (
269
276
  if (!traceStart.ok) throw new Error(traceStart.error.message);
270
277
  traceSession = traceStart.value ?? undefined;
271
278
  modelPorts = useModels
272
- ? await createMcpModelPorts(context, collection)
279
+ ? await createMcpModelPorts(context, parsed.input.collections)
273
280
  : null;
274
281
  const capsule = await buildContextCapsule(parsed.input, {
275
282
  store: context.store,