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.
- package/CNAME +1 -0
- package/LAUNCH-PLAN.md +419 -0
- package/README.md +90 -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 +25 -90
- 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 +42 -33
- 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 +4 -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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import { introspectMetaSchema, validateDocument } from '../index.js'
|
|
5
|
+
|
|
6
|
+
export function registerPrompts(mcpServer: any, collection: any) {
|
|
7
|
+
const modelDefs = collection.modelDefinitions as any[]
|
|
8
|
+
|
|
9
|
+
// Per-model creation prompts
|
|
10
|
+
for (const def of modelDefs) {
|
|
11
|
+
const modelName = (def as any).name as string
|
|
12
|
+
const promptName = `create-${modelName.toLowerCase()}`
|
|
13
|
+
|
|
14
|
+
mcpServer.prompt(promptName, {
|
|
15
|
+
description: `Guide creation of a new ${modelName} document with proper schema and sections`,
|
|
16
|
+
args: {
|
|
17
|
+
title: z.string().describe('Title for the new document'),
|
|
18
|
+
},
|
|
19
|
+
handler: (args: { title?: string }) => {
|
|
20
|
+
const fields = introspectMetaSchema((def as any).meta)
|
|
21
|
+
const sections = Object.entries((def as any).sections || {}).map(([key, sec]: [string, any]) => ({
|
|
22
|
+
key,
|
|
23
|
+
heading: sec.heading,
|
|
24
|
+
hasSchema: !!sec.schema,
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
const fieldDocs = fields.map((f: any) =>
|
|
28
|
+
`- **${f.name}** (${f.type}${f.required ? ', required' : ''})${f.description ? `: ${f.description}` : ''}${f.defaultValue !== undefined ? ` [default: ${JSON.stringify(f.defaultValue)}]` : ''}`,
|
|
29
|
+
).join('\n')
|
|
30
|
+
|
|
31
|
+
const sectionDocs = sections.map((s: any) =>
|
|
32
|
+
`- **${s.heading}** (key: \`${s.key}\`)${s.hasSchema ? ' — has schema validation' : ''}`,
|
|
33
|
+
).join('\n')
|
|
34
|
+
|
|
35
|
+
const content = [
|
|
36
|
+
`# Create a new ${modelName}`,
|
|
37
|
+
'',
|
|
38
|
+
`Title: ${args.title || '(not specified)'}`,
|
|
39
|
+
'',
|
|
40
|
+
'## Frontmatter Fields',
|
|
41
|
+
'',
|
|
42
|
+
fieldDocs || '(no schema fields defined)',
|
|
43
|
+
'',
|
|
44
|
+
'## Sections',
|
|
45
|
+
'',
|
|
46
|
+
sectionDocs || '(no sections defined)',
|
|
47
|
+
'',
|
|
48
|
+
'## Instructions',
|
|
49
|
+
'',
|
|
50
|
+
`Use the \`create_document\` tool with model="${modelName}" and fill in the meta fields.`,
|
|
51
|
+
'Then use `update_section` to populate each section with content.',
|
|
52
|
+
].join('\n')
|
|
53
|
+
|
|
54
|
+
return [{ role: 'user' as const, content }]
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
mcpServer.prompt('review-document', {
|
|
60
|
+
description: 'Fetch a document, run validation, and present it for review',
|
|
61
|
+
args: {
|
|
62
|
+
pathId: z.string().describe('Document path ID to review'),
|
|
63
|
+
},
|
|
64
|
+
handler: (args: { pathId?: string }) => {
|
|
65
|
+
const pathId = args.pathId
|
|
66
|
+
if (!pathId) {
|
|
67
|
+
return [{ role: 'user' as const, content: 'Error: pathId argument is required.' }]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const doc = collection.document(pathId)
|
|
71
|
+
if (!doc) {
|
|
72
|
+
return [{ role: 'user' as const, content: `Document not found: ${pathId}` }]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const def = collection.findModelDefinition(pathId)
|
|
76
|
+
let validationText = ''
|
|
77
|
+
if (def) {
|
|
78
|
+
const result = validateDocument(doc, def)
|
|
79
|
+
validationText = result.valid
|
|
80
|
+
? '\n**Validation: PASSED**\n'
|
|
81
|
+
: `\n**Validation: FAILED**\n\nErrors:\n${result.errors.map((e: any) => `- ${e.path.join('.')}: ${e.message}`).join('\n')}\n`
|
|
82
|
+
} else {
|
|
83
|
+
validationText = '\n*No model definition found — validation skipped.*\n'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const content = [
|
|
87
|
+
`# Review: ${doc.title}`,
|
|
88
|
+
'',
|
|
89
|
+
`**Path:** ${pathId}`,
|
|
90
|
+
`**Model:** ${def ? (def as any).name : 'untyped'}`,
|
|
91
|
+
validationText,
|
|
92
|
+
'## Outline',
|
|
93
|
+
'',
|
|
94
|
+
doc.toOutline(),
|
|
95
|
+
'',
|
|
96
|
+
'## Frontmatter',
|
|
97
|
+
'',
|
|
98
|
+
'```json',
|
|
99
|
+
JSON.stringify(doc.meta, null, 2),
|
|
100
|
+
'```',
|
|
101
|
+
'',
|
|
102
|
+
'## Content',
|
|
103
|
+
'',
|
|
104
|
+
doc.content,
|
|
105
|
+
].join('\n')
|
|
106
|
+
|
|
107
|
+
return [{ role: 'user' as const, content }]
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
mcpServer.prompt('teach', {
|
|
112
|
+
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.',
|
|
113
|
+
handler: async () => {
|
|
114
|
+
const modelsSummary = collection.generateModelSummary()
|
|
115
|
+
const toc = collection.tableOfContents({ title: 'Table of Contents' })
|
|
116
|
+
|
|
117
|
+
const packageRoot = path.resolve(import.meta.dir, '../..')
|
|
118
|
+
let primer = ''
|
|
119
|
+
let cli = ''
|
|
120
|
+
try {
|
|
121
|
+
primer = await fs.readFile(path.join(packageRoot, 'PRIMER.md'), 'utf8')
|
|
122
|
+
} catch {}
|
|
123
|
+
try {
|
|
124
|
+
cli = await fs.readFile(path.join(packageRoot, 'CLI.md'), 'utf8')
|
|
125
|
+
} catch {}
|
|
126
|
+
|
|
127
|
+
const content = [
|
|
128
|
+
'> **Quick start:** Call the `read_me` tool for a concise behavioral guide. This prompt provides the full reference.',
|
|
129
|
+
'',
|
|
130
|
+
modelsSummary.trimEnd(),
|
|
131
|
+
'', '---', '',
|
|
132
|
+
toc.trimEnd(),
|
|
133
|
+
'', '---', '',
|
|
134
|
+
cli.trimEnd(),
|
|
135
|
+
'', '---', '',
|
|
136
|
+
primer.trimEnd(),
|
|
137
|
+
].join('\n')
|
|
138
|
+
|
|
139
|
+
return [{ role: 'user' as const, content }]
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
mcpServer.prompt('query-guide', {
|
|
144
|
+
description: 'Show available models, fields, and query operators to help build queries',
|
|
145
|
+
args: {
|
|
146
|
+
intent: z.string().optional().describe('What you want to find (helps tailor the guide)'),
|
|
147
|
+
},
|
|
148
|
+
handler: () => {
|
|
149
|
+
const modelsInfo = modelDefs.map((def: any) => {
|
|
150
|
+
const fields = introspectMetaSchema(def.meta)
|
|
151
|
+
const fieldList = fields.map((f: any) => ` - ${f.name} (${f.type})`).join('\n')
|
|
152
|
+
return `### ${def.name} (prefix: ${def.prefix})\n${fieldList || ' (no schema fields)'}`
|
|
153
|
+
}).join('\n\n')
|
|
154
|
+
|
|
155
|
+
const content = [
|
|
156
|
+
'# Query Guide',
|
|
157
|
+
'',
|
|
158
|
+
'## Available Models',
|
|
159
|
+
'',
|
|
160
|
+
modelsInfo || '(no models registered)',
|
|
161
|
+
'',
|
|
162
|
+
'## Query Operators',
|
|
163
|
+
'',
|
|
164
|
+
'| Operator | Description | Example value |',
|
|
165
|
+
'|----------|-------------|---------------|',
|
|
166
|
+
'| eq | Exact equality (default) | `"published"` |',
|
|
167
|
+
'| in | Value is in array | `["draft", "published"]` |',
|
|
168
|
+
'| notIn | Value is not in array | `["archived"]` |',
|
|
169
|
+
'| gt / lt / gte / lte | Numeric/date comparison | `5` |',
|
|
170
|
+
'| contains | String contains substring | `"auth"` |',
|
|
171
|
+
'| startsWith / endsWith | String prefix/suffix | `"user-"` |',
|
|
172
|
+
'| regex | Regex pattern match | `"^v\\\\d+"` |',
|
|
173
|
+
'| exists / notExists | Field presence check | (no value needed) |',
|
|
174
|
+
'',
|
|
175
|
+
'## Example (MongoDB-style DSL)',
|
|
176
|
+
'',
|
|
177
|
+
'```json',
|
|
178
|
+
'{',
|
|
179
|
+
' "model": "Epic",',
|
|
180
|
+
' "where": {',
|
|
181
|
+
' "meta.status": "active",',
|
|
182
|
+
' "meta.priority": { "$in": ["high", "critical"] }',
|
|
183
|
+
' },',
|
|
184
|
+
' "sort": { "meta.priority": "desc" },',
|
|
185
|
+
' "limit": 10',
|
|
186
|
+
'}',
|
|
187
|
+
'```',
|
|
188
|
+
'',
|
|
189
|
+
'Where value shortcuts:',
|
|
190
|
+
'- Literal value → implicit $eq: `"meta.status": "active"`',
|
|
191
|
+
'- Array → implicit $in: `"meta.tags": ["a", "b"]`',
|
|
192
|
+
'- Operator object: `"meta.priority": { "$gt": 5 }`',
|
|
193
|
+
'- Multiple operators: `"meta.priority": { "$gte": 3, "$lte": 8 }`',
|
|
194
|
+
].join('\n')
|
|
195
|
+
|
|
196
|
+
return [{ role: 'user' as const, content }]
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { introspectMetaSchema } from '../index.js'
|
|
2
|
+
|
|
3
|
+
export function generateReadMe(collection: any, modelDefs: any[]) {
|
|
4
|
+
const lines: string[] = []
|
|
5
|
+
|
|
6
|
+
// Section 1: The Rules
|
|
7
|
+
const rootPath = collection.rootPath as string
|
|
8
|
+
|
|
9
|
+
lines.push(
|
|
10
|
+
'# Contentbase Collection Guide',
|
|
11
|
+
'',
|
|
12
|
+
`> **This collection is located at \`${rootPath}\`.**`,
|
|
13
|
+
'',
|
|
14
|
+
'## Rules — READ CAREFULLY',
|
|
15
|
+
'',
|
|
16
|
+
`**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.`,
|
|
17
|
+
'',
|
|
18
|
+
'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.',
|
|
19
|
+
'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.',
|
|
20
|
+
'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.',
|
|
21
|
+
'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.',
|
|
22
|
+
'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`.',
|
|
23
|
+
'',
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// Section 2: Models in This Collection
|
|
27
|
+
lines.push('## Models in This Collection', '')
|
|
28
|
+
|
|
29
|
+
for (const def of modelDefs) {
|
|
30
|
+
const name = def.name as string
|
|
31
|
+
const prefix = def.prefix as string
|
|
32
|
+
const description = def.description || ''
|
|
33
|
+
const prefixDocs = collection.available.filter((id: string) => id.startsWith(prefix + '/'))
|
|
34
|
+
const docCount = prefixDocs.length
|
|
35
|
+
|
|
36
|
+
lines.push(`### ${name}`, '')
|
|
37
|
+
lines.push(`- **Prefix:** \`${prefix}/\``)
|
|
38
|
+
lines.push(`- **Documents:** ${docCount}`)
|
|
39
|
+
if (description) lines.push(`- **Description:** ${description}`)
|
|
40
|
+
lines.push('')
|
|
41
|
+
|
|
42
|
+
// Fields
|
|
43
|
+
const fields = introspectMetaSchema(def.meta)
|
|
44
|
+
if (fields.length > 0) {
|
|
45
|
+
lines.push('**Frontmatter Fields:**', '')
|
|
46
|
+
lines.push('| Field | Type | Required | Default | Description |')
|
|
47
|
+
lines.push('|-------|------|----------|---------|-------------|')
|
|
48
|
+
for (const f of fields as any[]) {
|
|
49
|
+
const req = f.required ? 'yes' : 'no'
|
|
50
|
+
const def_val = f.defaultValue !== undefined ? `\`${JSON.stringify(f.defaultValue)}\`` : ''
|
|
51
|
+
const desc = f.description || ''
|
|
52
|
+
lines.push(`| ${f.name} | ${f.type} | ${req} | ${def_val} | ${desc} |`)
|
|
53
|
+
}
|
|
54
|
+
lines.push('')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Sections
|
|
58
|
+
const sections = Object.entries(def.sections || {})
|
|
59
|
+
if (sections.length > 0) {
|
|
60
|
+
lines.push('**Sections:**', '')
|
|
61
|
+
for (const [key, sec] of sections as [string, any][]) {
|
|
62
|
+
lines.push(`- **${sec.heading}** (key: \`${key}\`)${sec.schema ? ' — validated' : ''}`)
|
|
63
|
+
}
|
|
64
|
+
lines.push('')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Relationships
|
|
68
|
+
const relationships = Object.entries(def.relationships || {})
|
|
69
|
+
if (relationships.length > 0) {
|
|
70
|
+
lines.push('**Relationships:**', '')
|
|
71
|
+
for (const [key, rel] of relationships as [string, any][]) {
|
|
72
|
+
lines.push(`- \`${key}\` → ${rel.type} **${rel.model}**`)
|
|
73
|
+
}
|
|
74
|
+
lines.push('')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Computed & Scopes
|
|
78
|
+
const computedKeys = Object.keys(def.computed || {})
|
|
79
|
+
const scopeKeys = Object.keys(def.scopes || {})
|
|
80
|
+
if (computedKeys.length > 0) lines.push(`**Computed:** ${computedKeys.join(', ')}`, '')
|
|
81
|
+
if (scopeKeys.length > 0) lines.push(`**Scopes:** ${scopeKeys.join(', ')}`, '')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Section 3: Capability Map
|
|
85
|
+
lines.push(
|
|
86
|
+
'## Capability Map',
|
|
87
|
+
'',
|
|
88
|
+
'| Intent | Tool |',
|
|
89
|
+
'|--------|------|',
|
|
90
|
+
'| Orientation & guidance | `read_me` |',
|
|
91
|
+
'| See what models exist | `inspect` |',
|
|
92
|
+
'| Deep-dive one model | `get_model_info` |',
|
|
93
|
+
'| List documents | `list_documents` |',
|
|
94
|
+
'| Find by criteria | `query` |',
|
|
95
|
+
'| Full-text search | `search_content` |',
|
|
96
|
+
'| File-level grep | `text_search` |',
|
|
97
|
+
'| Keyword search (BM25) | `keyword_search` |',
|
|
98
|
+
'| Semantic search (embeddings) | `semantic_search` |',
|
|
99
|
+
'| Hybrid search (keyword + semantic) | `hybrid_search` |',
|
|
100
|
+
'| Create new document | `create_document` |',
|
|
101
|
+
'| Edit a section | `update_section` |',
|
|
102
|
+
'| Update frontmatter | `update_document` |',
|
|
103
|
+
'| Validate a document | `validate` |',
|
|
104
|
+
'| Delete a document | `delete_document` |',
|
|
105
|
+
'| Run a collection action | `run_action` |',
|
|
106
|
+
'',
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Section 4: Workflow
|
|
110
|
+
lines.push(
|
|
111
|
+
'## Recommended Workflow',
|
|
112
|
+
'',
|
|
113
|
+
'1. **Orientation** — Call `read_me` (this tool) at the start of every session.',
|
|
114
|
+
'2. **Discovery** — Use `list_documents` or `query` to find what exists.',
|
|
115
|
+
'3. **Reading** — Use `query` with `select` to fetch specific fields, or read a document resource.',
|
|
116
|
+
'4. **Creating** — Use `create_document` with the correct prefix. It scaffolds frontmatter and sections.',
|
|
117
|
+
'5. **Editing** — Use `update_section` for targeted section edits, `update_document` for frontmatter.',
|
|
118
|
+
'6. **Validation** — Always call `validate` after creating or editing.',
|
|
119
|
+
'',
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Section 5: Query Quick Reference
|
|
123
|
+
lines.push(
|
|
124
|
+
'## Query Quick Reference',
|
|
125
|
+
'',
|
|
126
|
+
'The `query` tool uses MongoDB-style DSL:',
|
|
127
|
+
'',
|
|
128
|
+
'- Literal value → `$eq`: `"meta.status": "active"`',
|
|
129
|
+
'- Array → `$in`: `"meta.tags": ["a", "b"]`',
|
|
130
|
+
'- Operator object: `"meta.priority": { "$gt": 5 }`',
|
|
131
|
+
'- Operators: `$eq`, `$neq`, `$in`, `$notIn`, `$gt`, `$lt`, `$gte`, `$lte`, `$contains`, `$startsWith`, `$endsWith`, `$regex`, `$exists`',
|
|
132
|
+
'- Supports `sort`, `limit`, `offset`, `select`, `scopes`, `method` (fetchAll/first/last/count)',
|
|
133
|
+
'',
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// Section 6: Document Anatomy
|
|
137
|
+
lines.push(
|
|
138
|
+
'## Document Anatomy',
|
|
139
|
+
'',
|
|
140
|
+
'```markdown',
|
|
141
|
+
'---',
|
|
142
|
+
'field: value # YAML frontmatter (model schema)',
|
|
143
|
+
'---',
|
|
144
|
+
'# Document Title # H1 = title',
|
|
145
|
+
'',
|
|
146
|
+
'## Section Name # H2 = sections (defined by model)',
|
|
147
|
+
'',
|
|
148
|
+
'Content here...',
|
|
149
|
+
'```',
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return lines.join('\n')
|
|
153
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import { buildSchemaJSON } from '../cli/commands/api/helpers.js'
|
|
4
|
+
|
|
5
|
+
export function registerResources(mcpServer: any, collection: any) {
|
|
6
|
+
mcpServer.resource('contentbase://schema', {
|
|
7
|
+
name: 'Collection Schema',
|
|
8
|
+
description: 'JSON schema of all registered models — fields, sections, relationships, computed properties',
|
|
9
|
+
mimeType: 'application/json',
|
|
10
|
+
handler: () => JSON.stringify(buildSchemaJSON(collection), null, 2),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
mcpServer.resource('contentbase://toc', {
|
|
14
|
+
name: 'Table of Contents',
|
|
15
|
+
description: 'Markdown table of contents for all documents in the collection',
|
|
16
|
+
mimeType: 'text/markdown',
|
|
17
|
+
handler: () => collection.tableOfContents({ title: 'Table of Contents' }),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
mcpServer.resource('contentbase://models-summary', {
|
|
21
|
+
name: 'Models Summary',
|
|
22
|
+
description: 'Generated README.md describing all model definitions with attributes, sections, and relationships',
|
|
23
|
+
mimeType: 'text/markdown',
|
|
24
|
+
handler: () => collection.generateModelSummary(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
mcpServer.resource('contentbase://primer', {
|
|
28
|
+
name: 'Contentbase Primer',
|
|
29
|
+
description: 'Combined teach output — models summary, table of contents, CLI reference, and API primer',
|
|
30
|
+
mimeType: 'text/markdown',
|
|
31
|
+
handler: async () => {
|
|
32
|
+
const modelsSummary = collection.generateModelSummary()
|
|
33
|
+
const toc = collection.tableOfContents({ title: 'Table of Contents' })
|
|
34
|
+
|
|
35
|
+
const packageRoot = path.resolve(import.meta.dir, '../..')
|
|
36
|
+
let primer = ''
|
|
37
|
+
let cli = ''
|
|
38
|
+
try {
|
|
39
|
+
primer = await fs.readFile(path.join(packageRoot, 'PRIMER.md'), 'utf8')
|
|
40
|
+
} catch {}
|
|
41
|
+
try {
|
|
42
|
+
cli = await fs.readFile(path.join(packageRoot, 'CLI.md'), 'utf8')
|
|
43
|
+
} catch {}
|
|
44
|
+
|
|
45
|
+
return [
|
|
46
|
+
modelsSummary.trimEnd(),
|
|
47
|
+
'',
|
|
48
|
+
'---',
|
|
49
|
+
'',
|
|
50
|
+
toc.trimEnd(),
|
|
51
|
+
'',
|
|
52
|
+
'---',
|
|
53
|
+
'',
|
|
54
|
+
cli.trimEnd(),
|
|
55
|
+
'',
|
|
56
|
+
'---',
|
|
57
|
+
'',
|
|
58
|
+
primer.trimEnd(),
|
|
59
|
+
'',
|
|
60
|
+
].join('\n')
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Per-document resources
|
|
65
|
+
for (const pathId of collection.available) {
|
|
66
|
+
const uri = `contentbase://documents/${pathId}`
|
|
67
|
+
const doc = collection.document(pathId)
|
|
68
|
+
mcpServer.resource(uri, {
|
|
69
|
+
name: doc.title || pathId,
|
|
70
|
+
description: `Document: ${pathId}`,
|
|
71
|
+
mimeType: 'application/json',
|
|
72
|
+
handler: () => {
|
|
73
|
+
const d = collection.document(pathId)
|
|
74
|
+
const modelDef = collection.findModelDefinition(pathId)
|
|
75
|
+
return JSON.stringify({
|
|
76
|
+
id: d.id,
|
|
77
|
+
title: d.title,
|
|
78
|
+
meta: d.meta,
|
|
79
|
+
content: d.content,
|
|
80
|
+
outline: d.toOutline(),
|
|
81
|
+
model: modelDef?.name || null,
|
|
82
|
+
createdAt: d.createdAt,
|
|
83
|
+
updatedAt: d.updatedAt,
|
|
84
|
+
size: d.size,
|
|
85
|
+
}, null, 2)
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { loadCollection } from '../cli/load-collection.js'
|
|
2
|
+
import { registerResources } from './resources.js'
|
|
3
|
+
import { registerQueryTools } from './tools/query.js'
|
|
4
|
+
import { registerSearchTools } from './tools/search.js'
|
|
5
|
+
import { registerMutationTools } from './tools/mutation.js'
|
|
6
|
+
import { registerPrompts } from './prompts.js'
|
|
7
|
+
|
|
8
|
+
export interface McpServerOptions {
|
|
9
|
+
transport: 'stdio' | 'http'
|
|
10
|
+
port: number
|
|
11
|
+
contentFolder?: string
|
|
12
|
+
modulePath?: string
|
|
13
|
+
mcpCompat?: 'standard' | 'codex'
|
|
14
|
+
stdioCompat?: 'standard' | 'codex' | 'auto'
|
|
15
|
+
watch?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function createMcpServer(container: any, options: McpServerOptions) {
|
|
19
|
+
const collection = await loadCollection({
|
|
20
|
+
contentFolder: options.contentFolder,
|
|
21
|
+
modulePath: options.modulePath,
|
|
22
|
+
})
|
|
23
|
+
const modelDefs = collection.modelDefinitions as any[]
|
|
24
|
+
|
|
25
|
+
console.error(`[cnotes mcp] Loaded collection: ${collection.rootPath}`)
|
|
26
|
+
console.error(`[cnotes mcp] Models: ${modelDefs.map((d: any) => d.name).join(', ') || '(none)'}`)
|
|
27
|
+
console.error(`[cnotes mcp] Documents: ${collection.available.length}`)
|
|
28
|
+
|
|
29
|
+
const mcpServer = container.server('mcp', {
|
|
30
|
+
transport: options.transport,
|
|
31
|
+
port: options.port,
|
|
32
|
+
serverName: 'contentbase',
|
|
33
|
+
serverVersion: '1.0.0',
|
|
34
|
+
mcpCompat: options.mcpCompat,
|
|
35
|
+
stdioCompat: options.stdioCompat,
|
|
36
|
+
}) as any
|
|
37
|
+
|
|
38
|
+
// Register all capabilities
|
|
39
|
+
registerResources(mcpServer, collection)
|
|
40
|
+
registerQueryTools(mcpServer, collection)
|
|
41
|
+
registerSearchTools(mcpServer, collection, container)
|
|
42
|
+
registerMutationTools(mcpServer, collection)
|
|
43
|
+
registerPrompts(mcpServer, collection)
|
|
44
|
+
|
|
45
|
+
return { mcpServer, collection }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function startMcpServer(container: any, options: McpServerOptions) {
|
|
49
|
+
const envCompat = process.env.MCP_HTTP_COMPAT?.toLowerCase()
|
|
50
|
+
const resolvedCompat = options.mcpCompat || (envCompat === 'codex' ? 'codex' : 'standard')
|
|
51
|
+
const envStdioCompat = process.env.MCP_STDIO_COMPAT?.toLowerCase()
|
|
52
|
+
const resolvedStdioCompat = options.stdioCompat
|
|
53
|
+
|| (envStdioCompat === 'codex' || envStdioCompat === 'auto' ? envStdioCompat : 'standard')
|
|
54
|
+
|
|
55
|
+
const { mcpServer, collection } = await createMcpServer(container, options)
|
|
56
|
+
|
|
57
|
+
await mcpServer.start({
|
|
58
|
+
transport: options.transport,
|
|
59
|
+
port: options.port,
|
|
60
|
+
mcpCompat: options.mcpCompat,
|
|
61
|
+
stdioCompat: options.stdioCompat,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (options.transport === 'http') {
|
|
65
|
+
console.log(`\nContentbase MCP listening on http://localhost:${options.port}/mcp`)
|
|
66
|
+
console.log(`Transport: HTTP (Streamable)`)
|
|
67
|
+
console.log(`Compatibility: ${resolvedCompat}`)
|
|
68
|
+
} else {
|
|
69
|
+
console.error(`[cnotes mcp] Server started (stdio transport)`)
|
|
70
|
+
console.error(`[cnotes mcp] Stdio compatibility: ${resolvedStdioCompat}`)
|
|
71
|
+
console.error(`[cnotes mcp] Tools: ${mcpServer._tools.size} | Resources: ${mcpServer._resources.size} | Prompts: ${mcpServer._prompts.size}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// File watching
|
|
75
|
+
if (options.watch !== false) {
|
|
76
|
+
const fileManager = container.feature('fileManager')
|
|
77
|
+
await fileManager.start({ rootPath: collection.rootPath })
|
|
78
|
+
await fileManager.watch()
|
|
79
|
+
|
|
80
|
+
const { debounce } = container.utils.lodash
|
|
81
|
+
const refreshCollection = debounce(async () => {
|
|
82
|
+
try {
|
|
83
|
+
const before = collection.available.length
|
|
84
|
+
await collection.load({ refresh: true })
|
|
85
|
+
const after = collection.available.length
|
|
86
|
+
if (after !== before) {
|
|
87
|
+
console.error(`[watch] Collection refreshed: ${before} → ${after} documents`)
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error(`[watch] Refresh failed: ${(err as Error).message}`)
|
|
91
|
+
}
|
|
92
|
+
}, 500)
|
|
93
|
+
|
|
94
|
+
fileManager.on('file:change', (event: { type: string; path: string }) => {
|
|
95
|
+
if (/\.(md|mdx)$/i.test(event.path)) {
|
|
96
|
+
refreshCollection()
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
console.error(`[cnotes mcp] Watching for file changes in ${collection.rootPath}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { mcpServer, collection }
|
|
103
|
+
}
|