@hasna/knowledge 0.2.3 → 0.2.4
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/README.md +86 -4
- package/bin/open-knowledge-mcp.js +1067 -1569
- package/bin/open-knowledge.js +227 -4
- package/docs/architecture/ai-native-knowledge-base.md +191 -0
- package/docs/architecture/hybrid-semantic-search.md +135 -0
- package/package.json +9 -4
- package/src/artifact-store.ts +184 -0
- package/src/cli.ts +642 -0
- package/src/knowledge-db.ts +231 -0
- package/src/mcp.js +533 -0
- package/src/schema.js +25 -0
- package/src/source-ref.ts +92 -0
- package/src/store.ts +16 -6
- package/src/wiki-layout.ts +104 -0
- package/src/workspace.ts +123 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
7
|
+
import { defaultStorePath, loadStore, saveStore, makeId, withLock } from './store.ts';
|
|
8
|
+
import { ensureKnowledgeWorkspace, readKnowledgeConfig, resolveScopedWorkspace } from './workspace.ts';
|
|
9
|
+
import { parseSourceRef } from './source-ref.ts';
|
|
10
|
+
|
|
11
|
+
const storePathField = z.string().optional().describe('Path to the JSON store file');
|
|
12
|
+
const scopeField = z.enum(['local', 'global', 'project']).optional().describe('Workspace scope');
|
|
13
|
+
|
|
14
|
+
function jsonText(data) {
|
|
15
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function errorText(message) {
|
|
19
|
+
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function shortIdFor(id) {
|
|
23
|
+
return id.replace(/^k_/, '').slice(0, 12);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveStorePath(storePath, scope) {
|
|
27
|
+
if (storePath) return storePath;
|
|
28
|
+
if (scope === 'project' || scope === 'local') {
|
|
29
|
+
return ensureKnowledgeWorkspace(resolveScopedWorkspace(scope).home).jsonStorePath;
|
|
30
|
+
}
|
|
31
|
+
return defaultStorePath();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readStoreLocked(storePath, fn) {
|
|
35
|
+
return withLock(storePath, () => fn(loadStore(storePath)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeStoreLocked(storePath, fn) {
|
|
39
|
+
return withLock(storePath, () => {
|
|
40
|
+
const db = loadStore(storePath);
|
|
41
|
+
const result = fn(db);
|
|
42
|
+
saveStore(storePath, db);
|
|
43
|
+
return result;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findItem(db, id) {
|
|
48
|
+
return db.items.find((item) => item.id === id || item.short_id === id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sortItems(items, sort = 'created', desc = false) {
|
|
52
|
+
const sorted = [...items].sort((a, b) => {
|
|
53
|
+
if (sort === 'title') return a.title.localeCompare(b.title);
|
|
54
|
+
return a.created_at.localeCompare(b.created_at);
|
|
55
|
+
});
|
|
56
|
+
if (desc) sorted.reverse();
|
|
57
|
+
return sorted;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function activeItems(items, includeArchived) {
|
|
61
|
+
return includeArchived ? items : items.filter((item) => !item.archived);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function registerTool(server, name, title, description, inputSchema, handler) {
|
|
65
|
+
server.registerTool(name, { title, description, inputSchema }, handler);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildServer() {
|
|
69
|
+
const server = new McpServer({
|
|
70
|
+
name: 'open-knowledge',
|
|
71
|
+
version: pkg.version,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
registerTool(server, 'ok_paths', 'Knowledge workspace paths', 'Show resolved workspace and store paths', {
|
|
75
|
+
scope: scopeField,
|
|
76
|
+
}, async ({ scope }) => {
|
|
77
|
+
const workspace = ensureKnowledgeWorkspace(resolveScopedWorkspace(scope).home);
|
|
78
|
+
return jsonText({
|
|
79
|
+
ok: true,
|
|
80
|
+
scope: scope ?? 'global',
|
|
81
|
+
home: workspace.home,
|
|
82
|
+
config_path: workspace.configPath,
|
|
83
|
+
json_store_path: workspace.jsonStorePath,
|
|
84
|
+
knowledge_db_path: workspace.knowledgeDbPath,
|
|
85
|
+
artifacts_dir: workspace.artifactsDir,
|
|
86
|
+
indexes_dir: workspace.indexesDir,
|
|
87
|
+
logs_dir: workspace.logsDir,
|
|
88
|
+
runs_dir: workspace.runsDir,
|
|
89
|
+
schemas_dir: workspace.schemasDir,
|
|
90
|
+
wiki_dir: workspace.wikiDir,
|
|
91
|
+
config: readKnowledgeConfig(workspace.configPath),
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
registerTool(server, 'ok_parse_source_ref', 'Parse source reference', 'Parse and validate an open-files, S3, file, or web source ref', {
|
|
96
|
+
uri: z.string().describe('Source reference URI'),
|
|
97
|
+
}, async ({ uri }) => {
|
|
98
|
+
try {
|
|
99
|
+
return jsonText({ ok: true, source_ref: parseSourceRef(uri) });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return errorText(error instanceof Error ? error.message : String(error));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
registerTool(server, 'ok_add', 'Add a knowledge item', 'Add a new item to the knowledge store', {
|
|
106
|
+
title: z.string().describe('Item title'),
|
|
107
|
+
content: z.string().describe('Item content/body'),
|
|
108
|
+
tags: z.array(z.string()).optional().describe('Tags to attach'),
|
|
109
|
+
metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata key-value pairs'),
|
|
110
|
+
url: z.string().optional().describe('Source URL or URI'),
|
|
111
|
+
store_path: storePathField,
|
|
112
|
+
scope: scopeField,
|
|
113
|
+
}, async ({ title, content, tags, metadata, url, store_path, scope }) => {
|
|
114
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
115
|
+
const item = writeStoreLocked(storePath, (db) => {
|
|
116
|
+
const now = new Date().toISOString();
|
|
117
|
+
const id = makeId();
|
|
118
|
+
const entry = {
|
|
119
|
+
id,
|
|
120
|
+
short_id: shortIdFor(id),
|
|
121
|
+
title,
|
|
122
|
+
content,
|
|
123
|
+
url: url ?? null,
|
|
124
|
+
tags: tags ?? [],
|
|
125
|
+
metadata: metadata ?? {},
|
|
126
|
+
archived: false,
|
|
127
|
+
created_at: now,
|
|
128
|
+
updated_at: now,
|
|
129
|
+
};
|
|
130
|
+
db.items.push(entry);
|
|
131
|
+
return entry;
|
|
132
|
+
});
|
|
133
|
+
return jsonText({ ok: true, item, message: `Added ${item.id}` });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
registerTool(server, 'ok_list', 'List knowledge items', 'List items with pagination, search, tag filtering, and sorting', {
|
|
137
|
+
search: z.string().optional().describe('Search text for title/content'),
|
|
138
|
+
tag: z.array(z.string()).optional().describe('Filter by tags; item must match all tags'),
|
|
139
|
+
include_archived: z.boolean().optional().describe('Include archived items'),
|
|
140
|
+
page: z.number().optional().describe('Page number'),
|
|
141
|
+
limit: z.number().optional().describe('Items per page'),
|
|
142
|
+
sort: z.enum(['created', 'title']).optional().describe('Sort field'),
|
|
143
|
+
desc: z.boolean().optional().describe('Sort descending'),
|
|
144
|
+
store_path: storePathField,
|
|
145
|
+
scope: scopeField,
|
|
146
|
+
}, async ({ search, tag, include_archived, page, limit, sort, desc, store_path, scope }) => {
|
|
147
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
148
|
+
return readStoreLocked(storePath, (db) => {
|
|
149
|
+
const q = search ? search.toLowerCase() : '';
|
|
150
|
+
const requiredTags = (tag ?? []).map((entry) => entry.toLowerCase());
|
|
151
|
+
let items = activeItems(db.items, include_archived);
|
|
152
|
+
if (q) items = items.filter((item) => item.title.toLowerCase().includes(q) || item.content.toLowerCase().includes(q));
|
|
153
|
+
if (requiredTags.length > 0) {
|
|
154
|
+
items = items.filter((item) => {
|
|
155
|
+
const itemTags = (item.tags ?? []).map((entry) => entry.toLowerCase());
|
|
156
|
+
return requiredTags.every((entry) => itemTags.includes(entry));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const p = page && page > 0 ? page : 1;
|
|
160
|
+
const l = limit && limit > 0 ? limit : 20;
|
|
161
|
+
const sorted = sortItems(items, sort ?? 'created', desc ?? false);
|
|
162
|
+
const start = (p - 1) * l;
|
|
163
|
+
const rows = sorted.slice(start, start + l);
|
|
164
|
+
return jsonText({
|
|
165
|
+
ok: true,
|
|
166
|
+
page: p,
|
|
167
|
+
limit: l,
|
|
168
|
+
total: sorted.length,
|
|
169
|
+
total_pages: Math.max(1, Math.ceil(sorted.length / l)),
|
|
170
|
+
items: rows,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
registerTool(server, 'ok_get', 'Get a knowledge item', 'Retrieve a single item by ID or short ID', {
|
|
176
|
+
id: z.string().describe('Item ID or short ID'),
|
|
177
|
+
store_path: storePathField,
|
|
178
|
+
scope: scopeField,
|
|
179
|
+
}, async ({ id, store_path, scope }) => {
|
|
180
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
181
|
+
return readStoreLocked(storePath, (db) => {
|
|
182
|
+
const item = findItem(db, id);
|
|
183
|
+
return item ? jsonText({ ok: true, item }) : errorText(`Item not found: ${id}`);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
registerTool(server, 'ok_update', 'Update a knowledge item', 'Update title, content, URL, tags, or metadata', {
|
|
188
|
+
id: z.string().describe('Item ID or short ID'),
|
|
189
|
+
title: z.string().optional(),
|
|
190
|
+
content: z.string().optional(),
|
|
191
|
+
url: z.string().optional(),
|
|
192
|
+
tags: z.array(z.string()).optional(),
|
|
193
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
194
|
+
store_path: storePathField,
|
|
195
|
+
scope: scopeField,
|
|
196
|
+
}, async ({ id, title, content, url, tags, metadata, store_path, scope }) => {
|
|
197
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
198
|
+
const result = writeStoreLocked(storePath, (db) => {
|
|
199
|
+
const item = findItem(db, id);
|
|
200
|
+
if (!item) return null;
|
|
201
|
+
if (title !== undefined) item.title = title;
|
|
202
|
+
if (content !== undefined) item.content = content;
|
|
203
|
+
if (url !== undefined) item.url = url;
|
|
204
|
+
if (tags) item.tags = [...new Set([...(item.tags ?? []), ...tags])];
|
|
205
|
+
if (metadata) item.metadata = { ...(item.metadata ?? {}), ...metadata };
|
|
206
|
+
item.updated_at = new Date().toISOString();
|
|
207
|
+
return item;
|
|
208
|
+
});
|
|
209
|
+
return result ? jsonText({ ok: true, item: result }) : errorText(`Item not found: ${id}`);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
registerTool(server, 'ok_delete', 'Delete a knowledge item', 'Permanently delete an item by ID. Requires confirm=true.', {
|
|
213
|
+
id: z.string().describe('Item ID or short ID'),
|
|
214
|
+
confirm: z.boolean().describe('Must be true to confirm deletion'),
|
|
215
|
+
store_path: storePathField,
|
|
216
|
+
scope: scopeField,
|
|
217
|
+
}, async ({ id, confirm, store_path, scope }) => {
|
|
218
|
+
if (!confirm) return errorText('Refusing delete without confirm=true.');
|
|
219
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
220
|
+
const deleted = writeStoreLocked(storePath, (db) => {
|
|
221
|
+
const before = db.items.length;
|
|
222
|
+
db.items = db.items.filter((item) => item.id !== id && item.short_id !== id);
|
|
223
|
+
return before !== db.items.length;
|
|
224
|
+
});
|
|
225
|
+
return deleted ? jsonText({ ok: true, deleted_id: id }) : errorText(`Item not found: ${id}`);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
registerTool(server, 'ok_archive', 'Archive a knowledge item', 'Soft-delete an item by setting archived=true', {
|
|
229
|
+
id: z.string(),
|
|
230
|
+
store_path: storePathField,
|
|
231
|
+
scope: scopeField,
|
|
232
|
+
}, async ({ id, store_path, scope }) => {
|
|
233
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
234
|
+
const item = writeStoreLocked(storePath, (db) => {
|
|
235
|
+
const entry = findItem(db, id);
|
|
236
|
+
if (!entry) return null;
|
|
237
|
+
entry.archived = true;
|
|
238
|
+
entry.updated_at = new Date().toISOString();
|
|
239
|
+
return entry;
|
|
240
|
+
});
|
|
241
|
+
return item ? jsonText({ ok: true, item }) : errorText(`Item not found: ${id}`);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
registerTool(server, 'ok_restore', 'Restore a knowledge item', 'Restore an archived item', {
|
|
245
|
+
id: z.string(),
|
|
246
|
+
store_path: storePathField,
|
|
247
|
+
scope: scopeField,
|
|
248
|
+
}, async ({ id, store_path, scope }) => {
|
|
249
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
250
|
+
const item = writeStoreLocked(storePath, (db) => {
|
|
251
|
+
const entry = findItem(db, id);
|
|
252
|
+
if (!entry) return null;
|
|
253
|
+
entry.archived = false;
|
|
254
|
+
entry.updated_at = new Date().toISOString();
|
|
255
|
+
return entry;
|
|
256
|
+
});
|
|
257
|
+
return item ? jsonText({ ok: true, item }) : errorText(`Item not found: ${id}`);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
registerTool(server, 'ok_upsert', 'Upsert a knowledge item', 'Create or update an item by ID', {
|
|
261
|
+
id: z.string(),
|
|
262
|
+
title: z.string().optional(),
|
|
263
|
+
content: z.string().optional(),
|
|
264
|
+
tags: z.array(z.string()).optional(),
|
|
265
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
266
|
+
store_path: storePathField,
|
|
267
|
+
scope: scopeField,
|
|
268
|
+
}, async ({ id, title, content, tags, metadata, store_path, scope }) => {
|
|
269
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
270
|
+
const item = writeStoreLocked(storePath, (db) => {
|
|
271
|
+
let entry = findItem(db, id);
|
|
272
|
+
const now = new Date().toISOString();
|
|
273
|
+
if (!entry) {
|
|
274
|
+
if (!title || !content) return null;
|
|
275
|
+
entry = {
|
|
276
|
+
id,
|
|
277
|
+
short_id: shortIdFor(id),
|
|
278
|
+
title,
|
|
279
|
+
content,
|
|
280
|
+
tags: tags ?? [],
|
|
281
|
+
metadata: metadata ?? {},
|
|
282
|
+
archived: false,
|
|
283
|
+
created_at: now,
|
|
284
|
+
updated_at: now,
|
|
285
|
+
};
|
|
286
|
+
db.items.push(entry);
|
|
287
|
+
return entry;
|
|
288
|
+
}
|
|
289
|
+
if (title !== undefined) entry.title = title;
|
|
290
|
+
if (content !== undefined) entry.content = content;
|
|
291
|
+
if (tags) entry.tags = [...new Set([...(entry.tags ?? []), ...tags])];
|
|
292
|
+
if (metadata) entry.metadata = { ...(entry.metadata ?? {}), ...metadata };
|
|
293
|
+
entry.updated_at = now;
|
|
294
|
+
return entry;
|
|
295
|
+
});
|
|
296
|
+
return item ? jsonText({ ok: true, item }) : errorText('New item requires both title and content.');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
registerTool(server, 'ok_untag', 'Remove tags from a knowledge item', 'Remove specific tags from an item', {
|
|
300
|
+
id: z.string(),
|
|
301
|
+
tags: z.array(z.string()),
|
|
302
|
+
store_path: storePathField,
|
|
303
|
+
scope: scopeField,
|
|
304
|
+
}, async ({ id, tags, store_path, scope }) => {
|
|
305
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
306
|
+
const result = writeStoreLocked(storePath, (db) => {
|
|
307
|
+
const item = findItem(db, id);
|
|
308
|
+
if (!item) return null;
|
|
309
|
+
const remove = new Set(tags.map((tag) => tag.toLowerCase()));
|
|
310
|
+
const before = (item.tags ?? []).length;
|
|
311
|
+
item.tags = (item.tags ?? []).filter((tag) => !remove.has(tag.toLowerCase()));
|
|
312
|
+
item.updated_at = new Date().toISOString();
|
|
313
|
+
return { item, removed: before - item.tags.length };
|
|
314
|
+
});
|
|
315
|
+
return result ? jsonText({ ok: true, ...result }) : errorText(`Item not found: ${id}`);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
registerTool(server, 'ok_bulk_delete', 'Bulk delete knowledge items', 'Delete multiple items by tag or search. Requires confirm=true.', {
|
|
319
|
+
tag: z.array(z.string()).optional(),
|
|
320
|
+
search: z.string().optional(),
|
|
321
|
+
confirm: z.boolean(),
|
|
322
|
+
store_path: storePathField,
|
|
323
|
+
scope: scopeField,
|
|
324
|
+
}, async ({ tag, search, confirm, store_path, scope }) => {
|
|
325
|
+
if (!confirm) return errorText('Refusing bulk delete without confirm=true.');
|
|
326
|
+
if (!tag && !search) return errorText('Missing filter. Use tag or search.');
|
|
327
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
328
|
+
const deleted = writeStoreLocked(storePath, (db) => {
|
|
329
|
+
const q = search ? search.toLowerCase() : '';
|
|
330
|
+
const tags = (tag ?? []).map((entry) => entry.toLowerCase());
|
|
331
|
+
const deleteIds = new Set(db.items.filter((item) => {
|
|
332
|
+
const matchesSearch = q ? item.title.toLowerCase().includes(q) || item.content.toLowerCase().includes(q) : false;
|
|
333
|
+
const itemTags = (item.tags ?? []).map((entry) => entry.toLowerCase());
|
|
334
|
+
const matchesTag = tags.length > 0 ? tags.some((entry) => itemTags.includes(entry)) : false;
|
|
335
|
+
return matchesSearch || matchesTag;
|
|
336
|
+
}).map((item) => item.id));
|
|
337
|
+
db.items = db.items.filter((item) => !deleteIds.has(item.id));
|
|
338
|
+
return deleteIds.size;
|
|
339
|
+
});
|
|
340
|
+
return jsonText({ ok: true, deleted });
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
registerTool(server, 'ok_prune', 'Prune knowledge items', 'Remove old and/or empty knowledge items. Requires confirm=true.', {
|
|
344
|
+
older_than_days: z.number().optional().describe('Remove items older than N days'),
|
|
345
|
+
empty: z.boolean().optional().describe('Remove items with empty content'),
|
|
346
|
+
confirm: z.boolean(),
|
|
347
|
+
store_path: storePathField,
|
|
348
|
+
scope: scopeField,
|
|
349
|
+
}, async ({ older_than_days, empty, confirm, store_path, scope }) => {
|
|
350
|
+
if (!confirm) return errorText('Refusing prune without confirm=true.');
|
|
351
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
352
|
+
const pruned = writeStoreLocked(storePath, (db) => {
|
|
353
|
+
const before = db.items.length;
|
|
354
|
+
let cutoff = null;
|
|
355
|
+
if (older_than_days !== undefined) {
|
|
356
|
+
cutoff = new Date();
|
|
357
|
+
cutoff.setDate(cutoff.getDate() - older_than_days);
|
|
358
|
+
}
|
|
359
|
+
db.items = db.items.filter((item) => {
|
|
360
|
+
if (cutoff && new Date(item.created_at) < cutoff) return false;
|
|
361
|
+
if (empty && item.content.trim().length === 0) return false;
|
|
362
|
+
return true;
|
|
363
|
+
});
|
|
364
|
+
return before - db.items.length;
|
|
365
|
+
});
|
|
366
|
+
return jsonText({ ok: true, pruned });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
registerTool(server, 'ok_dedupe', 'Dedupe knowledge items', 'Remove duplicate items by title and content. Requires confirm=true.', {
|
|
370
|
+
confirm: z.boolean(),
|
|
371
|
+
store_path: storePathField,
|
|
372
|
+
scope: scopeField,
|
|
373
|
+
}, async ({ confirm, store_path, scope }) => {
|
|
374
|
+
if (!confirm) return errorText('Refusing dedupe without confirm=true.');
|
|
375
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
376
|
+
const removed = writeStoreLocked(storePath, (db) => {
|
|
377
|
+
const seen = new Set();
|
|
378
|
+
const before = db.items.length;
|
|
379
|
+
db.items = db.items.filter((item) => {
|
|
380
|
+
const key = `${item.title}\u0000${item.content}`;
|
|
381
|
+
if (seen.has(key)) return false;
|
|
382
|
+
seen.add(key);
|
|
383
|
+
return true;
|
|
384
|
+
});
|
|
385
|
+
return before - db.items.length;
|
|
386
|
+
});
|
|
387
|
+
return jsonText({ ok: true, removed });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
registerTool(server, 'ok_stats', 'Knowledge store statistics', 'Get aggregate stats about the knowledge store', {
|
|
391
|
+
store_path: storePathField,
|
|
392
|
+
scope: scopeField,
|
|
393
|
+
}, async ({ store_path, scope }) => {
|
|
394
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
395
|
+
return readStoreLocked(storePath, (db) => {
|
|
396
|
+
const items = activeItems(db.items, false);
|
|
397
|
+
const tagCounts = {};
|
|
398
|
+
for (const item of items) {
|
|
399
|
+
for (const tag of item.tags ?? []) tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
|
|
400
|
+
}
|
|
401
|
+
return jsonText({
|
|
402
|
+
ok: true,
|
|
403
|
+
total: items.length,
|
|
404
|
+
archived: db.items.length - items.length,
|
|
405
|
+
tags: Object.fromEntries(Object.entries(tagCounts).sort((a, b) => b[1] - a[1])),
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
registerTool(server, 'ok_export', 'Export knowledge items', 'Export all items to a JSON file', {
|
|
411
|
+
file: z.string().optional().describe('Output file path'),
|
|
412
|
+
store_path: storePathField,
|
|
413
|
+
scope: scopeField,
|
|
414
|
+
}, async ({ file, store_path, scope }) => {
|
|
415
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
416
|
+
return readStoreLocked(storePath, (db) => {
|
|
417
|
+
const filePath = file || './knowledge-export.json';
|
|
418
|
+
writeFileSync(filePath, JSON.stringify(db, null, 2));
|
|
419
|
+
return jsonText({ ok: true, file: filePath, count: db.items.length });
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
registerTool(server, 'ok_import', 'Import knowledge items', 'Import items from an exported JSON file, skipping duplicate IDs', {
|
|
424
|
+
file: z.string().describe('Path to exported JSON file'),
|
|
425
|
+
store_path: storePathField,
|
|
426
|
+
scope: scopeField,
|
|
427
|
+
}, async ({ file, store_path, scope }) => {
|
|
428
|
+
if (!existsSync(file)) return errorText(`File not found: ${file}`);
|
|
429
|
+
const imported = JSON.parse(readFileSync(file, 'utf8'));
|
|
430
|
+
if (!imported || !Array.isArray(imported.items)) return errorText('Invalid import file: expected {"items": [...]}');
|
|
431
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
432
|
+
const result = writeStoreLocked(storePath, (db) => {
|
|
433
|
+
const existingIds = new Set(db.items.map((item) => item.id));
|
|
434
|
+
let added = 0;
|
|
435
|
+
for (const item of imported.items) {
|
|
436
|
+
if (!existingIds.has(item.id)) {
|
|
437
|
+
db.items.push(item);
|
|
438
|
+
existingIds.add(item.id);
|
|
439
|
+
added += 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return { added, skipped: imported.items.length - added };
|
|
443
|
+
});
|
|
444
|
+
return jsonText({ ok: true, ...result });
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
registerTool(server, 'ok_batch', 'Batch add knowledge items', 'Add multiple items at once', {
|
|
448
|
+
items: z.array(z.object({
|
|
449
|
+
id: z.string().optional(),
|
|
450
|
+
title: z.string(),
|
|
451
|
+
content: z.string(),
|
|
452
|
+
tags: z.array(z.string()).optional(),
|
|
453
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
454
|
+
created_at: z.string().optional(),
|
|
455
|
+
updated_at: z.string().optional(),
|
|
456
|
+
})),
|
|
457
|
+
store_path: storePathField,
|
|
458
|
+
scope: scopeField,
|
|
459
|
+
}, async ({ items, store_path, scope }) => {
|
|
460
|
+
const storePath = resolveStorePath(store_path, scope);
|
|
461
|
+
const result = writeStoreLocked(storePath, (db) => {
|
|
462
|
+
const existingIds = new Set(db.items.map((item) => item.id));
|
|
463
|
+
let added = 0;
|
|
464
|
+
let skipped = 0;
|
|
465
|
+
const now = new Date().toISOString();
|
|
466
|
+
for (const entry of items) {
|
|
467
|
+
if (entry.id && existingIds.has(entry.id)) {
|
|
468
|
+
skipped += 1;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const id = entry.id ?? makeId();
|
|
472
|
+
db.items.push({
|
|
473
|
+
id,
|
|
474
|
+
short_id: shortIdFor(id),
|
|
475
|
+
title: entry.title,
|
|
476
|
+
content: entry.content,
|
|
477
|
+
tags: entry.tags ?? [],
|
|
478
|
+
metadata: entry.metadata ?? {},
|
|
479
|
+
archived: false,
|
|
480
|
+
created_at: entry.created_at ?? now,
|
|
481
|
+
updated_at: entry.updated_at ?? now,
|
|
482
|
+
});
|
|
483
|
+
existingIds.add(id);
|
|
484
|
+
added += 1;
|
|
485
|
+
}
|
|
486
|
+
return { added, skipped };
|
|
487
|
+
});
|
|
488
|
+
return jsonText({ ok: true, ...result });
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
return server;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function printHelp() {
|
|
495
|
+
console.error(`Usage: open-knowledge-mcp [options]
|
|
496
|
+
|
|
497
|
+
Runs the @hasna/knowledge MCP server (stdio by default).
|
|
498
|
+
|
|
499
|
+
Options:
|
|
500
|
+
--http Serve MCP over Streamable HTTP (127.0.0.1)
|
|
501
|
+
--port <number> HTTP port (default: 8819, env: MCP_HTTP_PORT)
|
|
502
|
+
-h, --help Show this help text`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export async function main() {
|
|
506
|
+
if (process.argv.includes('-h') || process.argv.includes('--help')) {
|
|
507
|
+
printHelp();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const { isHttpMode, resolveMcpHttpPort, startMcpHttpServer } = await import('./mcp-http.js');
|
|
512
|
+
|
|
513
|
+
if (isHttpMode()) {
|
|
514
|
+
const handle = await startMcpHttpServer(buildServer, {
|
|
515
|
+
port: resolveMcpHttpPort(),
|
|
516
|
+
});
|
|
517
|
+
process.on('SIGINT', () => void handle.close().finally(() => process.exit(0)));
|
|
518
|
+
process.on('SIGTERM', () => void handle.close().finally(() => process.exit(0)));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const server = buildServer();
|
|
523
|
+
const transport = new StdioServerTransport();
|
|
524
|
+
await server.connect(transport);
|
|
525
|
+
console.error('open-knowledge MCP server running on stdio');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (import.meta.main) {
|
|
529
|
+
main().catch((err) => {
|
|
530
|
+
console.error('MCP server error:', err);
|
|
531
|
+
process.exit(1);
|
|
532
|
+
});
|
|
533
|
+
}
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const itemSchema = z.object({
|
|
4
|
+
id: z.string().min(1),
|
|
5
|
+
short_id: z.string().nullable().optional(),
|
|
6
|
+
title: z.string().min(1),
|
|
7
|
+
content: z.string(),
|
|
8
|
+
tags: z.array(z.string()).default([]),
|
|
9
|
+
metadata: z.record(z.string(), z.unknown()).default({}),
|
|
10
|
+
archived: z.boolean().default(false),
|
|
11
|
+
created_at: z.string(),
|
|
12
|
+
updated_at: z.string(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const storeSchema = z.object({
|
|
16
|
+
items: z.array(itemSchema.passthrough()).default([]),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function validateItem(data) {
|
|
20
|
+
return itemSchema.parse(data);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateStore(data) {
|
|
24
|
+
return storeSchema.parse(data);
|
|
25
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export type SourceRefKind = 'open-files' | 's3' | 'file' | 'web';
|
|
2
|
+
|
|
3
|
+
export interface BaseSourceRef {
|
|
4
|
+
kind: SourceRefKind;
|
|
5
|
+
uri: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface OpenFilesSourceRef extends BaseSourceRef {
|
|
9
|
+
kind: 'open-files';
|
|
10
|
+
entity: 'file' | 'source';
|
|
11
|
+
id: string;
|
|
12
|
+
revision_id?: string;
|
|
13
|
+
path?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface S3SourceRef extends BaseSourceRef {
|
|
17
|
+
kind: 's3';
|
|
18
|
+
bucket: string;
|
|
19
|
+
key: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FileSourceRef extends BaseSourceRef {
|
|
23
|
+
kind: 'file';
|
|
24
|
+
path: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WebSourceRef extends BaseSourceRef {
|
|
28
|
+
kind: 'web';
|
|
29
|
+
url: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SourceRef = OpenFilesSourceRef | S3SourceRef | FileSourceRef | WebSourceRef;
|
|
33
|
+
|
|
34
|
+
function assertNonEmpty(value: string | undefined, message: string): string {
|
|
35
|
+
if (!value) throw new Error(message);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseOpenFilesRef(uri: string): OpenFilesSourceRef {
|
|
40
|
+
const withoutScheme = uri.slice('open-files://'.length);
|
|
41
|
+
const parts = withoutScheme.split('/').filter(Boolean);
|
|
42
|
+
const entity = parts[0];
|
|
43
|
+
if (entity !== 'file' && entity !== 'source') {
|
|
44
|
+
throw new Error("Invalid open-files ref. Expected open-files://file/<id>, open-files://file/<id>/revision/<revision_id>, or open-files://source/<id>/path/<path>.");
|
|
45
|
+
}
|
|
46
|
+
const id = assertNonEmpty(parts[1], 'Invalid open-files ref. Missing id.');
|
|
47
|
+
if (entity === 'file') {
|
|
48
|
+
if (parts.length === 2) return { kind: 'open-files', uri, entity, id };
|
|
49
|
+
if (parts[2] === 'revision' && parts[3] && parts.length === 4) {
|
|
50
|
+
return { kind: 'open-files', uri, entity, id, revision_id: decodeURIComponent(parts[3]) };
|
|
51
|
+
}
|
|
52
|
+
throw new Error('Invalid open-files file ref. Expected open-files://file/<id>/revision/<revision_id>.');
|
|
53
|
+
}
|
|
54
|
+
const pathIndex = parts.indexOf('path');
|
|
55
|
+
const path = pathIndex >= 0 ? decodeURIComponent(parts.slice(pathIndex + 1).join('/')) : undefined;
|
|
56
|
+
return { kind: 'open-files', uri, entity, id, path };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseS3Ref(uri: string): S3SourceRef {
|
|
60
|
+
const parsed = new URL(uri);
|
|
61
|
+
const bucket = assertNonEmpty(parsed.hostname, 'Invalid s3 ref. Missing bucket.');
|
|
62
|
+
const key = decodeURIComponent(parsed.pathname.replace(/^\/+/, ''));
|
|
63
|
+
if (!key) throw new Error('Invalid s3 ref. Missing object key.');
|
|
64
|
+
return { kind: 's3', uri, bucket, key };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseFileRef(uri: string): FileSourceRef {
|
|
68
|
+
const parsed = new URL(uri);
|
|
69
|
+
return { kind: 'file', uri, path: decodeURIComponent(parsed.pathname) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseWebRef(uri: string): WebSourceRef {
|
|
73
|
+
const parsed = new URL(uri);
|
|
74
|
+
return { kind: 'web', uri, url: parsed.toString() };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseSourceRef(uri: string): SourceRef {
|
|
78
|
+
if (uri.startsWith('open-files://')) return parseOpenFilesRef(uri);
|
|
79
|
+
if (uri.startsWith('s3://')) return parseS3Ref(uri);
|
|
80
|
+
if (uri.startsWith('file://')) return parseFileRef(uri);
|
|
81
|
+
if (uri.startsWith('https://') || uri.startsWith('http://')) return parseWebRef(uri);
|
|
82
|
+
throw new Error(`Unsupported source ref scheme: ${uri}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isSupportedSourceRef(uri: string): boolean {
|
|
86
|
+
try {
|
|
87
|
+
parseSourceRef(uri);
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|