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