contentbase 0.1.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CNAME +1 -0
  2. package/LAUNCH-PLAN.md +419 -0
  3. package/README.md +90 -0
  4. package/index.html +1636 -0
  5. package/package.json +5 -4
  6. package/src/adapters/node-fs.ts +80 -0
  7. package/src/cli/commands/api/endpoints/doc.ts +2 -2
  8. package/src/cli/commands/api/endpoints/document.ts +2 -2
  9. package/src/cli/commands/api/endpoints/search-reindex.ts +6 -25
  10. package/src/cli/commands/api/endpoints/search-status.ts +3 -20
  11. package/src/cli/commands/api/endpoints/search.ts +3 -33
  12. package/src/cli/commands/api/endpoints/semantic-search.ts +3 -33
  13. package/src/cli/commands/embed.ts +8 -81
  14. package/src/cli/commands/mcp.ts +5 -1128
  15. package/src/cli/commands/search.ts +25 -90
  16. package/src/cli/commands/serve.ts +7 -32
  17. package/src/cli/index.ts +1 -1
  18. package/src/cli/load-collection.ts +2 -2
  19. package/src/collection.ts +42 -33
  20. package/src/document.ts +18 -0
  21. package/src/extract-sections.ts +3 -0
  22. package/src/index.ts +10 -0
  23. package/src/mcp/helpers.ts +7 -0
  24. package/src/mcp/model-info.ts +102 -0
  25. package/src/mcp/prompts.ts +199 -0
  26. package/src/mcp/readme.ts +153 -0
  27. package/src/mcp/resources.ts +89 -0
  28. package/src/mcp/server.ts +103 -0
  29. package/src/mcp/tools/mutation.ts +176 -0
  30. package/src/mcp/tools/query.ts +141 -0
  31. package/src/mcp/tools/search.ts +180 -0
  32. package/src/parse.ts +5 -0
  33. package/src/query/query-dsl.ts +1 -1
  34. package/src/relationships/has-many.ts +4 -2
  35. package/src/search/document-inputs.ts +85 -0
  36. package/src/search/luca-semantic-search.ts +75 -0
  37. package/src/types.ts +54 -0
  38. package/src/utils/index.ts +2 -0
  39. package/src/utils/match-pattern.ts +2 -2
  40. package/src/utils/strip-markdown.ts +59 -0
@@ -1,14 +1,6 @@
1
1
  import { z } from 'zod'
2
- import path from 'node:path'
3
- import fs from 'node:fs/promises'
4
- import { existsSync, readdirSync } from 'node:fs'
5
- import matter from 'gray-matter'
6
2
  import { commands } from '../registry.js'
7
- import { loadCollection } from '../load-collection.js'
8
- import { introspectMetaSchema, validateDocument } from '../../index.js'
9
- import { resolveModelDef, buildSchemaJSON } from './api/helpers.js'
10
- import { queryDSLSchema, executeQueryDSL } from '../../query/query-dsl.js'
11
- // MCPServer type comes from container.server('mcp', ...) at runtime
3
+ import { startMcpServer } from '../../mcp/server.js'
12
4
 
