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