create-server.mjs
479 lines 19.0 KB
Raw
1 /**
2 * Build Knowtation MCP surface (tools, resources, prompts, Phase C, subscriptions).
3 * Used by stdio and Streamable HTTP transports.
4 */
5
6 import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7 import { z } from 'zod';
8 import { loadConfig } from '../lib/config.mjs';
9 import { readNote, resolveVaultRelativePath } from '../lib/vault.mjs';
10 import { runListNotes } from '../lib/list-notes.mjs';
11 import { runSearch } from '../lib/search.mjs';
12 import { runKeywordSearch } from '../lib/keyword-search.mjs';
13 import { runIndex } from '../lib/indexer.mjs';
14 import { writeNote } from '../lib/write.mjs';
15 import { exportNotes } from '../lib/export.mjs';
16 import { runImport } from '../lib/import.mjs';
17 import { IMPORT_SOURCE_TYPES, IMPORT_SOURCE_TYPES_HELP } from '../lib/import-source-types.mjs';
18 import { attestBeforeExport } from '../lib/air.mjs';
19 import { storeMemory, createMemoryManager } from '../lib/memory.mjs';
20 import { registerKnowtationResources } from './resources/register.mjs';
21 import { registerPhaseCTools } from './tools/phase-c.mjs';
22 import { registerMemoryTools } from './tools/memory.mjs';
23 import { registerHubProposalTools } from './tools/hub-proposals.mjs';
24 import { registerEnrichTool } from './tools/enrich.mjs';
25 import { rerankWithSampling } from './tools/sampling-rerank.mjs';
26 import { registerResourceSubscriptionHandlers, notifyIndexMetadataResources } from './resource-subscriptions.mjs';
27 import { sendMcpToolProgress, sendMcpLog } from './tool-telemetry.mjs';
28 import { registerKnowtationPrompts } from './prompts/register.mjs';
29 import { tryBuildKnowtationMcpInstructions } from './server-instructions.mjs';
30
31 export function jsonResponse(obj) {
32 return { content: [{ type: 'text', text: JSON.stringify(obj) }] };
33 }
34
35 export function jsonError(msg, code = 'ERROR') {
36 return { content: [{ type: 'text', text: JSON.stringify({ error: msg, code }) }], isError: true };
37 }
38
39 /**
40 * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
41 */
42 export function mountKnowtationMcp(server) {
43 server.registerTool(
44 'search',
45 {
46 description:
47 'Search the vault: semantic (vector similarity, default) or keyword (substring / all-terms over path, body, and key frontmatter). Same filters as list-notes where applicable.',
48 inputSchema: {
49 query: z.string().describe('Search query string'),
50 mode: z.enum(['semantic', 'keyword']).optional().describe('semantic = meaning (indexed); keyword = literal text'),
51 match: z.enum(['phrase', 'all_terms']).optional().describe('Keyword only: phrase = whole query substring; all_terms = every token must appear (AND)'),
52 folder: z.string().optional().describe('Filter by folder path prefix'),
53 project: z.string().optional().describe('Filter by project slug'),
54 tag: z.string().optional().describe('Filter by tag'),
55 limit: z.number().optional().describe('Max results (default 10)'),
56 fields: z.enum(['path', 'path+snippet', 'full']).optional().describe('Result shape'),
57 snippet_chars: z.number().optional().describe('Max snippet length'),
58 count_only: z.boolean().optional().describe('Return count only'),
59 since: z.string().optional().describe('Filter by date (YYYY-MM-DD)'),
60 until: z.string().optional().describe('Filter by date (YYYY-MM-DD)'),
61 order: z.enum(['date', 'date-asc']).optional(),
62 chain: z.string().optional().describe('Causal chain filter'),
63 entity: z.string().optional().describe('Entity filter'),
64 episode: z.string().optional().describe('Episode filter'),
65 content_scope: z.enum(['all', 'notes', 'approval_logs']).optional().describe('Restrict to note files vs approval logs'),
66 network: z.string().optional().describe('Phase 12: filter by blockchain network (e.g. icp, ethereum, sepolia)'),
67 wallet_address: z.string().optional().describe('Phase 12: filter by wallet address or principal'),
68 payment_status: z.string().optional().describe('Phase 12: filter by payment status (pending, settled, failed, cancelled)'),
69 rerank: z.boolean().optional().describe('Phase F4: rerank results via sampling (default true for semantic; requires client sampling support)'),
70 },
71 },
72 async (args) => {
73 try {
74 const config = loadConfig();
75 const base = {
76 folder: args.folder,
77 project: args.project,
78 tag: args.tag,
79 limit: args.limit ?? 10,
80 fields: args.fields ?? 'path+snippet',
81 snippetChars: args.snippet_chars ?? 300,
82 countOnly: args.count_only,
83 since: args.since,
84 until: args.until,
85 order: args.order,
86 chain: args.chain,
87 entity: args.entity,
88 episode: args.episode,
89 content_scope: args.content_scope === 'all' ? undefined : args.content_scope,
90 network: args.network,
91 wallet_address: args.wallet_address,
92 payment_status: args.payment_status,
93 };
94 const out =
95 args.mode === 'keyword'
96 ? await runKeywordSearch(args.query, { ...base, match: args.match === 'all_terms' ? 'all_terms' : 'phrase' }, config)
97 : await runSearch(args.query, base, config);
98 if (args.rerank !== false && args.mode !== 'keyword' && !args.count_only && Array.isArray(out.results) && out.results.length > 1) {
99 out.results = await rerankWithSampling(server, args.query, out.results, args.limit ?? 10);
100 }
101 if (config.memory?.enabled) {
102 try {
103 const mm = createMemoryManager(config);
104 if (mm.shouldCapture('search')) {
105 mm.store('search', {
106 query: out.query,
107 mode: args.mode || 'semantic',
108 paths: (out.results || []).map((r) => r.path),
109 count: out.count ?? (out.results || []).length,
110 });
111 }
112 } catch (_) {}
113 }
114 return jsonResponse(out);
115 } catch (e) {
116 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
117 }
118 }
119 );
120
121 server.registerTool(
122 'get_note',
123 {
124 description: 'Return full content of one note by vault-relative path.',
125 inputSchema: {
126 path: z.string().describe('Vault-relative path (e.g. vault/inbox/foo.md)'),
127 body_only: z.boolean().optional().describe('Return only body'),
128 frontmatter_only: z.boolean().optional().describe('Return only frontmatter'),
129 },
130 },
131 async (args) => {
132 try {
133 const config = loadConfig();
134 resolveVaultRelativePath(config.vault_path, args.path);
135 const note = readNote(config.vault_path, args.path);
136 if (args.body_only) {
137 return jsonResponse({ path: note.path, body: note.body });
138 }
139 if (args.frontmatter_only) {
140 return jsonResponse({ path: note.path, frontmatter: note.frontmatter });
141 }
142 return jsonResponse({ path: note.path, frontmatter: note.frontmatter, body: note.body });
143 } catch (e) {
144 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
145 }
146 }
147 );
148
149 server.registerTool(
150 'list_notes',
151 {
152 description: 'List notes with optional filters (folder, project, tag, date range, blockchain fields).',
153 inputSchema: {
154 folder: z.string().optional(),
155 project: z.string().optional(),
156 tag: z.string().optional(),
157 since: z.string().optional(),
158 until: z.string().optional(),
159 chain: z.string().optional(),
160 entity: z.string().optional(),
161 episode: z.string().optional(),
162 limit: z.number().optional(),
163 offset: z.number().optional(),
164 order: z.enum(['date', 'date-asc']).optional(),
165 fields: z.enum(['path', 'path+metadata', 'full']).optional(),
166 count_only: z.boolean().optional(),
167 network: z.string().optional().describe('Phase 12: filter by blockchain network (e.g. icp, ethereum)'),
168 wallet_address: z.string().optional().describe('Phase 12: filter by wallet address or principal'),
169 payment_status: z.string().optional().describe('Phase 12: filter by payment status (pending, settled, failed, cancelled)'),
170 },
171 },
172 async (args) => {
173 try {
174 const config = loadConfig();
175 const out = runListNotes(config, {
176 folder: args.folder,
177 project: args.project,
178 tag: args.tag,
179 since: args.since,
180 until: args.until,
181 chain: args.chain,
182 entity: args.entity,
183 episode: args.episode,
184 limit: args.limit ?? 20,
185 offset: args.offset ?? 0,
186 order: args.order ?? 'date',
187 fields: args.fields ?? 'path+metadata',
188 countOnly: args.count_only,
189 network: args.network,
190 wallet_address: args.wallet_address,
191 payment_status: args.payment_status,
192 });
193 return jsonResponse(out);
194 } catch (e) {
195 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
196 }
197 }
198 );
199
200 server.registerTool(
201 'index',
202 {
203 description: 'Re-run indexer: vault → chunk → embed → vector store. With enrich=true, generate per-note summaries via sampling after indexing.',
204 inputSchema: {
205 enrich: z.boolean().optional().describe('Phase F3: generate per-note summaries via sampling after indexing (default false, expensive)'),
206 enrich_limit: z.number().optional().describe('Max notes to enrich (default 50)'),
207 },
208 },
209 async (args, extra) => {
210 try {
211 const t0 = Date.now();
212 const result = await runIndex({
213 onProgress: async (p) => {
214 await sendMcpToolProgress(extra, {
215 progress: p.progress,
216 total: p.total,
217 message: p.message,
218 });
219 },
220 });
221 await notifyIndexMetadataResources(server);
222 const config = loadConfig();
223 let enriched = 0;
224 if (args?.enrich) {
225 const { enrichIndexedNotes } = await import('./tools/index-enrich.mjs');
226 enriched = await enrichIndexedNotes(server, config, {
227 limit: args.enrich_limit ?? 50,
228 onProgress: async (done, total) => {
229 await sendMcpToolProgress(extra, {
230 progress: result.notesProcessed + done,
231 total: result.notesProcessed + total,
232 message: `enriching ${done}/${total}`,
233 });
234 },
235 });
236 }
237 if (config.memory?.enabled) {
238 try {
239 const mm = createMemoryManager(config);
240 if (mm.shouldCapture('index')) {
241 mm.store('index', {
242 notes_processed: result.notesProcessed,
243 chunks_indexed: result.chunksIndexed,
244 duration_ms: Date.now() - t0,
245 enriched,
246 });
247 }
248 } catch (_) {}
249 }
250 await sendMcpLog(server, 'info', {
251 event: 'index_complete',
252 notesProcessed: result.notesProcessed,
253 chunksIndexed: result.chunksIndexed,
254 enriched,
255 });
256 return jsonResponse({ ok: true, notesProcessed: result.notesProcessed, chunksIndexed: result.chunksIndexed, enriched });
257 } catch (e) {
258 await sendMcpLog(server, 'error', { event: 'index_failed', message: e.message || String(e) });
259 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
260 }
261 }
262 );
263
264 server.registerTool(
265 'write',
266 {
267 description: 'Create or overwrite a note. Use body for content, frontmatter for key=value pairs.',
268 inputSchema: {
269 path: z.string().describe('Vault-relative path'),
270 body: z.string().optional().describe('Note body content'),
271 frontmatter: z.record(z.string(), z.string()).optional().describe('Frontmatter as key-value'),
272 append: z.boolean().optional().describe('Append body to existing'),
273 },
274 },
275 async (args, _extra) => {
276 try {
277 const config = loadConfig();
278 const result = await writeNote(config.vault_path, args.path, {
279 body: args.body,
280 frontmatter: args.frontmatter,
281 append: args.append,
282 config,
283 });
284 if (config.memory?.enabled) {
285 try {
286 const mm = createMemoryManager(config);
287 if (mm.shouldCapture('write')) {
288 mm.store('write', {
289 path: result.path,
290 action: args.append ? 'append' : 'create',
291 air_id: result.air_id || undefined,
292 });
293 }
294 } catch (_) {}
295 }
296 const fm = args.frontmatter;
297 if (fm && Object.keys(fm).length > 0 && fm.title === undefined) {
298 await sendMcpLog(server, 'warning', {
299 event: 'write_missing_title',
300 path: args.path,
301 });
302 }
303 return jsonResponse(result);
304 } catch (e) {
305 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
306 }
307 }
308 );
309
310 server.registerTool(
311 'export',
312 {
313 description: 'Export note(s) to file or directory. path_or_query can be a vault path or search query.',
314 inputSchema: {
315 path_or_query: z.string().describe('Vault path (e.g. vault/inbox/foo.md) or search query'),
316 output: z.string().describe('Output file or directory path'),
317 format: z.enum(['md', 'html']).optional(),
318 project: z.string().optional().describe('Project filter when path_or_query is a query'),
319 },
320 },
321 async (args) => {
322 try {
323 const config = loadConfig();
324 let paths = [];
325 const looksLikePath =
326 !args.path_or_query.includes(' ') &&
327 (args.path_or_query.endsWith('.md') || args.path_or_query.includes('/'));
328 if (looksLikePath) {
329 try {
330 resolveVaultRelativePath(config.vault_path, args.path_or_query);
331 paths = [args.path_or_query];
332 } catch (_) {
333 // Fall through: treat as query
334 }
335 }
336 if (paths.length === 0) {
337 const result = await runSearch(args.path_or_query, {
338 limit: 50,
339 project: args.project,
340 fields: 'path',
341 });
342 paths = (result.results || []).map((r) => r.path).filter(Boolean);
343 }
344 if (!paths.length) {
345 return jsonError('No notes found for path or query', 'RUNTIME_ERROR');
346 }
347 if (config.air?.enabled) {
348 await attestBeforeExport(config, paths);
349 }
350 const result = exportNotes(config.vault_path, paths, args.output, { format: args.format ?? 'md' });
351 if (config.memory?.enabled) {
352 try {
353 const mm = createMemoryManager(config);
354 if (mm.shouldCapture('export')) {
355 mm.store('export', { provenance: result.provenance, exported: result.exported, format: args.format ?? 'md' });
356 }
357 } catch (_) {}
358 }
359 return jsonResponse({ exported: result.exported, provenance: result.provenance });
360 } catch (e) {
361 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
362 }
363 }
364 );
365
366 server.registerTool(
367 'import',
368 {
369 description: `Import from external source. source_type must be one of: ${IMPORT_SOURCE_TYPES_HELP}. For source_type "url", input is a full https URL string. For source_type "pdf", input is a filesystem path to a .pdf file. For source_type "docx", input is a filesystem path to a .docx file.`,
370 inputSchema: {
371 source_type: z
372 .enum(
373 /** @type {[string, string, ...string[]]} */ ([...IMPORT_SOURCE_TYPES])
374 )
375 .describe('Import source type'),
376 input: z.string().describe('Path to file, folder, export, or https URL when source_type is url'),
377 project: z.string().optional(),
378 output_dir: z.string().optional().describe('Vault-relative output directory'),
379 tags: z.array(z.string()).optional(),
380 dry_run: z.boolean().optional(),
381 url_mode: z
382 .enum(['auto', 'bookmark', 'extract'])
383 .optional()
384 .describe('When source_type is url: capture mode (default auto)'),
385 },
386 },
387 async (args, extra) => {
388 try {
389 await sendMcpToolProgress(extra, { progress: 0, message: `import start: ${args.source_type}` });
390 const config = loadConfig();
391 let mm;
392 if (config.memory?.enabled && !args.dry_run) {
393 try { mm = createMemoryManager(config); } catch (_) {}
394 }
395 const importOpts = {
396 project: args.project,
397 outputDir: args.output_dir,
398 tags: args.tags || [],
399 dryRun: args.dry_run,
400 ...(args.source_type === 'url' && args.url_mode ? { urlMode: args.url_mode } : {}),
401 onProgress: async (p) => {
402 await sendMcpToolProgress(extra, {
403 progress: p.progress,
404 total: p.total,
405 message: p.message,
406 });
407 },
408 };
409 if (mm && args.source_type === 'mem0-export' && mm.shouldCapture('capture')) {
410 importOpts.onMemoryEvent = (data) => {
411 try { mm.store('capture', data); } catch (_) {}
412 };
413 }
414 const result = await runImport(args.source_type, args.input, importOpts);
415 const n = result.count ?? 0;
416 if (mm) {
417 try {
418 if (mm.shouldCapture('import')) {
419 mm.store('import', {
420 source_type: args.source_type,
421 count: n,
422 paths: (result.imported || []).map((r) => r.path).slice(0, 50),
423 project: args.project || undefined,
424 });
425 }
426 } catch (_) {}
427 }
428 await sendMcpToolProgress(extra, {
429 progress: Math.max(1, n),
430 total: Math.max(1, n),
431 message: 'import complete',
432 });
433 await sendMcpLog(server, 'info', {
434 event: 'import_complete',
435 source_type: args.source_type,
436 count: result.count,
437 });
438 return jsonResponse({ imported: result.imported, count: result.count });
439 } catch (e) {
440 await sendMcpLog(server, 'error', { event: 'import_failed', message: e.message || String(e) });
441 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
442 }
443 }
444 );
445
446 registerKnowtationResources(server);
447 registerKnowtationPrompts(server);
448 registerPhaseCTools(server);
449 registerMemoryTools(server);
450 registerHubProposalTools(server);
451 registerEnrichTool(server);
452 registerResourceSubscriptionHandlers(server);
453 }
454
455 /**
456 * @returns {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer}
457 */
458 export function createKnowtationMcpServer() {
459 const instructions = tryBuildKnowtationMcpInstructions();
460 const server = new McpServer(
461 { name: 'knowtation', version: '0.1.0' },
462 { capabilities: { logging: {} }, instructions }
463 );
464 mountKnowtationMcp(server);
465 server.server.oninitialized = async () => {
466 const caps = server.server.getClientCapabilities?.();
467 if (!caps?.roots) return;
468 try {
469 const { roots } = await server.server.listRoots();
470 await sendMcpLog(server, 'info', {
471 event: 'client_roots',
472 roots: (roots || []).map((r) => ({ uri: r.uri, name: r.name })),
473 });
474 } catch (_) {
475 /* client may not implement roots/list */
476 }
477 };
478 return server;
479 }
File History 1 commit