@gmickel/gno 0.8.6 → 0.9.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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Background job manager for MCP write operations.
3
+ *
4
+ * @module src/core/job-manager
5
+ */
6
+
7
+ import type { SyncResult } from "../ingestion";
8
+
9
+ import { MCP_ERRORS } from "./errors";
10
+ import { acquireWriteLock, type WriteLockHandle } from "./file-lock";
11
+
12
+ const JOB_EXPIRATION_MS = 60 * 60 * 1000;
13
+ const JOB_MAX_RECENT = 100;
14
+ const DEFAULT_LOCK_TIMEOUT_MS = 5000;
15
+
16
+ export type JobType = "add" | "sync";
17
+
18
+ export type JobStatus = "running" | "completed" | "failed";
19
+
20
+ export interface JobRecord {
21
+ id: string;
22
+ type: JobType;
23
+ status: JobStatus;
24
+ startedAt: number;
25
+ completedAt?: number;
26
+ result?: SyncResult;
27
+ error?: string;
28
+ serverInstanceId: string;
29
+ }
30
+
31
+ export interface JobManagerOptions {
32
+ lockPath: string;
33
+ serverInstanceId: string;
34
+ toolMutex: {
35
+ acquire: () => Promise<() => void>;
36
+ };
37
+ lockTimeoutMs?: number;
38
+ }
39
+
40
+ export class JobError extends Error {
41
+ code: "LOCKED" | "JOB_CONFLICT";
42
+
43
+ constructor(code: "LOCKED" | "JOB_CONFLICT", message: string) {
44
+ super(message);
45
+ this.code = code;
46
+ this.name = "JobError";
47
+ }
48
+ }
49
+
50
+ export class JobManager {
51
+ #lockPath: string;
52
+ #serverInstanceId: string;
53
+ #toolMutex: JobManagerOptions["toolMutex"];
54
+ #lockTimeoutMs: number;
55
+ #activeJobId: string | null = null;
56
+ #jobs = new Map<string, JobRecord>();
57
+ #activeJobs = new Set<Promise<void>>();
58
+
59
+ constructor(options: JobManagerOptions) {
60
+ this.#lockPath = options.lockPath;
61
+ this.#serverInstanceId = options.serverInstanceId;
62
+ this.#toolMutex = options.toolMutex;
63
+ this.#lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
64
+ }
65
+
66
+ async startJob(
67
+ type: JobType,
68
+ fn: () => Promise<SyncResult>
69
+ ): Promise<string> {
70
+ this.#cleanupExpiredJobs();
71
+
72
+ if (this.#activeJobId) {
73
+ throw new JobError(
74
+ "JOB_CONFLICT",
75
+ `${MCP_ERRORS.JOB_CONFLICT.message} (${this.#activeJobId})`
76
+ );
77
+ }
78
+
79
+ const lock = await acquireWriteLock(this.#lockPath, this.#lockTimeoutMs);
80
+ if (!lock) {
81
+ throw new JobError("LOCKED", MCP_ERRORS.LOCKED.message);
82
+ }
83
+
84
+ return this.#startJobWithLock(type, fn, lock);
85
+ }
86
+
87
+ async startJobWithLock(
88
+ type: JobType,
89
+ lock: WriteLockHandle,
90
+ fn: () => Promise<SyncResult>
91
+ ): Promise<string> {
92
+ this.#cleanupExpiredJobs();
93
+
94
+ if (this.#activeJobId) {
95
+ throw new JobError(
96
+ "JOB_CONFLICT",
97
+ `${MCP_ERRORS.JOB_CONFLICT.message} (${this.#activeJobId})`
98
+ );
99
+ }
100
+
101
+ return this.#startJobWithLock(type, fn, lock);
102
+ }
103
+
104
+ getJob(jobId: string): JobRecord | undefined {
105
+ this.#cleanupExpiredJobs();
106
+ return this.#jobs.get(jobId);
107
+ }
108
+
109
+ listJobs(limit: number = 10): { active: JobRecord[]; recent: JobRecord[] } {
110
+ this.#cleanupExpiredJobs();
111
+
112
+ const jobs = Array.from(this.#jobs.values());
113
+ const active = jobs
114
+ .filter((job) => job.status === "running")
115
+ .sort((a, b) => a.startedAt - b.startedAt);
116
+
117
+ const recent = jobs
118
+ .filter((job) => job.status !== "running")
119
+ .sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0))
120
+ .slice(0, limit);
121
+
122
+ return { active, recent };
123
+ }
124
+
125
+ async shutdown(): Promise<void> {
126
+ await Promise.allSettled(this.#activeJobs);
127
+ }
128
+
129
+ #track(jobPromise: Promise<void>): void {
130
+ const tracked = jobPromise.catch(() => undefined);
131
+ this.#activeJobs.add(tracked);
132
+ void tracked.finally(() => {
133
+ this.#activeJobs.delete(tracked);
134
+ });
135
+ }
136
+
137
+ async #runJob(
138
+ job: JobRecord,
139
+ fn: () => Promise<SyncResult>,
140
+ lock: { release: () => Promise<void> }
141
+ ): Promise<void> {
142
+ try {
143
+ const release = await this.#toolMutex.acquire();
144
+ try {
145
+ const result = await fn();
146
+ job.status = "completed";
147
+ job.result = result;
148
+ } catch (e) {
149
+ job.status = "failed";
150
+ job.error = e instanceof Error ? e.message : String(e);
151
+ } finally {
152
+ release();
153
+ }
154
+ } catch (e) {
155
+ job.status = "failed";
156
+ job.error = e instanceof Error ? e.message : String(e);
157
+ } finally {
158
+ job.completedAt = Date.now();
159
+ this.#activeJobId = null;
160
+ await lock.release().catch(() => undefined);
161
+ this.#cleanupExpiredJobs();
162
+ }
163
+ }
164
+
165
+ #startJobWithLock(
166
+ type: JobType,
167
+ fn: () => Promise<SyncResult>,
168
+ lock: WriteLockHandle
169
+ ): string {
170
+ const jobId = crypto.randomUUID();
171
+ const job: JobRecord = {
172
+ id: jobId,
173
+ type,
174
+ status: "running",
175
+ startedAt: Date.now(),
176
+ serverInstanceId: this.#serverInstanceId,
177
+ };
178
+
179
+ this.#jobs.set(jobId, job);
180
+ this.#activeJobId = jobId;
181
+
182
+ const jobPromise = this.#runJob(job, fn, lock);
183
+ this.#track(jobPromise);
184
+
185
+ return jobId;
186
+ }
187
+
188
+ #cleanupExpiredJobs(now: number = Date.now()): void {
189
+ for (const [id, job] of this.#jobs) {
190
+ if (job.status === "running") {
191
+ continue;
192
+ }
193
+ const completedAt = job.completedAt ?? job.startedAt;
194
+ if (now - completedAt > JOB_EXPIRATION_MS) {
195
+ this.#jobs.delete(id);
196
+ }
197
+ }
198
+
199
+ const completed = Array.from(this.#jobs.values())
200
+ .filter((job) => job.status !== "running")
201
+ .sort((a, b) => (a.completedAt ?? 0) - (b.completedAt ?? 0));
202
+
203
+ if (completed.length <= JOB_MAX_RECENT) {
204
+ return;
205
+ }
206
+
207
+ const toRemove = completed.length - JOB_MAX_RECENT;
208
+ for (let i = 0; i < toRemove; i++) {
209
+ const job = completed[i];
210
+ if (job) {
211
+ this.#jobs.delete(job.id);
212
+ }
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Shared validation helpers for MCP and Web UI.
3
+ *
4
+ * @module src/core/validation
5
+ */
6
+
7
+ // node:fs/promises for realpath (no Bun equivalent)
8
+ import { realpath } from "node:fs/promises";
9
+ // node:os for homedir (no Bun os utils)
10
+ import { homedir } from "node:os";
11
+ // node:path for path utils (no Bun path utils)
12
+ import { isAbsolute, join, normalize, sep } from "node:path";
13
+
14
+ import { toAbsolutePath } from "../config/paths";
15
+
16
+ const DANGEROUS_ROOT_PATTERNS = [
17
+ "/",
18
+ homedir(),
19
+ "/etc",
20
+ "/usr",
21
+ "/bin",
22
+ "/var",
23
+ "/System",
24
+ "/Library",
25
+ join(homedir(), ".config"),
26
+ join(homedir(), ".local"),
27
+ join(homedir(), ".ssh"),
28
+ join(homedir(), ".gnupg"),
29
+ ];
30
+
31
+ async function resolveRealPathSafe(path: string): Promise<string> {
32
+ try {
33
+ return await realpath(path);
34
+ } catch {
35
+ return path;
36
+ }
37
+ }
38
+
39
+ export function normalizeCollectionName(name: string): string {
40
+ return name.trim().toLowerCase();
41
+ }
42
+
43
+ export function validateRelPath(relPath: string): string {
44
+ if (isAbsolute(relPath)) {
45
+ throw new Error("relPath must be relative");
46
+ }
47
+ if (relPath.includes("\0")) {
48
+ throw new Error("relPath contains invalid characters");
49
+ }
50
+
51
+ const normalized = normalize(relPath);
52
+ const segments = normalized.split(sep);
53
+ if (segments.includes("..")) {
54
+ throw new Error("relPath cannot escape collection root");
55
+ }
56
+
57
+ return normalized;
58
+ }
59
+
60
+ export async function validateCollectionRoot(
61
+ inputPath: string
62
+ ): Promise<string> {
63
+ const absPath = toAbsolutePath(inputPath);
64
+ const realPath = await resolveRealPathSafe(absPath);
65
+
66
+ const dangerousRoots = await Promise.all(
67
+ DANGEROUS_ROOT_PATTERNS.map((p) => resolveRealPathSafe(p))
68
+ );
69
+
70
+ if (dangerousRoots.includes(realPath)) {
71
+ throw new Error(`Cannot add ${inputPath}: resolves to dangerous root`);
72
+ }
73
+
74
+ return realPath;
75
+ }
@@ -5,6 +5,11 @@
5
5
  * @module src/ingestion/sync
6
6
  */
7
7
 
8
+ // node:fs/promises for stat (no Bun equivalent for file stats)
9
+ import { stat } from "node:fs/promises";
10
+ // node:path for join (no Bun path utils)
11
+ import { join } from "node:path";
12
+
8
13
  import type { Collection } from "../config/types";
9
14
  import type {
10
15
  ChunkInput,
@@ -411,6 +416,56 @@ export class SyncService {
411
416
  }
412
417
  }
413
418
 
419
+ /**
420
+ * Sync a specific set of files within a collection.
421
+ */
422
+ async syncFiles(
423
+ collection: Collection,
424
+ store: StorePort,
425
+ relPaths: string[],
426
+ options: SyncOptions = {}
427
+ ): Promise<FileSyncResult[]> {
428
+ const results: FileSyncResult[] = [];
429
+
430
+ for (const relPath of relPaths) {
431
+ const absPath = join(collection.path, relPath);
432
+ let stats: Awaited<ReturnType<typeof stat>>;
433
+ try {
434
+ stats = await stat(absPath);
435
+ } catch {
436
+ results.push({
437
+ relPath,
438
+ status: "error",
439
+ errorCode: "NOT_FOUND",
440
+ errorMessage: "File not found",
441
+ });
442
+ continue;
443
+ }
444
+
445
+ if (!stats.isFile()) {
446
+ results.push({
447
+ relPath,
448
+ status: "error",
449
+ errorCode: "NOT_FILE",
450
+ errorMessage: "Path is not a file",
451
+ });
452
+ continue;
453
+ }
454
+
455
+ const entry: WalkEntry = {
456
+ absPath,
457
+ relPath,
458
+ size: stats.size,
459
+ mtime: stats.mtime.toISOString(),
460
+ };
461
+
462
+ const result = await this.processFile(collection, entry, store, options);
463
+ results.push(result);
464
+ }
465
+
466
+ return results;
467
+ }
468
+
414
469
  /**
415
470
  * Sync a single collection.
416
471
  */
@@ -0,0 +1,86 @@
1
+ # MCP Server
2
+
3
+ GNO's Model Context Protocol server for AI agent integration.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ src/mcp/
9
+ ├── server.ts # MCP server setup, stdio transport
10
+ ├── tools/ # Tool implementations
11
+ │ ├── index.ts # Tool registry
12
+ │ ├── search.ts # gno_search (BM25)
13
+ │ ├── vsearch.ts # gno_vsearch (vector)
14
+ │ ├── query.ts # gno_query (hybrid)
15
+ │ ├── get.ts # gno_get (single doc)
16
+ │ ├── multi-get.ts # gno_multi_get (batch)
17
+ │ └── status.ts # gno_status
18
+ └── resources/ # Resource implementations
19
+ └── index.ts # gno:// URI scheme
20
+ ```
21
+
22
+ ## Specification
23
+
24
+ See `spec/mcp.md` for full MCP specification including:
25
+
26
+ - Tool schemas and responses
27
+ - Resource URI schemes
28
+ - Error codes
29
+ - Versioning
30
+
31
+ **Always update spec/mcp.md first** when adding/modifying tools.
32
+
33
+ ## Tool Pattern
34
+
35
+ Each tool follows this structure:
36
+
37
+ ```typescript
38
+ export const toolName: Tool = {
39
+ name: "gno_toolname",
40
+ description: "What this tool does",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: { /* ... */ },
44
+ required: ["query"],
45
+ },
46
+ };
47
+
48
+ export async function handleToolName(
49
+ args: ToolArgs,
50
+ store: SqliteAdapter,
51
+ // ... other ports
52
+ ): Promise<CallToolResult> {
53
+ // 1. Validate args
54
+ // 2. Execute operation
55
+ // 3. Return { content: [...], structuredContent: {...} }
56
+ }
57
+ ```
58
+
59
+ ## Response Format
60
+
61
+ All tools return both human-readable and structured content:
62
+
63
+ ```typescript
64
+ return {
65
+ content: [{ type: "text", text: "Human readable summary" }],
66
+ structuredContent: {
67
+ // Machine-readable data matching spec schemas
68
+ },
69
+ };
70
+ ```
71
+
72
+ ## Resources
73
+
74
+ Resources use `gno://` URI scheme:
75
+
76
+ - `gno://work/path/to/doc.md` - Document content
77
+ - `gno://collections` - List collections
78
+ - `gno://schemas/*` - JSON schemas
79
+
80
+ ## Testing
81
+
82
+ MCP tests in `test/mcp/`:
83
+
84
+ ```bash
85
+ bun test test/mcp/
86
+ ```
@@ -0,0 +1,86 @@
1
+ # MCP Server
2
+
3
+ GNO's Model Context Protocol server for AI agent integration.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ src/mcp/
9
+ ├── server.ts # MCP server setup, stdio transport
10
+ ├── tools/ # Tool implementations
11
+ │ ├── index.ts # Tool registry
12
+ │ ├── search.ts # gno_search (BM25)
13
+ │ ├── vsearch.ts # gno_vsearch (vector)
14
+ │ ├── query.ts # gno_query (hybrid)
15
+ │ ├── get.ts # gno_get (single doc)
16
+ │ ├── multi-get.ts # gno_multi_get (batch)
17
+ │ └── status.ts # gno_status
18
+ └── resources/ # Resource implementations
19
+ └── index.ts # gno:// URI scheme
20
+ ```
21
+
22
+ ## Specification
23
+
24
+ See `spec/mcp.md` for full MCP specification including:
25
+
26
+ - Tool schemas and responses
27
+ - Resource URI schemes
28
+ - Error codes
29
+ - Versioning
30
+
31
+ **Always update spec/mcp.md first** when adding/modifying tools.
32
+
33
+ ## Tool Pattern
34
+
35
+ Each tool follows this structure:
36
+
37
+ ```typescript
38
+ export const toolName: Tool = {
39
+ name: "gno_toolname",
40
+ description: "What this tool does",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: { /* ... */ },
44
+ required: ["query"],
45
+ },
46
+ };
47
+
48
+ export async function handleToolName(
49
+ args: ToolArgs,
50
+ store: SqliteAdapter,
51
+ // ... other ports
52
+ ): Promise<CallToolResult> {
53
+ // 1. Validate args
54
+ // 2. Execute operation
55
+ // 3. Return { content: [...], structuredContent: {...} }
56
+ }
57
+ ```
58
+
59
+ ## Response Format
60
+
61
+ All tools return both human-readable and structured content:
62
+
63
+ ```typescript
64
+ return {
65
+ content: [{ type: "text", text: "Human readable summary" }],
66
+ structuredContent: {
67
+ // Machine-readable data matching spec schemas
68
+ },
69
+ };
70
+ ```
71
+
72
+ ## Resources
73
+
74
+ Resources use `gno://` URI scheme:
75
+
76
+ - `gno://work/path/to/doc.md` - Document content
77
+ - `gno://collections` - List collections
78
+ - `gno://schemas/*` - JSON schemas
79
+
80
+ ## Testing
81
+
82
+ MCP tests in `test/mcp/`:
83
+
84
+ ```bash
85
+ bun test test/mcp/
86
+ ```
package/src/mcp/server.ts CHANGED
@@ -7,11 +7,15 @@
7
7
 
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ // node:path for join/dirname (no Bun path utils)
11
+ import { dirname, join } from "node:path";
10
12
 
