contentbase 0.2.0 → 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.
- package/CNAME +1 -0
- package/LAUNCH-PLAN.md +419 -0
- package/index.html +1636 -0
- package/package.json +5 -4
- package/src/adapters/node-fs.ts +80 -0
- package/src/cli/commands/api/endpoints/doc.ts +2 -2
- package/src/cli/commands/api/endpoints/document.ts +2 -2
- package/src/cli/commands/api/endpoints/search-reindex.ts +6 -25
- package/src/cli/commands/api/endpoints/search-status.ts +3 -20
- package/src/cli/commands/api/endpoints/search.ts +3 -33
- package/src/cli/commands/api/endpoints/semantic-search.ts +3 -33
- package/src/cli/commands/embed.ts +8 -81
- package/src/cli/commands/mcp.ts +5 -1128
- package/src/cli/commands/search.ts +4 -76
- package/src/cli/commands/serve.ts +7 -32
- package/src/cli/index.ts +1 -1
- package/src/cli/load-collection.ts +2 -2
- package/src/collection.ts +31 -30
- package/src/document.ts +18 -0
- package/src/extract-sections.ts +3 -0
- package/src/index.ts +10 -0
- package/src/mcp/helpers.ts +7 -0
- package/src/mcp/model-info.ts +102 -0
- package/src/mcp/prompts.ts +199 -0
- package/src/mcp/readme.ts +153 -0
- package/src/mcp/resources.ts +89 -0
- package/src/mcp/server.ts +103 -0
- package/src/mcp/tools/mutation.ts +176 -0
- package/src/mcp/tools/query.ts +141 -0
- package/src/mcp/tools/search.ts +180 -0
- package/src/parse.ts +5 -0
- package/src/query/query-dsl.ts +1 -1
- package/src/relationships/has-many.ts +2 -2
- package/src/search/document-inputs.ts +85 -0
- package/src/search/luca-semantic-search.ts +75 -0
- package/src/types.ts +54 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/match-pattern.ts +2 -2
- package/src/utils/strip-markdown.ts +59 -0
package/src/cli/commands/mcp.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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', {
|