13
5
  const argsSchema = z.object({
14
6
  transport: z.enum(['stdio', 'http']).default('stdio'),
@@ -20,1138 +12,23 @@ const argsSchema = z.object({
20
12
  watch: z.boolean().default(true),
21
13
  })
22
14
 
23
- // ---------------------------------------------------------------------------
24
- // Helpers
25
- // ---------------------------------------------------------------------------
26
-
27
- function errorResult(message: string) {
28
- return { content: [{ type: 'text' as const, text: message }], isError: true }
29
- }
30
-
31
- function textResult(text: string) {
32
- return { content: [{ type: 'text' as const, text }] }
33
- }
34
-
35
- // ---------------------------------------------------------------------------
36
- // read_me generator
37
- // ---------------------------------------------------------------------------
38
-
39
- function generateReadMe(collection: any, modelDefs: any[]) {
40
- const lines: string[] = []
41
-
42
- // Section 1: The Rules
43
- const rootPath = collection.rootPath as string
44
-
45
- lines.push(
46
- '# Contentbase Collection Guide',
47
- '',
48
- `> **This collection is located at \`${rootPath}\`.**`,
49
- '',
50
- '## Rules — READ CAREFULLY',
51
- '',
52
- `**Every markdown file under \`${rootPath}\` is a structured document governed by a model schema.** These are NOT freeform files. They have required frontmatter fields, expected section headings, and validation rules. Treat them accordingly.`,
53
- '',
54
- '1. **DO NOT write or edit markdown files directly** — not with Write, Edit, cat, echo, sed, or any other means. Use `create_document` to scaffold new documents. Use `update_section` to edit section content. Use `update_document` to change frontmatter. These tools ensure the document structure stays valid.',
55
- '2. **ALWAYS call `validate` after ANY mutation** — after creating, updating frontmatter, or editing sections. No exceptions. If validation fails, fix the document before moving on.',
56
- '3. **Use MCP tools to read content** — not cat, Read, or raw file access. Use `query` to fetch documents by criteria, `search_content` for full-text search, and `list_documents` for discovery.',
57
- '4. **A document\'s folder prefix = its model = its contract.** The prefix determines which schema governs the file — what frontmatter fields are required, what sections are expected, and what values are valid. Do not guess. Call `get_model_info` if you are unsure.',
58
- '5. **Every document MUST have a title** — either an H1 heading (`# Title`) or a `title` field in frontmatter. Validation will fail without one, unless the model sets `titleOptional: true`.',
59
- '',
60
- )
61
-
62
- // Section 2: Models in This Collection
63
- lines.push('## Models in This Collection', '')
64
-
65
- for (const def of modelDefs) {
66
- const name = def.name as string
67
- const prefix = def.prefix as string
68
- const description = def.description || ''
69
- const prefixDocs = collection.available.filter((id: string) => id.startsWith(prefix + '/'))
70
- const docCount = prefixDocs.length
71
-
72
- lines.push(`### ${name}`, '')
73
- lines.push(`- **Prefix:** \`${prefix}/\``)
74
- lines.push(`- **Documents:** ${docCount}`)
75
- if (description) lines.push(`- **Description:** ${description}`)
76
- lines.push('')
77
-
78
- // Fields
79
- const fields = introspectMetaSchema(def.meta)
80
- if (fields.length > 0) {
81
- lines.push('**Frontmatter Fields:**', '')
82
- lines.push('| Field | Type | Required | Default | Description |')
83
- lines.push('|-------|------|----------|---------|-------------|')
84
- for (const f of fields as any[]) {
85
- const req = f.required ? 'yes' : 'no'
86
- const def_val = f.defaultValue !== undefined ? `\`${JSON.stringify(f.defaultValue)}\`` : ''
87
- const desc = f.description || ''
88
- lines.push(`| ${f.name} | ${f.type} | ${req} | ${def_val} | ${desc} |`)
89
- }
90
- lines.push('')
91
- }
92
-
93
- // Sections
94
- const sections = Object.entries(def.sections || {})
95
- if (sections.length > 0) {
96
- lines.push('**Sections:**', '')
97
- for (const [key, sec] of sections as [string, any][]) {
98
- lines.push(`- **${sec.heading}** (key: \`${key}\`)${sec.schema ? ' — validated' : ''}`)
99
- }
100
- lines.push('')
101
- }
102
-
103
- // Relationships
104
- const relationships = Object.entries(def.relationships || {})
105
- if (relationships.length > 0) {
106
- lines.push('**Relationships:**', '')
107
- for (const [key, rel] of relationships as [string, any][]) {
108
- lines.push(`- \`${key}\` → ${rel.type} **${rel.model}**`)
109
- }
110
- lines.push('')
111
- }
112
-
113
- // Computed & Scopes
114
- const computedKeys = Object.keys(def.computed || {})
115
- const scopeKeys = Object.keys(def.scopes || {})
116
- if (computedKeys.length > 0) lines.push(`**Computed:** ${computedKeys.join(', ')}`, '')
117
- if (scopeKeys.length > 0) lines.push(`**Scopes:** ${scopeKeys.join(', ')}`, '')
118
- }
119
-
120
- // Section 3: Capability Map
121
- lines.push(
122
- '## Capability Map',
123
- '',
124
- '| Intent | Tool |',
125
- '|--------|------|',
126
- '| Orientation & guidance | `read_me` |',
127
- '| See what models exist | `inspect` |',
128
- '| Deep-dive one model | `get_model_info` |',
129
- '| List documents | `list_documents` |',
130
- '| Find by criteria | `query` |',
131
- '| Full-text search | `search_content` |',
132
- '| File-level grep | `text_search` |',
133
- '| Keyword search (BM25) | `keyword_search` |',
134
- '| Semantic search (embeddings) | `semantic_search` |',
135
- '| Hybrid search (keyword + semantic) | `hybrid_search` |',
136
- '| Create new document | `create_document` |',
137
- '| Edit a section | `update_section` |',
138
- '| Update frontmatter | `update_document` |',
139
- '| Validate a document | `validate` |',
140
- '| Delete a document | `delete_document` |',
141
- '| Run a collection action | `run_action` |',
142
- '',
143
- )
144
-
145
- // Section 4: Workflow
146
- lines.push(
147
- '## Recommended Workflow',
148
- '',
149
- '1. **Orientation** — Call `read_me` (this tool) at the start of every session.',
150
- '2. **Discovery** — Use `list_documents` or `query` to find what exists.',
151
- '3. **Reading** — Use `query` with `select` to fetch specific fields, or read a document resource.',
152
- '4. **Creating** — Use `create_document` with the correct prefix. It scaffolds frontmatter and sections.',
153
- '5. **Editing** — Use `update_section` for targeted section edits, `update_document` for frontmatter.',
154
- '6. **Validation** — Always call `validate` after creating or editing.',
155
- '',
156
- )
157
-
158
- // Section 5: Query Quick Reference
159
- lines.push(
160
- '## Query Quick Reference',
161
- '',
162
- 'The `query` tool uses MongoDB-style DSL:',
163
- '',
164
- '- Literal value → `$eq`: `"meta.status": "active"`',
165
- '- Array → `$in`: `"meta.tags": ["a", "b"]`',
166
- '- Operator object: `"meta.priority": { "$gt": 5 }`',
167
- '- Operators: `$eq`, `$neq`, `$in`, `$notIn`, `$gt`, `$lt`, `$gte`, `$lte`, `$contains`, `$startsWith`, `$endsWith`, `$regex`, `$exists`',
168
- '- Supports `sort`, `limit`, `offset`, `select`, `scopes`, `method` (fetchAll/first/last/count)',
169
- '',
170
- )
171
-
172
- // Section 6: Document Anatomy
173
- lines.push(
174
- '## Document Anatomy',
175
- '',
176
- '```markdown',
177
- '---',
178
- 'field: value # YAML frontmatter (model schema)',
179
- '---',
180
- '# Document Title # H1 = title',
181
- '',
182
- '## Section Name # H2 = sections (defined by model)',
183
- '',
184
- 'Content here...',
185
- '```',
186
- )
187
-
188
- return lines.join('\n')
189
- }
190
-
191
- // ---------------------------------------------------------------------------
192
- // Model info generator
193
- // ---------------------------------------------------------------------------
194
-
195
- function generateModelInfo(collection: any, def: any) {
196
- const lines: string[] = []
197
- const name = def.name as string
198
- const prefix = def.prefix as string
199
- const description = def.description || ''
200
- const prefixDocs = collection.available.filter((id: string) => id.startsWith(prefix + '/'))
201
-
202
- lines.push(`# Model: ${name}`, '')
203
- lines.push(`- **Prefix:** \`${prefix}/\``)
204
- lines.push(`- **Documents:** ${prefixDocs.length}`)
205
- if (description) lines.push(`- **Description:** ${description}`)
206
- lines.push('')
207
-
208
- // Fields
209
- const fields = introspectMetaSchema(def.meta)
210
- if (fields.length > 0) {
211
- lines.push('## Frontmatter Fields', '')
212
- lines.push('| Field | Type | Required | Default | Description |')
213
- lines.push('|-------|------|----------|---------|-------------|')
214
- for (const f of fields as any[]) {
215
- const req = f.required ? 'yes' : 'no'
216
- const def_val = f.defaultValue !== undefined ? `\`${JSON.stringify(f.defaultValue)}\`` : ''
217
- const desc = f.description || ''
218
- lines.push(`| ${f.name} | ${f.type} | ${req} | ${def_val} | ${desc} |`)
219
- }
220
- lines.push('')
221
- }
222
-
223
- // Sections
224
- const sections = Object.entries(def.sections || {})
225
- if (sections.length > 0) {
226
- lines.push('## Sections', '')
227
- for (const [key, sec] of sections as [string, any][]) {
228
- lines.push(`- **${sec.heading}** (key: \`${key}\`)${sec.schema ? ' — has schema validation' : ''}`)
229
- if (sec.alternatives?.length) {
230
- lines.push(` Alternatives: ${sec.alternatives.join(', ')}`)
231
- }
232
- }
233
- lines.push('')
234
- }
235
-
236
- // Relationships
237
- const relationships = Object.entries(def.relationships || {})
238
- if (relationships.length > 0) {
239
- lines.push('## Relationships', '')
240
- for (const [key, rel] of relationships as [string, any][]) {
241
- lines.push(`- \`${key}\` → ${rel.type} **${rel.model}**`)
242
- }
243
- lines.push('')
244
- }
245
-
246
- // Computed & Scopes
247
- const computedKeys = Object.keys(def.computed || {})
248
- if (computedKeys.length > 0) {
249
- lines.push('## Computed Properties', '')
250
- lines.push(computedKeys.map(k => `- \`${k}\``).join('\n'))
251
- lines.push('')
252
- }
253
-
254
- const scopeKeys = Object.keys(def.scopes || {})
255
- if (scopeKeys.length > 0) {
256
- lines.push('## Named Scopes', '')
257
- lines.push(scopeKeys.map(k => `- \`${k}\``).join('\n'))
258
- lines.push('')
259
- }
260
-
261
- // Existing documents
262
- if (prefixDocs.length > 0) {
263
- lines.push('## Existing Documents', '')
264
- for (const id of prefixDocs) {
265
- lines.push(`- \`${id}\``)
266
- }
267
- lines.push('')
268
- }
269
-
270
- // Example scaffold
271
- const defaultMeta: Record<string, any> = {}
272
- for (const f of fields as any[]) {
273
- if (f.defaultValue !== undefined) {
274
- defaultMeta[f.name] = f.defaultValue
275
- } else if (f.required) {
276
- defaultMeta[f.name] = `<${f.type}>`
277
- }
278
- }
279
- const sectionHeadings = sections.map(([, sec]: [string, any]) => `## ${sec.heading}\n\n`)
280
-
281
- lines.push('## Example Document', '')
282
- lines.push('```markdown')
283
- lines.push('---')
284
- for (const [k, v] of Object.entries(defaultMeta)) {
285
- lines.push(`${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
286
- }
287
- lines.push('---')
288
- lines.push(`# Your Title Here`)
289
- lines.push('')
290
- lines.push(sectionHeadings.join(''))
291
- lines.push('```')
292
-
293
- return lines.join('\n')
294
- }
295
-
296
- // ---------------------------------------------------------------------------
297
- // Command handler
298
- // ---------------------------------------------------------------------------
299
-
300
15
  async function handler(options: z.infer<typeof argsSchema>, context: { container: any }) {
301
16
  const container = context.container
302
- const envCompat = process.env.MCP_HTTP_COMPAT?.toLowerCase()
303
- const resolvedCompat = options.mcpCompat || (envCompat === 'codex' ? 'codex' : 'standard')
304
- const envStdioCompat = process.env.MCP_STDIO_COMPAT?.toLowerCase()
305
- const resolvedStdioCompat = options.stdioCompat
306
- || (envStdioCompat === 'codex' || envStdioCompat === 'auto' ? envStdioCompat : 'standard')
307
17
 
308
18
  // Resolve content folder: positional arg > --contentFolder > ./docs
309
19
  const positionalFolder = container.argv._[1] as string | undefined
310
20
  const contentFolder = positionalFolder || options.contentFolder || undefined
311
21
  const modulePath = options.modulePath || undefined
312
22
 
313
- const collection = await loadCollection({ contentFolder, modulePath })
314
- const modelDefs = collection.modelDefinitions as any[]
315
-
316
- console.error(`[cnotes mcp] Loaded collection: ${collection.rootPath}`)
317
- console.error(`[cnotes mcp] Models: ${modelDefs.map((d: any) => d.name).join(', ') || '(none)'}`)
318
- console.error(`[cnotes mcp] Documents: ${collection.available.length}`)
319
-
320
- const mcpServer = container.server('mcp', {
321
- transport: options.transport,
322
- port: options.port,
323
- serverName: 'contentbase',
324
- serverVersion: '1.0.0',
325
- mcpCompat: options.mcpCompat,
326
- stdioCompat: options.stdioCompat,
327
- }) as any
328
-
329
- // =========================================================================
330
- // RESOURCES
331
- // =========================================================================
332
-
333
- mcpServer.resource('contentbase://schema', {
334
- name: 'Collection Schema',
335
- description: 'JSON schema of all registered models — fields, sections, relationships, computed properties',
336
- mimeType: 'application/json',
337
- handler: () => JSON.stringify(buildSchemaJSON(collection), null, 2),
338
- })
339
-
340
- mcpServer.resource('contentbase://toc', {
341
- name: 'Table of Contents',
342
- description: 'Markdown table of contents for all documents in the collection',
343
- mimeType: 'text/markdown',
344
- handler: () => collection.tableOfContents({ title: 'Table of Contents' }),
345
- })
346
-
347
- mcpServer.resource('contentbase://models-summary', {
348
- name: 'Models Summary',
349
- description: 'Generated README.md describing all model definitions with attributes, sections, and relationships',
350
- mimeType: 'text/markdown',
351
- handler: () => collection.generateModelSummary(),
352
- })
353
-
354
- mcpServer.resource('contentbase://primer', {
355
- name: 'Contentbase Primer',
356
- description: 'Combined teach output — models summary, table of contents, CLI reference, and API primer',
357
- mimeType: 'text/markdown',
358
- handler: async () => {
359
- const modelsSummary = collection.generateModelSummary()
360
- const toc = collection.tableOfContents({ title: 'Table of Contents' })
361
-
362
- const packageRoot = path.resolve(import.meta.dir, '../../..')
363
- let primer = ''
364
- let cli = ''
365
- try {
366
- primer = await fs.readFile(path.join(packageRoot, 'PRIMER.md'), 'utf8')
367
- } catch {}
368
- try {
369
- cli = await fs.readFile(path.join(packageRoot, 'CLI.md'), 'utf8')
370
- } catch {}
371
-
372
- return [
373
- modelsSummary.trimEnd(),
374
- '',
375
- '---',
376
- '',
377
- toc.trimEnd(),
378
- '',
379
- '---',
380
- '',
381
- cli.trimEnd(),
382
- '',
383
- '---',
384
- '',
385
- primer.trimEnd(),
386
- '',
387
- ].join('\n')
388
- },
389
- })
390
-
391
- // Per-document resources
392
- for (const pathId of collection.available) {
393
- const uri = `contentbase://documents/${pathId}`
394
- const doc = collection.document(pathId)
395
- mcpServer.resource(uri, {
396
- name: doc.title || pathId,
397
- description: `Document: ${pathId}`,
398
- mimeType: 'application/json',
399
- handler: () => {
400
- const d = collection.document(pathId)
401
- const modelDef = collection.findModelDefinition(pathId)
402
- return JSON.stringify({
403
- id: d.id,
404
- title: d.title,
405
- meta: d.meta,
406
- content: d.content,
407
- outline: d.toOutline(),
408
- model: modelDef?.name || null,
409
- createdAt: d.createdAt,
410
- updatedAt: d.updatedAt,
411
- size: d.size,
412
- }, null, 2)
413
- },
414
- })
415
- }
416
-
417
- // =========================================================================
418
- // TOOLS
419
- // =========================================================================
420
-
421
- // -- read_me: entry-point guidance for AI agents --
422
- const readMeContent = generateReadMe(collection, modelDefs)
423
-
424
- mcpServer.tool('read_me', {
425
- description: [
426
- 'Returns the content collection guide. Call this BEFORE working with any documents.',
427
- 'Contains model definitions, available tools, query syntax, and recommended workflow.',
428
- 'Call this at the start of every session to understand the collection structure.',
429
- ].join('\n'),
430
- schema: z.object({}),
431
- handler: () => textResult(readMeContent),
432
- })
433
-
434
- mcpServer.tool('inspect', {
435
- description: 'Overview of the collection — registered models, document count, available actions. Call `read_me` first if this is your first interaction.',
436
- schema: z.object({}),
437
- handler: () => {
438
- const schema = buildSchemaJSON(collection)
439
- const overview = {
440
- rootPath: collection.rootPath,
441
- documentCount: collection.available.length,
442
- models: Object.values(schema),
443
- actions: collection.availableActions,
444
- }
445
- return textResult(JSON.stringify(overview, null, 2))
446
- },
447
- })
448
-
449
- mcpServer.tool('get_model_info', {
450
- description: 'Get detailed information about a single model — fields, sections, relationships, example document. Use when you need to understand a model before creating or editing its documents.',
451
- schema: z.object({
452
- model: z.string().describe('Model name or prefix'),
453
- }),
454
- handler: (args) => {
455
- const def = resolveModelDef(collection, args.model)
456
- if (!def) {
457
- return errorResult(`Unknown model: ${args.model}. Available: ${modelDefs.map((d: any) => d.name).join(', ')}`)
458
- }
459
- return textResult(generateModelInfo(collection, def))
460
- },
461
- })
462
-
463
- mcpServer.tool('list_documents', {
464
- description: 'List all document path IDs in the collection, optionally filtered by model name or prefix. The prefix before the slash indicates the model.',
465
- schema: z.object({
466
- model: z.string().optional().describe('Filter by model name or prefix'),
467
- }),
468
- handler: (args) => {
469
- let ids = collection.available
470
-
471
- if (args.model) {
472
- const def = resolveModelDef(collection, args.model)
473
- if (def) {
474
- const prefix = (def as any).prefix + '/'
475
- ids = ids.filter((id: string) => id.startsWith(prefix))
476
- } else {
477
- return errorResult(`Unknown model: ${args.model}. Available: ${modelDefs.map((d: any) => d.name).join(', ')}`)
478
- }
479
- }
480
-
481
- return textResult(JSON.stringify(ids, null, 2))
482
- },
483
- })
484
-
485
- mcpServer.tool('query', {
486
- description: [
487
- 'Query typed model instances with MongoDB-style filtering. See `read_me` output for full syntax reference.',
488
- 'Where clause: keys are dot-notation paths, values are literals (implies $eq),',
489
- 'arrays (implies $in), or operator objects like { "$gt": 5 }.',
490
- 'Operators: $eq, $neq, $in, $notIn, $gt, $lt, $gte, $lte,',
491
- '$contains, $startsWith, $endsWith, $regex, $exists.',
492
- 'Supports sort, limit, offset, select, and method (fetchAll/first/last/count).',
493
- ].join(' '),
494
- schema: z.object({
495
- model: z.string().describe('Model name to query'),
496
- where: z.any().optional().describe(
497
- 'MongoDB-style where clause. Keys are field paths, values are literals (implicit $eq), arrays (implicit $in), or operator objects like { "$gt": 5 }. Also accepts legacy array format for backward compat.',
498
- ),
499
- sort: z.record(z.string(), z.enum(['asc', 'desc'])).optional().describe(
500
- 'Sort specification, e.g. { "meta.priority": "desc" }',
501
- ),
502
- select: z.array(z.string()).optional().describe('Fields to include in output (default: all)'),
503
- related: z.array(z.string()).optional().describe('Relationship names to include in results (e.g. ["plans", "goal"])'),
504
- scopes: z.array(z.string()).optional().describe('Named scopes to apply before filtering'),
505
- limit: z.number().optional().describe('Maximum results to return'),
506
- offset: z.number().optional().describe('Number of results to skip'),
507
- method: z.enum(['fetchAll', 'first', 'last', 'count']).optional().describe(
508
- 'Terminal operation (default: fetchAll)',
509
- ),
510
- }),
511
- handler: async (args) => {
512
- try {
513
- // Backward compat: convert legacy array-style where to MongoDB-style
514
- let whereClause = args.where
515
- if (Array.isArray(whereClause)) {
516
- const converted: Record<string, unknown> = {}
517
- for (const cond of whereClause) {
518
- const op = cond.operator || 'eq'
519
- if (op === 'eq') {
520
- converted[cond.path] = cond.value
521
- } else if (op === 'notExists') {
522
- converted[cond.path] = { $exists: false }
523
- } else if (op === 'exists') {
524
- converted[cond.path] = { $exists: true }
525
- } else {
526
- converted[cond.path] = { [`$${op}`]: cond.value }
527
- }
528
- }
529
- whereClause = converted
530
- }
531
-
532
- const dsl = queryDSLSchema.parse({
533
- model: args.model,
534
- where: whereClause,
535
- sort: args.sort,
536
- select: args.select,
537
- related: args.related,
538
- scopes: args.scopes,
539
- limit: args.limit,
540
- offset: args.offset,
541
- method: args.method,
542
- })
543
-
544
- const result = await executeQueryDSL(collection, dsl)
545
- return textResult(JSON.stringify(result, null, 2))
546
- } catch (error: any) {
547
- return errorResult(error.message)
548
- }
549
- },
550
- })
551
-
552
- mcpServer.tool('search_content', {
553
- description: 'Full-text regex search across all document content. Returns matching document IDs with context. Searches document body text, not metadata — for metadata filtering, use `query` instead.',
554
- schema: z.object({
555
- pattern: z.string().describe('Regex pattern to search for'),
556
- model: z.string().optional().describe('Limit search to a specific model'),
557
- caseSensitive: z.boolean().default(false).describe('Case-sensitive matching'),
558
- }),
559
- handler: (args) => {
560
- const flags = args.caseSensitive ? 'g' : 'gi'
561
- let regex: RegExp
562
- try {
563
- regex = new RegExp(args.pattern, flags)
564
- } catch (e: any) {
565
- return errorResult(`Invalid regex: ${e.message}`)
566
- }
567
-
568
- let ids = collection.available
569
- if (args.model) {
570
- const def = resolveModelDef(collection, args.model)
571
- if (def) {
572
- const prefix = (def as any).prefix + '/'
573
- ids = ids.filter((id: string) => id.startsWith(prefix))
574
- }
575
- }
576
-
577
- const results: Array<{ pathId: string; matches: string[] }> = []
578
-
579
- for (const pathId of ids) {
580
- const doc = collection.document(pathId)
581
- const content = doc.content
582
- const matches: string[] = []
583
-
584
- for (const line of content.split('\n')) {
585
- if (regex.test(line)) {
586
- matches.push(line.trim())
587
- }
588
- regex.lastIndex = 0
589
- }
590
-
591
- if (matches.length > 0) {
592
- results.push({ pathId, matches: matches.slice(0, 10) })
593
- }
594
- }
595
-
596
- return textResult(JSON.stringify(results, null, 2))
597
- },
598
- })
599
-
600
- mcpServer.tool('text_search', {
601
- description: 'Search file contents with pattern matching using ripgrep. Returns distinct file matches by default, or line-level detail with expanded=true.',
602
- schema: z.object({
603
- pattern: z.string().describe('Text or regex pattern to search for'),
604
- expanded: z.boolean().default(false).describe('Return line-level matches instead of just file paths'),
605
- include: z.string().optional().describe('Glob filter (e.g. "*.md")'),
606
- exclude: z.string().optional().describe('Glob filter (e.g. "node_modules")'),
607
- ignoreCase: z.boolean().default(false).describe('Case insensitive search'),
608
- maxResults: z.number().optional().describe('Limit number of results'),
609
- }),
610
- handler: async (args) => {
611
- const grep = container.feature('grep')
612
- const searchPath = collection.rootPath
613
-
614
- const grepOpts: any = {
615
- path: searchPath,
616
- ignoreCase: args.ignoreCase,
617
- maxResults: args.maxResults,
618
- include: args.include,
619
- exclude: args.exclude,
620
- }
621
-
622
- if (!args.expanded) {
623
- const files = await grep.filesContaining(args.pattern, grepOpts)
624
- return textResult(JSON.stringify({ files, count: files.length }, null, 2))
625
- }
626
-
627
- const results = await grep.search({ ...grepOpts, pattern: args.pattern })
628
- const grouped = new Map<string, Array<{ line: number; column?: number; content: string }>>()
629
- for (const match of results) {
630
- if (!grouped.has(match.file)) grouped.set(match.file, [])
631
- grouped.get(match.file)!.push({ line: match.line, column: match.column, content: match.content })
632
- }
633
-
634
- const files = Array.from(grouped.entries()).map(([file, matches]) => ({ file, matches }))
635
- return textResult(JSON.stringify({ files, count: files.length }, null, 2))
636
- },
637
- })
638
-
639
- // =========================================================================
640
- // SEMANTIC SEARCH TOOLS
641
- // =========================================================================
642
-
643
- let _semanticSearch: any = null
644
-
645
- function hasSearchIndex(): boolean {
646
- const dbDir = path.join(collection.rootPath, '.contentbase')
647
- if (!existsSync(dbDir)) return false
648
- try {
649
- const files = readdirSync(dbDir) as string[]
650
- return files.some((f: string) => f.startsWith('search.') && f.endsWith('.sqlite'))
651
- } catch {
652
- return false
653
- }
654
- }
655
-
656
- async function getSemanticSearch() {
657
- if (_semanticSearch?.state?.get('dbReady')) return _semanticSearch
658
-
659
- const { SemanticSearch } = await import('@soederpop/luca/agi')
660
- if (!container.features.available.includes('semanticSearch')) {
661
- SemanticSearch.attach(container)
662
- }
663
-
664
- const dbPath = path.join(collection.rootPath, '.contentbase/search.sqlite')
665
- _semanticSearch = container.feature('semanticSearch', { dbPath })
666
- await _semanticSearch.initDb()
667
- return _semanticSearch
668
- }
669
-
670
- mcpServer.tool('keyword_search', {
671
- description: 'Fast keyword search using BM25 ranking. Best for exact terms, identifiers, and known phrases. Requires a search index — run `cnotes embed` first if not indexed.',
672
- schema: z.object({
673
- query: z.string().describe('Search query text'),
674
- limit: z.number().optional().default(10).describe('Maximum results to return'),
675
- model: z.string().optional().describe('Filter results to a specific model name'),
676
- }),
677
- handler: async (args) => {
678
- if (!hasSearchIndex()) {
679
- return errorResult('No search index found. Run: cnotes embed')
680
- }
681
- try {
682
- const ss = await getSemanticSearch()
683
- const results = await ss.search(args.query, {
684
- limit: args.limit,
685
- model: args.model,
686
- })
687
- return textResult(JSON.stringify(results, null, 2))
688
- } catch (error: any) {
689
- return errorResult(`Search failed: ${error.message}`)
690
- }
691
- },
692
- })
693
-
694
- mcpServer.tool('semantic_search', {
695
- description: 'Search by meaning using vector embeddings. Finds conceptually related documents even without keyword matches. Requires a search index — run `cnotes embed` first if not indexed.',
696
- schema: z.object({
697
- query: z.string().describe('Search query text'),
698
- limit: z.number().optional().default(10).describe('Maximum results to return'),
699
- model: z.string().optional().describe('Filter results to a specific model name'),
700
- }),
701
- handler: async (args) => {
702
- if (!hasSearchIndex()) {
703
- return errorResult('No search index found. Run: cnotes embed')
704
- }
705
- try {
706
- const ss = await getSemanticSearch()
707
- const results = await ss.vectorSearch(args.query, {
708
- limit: args.limit,
709
- model: args.model,
710
- })
711
- return textResult(JSON.stringify(results, null, 2))
712
- } catch (error: any) {
713
- return errorResult(`Search failed: ${error.message}`)
714
- }
715
- },
716
- })
717
-
718
- mcpServer.tool('hybrid_search', {
719
- description: 'Combined keyword + semantic search with score fusion. Best for general questions about the collection. Requires a search index — run `cnotes embed` first if not indexed.',
720
- schema: z.object({
721
- query: z.string().describe('Search query text'),
722
- limit: z.number().optional().default(10).describe('Maximum results to return'),
723
- model: z.string().optional().describe('Filter results to a specific model name'),
724
- where: z.record(z.string(), z.any()).optional().describe('Metadata filters, e.g. {"status": "approved"}'),
725
- }),
726
- handler: async (args) => {
727
- if (!hasSearchIndex()) {
728
- return errorResult('No search index found. Run: cnotes embed')
729
- }
730
- try {
731
- const ss = await getSemanticSearch()
732
- const results = await ss.hybridSearch(args.query, {
733
- limit: args.limit,
734
- model: args.model,
735
- where: args.where,
736
- })
737
- return textResult(JSON.stringify(results, null, 2))
738
- } catch (error: any) {
739
- return errorResult(`Search failed: ${error.message}`)
740
- }
741
- },
742
- })
743
-
744
- mcpServer.tool('validate', {
745
- description: 'Validate a document against its model schema. Returns validation result with any errors. **ALWAYS call after create/update operations** to confirm the document conforms to its model.',
746
- schema: z.object({
747
- pathId: z.string().describe('Document path ID'),
748
- model: z.string().optional().describe('Model name (auto-detected if omitted)'),
749
- }),
750
- handler: (args) => {
751
- const doc = collection.document(args.pathId)
752
- if (!doc) return errorResult(`Document not found: ${args.pathId}`)
753
-
754
- const def = args.model
755
- ? resolveModelDef(collection, args.model)
756
- : collection.findModelDefinition(args.pathId)
757
-
758
- if (!def) {
759
- return errorResult(`No model definition found for ${args.pathId}. Specify one with the model parameter.`)
760
- }
761
-
762
- const result = validateDocument(doc, def)
763
- return textResult(JSON.stringify(result, null, 2))
764
- },
765
- })
766
-
767
- mcpServer.tool('create_document', {
768
- description: '**ALWAYS use this instead of writing markdown files directly.** Creates a new document with proper scaffolding from a model definition — generates correct frontmatter defaults and section headings. Call `validate` after creation.',
769
- schema: z.object({
770
- pathId: z.string().describe('Path ID for the new document (e.g. "epics/my-new-epic")'),
771
- title: z.string().describe('Document title (used as the H1 heading)'),
772
- meta: z.record(z.string(), z.any()).optional().describe('Frontmatter fields to set'),
773
- model: z.string().optional().describe('Model name (auto-detected from pathId prefix if omitted)'),
774
- }),
775
- handler: async (args) => {
776
- if (collection.available.includes(args.pathId)) {
777
- return errorResult(`Document already exists: ${args.pathId}`)
778
- }
779
-
780
- const def = args.model
781
- ? resolveModelDef(collection, args.model)
782
- : collection.findModelDefinition(args.pathId)
783
-
784
- const metaData = { ...((def as any)?.defaults || {}), ...(args.meta || {}) }
785
-
786
- const sectionHeadings = def
787
- ? Object.values((def as any).sections || {}).map((s: any) => `## ${s.heading}\n\n`)
788
- : []
789
-
790
- const body = [
791
- `# ${args.title}`,
792
- '',
793
- ...sectionHeadings,
794
- ].join('\n')
795
-
796
- const content = matter.stringify(body, metaData)
797
-
798
- await collection.saveItem(args.pathId, { content })
799
- await collection.load({ refresh: true })
800
-
801
- return textResult(JSON.stringify({
802
- created: args.pathId,
803
- model: def ? (def as any).name : null,
804
- meta: metaData,
805
- }, null, 2))
806
- },
807
- })
808
-
809
- mcpServer.tool('update_document', {
810
- description: 'Update a document\'s frontmatter and/or replace its entire content body. Use for frontmatter changes. For section-level edits, prefer `update_section` instead. Call `validate` after.',
811
- schema: z.object({
812
- pathId: z.string().describe('Document path ID'),
813
- meta: z.record(z.string(), z.any()).optional().describe('Frontmatter fields to merge (existing fields are preserved unless overridden)'),
814
- content: z.string().optional().describe('New markdown content body (replaces everything after frontmatter)'),
815
- }),
816
- handler: async (args) => {
817
- const doc = collection.document(args.pathId)
818
- if (!doc) return errorResult(`Document not found: ${args.pathId}`)
819
-
820
- const currentMeta = { ...doc.meta }
821
- const newMeta = args.meta ? { ...currentMeta, ...args.meta } : currentMeta
822
- const newContent = args.content ?? doc.content
823
-
824
- const fullContent = matter.stringify(newContent, newMeta)
825
- await collection.saveItem(args.pathId, { content: fullContent })
826
- await collection.load({ refresh: true })
827
-
828
- return textResult(JSON.stringify({
829
- updated: args.pathId,
830
- meta: newMeta,
831
- }, null, 2))
832
- },
833
- })
834
-
835
- mcpServer.tool('update_section', {
836
- description: 'Preferred way to edit document content. Surgically edit a specific section — replace, append, or remove. Target a section by its heading name. Call `validate` after.',
837
- schema: z.object({
838
- pathId: z.string().describe('Document path ID'),
839
- heading: z.string().describe('Section heading text to target (e.g. "Overview", "Requirements")'),
840
- action: z.enum(['replace', 'append', 'remove']).describe('What to do with the section'),
841
- content: z.string().optional().describe('New content (required for replace/append, ignored for remove)'),
842
- }),
843
- handler: async (args) => {
844
- let doc = collection.document(args.pathId)
845
- if (!doc) return errorResult(`Document not found: ${args.pathId}`)
846
-
847
- switch (args.action) {
848
- case 'replace': {
849
- if (!args.content) return errorResult('Content is required for replace action')
850
- doc = doc.replaceSectionContent(args.heading, args.content)
851
- break
852
- }
853
- case 'append': {
854
- if (!args.content) return errorResult('Content is required for append action')
855
- doc = doc.appendToSection(args.heading, args.content)
856
- break
857
- }
858
- case 'remove': {
859
- doc = doc.removeSection(args.heading)
860
- break
861
- }
862
- }
863
-
864
- const fullContent = matter.stringify(doc.content, doc.meta)
865
- await collection.saveItem(args.pathId, { content: fullContent })
866
- await collection.load({ refresh: true })
867
-
868
- return textResult(JSON.stringify({
869
- updated: args.pathId,
870
- action: args.action,
871
- heading: args.heading,
872
- }, null, 2))
873
- },
874
- })
875
-
876
- mcpServer.tool('delete_document', {
877
- description: 'Delete a document from the collection permanently. Cannot be undone except through version control.',
878
- schema: z.object({
879
- pathId: z.string().describe('Document path ID to delete'),
880
- }),
881
- handler: async (args) => {
882
- if (!collection.available.includes(args.pathId)) {
883
- return errorResult(`Document not found: ${args.pathId}`)
884
- }
885
-
886
- await collection.deleteItem(args.pathId)
887
- await collection.load({ refresh: true })
888
- return textResult(JSON.stringify({ deleted: args.pathId }, null, 2))
889
- },
890
- })
891
-
892
- mcpServer.tool('run_action', {
893
- description: 'Execute a registered collection action by name.',
894
- schema: z.object({
895
- name: z.string().describe('Action name'),
896
- args: z.array(z.any()).optional().describe('Arguments to pass to the action'),
897
- }),
898
- handler: async (toolArgs) => {
899
- if (!collection.availableActions.includes(toolArgs.name)) {
900
- return errorResult(
901
- `Unknown action: ${toolArgs.name}. Available: ${collection.availableActions.join(', ') || '(none)'}`,
902
- )
903
- }
904
-
905
- const result = await collection.runAction(toolArgs.name, ...(toolArgs.args || []))
906
- const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2)
907
- return textResult(text)
908
- },
909
- })
910
-
911
- // =========================================================================
912
- // PROMPTS
913
- // =========================================================================
914
-
915
- for (const def of modelDefs) {
916
- const modelName = (def as any).name as string
917
- const promptName = `create-${modelName.toLowerCase()}`
918
-
919
- mcpServer.prompt(promptName, {
920
- description: `Guide creation of a new ${modelName} document with proper schema and sections`,
921
- args: {
922
- title: z.string().describe('Title for the new document'),
923
- },
924
- handler: (args) => {
925
- const fields = introspectMetaSchema((def as any).meta)
926
- const sections = Object.entries((def as any).sections || {}).map(([key, sec]: [string, any]) => ({
927
- key,
928
- heading: sec.heading,
929
- hasSchema: !!sec.schema,
930
- }))
931
-
932
- const fieldDocs = fields.map((f: any) =>
933
- `- **${f.name}** (${f.type}${f.required ? ', required' : ''})${f.description ? `: ${f.description}` : ''}${f.defaultValue !== undefined ? ` [default: ${JSON.stringify(f.defaultValue)}]` : ''}`,
934
- ).join('\n')
935
-
936
- const sectionDocs = sections.map((s: any) =>
937
- `- **${s.heading}** (key: \`${s.key}\`)${s.hasSchema ? ' — has schema validation' : ''}`,
938
- ).join('\n')
939
-
940
- const content = [
941
- `# Create a new ${modelName}`,
942
- '',
943
- `Title: ${args.title || '(not specified)'}`,
944
- '',
945
- '## Frontmatter Fields',
946
- '',
947
- fieldDocs || '(no schema fields defined)',
948
- '',
949
- '## Sections',
950
- '',
951
- sectionDocs || '(no sections defined)',
952
- '',
953
- '## Instructions',
954
- '',
955
- `Use the \`create_document\` tool with model="${modelName}" and fill in the meta fields.`,
956
- 'Then use `update_section` to populate each section with content.',
957
- ].join('\n')
958
-
959
- return [{ role: 'user' as const, content }]
960
- },
961
- })
962
- }
963
-
964
- mcpServer.prompt('review-document', {
965
- description: 'Fetch a document, run validation, and present it for review',
966
- args: {
967
- pathId: z.string().describe('Document path ID to review'),
968
- },
969
- handler: (args) => {
970
- const pathId = args.pathId
971
- if (!pathId) {
972
- return [{ role: 'user' as const, content: 'Error: pathId argument is required.' }]
973
- }
974
-
975
- const doc = collection.document(pathId)
976
- if (!doc) {
977
- return [{ role: 'user' as const, content: `Document not found: ${pathId}` }]
978
- }
979
-
980
- const def = collection.findModelDefinition(pathId)
981
- let validationText = ''
982
- if (def) {
983
- const result = validateDocument(doc, def)
984
- validationText = result.valid
985
- ? '\n**Validation: PASSED**\n'
986
- : `\n**Validation: FAILED**\n\nErrors:\n${result.errors.map((e: any) => `- ${e.path.join('.')}: ${e.message}`).join('\n')}\n`
987
- } else {
988
- validationText = '\n*No model definition found — validation skipped.*\n'
989
- }
990
-
991
- const content = [
992
- `# Review: ${doc.title}`,
993
- '',
994
- `**Path:** ${pathId}`,
995
- `**Model:** ${def ? (def as any).name : 'untyped'}`,
996
- validationText,
997
- '## Outline',
998
- '',
999
- doc.toOutline(),
1000
- '',
1001
- '## Frontmatter',
1002
- '',
1003
- '```json',
1004
- JSON.stringify(doc.meta, null, 2),
1005
- '```',
1006
- '',
1007
- '## Content',
1008
- '',
1009
- doc.content,
1010
- ].join('\n')
1011
-
1012
- return [{ role: 'user' as const, content }]
1013
- },
1014
- })
1015
-
1016
- mcpServer.prompt('teach', {
1017
- description: 'Full contentbase documentation — models, table of contents, CLI reference, and API primer. For a quick-start behavioral guide, use the `read_me` tool instead.',
1018
- handler: async () => {
1019
- const modelsSummary = collection.generateModelSummary()
1020
- const toc = collection.tableOfContents({ title: 'Table of Contents' })
1021
-
1022
- const packageRoot = path.resolve(import.meta.dir, '../../..')
1023
- let primer = ''
1024
- let cli = ''
1025
- try {
1026
- primer = await fs.readFile(path.join(packageRoot, 'PRIMER.md'), 'utf8')
1027
- } catch {}
1028
- try {
1029
- cli = await fs.readFile(path.join(packageRoot, 'CLI.md'), 'utf8')
1030
- } catch {}
1031
-
1032
- const content = [
1033
- '> **Quick start:** Call the `read_me` tool for a concise behavioral guide. This prompt provides the full reference.',
1034
- '',
1035
- modelsSummary.trimEnd(),
1036
- '', '---', '',
1037
- toc.trimEnd(),
1038
- '', '---', '',
1039
- cli.trimEnd(),
1040
- '', '---', '',
1041
- primer.trimEnd(),
1042
- ].join('\n')
1043
-
1044
- return [{ role: 'user' as const, content }]
1045
- },
1046
- })
1047
-
1048
- mcpServer.prompt('query-guide', {
1049
- description: 'Show available models, fields, and query operators to help build queries',
1050
- args: {
1051
- intent: z.string().optional().describe('What you want to find (helps tailor the guide)'),
1052
- },
1053
- handler: () => {
1054
- const modelsInfo = modelDefs.map((def: any) => {
1055
- const fields = introspectMetaSchema(def.meta)
1056
- const fieldList = fields.map((f: any) => ` - ${f.name} (${f.type})`).join('\n')
1057
- return `### ${def.name} (prefix: ${def.prefix})\n${fieldList || ' (no schema fields)'}`
1058
- }).join('\n\n')
1059
-
1060
- const content = [
1061
- '# Query Guide',
1062
- '',
1063
- '## Available Models',
1064
- '',
1065
- modelsInfo || '(no models registered)',
1066
- '',
1067
- '## Query Operators',
1068
- '',
1069
- '| Operator | Description | Example value |',
1070
- '|----------|-------------|---------------|',
1071
- '| eq | Exact equality (default) | `"published"` |',
1072
- '| in | Value is in array | `["draft", "published"]` |',
1073
- '| notIn | Value is not in array | `["archived"]` |',
1074
- '| gt / lt / gte / lte | Numeric/date comparison | `5` |',
1075
- '| contains | String contains substring | `"auth"` |',
1076
- '| startsWith / endsWith | String prefix/suffix | `"user-"` |',
1077
- '| regex | Regex pattern match | `"^v\\\\d+"` |',
1078
- '| exists / notExists | Field presence check | (no value needed) |',
1079
- '',
1080
- '## Example (MongoDB-style DSL)',
1081
- '',
1082
- '```json',
1083
- '{',
1084
- ' "model": "Epic",',
1085
- ' "where": {',
1086
- ' "meta.status": "active",',
1087
- ' "meta.priority": { "$in": ["high", "critical"] }',
1088
- ' },',
1089
- ' "sort": { "meta.priority": "desc" },',
1090
- ' "limit": 10',
1091
- '}',
1092
- '```',
1093
- '',
1094
- 'Where value shortcuts:',
1095
- '- Literal value → implicit $eq: `"meta.status": "active"`',
1096
- '- Array → implicit $in: `"meta.tags": ["a", "b"]`',
1097
- '- Operator object: `"meta.priority": { "$gt": 5 }`',
1098
- '- Multiple operators: `"meta.priority": { "$gte": 3, "$lte": 8 }`',
1099
- ].join('\n')
1100
-
1101
- return [{ role: 'user' as const, content }]
1102
- },
1103
- })
1104
-
1105
- // =========================================================================
1106
- // START
1107
- // =========================================================================
1108
-
1109
- await mcpServer.start({
23
+ await startMcpServer(container, {
1110
24
  transport: options.transport,
1111
25
  port: options.port,
26
+ contentFolder,
27
+ modulePath,
1112
28
  mcpCompat: options.mcpCompat,
1113
29
  stdioCompat: options.stdioCompat,
30
+ watch: options.watch,
1114
31
  })
1115
-
1116
- if (options.transport === 'http') {
1117
- console.log(`\nContentbase MCP listening on http://localhost:${options.port}/mcp`)
1118
- console.log(`Transport: HTTP (Streamable)`)
1119
- console.log(`Compatibility: ${resolvedCompat}`)
1120
- } else {
1121
- console.error(`[cnotes mcp] Server started (stdio transport)`)
1122
- console.error(`[cnotes mcp] Stdio compatibility: ${resolvedStdioCompat}`)
1123
- console.error(`[cnotes mcp] Tools: ${mcpServer._tools.size} | Resources: ${mcpServer._resources.size} | Prompts: ${mcpServer._prompts.size}`)
1124
- }
1125
-
1126
- // ---------------------------------------------------------------------------
1127
- // File watching
1128
- // ---------------------------------------------------------------------------
1129
- if (options.watch !== false) {
1130
- const fileManager = container.feature('fileManager')
1131
- await fileManager.start({ rootPath: collection.rootPath })
1132
- await fileManager.watch()
1133
-
1134
- const { debounce } = container.utils.lodash
1135
- const refreshCollection = debounce(async () => {
1136
- try {
1137
- const before = collection.available.length
1138
- await collection.load({ refresh: true })
1139
- const after = collection.available.length
1140
- if (after !== before) {
1141
- console.error(`[watch] Collection refreshed: ${before} → ${after} documents`)
1142
- }
1143
- } catch (err) {
1144
- console.error(`[watch] Refresh failed: ${(err as Error).message}`)
1145
- }
1146
- }, 500)
1147
-
1148
- fileManager.on('file:change', (event: { type: string; path: string }) => {
1149
- if (/\.(md|mdx)$/i.test(event.path)) {
1150
- refreshCollection()
1151
- }
1152
- })
1153
- console.error(`[cnotes mcp] Watching for file changes in ${collection.rootPath}`)
1154
- }
1155
32
  }
1156
33
 
1157
34
  commands.register('mcp', {