@gmickel/gno 1.17.0 → 1.18.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 (51) hide show
  1. package/README.md +14 -2
  2. package/assets/skill/SKILL.md +17 -1
  3. package/assets/skill/mcp-reference.md +21 -0
  4. package/package.json +2 -2
  5. package/src/cli/commands/daemon.ts +69 -2
  6. package/src/cli/commands/models/pull.ts +13 -3
  7. package/src/cli/commands/status.ts +2 -0
  8. package/src/cli/detach.ts +37 -20
  9. package/src/cli/program.ts +74 -27
  10. package/src/config/index.ts +3 -0
  11. package/src/config/types.ts +37 -0
  12. package/src/core/job-manager.ts +19 -0
  13. package/src/core/mutation-generations.ts +33 -0
  14. package/src/llm/cache.ts +13 -3
  15. package/src/llm/nodeLlamaCpp/adapter.ts +10 -1
  16. package/src/llm/nodeLlamaCpp/lifecycle.ts +71 -0
  17. package/src/mcp/context.ts +161 -0
  18. package/src/mcp/http-security.ts +477 -0
  19. package/src/mcp/http-session.ts +272 -0
  20. package/src/mcp/http-transport.ts +370 -0
  21. package/src/mcp/resources/index.ts +141 -134
  22. package/src/mcp/server.ts +19 -79
  23. package/src/mcp/tools/add-collection.ts +3 -1
  24. package/src/mcp/tools/capture.ts +3 -0
  25. package/src/mcp/tools/clear-collection-embeddings.ts +2 -0
  26. package/src/mcp/tools/context.ts +9 -8
  27. package/src/mcp/tools/embed.ts +62 -52
  28. package/src/mcp/tools/index-cmd.ts +88 -74
  29. package/src/mcp/tools/index.ts +22 -2
  30. package/src/mcp/tools/remove-collection.ts +2 -0
  31. package/src/mcp/tools/status.ts +11 -0
  32. package/src/mcp/tools/sync.ts +16 -14
  33. package/src/mcp/tools/workspace-write.ts +7 -3
  34. package/src/serve/background-runtime.ts +12 -212
  35. package/src/serve/embed-scheduler.ts +74 -43
  36. package/src/serve/index.ts +9 -0
  37. package/src/serve/jobs.ts +78 -80
  38. package/src/serve/public/components/HealthCenter.tsx +74 -1
  39. package/src/serve/public/globals.built.css +1 -1
  40. package/src/serve/public/pages/Dashboard.tsx +1 -0
  41. package/src/serve/resident-admission.ts +159 -0
  42. package/src/serve/resident-background-work.ts +39 -0
  43. package/src/serve/resident-request.ts +55 -0
  44. package/src/serve/resident-runtime.ts +490 -0
  45. package/src/serve/resident-status.ts +96 -0
  46. package/src/serve/routes/api.ts +263 -167
  47. package/src/serve/routes/mcp.ts +69 -0
  48. package/src/serve/server.ts +191 -37
  49. package/src/serve/status-model.ts +51 -0
  50. package/src/serve/status.ts +5 -0
  51. package/src/store/sqlite/adapter.ts +26 -9
