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