11
13
  import type { Collection, Config } from "../config/types";
12
14
  import type { SqliteAdapter } from "../store/sqlite/adapter";
13
15
 
14
- import { MCP_SERVER_NAME, VERSION } from "../app/constants";
16
+ import { MCP_SERVER_NAME, VERSION, getIndexDbPath } from "../app/constants";
17
+ import { JobManager } from "../core/job-manager";
18
+ import { envIsSet } from "../llm/policy";
15
19
 
16
20
  // ─────────────────────────────────────────────────────────────────────────────
17
21
  // Simple Promise Mutex (avoids async-mutex dependency)
@@ -54,6 +58,10 @@ export interface ToolContext {
54
58
  collections: Collection[];
55
59
  actualConfigPath: string;
56
60
  toolMutex: Mutex;
61
+ jobManager: JobManager;
62
+ serverInstanceId: string;
63
+ writeLockPath: string;
64
+ enableWrite: boolean;
57
65
  isShuttingDown: () => boolean;
58
66
  }
59
67
 
@@ -65,6 +73,7 @@ export interface McpServerOptions {
65
73
  indexName?: string;
66
74
  configPath?: string;
67
75
  verbose?: boolean;
76
+ enableWrite?: boolean;
68
77
  }
69
78
 
70
79
  // ─────────────────────────────────────────────────────────────────────────────