@@ -92,6 +92,9 @@ function formatResourceContent(
92
92
  * Register gno:// resources with the MCP server.
93
93
  */
94
94
  export function registerResources(server: McpServer, ctx: ToolContext): void {
95
+ const withSnapshot = <T>(operation: () => Promise<T>): Promise<T> =>
96
+ ctx.runWithSnapshot?.(operation) ?? operation();
97
+
95
98
  // Resource template for gno://{collection}/{path} URIs
96
99
  const template = new ResourceTemplate(`${URI_PREFIX}{collection}/{+path}`, {
97
100
  list: async () => {
@@ -113,101 +116,103 @@ export function registerResources(server: McpServer, ctx: ToolContext): void {
113
116
  });
114
117
 
115
118
  // Register the template-based resource handler
116
- server.resource("gno-document", template, {}, async (uri, _variables) => {
117
- // Check shutdown before acquiring mutex
118
- if (ctx.isShuttingDown()) {
119
- throw new Error("Server is shutting down");
120
- }
121
-
122
- // Serialize resource reads same as tools (prevent concurrent DB access + shutdown race)
123
- const release = await ctx.toolMutex.acquire();
124
- try {
125
- // Use parseUri for proper URL decoding (handles %20, etc.)
126
- const parsed = parseUri(uri.href);
127
- if (!parsed) {
128
- throw new Error(`Invalid gno:// URI: ${uri.href}`);
129
- }
130
-
131
- const resolution = resolveEffectiveIndex([uri.href], ctx.indexName);
132
- if (!resolution.ok) {
133
- throw new Error(resolution.error);
119
+ server.resource("gno-document", template, {}, (uri, _variables) =>
120
+ withSnapshot(async () => {
121
+ // Check shutdown before acquiring mutex
122
+ if (ctx.isShuttingDown()) {
123
+ throw new Error("Server is shutting down");
134
124
  }
135
- const scoped = await openScopedIndexStore({
136
- activeStore: ctx.store,
137
- activeIndexName: ctx.indexName,
138
- requestedIndexName: resolution.value.indexName,
139
- config: ctx.config,
140
- configPath: ctx.actualConfigPath,
141
- });
142
125
 
126
+ // Serialize resource reads same as tools (prevent concurrent DB access + shutdown race)
127
+ const release = await ctx.toolMutex.acquire();
143
128
  try {
144
- const { collection, path } = parsed;
145
-
146
- // Validate collection exists
147
- const collectionExists = ctx.collections.some(
148
- (c) => c.name === collection
149
- );
150
- if (!collectionExists) {
151
- throw new Error(`Collection not found: ${collection}`);
129
+ // Use parseUri for proper URL decoding (handles %20, etc.)
130
+ const parsed = parseUri(uri.href);
131
+ if (!parsed) {
132
+ throw new Error(`Invalid gno:// URI: ${uri.href}`);
152
133
  }
153
134
 
154
- // Look up document (path is properly decoded by parseUri)
155
- const docResult = await scoped.store.getDocument(collection, path);
156
- if (!docResult.ok) {
157
- throw new Error(
158
- `Failed to lookup document: ${docResult.error.message}`
159
- );
135
+ const resolution = resolveEffectiveIndex([uri.href], ctx.indexName);
136
+ if (!resolution.ok) {
137
+ throw new Error(resolution.error);
160
138
  }
139
+ const scoped = await openScopedIndexStore({
140
+ activeStore: ctx.store,
141
+ activeIndexName: ctx.indexName,
142
+ requestedIndexName: resolution.value.indexName,
143
+ config: ctx.config,
144
+ configPath: ctx.actualConfigPath,
145
+ });
146
+
147
+ try {
148
+ const { collection, path } = parsed;
149
+
150
+ // Validate collection exists
151
+ const collectionExists = ctx.collections.some(
152
+ (c) => c.name === collection
153
+ );
154
+ if (!collectionExists) {
155
+ throw new Error(`Collection not found: ${collection}`);
156
+ }
161
157
 
162
- const doc = docResult.value;
163
- if (!doc) {
164
- throw new Error(`Document not found: ${uri.href}`);
165
- }
158
+ // Look up document (path is properly decoded by parseUri)
159
+ const docResult = await scoped.store.getDocument(collection, path);
160
+ if (!docResult.ok) {
161
+ throw new Error(
162
+ `Failed to lookup document: ${docResult.error.message}`
163
+ );
164
+ }
166
165
 
167
- // Get content
168
- if (!doc.mirrorHash) {
169
- throw new Error(`Document has no indexed content: ${uri.href}`);
170
- }
166
+ const doc = docResult.value;
167
+ if (!doc) {
168
+ throw new Error(`Document not found: ${uri.href}`);
169
+ }
170
+
171
+ // Get content
172
+ if (!doc.mirrorHash) {
173
+ throw new Error(`Document has no indexed content: ${uri.href}`);
174
+ }
175
+
176
+ const contentResult = await scoped.store.getContent(doc.mirrorHash);
177
+ if (!contentResult.ok) {
178
+ throw new Error(
179
+ `Failed to read content: ${contentResult.error.message}`
180
+ );
181
+ }
182
+
183
+ const content = contentResult.value ?? "";
171
184
 
172
- const contentResult = await scoped.store.getContent(doc.mirrorHash);
173
- if (!contentResult.ok) {
174
- throw new Error(
175
- `Failed to read content: ${contentResult.error.message}`
185
+ // Format with header and line numbers
186
+ const formattedContent = formatResourceContent(
187
+ doc,
188
+ content,
189
+ ctx,
190
+ scoped.indexName
191
+ );
192
+
193
+ // Build canonical URI
194
+ const canonicalUri = decorateUriForIndex(
195
+ buildUri(collection, path),
196
+ scoped.indexName
176
197
  );
177
- }
178
198
 
179
- const content = contentResult.value ?? "";
180
-
181
- // Format with header and line numbers
182
- const formattedContent = formatResourceContent(
183
- doc,
184
- content,
185
- ctx,
186
- scoped.indexName
187
- );
188
-
189
- // Build canonical URI
190
- const canonicalUri = decorateUriForIndex(
191
- buildUri(collection, path),
192
- scoped.indexName
193
- );
194
-
195
- return {
196
- contents: [
197
- {
198
- uri: canonicalUri,
199
- mimeType: "text/markdown",
200
- text: formattedContent,
201
- },
202
- ],
203
- };
199
+ return {
200
+ contents: [
201
+ {
202
+ uri: canonicalUri,
203
+ mimeType: "text/markdown",
204
+ text: formattedContent,
205
+ },
206
+ ],
207
+ };
208
+ } finally {
209
+ await scoped.close();
210
+ }
204
211
  } finally {
205
- await scoped.close();
212
+ release();
206
213
  }
207
- } finally {
208
- release();
209
- }
210
- });
214
+ })
215
+ );
211
216
 
212
217
  // Register gno://tags resource for listing tags
213
218
  // Use ResourceTemplate with RFC6570 query expansion for proper routing
@@ -231,65 +236,67 @@ export function registerResources(server: McpServer, ctx: ToolContext): void {
231
236
  "gno-tags",
232
237
  tagsTemplate,
233
238
  { mimeType: "application/json" },
234
- async (uri) => {
235
- // Check shutdown before acquiring mutex
236
- if (ctx.isShuttingDown()) {
237
- throw new Error("Server is shutting down");
238
- }
239
-
240
- const release = await ctx.toolMutex.acquire();
241
- try {
242
- // Parse query params from URI
243
- const url = new URL(uri.href);
244
- const collectionParam = url.searchParams.get("collection") || undefined;
245
- const prefixParam = url.searchParams.get("prefix") || undefined;
246
-
247
- // Normalize and validate collection (case-insensitive)
248
- let collection: string | undefined;
249
- if (collectionParam) {
250
- collection = normalizeCollectionName(collectionParam);
251
- const exists = ctx.collections.some(
252
- (c) => c.name.toLowerCase() === collection
253
- );
254
- if (!exists) {
255
- throw new Error(
256
- `${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${collectionParam}`
257
- );
258
- }
239
+ (uri) =>
240
+ withSnapshot(async () => {
241
+ // Check shutdown before acquiring mutex
242
+ if (ctx.isShuttingDown()) {
243
+ throw new Error("Server is shutting down");
259
244
  }
260
245
 
261
- // Normalize and validate prefix
262
- let prefix: string | undefined;
263
- if (prefixParam) {
264
- const trimmed = prefixParam.trim().replace(/\/+$/, "");
265
- if (trimmed.length > 0) {
266
- prefix = normalizeTag(trimmed);
267
- if (!validateTag(prefix)) {
246
+ const release = await ctx.toolMutex.acquire();
247
+ try {
248
+ // Parse query params from URI
249
+ const url = new URL(uri.href);
250
+ const collectionParam =
251
+ url.searchParams.get("collection") || undefined;
252
+ const prefixParam = url.searchParams.get("prefix") || undefined;
253
+
254
+ // Normalize and validate collection (case-insensitive)
255
+ let collection: string | undefined;
256
+ if (collectionParam) {
257
+ collection = normalizeCollectionName(collectionParam);
258
+ const exists = ctx.collections.some(
259
+ (c) => c.name.toLowerCase() === collection
260
+ );
261
+ if (!exists) {
268
262
  throw new Error(
269
- `${MCP_ERRORS.INVALID_INPUT.code}: Invalid tag prefix "${prefixParam}"`
263
+ `${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${collectionParam}`
270
264
  );
271
265
  }
272
266
  }
273
- }
274
267
 
275
- // Get tag counts
276
- const result = await ctx.store.getTagCounts({ collection, prefix });
277
- if (!result.ok) {
278
- throw new Error(`Failed to get tags: ${result.error.message}`);
279
- }
268
+ // Normalize and validate prefix
269
+ let prefix: string | undefined;
270
+ if (prefixParam) {
271
+ const trimmed = prefixParam.trim().replace(/\/+$/, "");
272
+ if (trimmed.length > 0) {
273
+ prefix = normalizeTag(trimmed);
274
+ if (!validateTag(prefix)) {
275
+ throw new Error(
276
+ `${MCP_ERRORS.INVALID_INPUT.code}: Invalid tag prefix "${prefixParam}"`
277
+ );
278
+ }
279
+ }
280
+ }
280
281
 
281
- return {
282
- contents: [
283
- {
284
- uri: uri.href,
285
- mimeType: "application/json",
286
- text: formatTagsContent(result.value, collection, prefix),
287
- },
288
- ],
289
- };
290
- } finally {
291
- release();
292
- }
293
- }
282
+ // Get tag counts
283
+ const result = await ctx.store.getTagCounts({ collection, prefix });
284
+ if (!result.ok) {
285
+ throw new Error(`Failed to get tags: ${result.error.message}`);
286
+ }
287
+
288
+ return {
289
+ contents: [
290
+ {
291
+ uri: uri.href,
292
+ mimeType: "application/json",
293
+ text: formatTagsContent(result.value, collection, prefix),
294
+ },
295
+ ],
296
+ };
297
+ } finally {
298
+ release();
299
+ }
300
+ })
294
301
  );
295
302
  }
package/src/mcp/server.ts CHANGED
@@ -5,14 +5,10 @@
5
5
  * @module src/mcp/server
6
6
  */
7
7
 
8
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
9
  // node:path for join/dirname (no Bun path utils)
11
10
  import { dirname, join } from "node:path";
12
11
 
13
- import type { Collection, Config } from "../config/types";
14
- import type { SqliteAdapter } from "../store/sqlite/adapter";
15
-
16
12
  import {
17
13
  DEFAULT_INDEX_NAME,
18
14
  MCP_SERVER_NAME,
@@ -23,55 +19,14 @@ import { canonicalizeIndexName } from "../app/index-name";
23
19
  import { JobManager } from "../core/job-manager";
24
20
  import { envIsSet } from "../llm/policy";
25
21
  import { MCP_ACTIVATION_VERIFICATION_ENV } from "./activation-verification-mode";
22
+ import {
23
+ createMcpServerSurface,
24
+ createToolContext,
25
+ Mutex,
26
+ type ToolContext,
27
+ } from "./context";
26
28
 
27
- // ─────────────────────────────────────────────────────────────────────────────
28
- // Simple Promise Mutex (avoids async-mutex dependency)
29
- // ─────────────────────────────────────────────────────────────────────────────
30
-
31
- class Mutex {
32
- #locked = false;
33
- readonly #queue: Array<() => void> = [];
34
-
35
- acquire(): Promise<() => void> {
36
- return new Promise((resolve) => {
37
- const tryAcquire = () => {
38
- if (this.#locked) {
39
- this.#queue.push(tryAcquire);
40
- } else {
41
- this.#locked = true;
42
- resolve(() => this.#release());
43
- }
44
- };
45
- tryAcquire();
46
- });
47
- }
48
-
49
- #release(): void {
50
- this.#locked = false;
51
- const next = this.#queue.shift();
52
- if (next) {
53
- next();
54
- }
55
- }
56
- }
57
-
58
- // ─────────────────────────────────────────────────────────────────────────────
59
- // Tool Context
60
- // ─────────────────────────────────────────────────────────────────────────────
61
-
62
- export interface ToolContext {
63
- store: SqliteAdapter;
64
- config: Config;
65
- collections: Collection[];
66
- actualConfigPath: string;
67
- indexName: string;
68
- toolMutex: Mutex;
69
- jobManager: JobManager;
70
- serverInstanceId: string;
71
- writeLockPath: string;
72
- enableWrite: boolean;
73
- isShuttingDown: () => boolean;
74
- }
29
+ export type { ToolContext } from "./context";
75
30
 
76
31
  // ─────────────────────────────────────────────────────────────────────────────
77
32
  // Server Options
@@ -136,21 +91,7 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
136
91
  process.exit(1);
137
92
  }
138
93
 
139
- const { store, config, collections, actualConfigPath } = init;
140
-
141
- // Create MCP server
142
- const server = new McpServer(
143
- {
144
- name: MCP_SERVER_NAME,
145
- version: VERSION,
146
- },
147
- {
148
- capabilities: {
149
- tools: { listChanged: false },
150
- resources: { subscribe: false, listChanged: false },
151
- },
152
- }
153
- );
94
+ const { store, config, actualConfigPath } = init;
154
95
 
155
96
  // Sequential execution mutex
156
97
  const toolMutex = new Mutex();
@@ -173,10 +114,13 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
173
114
  let shuttingDown = false;
174
115
 
175
116
  // Tool context (passed to all handlers)
176
- const ctx: ToolContext = {
117
+ let currentConfig = config;
118
+ const ctx: ToolContext = createToolContext({
177
119
  store,
178
- config,
179
- collections,
120
+ getConfig: () => currentConfig,
121
+ setConfig: (nextConfig) => {
122
+ currentConfig = nextConfig;
123
+ },
180
124
  actualConfigPath,
181
125
  indexName: canonicalizeIndexName(options.indexName ?? DEFAULT_INDEX_NAME),
182
126
  toolMutex,
@@ -185,15 +129,11 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
185
129
  writeLockPath,
186
130
  enableWrite,
187
131
  isShuttingDown: () => shuttingDown,
188
- };
189
-
190
- // Register tools (T10.2)
191
- const { registerTools } = await import("./tools/index.js");
192
- registerTools(server, ctx);
193
-
194
- // Register resources (T10.3)
195
- const { registerResources } = await import("./resources/index.js");
196
- registerResources(server, ctx);
132
+ });
133
+ const server = createMcpServerSurface(ctx, {
134
+ name: MCP_SERVER_NAME,
135
+ version: VERSION,
136
+ });
197
137
 
198
138
  if (options.verbose) {
199
139
  console.error(
@@ -14,6 +14,7 @@ import { applyConfigChange } from "../../core/config-mutation";
14
14
  import { MCP_ERRORS } from "../../core/errors";
15
15
  import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
16
16
  import { JobError } from "../../core/job-manager";
17
+ import { recordContentMutation } from "../../core/mutation-generations";
17
18
  import {
18
19
  normalizeCollectionName,
19
20
  validateCollectionRoot,
@@ -152,9 +153,10 @@ export function handleAddCollection(
152
153
  gitPull: args.gitPull ?? false,
153
154
  runUpdateCmd: false,
154
155
  },
155
- ctx.config
156
+ mutationResult.config
156
157
  )
157
158
  );
159
+ recordContentMutation(result, ctx.markContentMutation);
158
160
 
159
161
  return {
160
162
  collections: [result],
@@ -210,6 +210,9 @@ export function handleCapture(
210
210
  serverInstanceId: ctx.serverInstanceId,
211
211
  }) as McpCaptureResult;
212
212
  }
213
+ if (syncResult.status === "added" || syncResult.status === "updated") {
214
+ ctx.markContentMutation?.();
215
+ }
213
216
 
214
217
  let docid = syncResult.docid;
215
218
  let documentId: number | undefined;
@@ -8,6 +8,7 @@ import type { ToolContext } from "../server";
8
8
 
9
9
  import { MCP_ERRORS } from "../../core/errors";
10
10
  import { withWriteLock } from "../../core/file-lock";
11
+ import { recordIndexMutation } from "../../core/mutation-generations";
11
12
  import { resolveModelUri } from "../../llm/registry";
12
13
  import { runTool, type ToolResult } from "./index";
13
14
 
@@ -81,6 +82,7 @@ export function handleClearCollectionEmbeddings(
81
82
  if (!result.ok) {
82
83
  throw new Error(`${result.error.code}: ${result.error.message}`);
83
84
  }
85
+ recordIndexMutation(result.value.deletedVectors, ctx.markIndexMutation);
84
86
 
85
87
  return {
86
88
  ...result.value,
@@ -1,5 +1,6 @@
1
1
  /** MCP Context Capsule tools over the shared application runtime. */
2
2
 
3
+ import type { ModelLease } from "../../llm/nodeLlamaCpp/lifecycle";
3
4
  import type { EmbeddingPort, RerankPort } from "../../llm/types";
4
5
  import type { VectorIndexPort } from "../../store/vector";
5
6
  import type { ToolContext } from "../server";
@@ -64,7 +65,8 @@ const runContextTool = async (
64
65
  }
65
66
  const release = await context.toolMutex.acquire();
66
67
  try {
67
- return asToolResult(await operation());
68
+ const data = await (context.runWithSnapshot?.(operation) ?? operation());
69
+ return asToolResult(data);
68
70
  } catch (error) {
69
71
  return asToolError(error);
70
72
  } finally {
@@ -82,19 +84,17 @@ interface McpModelPorts {
82
84
  interface McpModelPortFactory {
83
85
  createEmbeddingPort: LlmAdapter["createEmbeddingPort"];
84
86
  createRerankPort: LlmAdapter["createRerankPort"];
85
- dispose: LlmAdapter["dispose"];
87
+ acquireModelLease?: LlmAdapter["acquireModelLease"];
86
88
  }
87
89
 
88
90
  export const disposeContextModelOwners = async (
89
91
  portOwners: readonly { dispose(): Promise<void> }[],
90
- managerOwner: { dispose(): Promise<void> }
92
+ lease?: ModelLease
91
93
  ): Promise<void> => {
92
94
  await Promise.allSettled(
93
95
  portOwners.map((owner) => Promise.resolve().then(() => owner.dispose()))
94
96
  );
95
- await Promise.allSettled([
96
- Promise.resolve().then(() => managerOwner.dispose()),
97
- ]);
97
+ lease?.release();
98
98
  };
99
99
 
100
100
  export const createMcpModelPorts = async (
@@ -104,6 +104,7 @@ export const createMcpModelPorts = async (
104
104
  ): Promise<McpModelPorts> => {
105
105
  const llm = new LlmAdapter(context.config);
106
106
  const factory = factoryOverride ?? llm;
107
+ const lease = factory.acquireModelLease?.();
107
108
  const policy = resolveDownloadPolicy(process.env, {});
108
109
  const progress = createNonTtyProgressRenderer();
109
110
  const embedUri = resolveModelUri(
@@ -151,7 +152,7 @@ export const createMcpModelPorts = async (
151
152
  [ownedEmbedPort, rerankPort].filter(
152
153
  (port): port is EmbeddingPort | RerankPort => port !== null
153
154
  ),
154
- factory
155
+ lease
155
156
  );
156
157
  },
157
158
  };
@@ -160,7 +161,7 @@ export const createMcpModelPorts = async (
160
161
  [ownedEmbedPort, rerankPort].filter(
161
162
  (port): port is EmbeddingPort | RerankPort => port !== null
162
163
  ),
163
- factory
164
+ lease
164
165
  );
165
166
  throw error;
166
167
  }