@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,190 @@
1
+ /**
2
+ * MCP gno_add_collection tool - add collection and sync.
3
+ *
4
+ * @module src/mcp/tools/add-collection
5
+ */
6
+
7
+ // node:path for basename (no Bun path utils)
8
+ import { basename } from "node:path";
9
+
10
+ import type { ToolContext } from "../server";
11
+
12
+ import { addCollection } from "../../collection/add";
13
+ import { applyConfigChange } from "../../core/config-mutation";
14
+ import { MCP_ERRORS } from "../../core/errors";
15
+ import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
16
+ import { JobError } from "../../core/job-manager";
17
+ import {
18
+ normalizeCollectionName,
19
+ validateCollectionRoot,
20
+ } from "../../core/validation";
21
+ import { defaultSyncService } from "../../ingestion";
22
+ import { runTool, type ToolResult } from "./index";
23
+
24
+ interface AddCollectionInput {
25
+ path: string;
26
+ name?: string;
27
+ pattern?: string;
28
+ include?: string[];
29
+ exclude?: string[];
30
+ gitPull?: boolean;
31
+ }
32
+
33
+ interface AddCollectionResult {
34
+ jobId: string;
35
+ collection: string;
36
+ status: "started";
37
+ }
38
+
39
+ function formatAddCollectionResult(result: AddCollectionResult): string {
40
+ return [
41
+ `Job: ${result.jobId}`,
42
+ `Collection: ${result.collection}`,
43
+ `Status: ${result.status}`,
44
+ ].join("\n");
45
+ }
46
+
47
+ function mapConfigError(code: string, message: string): Error {
48
+ switch (code) {
49
+ case "DUPLICATE":
50
+ return new Error(`${MCP_ERRORS.DUPLICATE.code}: ${message}`);
51
+ case "PATH_NOT_FOUND":
52
+ return new Error(`${MCP_ERRORS.PATH_NOT_FOUND.code}: ${message}`);
53
+ case "VALIDATION":
54
+ return new Error(`${MCP_ERRORS.INVALID_PATH.code}: ${message}`);
55
+ default:
56
+ return new Error(`RUNTIME: ${message}`);
57
+ }
58
+ }
59
+
60
+ export function handleAddCollection(
61
+ args: AddCollectionInput,
62
+ ctx: ToolContext
63
+ ): Promise<ToolResult> {
64
+ return runTool(
65
+ ctx,
66
+ "gno_add_collection",
67
+ async () => {
68
+ if (!ctx.enableWrite) {
69
+ throw new Error("Write tools disabled. Start MCP with --enable-write.");
70
+ }
71
+
72
+ let lock: WriteLockHandle | null = null;
73
+ let handedOff = false;
74
+
75
+ try {
76
+ lock = await acquireWriteLock(ctx.writeLockPath);
77
+ if (!lock) {
78
+ throw new Error(
79
+ `${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`
80
+ );
81
+ }
82
+
83
+ let absPath: string;
84
+ try {
85
+ absPath = await validateCollectionRoot(args.path);
86
+ } catch (error) {
87
+ const message =
88
+ error instanceof Error ? error.message : String(error);
89
+ throw new Error(`${MCP_ERRORS.INVALID_PATH.code}: ${message}`);
90
+ }
91
+
92
+ const rawName = args.name ?? basename(absPath);
93
+ const collectionName = normalizeCollectionName(rawName);
94
+ if (!collectionName) {
95
+ throw new Error(
96
+ `${MCP_ERRORS.INVALID_PATH.code}: Collection name cannot be empty`
97
+ );
98
+ }
99
+
100
+ const mutationResult = await applyConfigChange(
101
+ {
102
+ store: ctx.store,
103
+ configPath: ctx.actualConfigPath,
104
+ onConfigUpdated: (config) => {
105
+ ctx.config = config;
106
+ ctx.collections = config.collections;
107
+ },
108
+ },
109
+ async (config) => {
110
+ const addResult = await addCollection(config, {
111
+ path: absPath,
112
+ name: collectionName,
113
+ pattern: args.pattern,
114
+ include: args.include,
115
+ exclude: args.exclude,
116
+ });
117
+
118
+ if (!addResult.ok) {
119
+ return {
120
+ ok: false,
121
+ error: addResult.message,
122
+ code: addResult.code,
123
+ };
124
+ }
125
+
126
+ return {
127
+ ok: true,
128
+ config: addResult.config,
129
+ value: addResult.collection,
130
+ };
131
+ }
132
+ );
133
+
134
+ if (!mutationResult.ok) {
135
+ throw mapConfigError(mutationResult.code, mutationResult.error);
136
+ }
137
+
138
+ const collection = mutationResult.value;
139
+ if (!collection) {
140
+ throw new Error("RUNTIME: Collection missing after add");
141
+ }
142
+
143
+ const jobId = await ctx.jobManager.startJobWithLock(
144
+ "add",
145
+ lock,
146
+ async () => {
147
+ const result = await defaultSyncService.syncCollection(
148
+ collection,
149
+ ctx.store,
150
+ {
151
+ gitPull: args.gitPull ?? false,
152
+ runUpdateCmd: false,
153
+ }
154
+ );
155
+
156
+ return {
157
+ collections: [result],
158
+ totalDurationMs: result.durationMs,
159
+ totalFilesProcessed: result.filesProcessed,
160
+ totalFilesAdded: result.filesAdded,
161
+ totalFilesUpdated: result.filesUpdated,
162
+ totalFilesErrored: result.filesErrored,
163
+ totalFilesSkipped: result.filesSkipped,
164
+ };
165
+ }
166
+ );
167
+
168
+ handedOff = true;
169
+
170
+ const result: AddCollectionResult = {
171
+ jobId,
172
+ collection: collection.name,
173
+ status: "started",
174
+ };
175
+
176
+ return result;
177
+ } catch (error) {
178
+ if (error instanceof JobError) {
179
+ throw new Error(`${error.code}: ${error.message}`);
180
+ }
181
+ throw error;
182
+ } finally {
183
+ if (lock && !handedOff) {
184
+ await lock.release();
185
+ }
186
+ }
187
+ },
188
+ formatAddCollectionResult
189
+ );
190
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * MCP gno_capture tool - create a new document.
3
+ *
4
+ * @module src/mcp/tools/capture
5
+ */
6
+
7
+ // node:fs/promises for mkdir (no Bun equivalent for structure ops)
8
+ import { mkdir } from "node:fs/promises";
9
+ // node:path for path utils (no Bun path utils)
10
+ import { dirname, extname, join } from "node:path";
11
+
12
+ import type { ToolContext } from "../server";
13
+
14
+ import { buildUri } from "../../app/constants";
15
+ import { MCP_ERRORS } from "../../core/errors";
16
+ import { withWriteLock } from "../../core/file-lock";
17
+ import { atomicWrite } from "../../core/file-ops";
18
+ import {
19
+ normalizeCollectionName,
20
+ validateRelPath,
21
+ } from "../../core/validation";
22
+ import { defaultSyncService } from "../../ingestion";
23
+ import { extractTitle } from "../../pipeline/contextual";
24
+ import { runTool, type ToolResult } from "./index";
25
+
26
+ interface CaptureInput {
27
+ collection: string;
28
+ content: string;
29
+ title?: string;
30
+ path?: string;
31
+ overwrite?: boolean;
32
+ }
33
+
34
+ interface CaptureResult {
35
+ docid: string;
36
+ uri: string;
37
+ absPath: string;
38
+ collection: string;
39
+ relPath: string;
40
+ created: boolean;
41
+ overwritten: boolean;
42
+ serverInstanceId: string;
43
+ }
44
+
45
+ const SENSITIVE_SUBPATHS = new Set([
46
+ ".ssh",
47
+ ".gnupg",
48
+ ".aws",
49
+ ".config",
50
+ ".git",
51
+ "node_modules",
52
+ ]);
53
+
54
+ function sanitizeFilename(title: string): string {
55
+ return title
56
+ .toLowerCase()
57
+ .trim()
58
+ .replaceAll(/[^\w\s-]/g, "")
59
+ .replaceAll(/\s+/g, "-")
60
+ .replaceAll(/-+/g, "-")
61
+ .replace(/^-|-$/g, "");
62
+ }
63
+
64
+ function ensureMarkdownExtension(relPath: string): string {
65
+ return extname(relPath) ? relPath : `${relPath}.md`;
66
+ }
67
+
68
+ function assertNotSensitive(relPath: string): void {
69
+ const firstSegment = relPath.split(/[\\/]/)[0];
70
+ if (firstSegment && SENSITIVE_SUBPATHS.has(firstSegment)) {
71
+ throw new Error(
72
+ `${MCP_ERRORS.INVALID_PATH.code}: Cannot write to sensitive directory: ${firstSegment}`
73
+ );
74
+ }
75
+ }
76
+
77
+ function generateFilename(title: string | undefined, content: string): string {
78
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
79
+ const fallback = `note-${timestamp}.md`;
80
+ const baseTitle = title?.trim() || extractTitle(content, fallback);
81
+ const slug = sanitizeFilename(baseTitle);
82
+ const safeSlug = slug || `note-${timestamp}`;
83
+ return `${safeSlug}.md`;
84
+ }
85
+
86
+ function formatCaptureResult(result: CaptureResult): string {
87
+ const lines: string[] = [];
88
+ lines.push(`Doc: ${result.docid}`);
89
+ lines.push(`URI: ${result.uri}`);
90
+ lines.push(`Path: ${result.absPath}`);
91
+ lines.push(`Created: ${result.created ? "yes" : "no"}`);
92
+ lines.push(`Overwritten: ${result.overwritten ? "yes" : "no"}`);
93
+ return lines.join("\n");
94
+ }
95
+
96
+ export function handleCapture(
97
+ args: CaptureInput,
98
+ ctx: ToolContext
99
+ ): Promise<ToolResult> {
100
+ return runTool(
101
+ ctx,
102
+ "gno_capture",
103
+ async () => {
104
+ if (!ctx.enableWrite) {
105
+ throw new Error("Write tools disabled. Start MCP with --enable-write.");
106
+ }
107
+
108
+ return await withWriteLock(ctx.writeLockPath, async () => {
109
+ const collectionName = normalizeCollectionName(args.collection);
110
+ const collection = ctx.collections.find(
111
+ (c) => c.name.toLowerCase() === collectionName
112
+ );
113
+ if (!collection) {
114
+ throw new Error(
115
+ `${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${args.collection}`
116
+ );
117
+ }
118
+
119
+ let relPath: string;
120
+ if (args.path) {
121
+ try {
122
+ relPath = ensureMarkdownExtension(validateRelPath(args.path));
123
+ } catch (error) {
124
+ const message =
125
+ error instanceof Error ? error.message : String(error);
126
+ throw new Error(`${MCP_ERRORS.INVALID_PATH.code}: ${message}`);
127
+ }
128
+ } else {
129
+ relPath = generateFilename(args.title, args.content);
130
+ }
131
+
132
+ assertNotSensitive(relPath);
133
+
134
+ const absPath = join(collection.path, relPath);
135
+ const file = Bun.file(absPath);
136
+ const exists = await file.exists();
137
+ if (exists && !args.overwrite) {
138
+ throw new Error(
139
+ `${MCP_ERRORS.CONFLICT.code}: File exists: ${relPath}`
140
+ );
141
+ }
142
+
143
+ await mkdir(dirname(absPath), { recursive: true });
144
+ await atomicWrite(absPath, args.content);
145
+
146
+ const results = await defaultSyncService.syncFiles(
147
+ collection,
148
+ ctx.store,
149
+ [relPath],
150
+ { runUpdateCmd: false, gitPull: false }
151
+ );
152
+ const syncResult = results[0];
153
+ if (!syncResult) {
154
+ throw new Error("RUNTIME: Sync result missing");
155
+ }
156
+ if (syncResult.status === "error") {
157
+ throw new Error(
158
+ `INGEST_ERROR: ${syncResult.errorCode ?? "ERROR"} - ${
159
+ syncResult.errorMessage ?? "Unknown error"
160
+ }`
161
+ );
162
+ }
163
+
164
+ let docid = syncResult.docid;
165
+ if (!docid) {
166
+ const docResult = await ctx.store.getDocument(
167
+ collectionName,
168
+ relPath
169
+ );
170
+ if (!docResult.ok) {
171
+ throw new Error(docResult.error.message);
172
+ }
173
+ if (!docResult.value) {
174
+ throw new Error("RUNTIME: Document missing after sync");
175
+ }
176
+ docid = docResult.value.docid;
177
+ }
178
+
179
+ return {
180
+ docid,
181
+ uri: buildUri(collectionName, relPath),
182
+ absPath,
183
+ collection: collectionName,
184
+ relPath,
185
+ created: !exists,
186
+ overwritten: exists,
187
+ serverInstanceId: ctx.serverInstanceId,
188
+ };
189
+ });
190
+ },
191
+ formatCaptureResult
192
+ );
193
+ }
@@ -10,11 +10,17 @@ import { z } from "zod";
10
10
 
11
11
  import type { ToolContext } from "../server";
12
12
 
13
+ import { handleAddCollection } from "./add-collection";
14
+ import { handleCapture } from "./capture";
13
15
  import { handleGet } from "./get";
16
+ import { handleJobStatus } from "./job-status";
17
+ import { handleListJobs } from "./list-jobs";
14
18
  import { handleMultiGet } from "./multi-get";
15
19
  import { handleQuery } from "./query";
20
+ import { handleRemoveCollection } from "./remove-collection";
16
21
  import { handleSearch } from "./search";
17
22
  import { handleStatus } from "./status";
23
+ import { handleSync } from "./sync";
18
24
  import { handleVsearch } from "./vsearch";
19
25
 
20
26
  // ─────────────────────────────────────────────────────────────────────────────
@@ -29,6 +35,33 @@ const searchInputSchema = z.object({
29
35
  lang: z.string().optional(),
30
36
  });
31
37
 
38
+ const captureInputSchema = z.object({
39
+ collection: z.string().min(1, "Collection cannot be empty"),
40
+ content: z.string(),
41
+ title: z.string().optional(),
42
+ path: z.string().optional(),
43
+ overwrite: z.boolean().default(false),
44
+ });
45
+
46
+ const addCollectionInputSchema = z.object({
47
+ path: z.string().min(1, "Path cannot be empty"),
48
+ name: z.string().optional(),
49
+ pattern: z.string().optional(),
50
+ include: z.array(z.string()).optional(),
51
+ exclude: z.array(z.string()).optional(),
52
+ gitPull: z.boolean().default(false),
53
+ });
54
+
55
+ const syncInputSchema = z.object({
56
+ collection: z.string().optional(),
57
+ gitPull: z.boolean().default(false),
58
+ runUpdateCmd: z.boolean().default(false),
59
+ });
60
+
61
+ const removeCollectionInputSchema = z.object({
62
+ collection: z.string().min(1, "Collection cannot be empty"),
63
+ });
64
+
32
65
  const vsearchInputSchema = z.object({
33
66
  query: z.string().min(1, "Query cannot be empty"),
34
67
  collection: z.string().optional(),
@@ -65,6 +98,14 @@ const multiGetInputSchema = z.object({
65
98
 
66
99
  const statusInputSchema = z.object({});
67
100
 
101
+ const jobStatusInputSchema = z.object({
102
+ jobId: z.string().min(1, "Job ID cannot be empty"),
103
+ });
104
+
105
+ const listJobsInputSchema = z.object({
106
+ limit: z.number().int().min(1).max(100).default(10),
107
+ });
108
+
68
109
  // ─────────────────────────────────────────────────────────────────────────────
69
110
  // Tool Result Type
70
111
  // ─────────────────────────────────────────────────────────────────────────────
@@ -106,15 +147,31 @@ export async function runTool<T>(
106
147
  // Exception firewall: never throw, always return isError
107
148
  const message = e instanceof Error ? e.message : String(e);
108
149
  console.error(`[MCP] ${name} error:`, message);
150
+ const parsedError = parseErrorMessage(message);
109
151
  return {
110
152
  isError: true,
111
153
  content: [{ type: "text", text: `Error: ${message}` }],
154
+ structuredContent: parsedError,
112
155
  };
113
156
  } finally {
114
157
  release();
115
158
  }
116
159
  }
117
160
 
161
+ function parseErrorMessage(message: string): { [x: string]: unknown } {
162
+ const match = message.match(/^([A-Z_]+):\s*(.*)$/);
163
+ if (match) {
164
+ return {
165
+ error: match[1],
166
+ message: match[2] || message,
167
+ };
168
+ }
169
+ return {
170
+ error: "RUNTIME",
171
+ message,
172
+ };
173
+ }
174
+
118
175
  // ─────────────────────────────────────────────────────────────────────────────
119
176
  // Tool Registration
120
177
  // ─────────────────────────────────────────────────────────────────────────────
@@ -162,4 +219,48 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
162
219
  statusInputSchema.shape,
163
220
  (args) => handleStatus(args, ctx)
164
221
  );
222
+
223
+ if (ctx.enableWrite) {
224
+ server.tool(
225
+ "gno_capture",
226
+ "Create a new document",
227
+ captureInputSchema.shape,
228
+ (args) => handleCapture(args, ctx)
229
+ );
230
+
231
+ server.tool(
232
+ "gno_add_collection",
233
+ "Add a collection and start indexing",
234
+ addCollectionInputSchema.shape,
235
+ (args) => handleAddCollection(args, ctx)
236
+ );
237
+
238
+ server.tool(
239
+ "gno_sync",
240
+ "Sync one or all collections",
241
+ syncInputSchema.shape,
242
+ (args) => handleSync(args, ctx)
243
+ );
244
+
245
+ server.tool(
246
+ "gno_remove_collection",
247
+ "Remove a collection from config",
248
+ removeCollectionInputSchema.shape,
249
+ (args) => handleRemoveCollection(args, ctx)
250
+ );
251
+ }
252
+
253
+ server.tool(
254
+ "gno_job_status",
255
+ "Get status of an async job",
256
+ jobStatusInputSchema.shape,
257
+ (args) => handleJobStatus(args, ctx)
258
+ );
259
+
260
+ server.tool(
261
+ "gno_list_jobs",
262
+ "List active and recent jobs",
263
+ listJobsInputSchema.shape,
264
+ (args) => handleListJobs(args, ctx)
265
+ );
165
266
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * MCP gno_job_status tool - job status lookup.
3
+ *
4
+ * @module src/mcp/tools/job-status
5
+ */
6
+
7
+ import type { JobRecord } from "../../core/job-manager";
8
+ import type { SyncResult } from "../../ingestion";
9
+ import type { ToolContext } from "../server";
10
+
11
+ import { runTool, type ToolResult } from "./index";
12
+
13
+ interface JobStatusInput {
14
+ jobId: string;
15
+ }
16
+
17
+ interface JobStatusResult {
18
+ jobId: string;
19
+ type: JobRecord["type"];
20
+ status: JobRecord["status"];
21
+ startedAt: string;
22
+ completedAt?: string;
23
+ result?: SyncResult;
24
+ error?: string;
25
+ serverInstanceId: string;
26
+ }
27
+
28
+ function formatJobStatus(result: JobStatusResult): string {
29
+ const lines: string[] = [];
30
+
31
+ lines.push(`Job: ${result.jobId}`);
32
+ lines.push(`Type: ${result.type}`);
33
+ lines.push(`Status: ${result.status}`);
34
+ lines.push(`Started: ${result.startedAt}`);
35
+
36
+ if (result.completedAt) {
37
+ lines.push(`Completed: ${result.completedAt}`);
38
+ }
39
+
40
+ if (result.error) {
41
+ lines.push(`Error: ${result.error}`);
42
+ }
43
+
44
+ if (result.result) {
45
+ lines.push(
46
+ `Total: ${result.result.totalFilesAdded} added, ${result.result.totalFilesUpdated} updated, ` +
47
+ `${result.result.totalFilesErrored} errors`
48
+ );
49
+ lines.push(`Duration: ${result.result.totalDurationMs}ms`);
50
+ }
51
+
52
+ return lines.join("\n");
53
+ }
54
+
55
+ function toJobStatusResult(job: JobRecord): JobStatusResult {
56
+ return {
57
+ jobId: job.id,
58
+ type: job.type,
59
+ status: job.status,
60
+ startedAt: new Date(job.startedAt).toISOString(),
61
+ completedAt: job.completedAt
62
+ ? new Date(job.completedAt).toISOString()
63
+ : undefined,
64
+ result: job.result,
65
+ error: job.error,
66
+ serverInstanceId: job.serverInstanceId,
67
+ };
68
+ }
69
+
70
+ export function handleJobStatus(
71
+ args: JobStatusInput,
72
+ ctx: ToolContext
73
+ ): Promise<ToolResult> {
74
+ return runTool(
75
+ ctx,
76
+ "gno_job_status",
77
+ async () => {
78
+ const job = ctx.jobManager.getJob(args.jobId);
79
+ if (!job) {
80
+ throw new Error(`NOT_FOUND: Job not found: ${args.jobId}`);
81
+ }
82
+
83
+ return toJobStatusResult(job);
84
+ },
85
+ formatJobStatus
86
+ );
87
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * MCP gno_list_jobs tool - list active/recent jobs.
3
+ *
4
+ * @module src/mcp/tools/list-jobs
5
+ */
6
+
7
+ import type { JobRecord } from "../../core/job-manager";
8
+ import type { ToolContext } from "../server";
9
+
10
+ import { runTool, type ToolResult } from "./index";
11
+
12
+ interface ListJobsInput {
13
+ limit?: number;
14
+ }
15
+
16
+ interface ListJobsResult {
17
+ active: Array<{
18
+ jobId: string;
19
+ type: JobRecord["type"];
20
+ startedAt: string;
21
+ }>;
22
+ recent: Array<{
23
+ jobId: string;
24
+ type: JobRecord["type"];
25
+ status: "completed" | "failed";
26
+ completedAt: string;
27
+ }>;
28
+ }
29
+
30
+ function formatListJobs(result: ListJobsResult): string {
31
+ const lines: string[] = [];
32
+
33
+ lines.push(`Active: ${result.active.length}`);
34
+ for (const job of result.active) {
35
+ lines.push(` ${job.jobId} (${job.type}) started ${job.startedAt}`);
36
+ }
37
+
38
+ lines.push("");
39
+ lines.push(`Recent: ${result.recent.length}`);
40
+ for (const job of result.recent) {
41
+ lines.push(
42
+ ` ${job.jobId} (${job.type}) ${job.status} at ${job.completedAt}`
43
+ );
44
+ }
45
+
46
+ return lines.join("\n");
47
+ }
48
+
49
+ function toListJobsResult(
50
+ active: JobRecord[],
51
+ recent: JobRecord[]
52
+ ): ListJobsResult {
53
+ return {
54
+ active: active.map((job) => ({
55
+ jobId: job.id,
56
+ type: job.type,
57
+ startedAt: new Date(job.startedAt).toISOString(),
58
+ })),
59
+ recent: recent.map((job) => ({
60
+ jobId: job.id,
61
+ type: job.type,
62
+ status: job.status === "failed" ? "failed" : "completed",
63
+ completedAt: new Date(job.completedAt ?? job.startedAt).toISOString(),
64
+ })),
65
+ };
66
+ }
67
+
68
+ export function handleListJobs(
69
+ args: ListJobsInput,
70
+ ctx: ToolContext
71
+ ): Promise<ToolResult> {
72
+ return runTool(
73
+ ctx,
74
+ "gno_list_jobs",
75
+ async () => {
76
+ const { active, recent } = ctx.jobManager.listJobs(args.limit ?? 10);
77
+ return toListJobsResult(active, recent);
78
+ },
79
+ formatListJobs
80
+ );
81
+ }