@@ -133,6 +142,19 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
133
142
  // Sequential execution mutex
134
143
  const toolMutex = new Mutex();
135
144
 
145
+ // Server instance ID (per-process)
146
+ const serverInstanceId = crypto.randomUUID();
147
+
148
+ const enableWrite =
149
+ options.enableWrite ?? envIsSet(process.env, "GNO_MCP_ENABLE_WRITE");
150
+ const dbPath = getIndexDbPath(options.indexName);
151
+ const writeLockPath = join(dirname(dbPath), ".mcp-write.lock");
152
+ const jobManager = new JobManager({
153
+ lockPath: writeLockPath,
154
+ serverInstanceId,
155
+ toolMutex,
156
+ });
157
+
136
158
  // Shutdown state
137
159
  let shuttingDown = false;
138
160
 
@@ -143,6 +165,10 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
143
165
  collections,
144
166
  actualConfigPath,
145
167
  toolMutex,
168
+ jobManager,
169
+ serverInstanceId,
170
+ writeLockPath,
171
+ enableWrite,
146
172
  isShuttingDown: () => shuttingDown,
147
173
  };
148
174
 
@@ -176,17 +202,20 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
176
202
  const release = await toolMutex.acquire();
177
203
  release();
178
204
 
179
- // 2. Close MCP server/transport (flush buffers, clean disconnect)
205
+ // 2. Wait for background jobs before closing DB
206
+ await jobManager.shutdown();
207
+
208
+ // 3. Close MCP server/transport (flush buffers, clean disconnect)
180
209
  try {
181
210
  await server.close();
182
211
  } catch {
183
212
  // Best-effort - server may already be closed
184
213
  }
185
214
 
186
- // 3. Close DB (safe now - no tool is running)
215
+ // 4. Close DB (safe now - no tool or job is running)
187
216
  await store.close();
188
217
 
189
- // 4. Exit
218
+ // 5. Exit
190
219
  process.exit(0);
191
220
  };
192
221