@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,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
|
+
}
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -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
|
+
}
|