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