@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.
- package/package.json +2 -1
- package/src/cli/AGENTS.md +96 -0
- package/src/cli/CLAUDE.md +96 -0
- package/src/cli/commands/mcp/install.ts +3 -1
- package/src/cli/commands/mcp/paths.ts +20 -4
- package/src/cli/commands/mcp.ts +5 -1
- package/src/cli/program.ts +13 -2
- package/src/core/config-mutation.ts +110 -0
- package/src/core/errors.ts +40 -0
- package/src/core/file-lock.ts +144 -0
- package/src/core/file-ops.ts +24 -0
- package/src/core/job-manager.ts +215 -0
- package/src/core/validation.ts +75 -0
- package/src/ingestion/sync.ts +55 -0
- package/src/mcp/AGENTS.md +86 -0
- package/src/mcp/CLAUDE.md +86 -0
- package/src/mcp/server.ts +33 -4
- package/src/mcp/tools/add-collection.ts +190 -0
- package/src/mcp/tools/capture.ts +193 -0
- package/src/mcp/tools/index.ts +101 -0
- package/src/mcp/tools/job-status.ts +87 -0
- package/src/mcp/tools/list-jobs.ts +81 -0
- package/src/mcp/tools/remove-collection.ts +106 -0
- package/src/mcp/tools/sync.ts +153 -0
- package/src/serve/AGENTS.md +180 -0
- package/src/serve/CLAUDE.md +75 -0
- package/src/serve/config-sync.ts +16 -102
- package/src/serve/public/components/GnoLogo.tsx +48 -0
- package/src/serve/public/pages/Dashboard.tsx +2 -2
- package/src/serve/routes/api.ts +14 -54
|
@@ -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
|
+
}
|
package/src/ingestion/sync.ts
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
215
|
+
// 4. Close DB (safe now - no tool or job is running)
|
|
187
216
|
await store.close();
|
|
188
217
|
|
|
189
|
-
//
|
|
218
|
+
// 5. Exit
|
|
190
219
|
process.exit(0);
|
|
191
220
|
};
|
|
192
221
|
|