@contentrain/mcp 0.3.1 → 1.0.3

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 (115) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +154 -238
  3. package/dist/apply-manager-BAeqrAHX.mjs +751 -0
  4. package/dist/apply-manager-BAeqrAHX.mjs.map +1 -0
  5. package/dist/astro-parser-RofKmKQi.mjs +166 -0
  6. package/dist/astro-parser-RofKmKQi.mjs.map +1 -0
  7. package/dist/branch-lifecycle-DB6sgeJi.mjs +97 -0
  8. package/dist/branch-lifecycle-DB6sgeJi.mjs.map +1 -0
  9. package/dist/chunk-BEJ448es.mjs +35 -0
  10. package/dist/compiler-rIC4ETHv.mjs +44509 -0
  11. package/dist/compiler-rIC4ETHv.mjs.map +1 -0
  12. package/dist/compiler-sfc.cjs-BbD04rn_.mjs +50279 -0
  13. package/dist/compiler-sfc.cjs-BbD04rn_.mjs.map +1 -0
  14. package/dist/config-CPlhDwsv.mjs +27 -0
  15. package/dist/config-CPlhDwsv.mjs.map +1 -0
  16. package/dist/context-CRBCTYPB.mjs +46 -0
  17. package/dist/context-CRBCTYPB.mjs.map +1 -0
  18. package/dist/core/apply-manager.d.mts +144 -0
  19. package/dist/core/apply-manager.d.mts.map +1 -0
  20. package/dist/core/apply-manager.mjs +6 -0
  21. package/dist/core/config.d.mts +8 -0
  22. package/dist/core/config.d.mts.map +1 -0
  23. package/dist/core/config.mjs +2 -0
  24. package/dist/core/content-manager.d.mts +53 -0
  25. package/dist/core/content-manager.d.mts.map +1 -0
  26. package/dist/core/content-manager.mjs +2 -0
  27. package/dist/core/context.d.mts +13 -0
  28. package/dist/core/context.d.mts.map +1 -0
  29. package/dist/core/context.mjs +3 -0
  30. package/dist/core/graph-builder.d.mts +12 -0
  31. package/dist/core/graph-builder.d.mts.map +1 -0
  32. package/dist/core/graph-builder.mjs +2 -0
  33. package/dist/core/meta-manager.d.mts +14 -0
  34. package/dist/core/meta-manager.d.mts.map +1 -0
  35. package/dist/core/meta-manager.mjs +2 -0
  36. package/dist/core/model-manager.d.mts +44 -0
  37. package/dist/core/model-manager.d.mts.map +1 -0
  38. package/dist/core/model-manager.mjs +2 -0
  39. package/dist/core/scan-config.d.mts +24 -0
  40. package/dist/core/scan-config.d.mts.map +1 -0
  41. package/dist/core/scan-config.mjs +2 -0
  42. package/dist/core/scanner.d.mts +25 -0
  43. package/dist/core/scanner.d.mts.map +1 -0
  44. package/dist/core/scanner.mjs +3 -0
  45. package/dist/core/validator.d.mts +23 -0
  46. package/dist/core/validator.d.mts.map +1 -0
  47. package/dist/core/validator.mjs +3 -0
  48. package/dist/detect-C8XmJ4S6.mjs +362 -0
  49. package/dist/detect-C8XmJ4S6.mjs.map +1 -0
  50. package/dist/fs-BbuGnVcG.mjs +52 -0
  51. package/dist/fs-BbuGnVcG.mjs.map +1 -0
  52. package/dist/git/branch-lifecycle.d.mts +28 -0
  53. package/dist/git/branch-lifecycle.d.mts.map +1 -0
  54. package/dist/git/branch-lifecycle.mjs +2 -0
  55. package/dist/git/transaction.d.mts +25 -0
  56. package/dist/git/transaction.d.mts.map +1 -0
  57. package/dist/git/transaction.mjs +4 -0
  58. package/dist/graph-builder-fPrDDwnu.mjs +162 -0
  59. package/dist/graph-builder-fPrDDwnu.mjs.map +1 -0
  60. package/dist/id-BdD4HvMY.mjs +12 -0
  61. package/dist/id-BdD4HvMY.mjs.map +1 -0
  62. package/dist/index.d.mts +1 -359
  63. package/dist/index.mjs +15 -2161
  64. package/dist/index.mjs.map +1 -1
  65. package/dist/meta-manager-CECrfsKm.mjs +53 -0
  66. package/dist/meta-manager-CECrfsKm.mjs.map +1 -0
  67. package/dist/model-manager-sfPdrVIJ.mjs +1016 -0
  68. package/dist/model-manager-sfPdrVIJ.mjs.map +1 -0
  69. package/dist/node-Cbq5eR5i.mjs +300 -0
  70. package/dist/node-Cbq5eR5i.mjs.map +1 -0
  71. package/dist/scan-config-AJGxCdNd.mjs +159 -0
  72. package/dist/scan-config-AJGxCdNd.mjs.map +1 -0
  73. package/dist/scanner-BdTiv8sw.mjs +619 -0
  74. package/dist/scanner-BdTiv8sw.mjs.map +1 -0
  75. package/dist/serializer-Dueih-i2.mjs +33 -0
  76. package/dist/serializer-Dueih-i2.mjs.map +1 -0
  77. package/dist/server-CC12z_xq.mjs +1868 -0
  78. package/dist/server-CC12z_xq.mjs.map +1 -0
  79. package/dist/server.d.mts +7 -0
  80. package/dist/server.d.mts.map +1 -0
  81. package/dist/server.mjs +11 -0
  82. package/dist/source-map-generator-DpO-ejrt.mjs +893 -0
  83. package/dist/source-map-generator-DpO-ejrt.mjs.map +1 -0
  84. package/dist/svelte-parser-COTNG92w.mjs +225 -0
  85. package/dist/svelte-parser-COTNG92w.mjs.map +1 -0
  86. package/dist/templates/index.d.mts +8 -0
  87. package/dist/templates/index.d.mts.map +1 -0
  88. package/dist/templates/index.mjs +2 -0
  89. package/dist/templates-3HWi-PTu.mjs +824 -0
  90. package/dist/templates-3HWi-PTu.mjs.map +1 -0
  91. package/dist/transaction-DXkMulzr.mjs +128 -0
  92. package/dist/transaction-DXkMulzr.mjs.map +1 -0
  93. package/dist/tsx-parser-Bf8Firrq.mjs +2 -0
  94. package/dist/tsx-parser-CYVyBWjn.mjs +234 -0
  95. package/dist/tsx-parser-CYVyBWjn.mjs.map +1 -0
  96. package/dist/util/detect.d.mts +20 -0
  97. package/dist/util/detect.d.mts.map +1 -0
  98. package/dist/util/detect.mjs +2 -0
  99. package/dist/util/fs.d.mts +12 -0
  100. package/dist/util/fs.d.mts.map +1 -0
  101. package/dist/util/fs.mjs +2 -0
  102. package/dist/util/id.d.mts +6 -0
  103. package/dist/util/id.d.mts.map +1 -0
  104. package/dist/util/id.mjs +2 -0
  105. package/dist/util/serializer.d.mts +14 -0
  106. package/dist/util/serializer.d.mts.map +1 -0
  107. package/dist/util/serializer.mjs +2 -0
  108. package/dist/validator-C3lZqO3U.mjs +755 -0
  109. package/dist/validator-C3lZqO3U.mjs.map +1 -0
  110. package/dist/vue-parser-CjS4YAoQ.mjs +225 -0
  111. package/dist/vue-parser-CjS4YAoQ.mjs.map +1 -0
  112. package/package.json +113 -43
  113. package/dist/fs-D83-Ii_y.mjs +0 -75
  114. package/dist/fs-D83-Ii_y.mjs.map +0 -1
  115. package/dist/index.d.mts.map +0 -1
@@ -0,0 +1,1868 @@
1
+ import { a as readJson, n as ensureDir, r as pathExists, s as writeJson, t as contentrainDir } from "./fs-BbuGnVcG.mjs";
2
+ import { n as readVocabulary, t as readConfig } from "./config-CPlhDwsv.mjs";
3
+ import { n as readMeta, r as writeMeta } from "./meta-manager-CECrfsKm.mjs";
4
+ import { S as writeContent, _ as resolveMdFilePath, a as fieldDefZodSchema, c as validateModelDefinition, d as listContent, g as resolveLocaleStrategy, h as resolveJsonFilePath, i as deleteModel, l as writeModel, m as resolveContentDir, n as checkReferences, o as listModels, r as countEntries, s as readModel, u as deleteContent } from "./model-manager-sfPdrVIJ.mjs";
5
+ import { t as readContext } from "./context-CRBCTYPB.mjs";
6
+ import { t as detectStack } from "./detect-C8XmJ4S6.mjs";
7
+ import { n as cleanupMergedBranches, t as checkBranchHealth } from "./branch-lifecycle-DB6sgeJi.mjs";
8
+ import { n as listTemplates, t as getTemplate } from "./templates-3HWi-PTu.mjs";
9
+ import { n as createTransaction, t as buildBranchName } from "./transaction-DXkMulzr.mjs";
10
+ import { t as validateProject } from "./validator-C3lZqO3U.mjs";
11
+ import { t as buildGraph } from "./graph-builder-fPrDDwnu.mjs";
12
+ import { n as scanCandidates, r as scanSummary } from "./scanner-BdTiv8sw.mjs";
13
+ import { n as applyExtract, r as applyReuse } from "./apply-manager-BAeqrAHX.mjs";
14
+ import { join } from "node:path";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { z } from "zod";
17
+ import { appendFile, readFile, writeFile } from "node:fs/promises";
18
+ import { simpleGit } from "simple-git";
19
+ //#region src/tools/context.ts
20
+ function registerContextTools(server, projectRoot) {
21
+ server.tool("contentrain_status", "Get full project status (read-only). Returns config, models, context. Do NOT manually edit .contentrain/ based on this output.", {}, async () => {
22
+ if (!await pathExists(join(contentrainDir(projectRoot), "config.json"))) {
23
+ const detectedStack = await detectStack(projectRoot);
24
+ return { content: [{
25
+ type: "text",
26
+ text: JSON.stringify({
27
+ initialized: false,
28
+ detected_stack: detectedStack,
29
+ suggestion: "Run contentrain_init to set up .contentrain/ structure",
30
+ next_steps: ["Run contentrain_init"]
31
+ }, null, 2)
32
+ }] };
33
+ }
34
+ const config = await readConfig(projectRoot);
35
+ const models = await listModels(projectRoot);
36
+ const context = await readContext(projectRoot);
37
+ const vocabulary = await readVocabulary(projectRoot);
38
+ const errors = [];
39
+ if (!config) errors.push(".contentrain/config.json missing");
40
+ const result = {
41
+ initialized: true,
42
+ config: config ? {
43
+ stack: config.stack,
44
+ workflow: config.workflow,
45
+ locales: config.locales,
46
+ domains: config.domains,
47
+ repository: config.repository
48
+ } : null,
49
+ models,
50
+ context: context ? {
51
+ lastOperation: context.lastOperation,
52
+ stats: context.stats
53
+ } : null,
54
+ vocabulary_size: vocabulary ? Object.keys(vocabulary.terms).length : 0
55
+ };
56
+ if (await pathExists(join(projectRoot, ".git"))) try {
57
+ const cleanup = await cleanupMergedBranches(projectRoot);
58
+ const health = await checkBranchHealth(projectRoot);
59
+ result["branches"] = {
60
+ total: health.total,
61
+ merged: health.merged,
62
+ unmerged: health.unmerged,
63
+ cleaned_up: cleanup.deleted
64
+ };
65
+ if (health.message) result["branch_warning"] = health.message;
66
+ if (health.blocked) errors.push(health.message);
67
+ } catch {}
68
+ if (errors.length > 0) result["validation"] = {
69
+ errors: errors.length,
70
+ warnings: 0,
71
+ summary: errors
72
+ };
73
+ const nextSteps = [];
74
+ if (models.length === 0) nextSteps.push("Create models with contentrain_model_save");
75
+ if (errors.length > 0) nextSteps.push(`Fix ${errors.length} validation error(s)`);
76
+ if (nextSteps.length === 0) nextSteps.push("Use contentrain_describe to inspect a model");
77
+ result["next_steps"] = nextSteps;
78
+ return { content: [{
79
+ type: "text",
80
+ text: JSON.stringify(result, null, 2)
81
+ }] };
82
+ });
83
+ server.tool("contentrain_describe", "Get full schema of a single model (read-only). Do NOT manually create content files — use contentrain_content_save instead.", {
84
+ model: z.string().describe("Model ID (e.g. \"blog-post\", \"hero\")"),
85
+ include_sample: z.boolean().optional().default(false).describe("Include one sample entry"),
86
+ locale: z.string().optional().describe("Locale for sample content (default: config default locale)")
87
+ }, async ({ model: modelId, include_sample, locale }) => {
88
+ const modelDef = await readModel(projectRoot, modelId);
89
+ if (!modelDef) return {
90
+ content: [{
91
+ type: "text",
92
+ text: JSON.stringify({ error: `Model "${modelId}" not found` })
93
+ }],
94
+ isError: true
95
+ };
96
+ const config = await readConfig(projectRoot);
97
+ const effectiveLocale = locale ?? config?.locales.default ?? "en";
98
+ const stats = await countEntries(projectRoot, modelDef);
99
+ const result = {
100
+ id: modelDef.id,
101
+ name: modelDef.name,
102
+ kind: modelDef.kind,
103
+ domain: modelDef.domain,
104
+ i18n: modelDef.i18n,
105
+ description: modelDef.description,
106
+ fields: modelDef.fields,
107
+ stats: {
108
+ total_entries: stats.total,
109
+ locales: stats.locales
110
+ }
111
+ };
112
+ if (include_sample) {
113
+ const sample = await getSample(projectRoot, modelDef, effectiveLocale);
114
+ if (sample) result["sample"] = sample;
115
+ }
116
+ result["import_snippet"] = generateImportSnippet(modelDef, config?.stack ?? "other", effectiveLocale);
117
+ return { content: [{
118
+ type: "text",
119
+ text: JSON.stringify(result, null, 2)
120
+ }] };
121
+ });
122
+ server.tool("contentrain_describe_format", "Describes the Contentrain content file format for any language/platform. Returns a comprehensive specification of the file structure, JSON formats, markdown conventions, meta files, and locale strategies.", {}, async () => {
123
+ return { content: [{
124
+ type: "text",
125
+ text: JSON.stringify({
126
+ overview: "Contentrain stores content as plain JSON and Markdown files in a .contentrain/ directory at the project root. All files are committed to git.",
127
+ directory_structure: {
128
+ description: "Standard layout of .contentrain/ directory",
129
+ layout: {
130
+ ".contentrain/config.json": "Project configuration (stack, workflow, locales, domains)",
131
+ ".contentrain/context.json": "Last operation metadata (written by MCP after every write)",
132
+ ".contentrain/vocabulary.json": "Shared vocabulary/terms (optional)",
133
+ ".contentrain/models/{model-id}.json": "Model schema definitions",
134
+ ".contentrain/content/{domain}/{model-id}/": "Content files per model",
135
+ ".contentrain/meta/{model-id}/": "Metadata files per model (status, source, timestamps)"
136
+ }
137
+ },
138
+ model_kinds: {
139
+ collection: {
140
+ description: "Multiple entries stored as an object-map keyed by entry ID",
141
+ storage_format: "JSON object-map: { \"entry-id-1\": { ...fields }, \"entry-id-2\": { ...fields } }",
142
+ note: "Keys are sorted alphabetically for canonical output. Entry IDs are alphanumeric (1-40 chars, hyphens/underscores allowed).",
143
+ example: "{ \"abc123\": { \"title\": \"Hello\", \"slug\": \"hello\" }, \"def456\": { \"title\": \"World\", \"slug\": \"world\" } }"
144
+ },
145
+ singleton: {
146
+ description: "Single entry (e.g. site settings, hero section)",
147
+ storage_format: "JSON object with field key-value pairs: { \"title\": \"My Site\", \"description\": \"...\" }"
148
+ },
149
+ document: {
150
+ description: "Markdown files with YAML-like frontmatter",
151
+ storage_format: "Markdown file: ---\\nslug: my-post\\ntitle: My Post\\n---\\n\\nMarkdown body content here.",
152
+ frontmatter_rules: [
153
+ "Delimited by --- on its own line",
154
+ "Key-value pairs: key: value",
155
+ "Arrays: key:\\n - item1\\n - item2",
156
+ "Inline arrays: key: [item1, item2]",
157
+ "Values auto-parsed: true/false -> boolean, integers -> number, quoted -> string",
158
+ "\"body\" key is reserved for the markdown content below the frontmatter"
159
+ ]
160
+ },
161
+ dictionary: {
162
+ description: "Flat key-value string map (e.g. i18n translation files)",
163
+ storage_format: "JSON object: { \"greeting\": \"Hello\", \"farewell\": \"Goodbye\" }",
164
+ note: "All values must be strings."
165
+ }
166
+ },
167
+ meta_files: {
168
+ description: "Metadata is stored separately from content in .contentrain/meta/{model-id}/",
169
+ collection_meta: "Object-map: { \"entry-id\": { \"status\": \"draft\", \"source\": \"agent\", \"updated_by\": \"contentrain-mcp\" } }",
170
+ singleton_meta: "Single object: { \"status\": \"published\", \"source\": \"human\", \"updated_by\": \"user@example.com\" }",
171
+ fields: {
172
+ status: "Content lifecycle status: \"draft\" | \"in_review\" | \"published\" | \"rejected\" | \"archived\"",
173
+ source: "Who created the entry: \"agent\" | \"human\" | \"import\"",
174
+ updated_by: "Author identifier string",
175
+ approved_by: "Optional: approver identifier",
176
+ version: "Optional: version string",
177
+ publish_at: "Optional: ISO 8601 date for scheduled publishing",
178
+ expire_at: "Optional: ISO 8601 date for scheduled expiry (must be after publish_at)"
179
+ }
180
+ },
181
+ locale_strategies: {
182
+ description: "How localized content files are organized. Set per-model via locale_strategy field.",
183
+ strategies: {
184
+ file: {
185
+ description: "Default. Each locale is a separate file in the model directory.",
186
+ json_pattern: "{content-dir}/{locale}.json (e.g. en.json, tr.json)",
187
+ md_pattern: "{content-dir}/{slug}/{locale}.md"
188
+ },
189
+ suffix: {
190
+ description: "Locale appended to filename.",
191
+ json_pattern: "{content-dir}/{model-id}.{locale}.json",
192
+ md_pattern: "{content-dir}/{slug}.{locale}.md"
193
+ },
194
+ directory: {
195
+ description: "Locale as subdirectory.",
196
+ json_pattern: "{content-dir}/{locale}/{model-id}.json",
197
+ md_pattern: "{content-dir}/{locale}/{slug}.md"
198
+ },
199
+ none: {
200
+ description: "No locale in path (single-language or externally managed).",
201
+ json_pattern: "{content-dir}/{model-id}.json",
202
+ md_pattern: "{content-dir}/{slug}.md"
203
+ }
204
+ }
205
+ },
206
+ i18n_disabled: {
207
+ description: "When a model has i18n:false, locale is ignored in file paths.",
208
+ json_pattern: "{content-dir}/data.json (always \"data.json\", no locale suffix)",
209
+ md_pattern: "{content-dir}/{slug}.md (no locale in path)"
210
+ },
211
+ canonical_serialization: {
212
+ description: "All JSON files use deterministic serialization for clean git diffs.",
213
+ rules: [
214
+ "Object keys sorted alphabetically",
215
+ "2-space indentation",
216
+ "Trailing newline at end of file",
217
+ "No trailing commas",
218
+ "UTF-8 encoding"
219
+ ]
220
+ },
221
+ content_path_override: {
222
+ description: "Models can specify a custom content_path to store content outside .contentrain/content/.",
223
+ example: "A model with content_path: \"content/blog\" stores files at {project-root}/content/blog/ instead of .contentrain/content/{domain}/{model-id}/",
224
+ note: "This is useful for frameworks that expect content in specific directories (e.g. Nuxt Content, Astro)."
225
+ },
226
+ reading_content: { steps: [
227
+ "1. Read .contentrain/config.json to get locales and workflow settings",
228
+ "2. Read .contentrain/models/{model-id}.json to get the schema and model kind",
229
+ "3. Determine content directory: model.content_path or .contentrain/content/{domain}/{model-id}/",
230
+ "4. Determine file path using locale_strategy and i18n flag",
231
+ "5. Parse JSON (collection/singleton/dictionary) or Markdown frontmatter (document)",
232
+ "6. Optionally read .contentrain/meta/{model-id}/{locale}.json for status/metadata"
233
+ ] }
234
+ }, null, 2)
235
+ }] };
236
+ });
237
+ }
238
+ async function getSample(projectRoot, model, locale) {
239
+ const { readJson, readDir } = await import("./util/fs.mjs");
240
+ const cDir = resolveContentDir(projectRoot, model);
241
+ if (model.kind === "collection") {
242
+ const data = await readJson(resolveJsonFilePath(cDir, model, locale));
243
+ if (!data) return null;
244
+ const firstKey = Object.keys(data)[0];
245
+ return firstKey ? {
246
+ id: firstKey,
247
+ ...data[firstKey]
248
+ } : null;
249
+ }
250
+ if (model.kind === "singleton" || model.kind === "dictionary") return readJson(resolveJsonFilePath(cDir, model, locale));
251
+ if (model.kind === "document") {
252
+ const strategy = resolveLocaleStrategy(model);
253
+ if (!model.i18n) {
254
+ const slug = (await readDir(cDir)).find((item) => item.endsWith(".md"))?.replace(/\.md$/u, "");
255
+ return slug ? {
256
+ slug,
257
+ locale
258
+ } : null;
259
+ }
260
+ if (strategy === "file") {
261
+ const slug = (await readDir(cDir))[0];
262
+ return slug ? {
263
+ slug,
264
+ locale
265
+ } : null;
266
+ }
267
+ if (strategy === "suffix") {
268
+ const suffix = `.${locale}.md`;
269
+ const slug = (await readDir(cDir)).find((file) => file.endsWith(suffix))?.slice(0, -suffix.length);
270
+ return slug ? {
271
+ slug,
272
+ locale
273
+ } : null;
274
+ }
275
+ if (strategy === "directory") {
276
+ const slug = (await readDir(join(cDir, locale))).find((item) => item.endsWith(".md"))?.replace(/\.md$/u, "");
277
+ return slug ? {
278
+ slug,
279
+ locale
280
+ } : null;
281
+ }
282
+ const slug = (await readDir(cDir)).find((item) => item.endsWith(".md"))?.replace(/\.md$/u, "");
283
+ return slug ? {
284
+ slug,
285
+ locale
286
+ } : null;
287
+ }
288
+ return null;
289
+ }
290
+ function buildContentPath(model, locale) {
291
+ const strategy = model.locale_strategy ?? "file";
292
+ const baseDir = model.content_path ?? `.contentrain/content/${model.domain}/${model.id}`;
293
+ if (model.kind === "document") {
294
+ if (!model.i18n) return `${baseDir}/{slug}.md`;
295
+ switch (strategy) {
296
+ case "suffix": return `${baseDir}/{slug}.${locale}.md`;
297
+ case "directory": return `${baseDir}/${locale}/{slug}.md`;
298
+ case "none": return `${baseDir}/{slug}.md`;
299
+ default: return `${baseDir}/{slug}/${locale}.md`;
300
+ }
301
+ }
302
+ if (!model.i18n) return `${baseDir}/data.json`;
303
+ switch (strategy) {
304
+ case "suffix": return `${baseDir}/${model.id}.${locale}.json`;
305
+ case "directory": return `${baseDir}/${locale}/${model.id}.json`;
306
+ case "none": return `${baseDir}/${model.id}.json`;
307
+ default: return `${baseDir}/${locale}.json`;
308
+ }
309
+ }
310
+ function generateImportSnippet(model, stack, locale) {
311
+ const path = buildContentPath(model, locale);
312
+ const snippets = { generic: `import data from '${path}'` };
313
+ switch (stack) {
314
+ case "nuxt":
315
+ snippets["nuxt"] = model.kind === "document" ? `const { data } = await useAsyncData(() => queryContent('${model.domain}/${model.id}').locale('${locale}').find())` : `const { data } = await useFetch('/api/content/${model.id}?locale=${locale}')`;
316
+ break;
317
+ case "next":
318
+ snippets["next"] = `import data from '@/${path}'`;
319
+ break;
320
+ case "astro":
321
+ snippets["astro"] = `import data from '~/${path}'`;
322
+ break;
323
+ }
324
+ return snippets;
325
+ }
326
+ //#endregion
327
+ //#region src/tools/setup.ts
328
+ function registerSetupTools(server, projectRoot) {
329
+ server.tool("contentrain_init", "Initialize .contentrain/ structure. Changes are auto-committed to git — do NOT manually create .contentrain/ files.", {
330
+ stack: z.string().optional().describe("Framework stack (nuxt, next, astro, svelte, react-vite, other). Auto-detected if omitted."),
331
+ locales: z.array(z.string()).optional().describe("Supported locales. Default: [\"en\"]"),
332
+ domains: z.array(z.string()).optional().describe("Content domains. Default: auto-suggested")
333
+ }, async ({ stack, locales, domains }) => {
334
+ if (await pathExists(join(contentrainDir(projectRoot), "config.json"))) return {
335
+ content: [{
336
+ type: "text",
337
+ text: JSON.stringify({
338
+ error: "Already initialized",
339
+ suggestion: "Use contentrain_status to see current state"
340
+ })
341
+ }],
342
+ isError: true
343
+ };
344
+ const detectedStack = stack ?? await detectStack(projectRoot);
345
+ const supportedLocales = locales ?? ["en"];
346
+ const suggestedDomains = domains ?? suggestDomains(projectRoot);
347
+ if (!await pathExists(join(projectRoot, ".git"))) await simpleGit(projectRoot).init();
348
+ const initHealth = await checkBranchHealth(projectRoot);
349
+ if (initHealth.blocked) return {
350
+ content: [{
351
+ type: "text",
352
+ text: JSON.stringify({
353
+ error: initHealth.message,
354
+ action: "blocked",
355
+ hint: "Merge or delete old contentrain/* branches before creating new ones."
356
+ }, null, 2)
357
+ }],
358
+ isError: true
359
+ };
360
+ const branch = buildBranchName("new", "init");
361
+ const tx = await createTransaction(projectRoot, branch);
362
+ try {
363
+ await tx.write(async (wt) => {
364
+ const wtCrDir = join(wt, ".contentrain");
365
+ await Promise.all([
366
+ "models",
367
+ "content",
368
+ "meta"
369
+ ].map(async (dir) => {
370
+ await ensureDir(join(wtCrDir, dir));
371
+ await writeFile(join(wtCrDir, dir, ".gitkeep"), "", "utf-8");
372
+ }));
373
+ const config = {
374
+ version: 1,
375
+ stack: detectedStack,
376
+ workflow: "auto-merge",
377
+ locales: {
378
+ default: supportedLocales[0] ?? "en",
379
+ supported: supportedLocales
380
+ },
381
+ domains: suggestedDomains
382
+ };
383
+ await writeJson(join(wtCrDir, "config.json"), config);
384
+ await writeJson(join(wtCrDir, "vocabulary.json"), {
385
+ version: 1,
386
+ terms: {}
387
+ });
388
+ await writeJson(join(wtCrDir, "context.json"), {
389
+ version: "1",
390
+ lastOperation: {
391
+ tool: "contentrain_init",
392
+ model: "",
393
+ locale: supportedLocales[0] ?? "en",
394
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
395
+ source: process.env["CONTENTRAIN_SOURCE"] === "mcp-studio" ? "mcp-studio" : "mcp-local"
396
+ },
397
+ stats: {
398
+ models: 0,
399
+ entries: 0,
400
+ locales: supportedLocales,
401
+ lastSync: (/* @__PURE__ */ new Date()).toISOString()
402
+ }
403
+ });
404
+ await updateGitignore(wt);
405
+ });
406
+ await tx.commit("[contentrain] init: project setup");
407
+ const gitResult = await tx.complete({
408
+ tool: "contentrain_init",
409
+ model: "",
410
+ locale: supportedLocales[0] ?? "en"
411
+ });
412
+ return { content: [{
413
+ type: "text",
414
+ text: JSON.stringify({
415
+ status: "committed",
416
+ message: "Project initialized and committed to git. Do NOT manually edit .contentrain/ files.",
417
+ config_created: ".contentrain/config.json",
418
+ detected_stack: detectedStack,
419
+ detected_locales: supportedLocales,
420
+ suggested_domains: suggestedDomains,
421
+ files_created: [
422
+ ".contentrain/config.json",
423
+ ".contentrain/vocabulary.json",
424
+ ".contentrain/context.json"
425
+ ],
426
+ directories_created: [
427
+ ".contentrain/models/",
428
+ ".contentrain/content/",
429
+ ".contentrain/meta/"
430
+ ],
431
+ gitignore_updated: true,
432
+ git: {
433
+ branch,
434
+ action: gitResult.action,
435
+ commit: gitResult.commit
436
+ },
437
+ next_steps: ["Create models with contentrain_model_save or contentrain_scaffold", "Use contentrain_scan to find hardcoded strings"]
438
+ }, null, 2)
439
+ }] };
440
+ } catch (error) {
441
+ await tx.cleanup();
442
+ return {
443
+ content: [{
444
+ type: "text",
445
+ text: JSON.stringify({ error: `Init failed: ${error instanceof Error ? error.message : String(error)}` })
446
+ }],
447
+ isError: true
448
+ };
449
+ } finally {
450
+ await tx.cleanup();
451
+ }
452
+ });
453
+ server.tool("contentrain_scaffold", `Template-based project setup. Available templates: ${listTemplates().join(", ")}. Changes are auto-committed to git.`, {
454
+ template: z.string().describe("Template ID: blog, landing, docs, ecommerce, saas, i18n, mobile"),
455
+ locales: z.array(z.string()).optional().describe("Override locales"),
456
+ with_sample_content: z.boolean().optional().default(true).describe("Include sample content (default: true)")
457
+ }, async ({ template: templateId, locales, with_sample_content }) => {
458
+ const config = await readConfig(projectRoot);
459
+ if (!config) return {
460
+ content: [{
461
+ type: "text",
462
+ text: JSON.stringify({ error: "Project not initialized. Run contentrain_init first." })
463
+ }],
464
+ isError: true
465
+ };
466
+ const tmpl = getTemplate(templateId);
467
+ if (!tmpl) return {
468
+ content: [{
469
+ type: "text",
470
+ text: JSON.stringify({ error: `Unknown template: "${templateId}". Available: ${listTemplates().join(", ")}` })
471
+ }],
472
+ isError: true
473
+ };
474
+ const scaffoldHealth = await checkBranchHealth(projectRoot);
475
+ if (scaffoldHealth.blocked) return {
476
+ content: [{
477
+ type: "text",
478
+ text: JSON.stringify({
479
+ error: scaffoldHealth.message,
480
+ action: "blocked",
481
+ hint: "Merge or delete old contentrain/* branches before creating new ones."
482
+ }, null, 2)
483
+ }],
484
+ isError: true
485
+ };
486
+ const effectiveLocales = locales ?? config.locales.supported;
487
+ const defaultLocale = effectiveLocales[0] ?? "en";
488
+ const branch = buildBranchName("new", `scaffold-${templateId}`, defaultLocale);
489
+ const tx = await createTransaction(projectRoot, branch);
490
+ try {
491
+ const modelsCreated = [];
492
+ let contentCreated = 0;
493
+ let vocabAdded = 0;
494
+ await tx.write(async (wt) => {
495
+ await Promise.all(tmpl.models.map(async (model) => {
496
+ await writeModel(wt, model);
497
+ modelsCreated.push({
498
+ id: model.id,
499
+ kind: model.kind,
500
+ fields: model.fields ? Object.keys(model.fields).length : 0
501
+ });
502
+ }));
503
+ if (with_sample_content && tmpl.sample_content) contentCreated = await writeSampleContent(wt, tmpl, effectiveLocales, config);
504
+ if (tmpl.vocabulary) vocabAdded = await mergeVocabulary(wt, tmpl.vocabulary, effectiveLocales);
505
+ });
506
+ await tx.commit(`[contentrain] scaffold: ${templateId} (${defaultLocale})`);
507
+ const gitResult = await tx.complete({
508
+ tool: "contentrain_scaffold",
509
+ model: tmpl.models.map((m) => m.id).join(", "),
510
+ locale: defaultLocale
511
+ });
512
+ return { content: [{
513
+ type: "text",
514
+ text: JSON.stringify({
515
+ status: "committed",
516
+ message: "Scaffold applied and committed to git. Do NOT manually edit .contentrain/ files.",
517
+ models_created: modelsCreated,
518
+ content_created: contentCreated,
519
+ vocabulary_terms_added: vocabAdded,
520
+ git: {
521
+ branch,
522
+ action: gitResult.action,
523
+ commit: gitResult.commit
524
+ },
525
+ context_updated: true,
526
+ next_steps: [
527
+ "Customize models with contentrain_model_save",
528
+ "Add content with contentrain_content_save",
529
+ "Run contentrain_submit when ready"
530
+ ]
531
+ }, null, 2)
532
+ }] };
533
+ } catch (error) {
534
+ await tx.cleanup();
535
+ return {
536
+ content: [{
537
+ type: "text",
538
+ text: JSON.stringify({ error: `Scaffold failed: ${error instanceof Error ? error.message : String(error)}` })
539
+ }],
540
+ isError: true
541
+ };
542
+ } finally {
543
+ await tx.cleanup();
544
+ }
545
+ });
546
+ }
547
+ function suggestDomains(_projectRoot) {
548
+ return [
549
+ "marketing",
550
+ "blog",
551
+ "system"
552
+ ];
553
+ }
554
+ async function updateGitignore(projectRoot) {
555
+ const gitignorePath = join(projectRoot, ".gitignore");
556
+ const cacheEntry = ".contentrain/.cache/";
557
+ if (await pathExists(gitignorePath)) {
558
+ if (!(await readFile(gitignorePath, "utf-8")).includes(cacheEntry)) await appendFile(gitignorePath, `\n# Contentrain cache\n${cacheEntry}\n`);
559
+ } else await writeFile(gitignorePath, `# Contentrain cache\n${cacheEntry}\n`, "utf-8");
560
+ }
561
+ async function writeSampleContent(worktreePath, tmpl, locales, config) {
562
+ if (!tmpl.sample_content) return 0;
563
+ let count = 0;
564
+ for (const model of tmpl.models) {
565
+ const sampleData = tmpl.sample_content[model.id];
566
+ if (!sampleData) continue;
567
+ for (const [localeOrKey, data] of Object.entries(sampleData)) {
568
+ const entryMap = data;
569
+ let targetLocale;
570
+ if (localeOrKey === "data") targetLocale = locales[0] ?? "en";
571
+ else if (locales.includes(localeOrKey)) targetLocale = localeOrKey;
572
+ else continue;
573
+ let entries;
574
+ if (model.kind === "singleton" || model.kind === "dictionary") entries = [{
575
+ locale: targetLocale,
576
+ data: entryMap
577
+ }];
578
+ else if (model.kind === "document") entries = Object.entries(entryMap).map(([slug, fields]) => ({
579
+ slug,
580
+ locale: targetLocale,
581
+ data: fields
582
+ }));
583
+ else entries = Object.entries(entryMap).map(([id, fields]) => ({
584
+ id,
585
+ locale: targetLocale,
586
+ data: fields
587
+ }));
588
+ await writeContent(worktreePath, model, entries, config);
589
+ count += entries.length;
590
+ }
591
+ }
592
+ return count;
593
+ }
594
+ async function mergeVocabulary(projectRoot, newTerms, _locales) {
595
+ const vocabPath = join(contentrainDir(projectRoot), "vocabulary.json");
596
+ const vocab = await readVocabulary(projectRoot) ?? {
597
+ version: 1,
598
+ terms: {}
599
+ };
600
+ let added = 0;
601
+ for (const [key, translations] of Object.entries(newTerms)) {
602
+ if (!(key in vocab.terms)) added++;
603
+ vocab.terms[key] = {
604
+ ...vocab.terms[key],
605
+ ...translations
606
+ };
607
+ }
608
+ await writeJson(vocabPath, vocab);
609
+ return added;
610
+ }
611
+ //#endregion
612
+ //#region src/tools/model.ts
613
+ const fieldDefSchema = fieldDefZodSchema;
614
+ function registerModelTools(server, projectRoot) {
615
+ server.tool("contentrain_model_save", "Create or update a model definition. Changes are auto-committed to git — do NOT manually edit .contentrain/ files after calling this tool.", {
616
+ id: z.string().describe("Model ID (kebab-case, e.g. \"blog-post\")"),
617
+ name: z.string().describe("Human-readable name"),
618
+ kind: z.enum([
619
+ "singleton",
620
+ "collection",
621
+ "document",
622
+ "dictionary"
623
+ ]).describe("Model kind"),
624
+ domain: z.string().describe("Content domain (e.g. \"blog\", \"marketing\", \"system\")"),
625
+ i18n: z.boolean().describe("Whether this model supports localization"),
626
+ description: z.string().optional().describe("Model description"),
627
+ fields: fieldDefSchema.optional().describe("Field definitions (not needed for dictionary)"),
628
+ content_path: z.string().optional().describe("Framework-relative path for content files (e.g. \"content/blog\", \"locales\"). When set, content is written here instead of .contentrain/content/"),
629
+ locale_strategy: z.enum([
630
+ "file",
631
+ "suffix",
632
+ "directory",
633
+ "none"
634
+ ]).optional().describe("How locale is encoded in file names. Default: \"file\"")
635
+ }, async (input) => {
636
+ const config = await readConfig(projectRoot);
637
+ if (!config) return {
638
+ content: [{
639
+ type: "text",
640
+ text: JSON.stringify({ error: "Project not initialized. Run contentrain_init first." })
641
+ }],
642
+ isError: true
643
+ };
644
+ const errors = validateModel(input);
645
+ if (errors.length > 0) return {
646
+ content: [{
647
+ type: "text",
648
+ text: JSON.stringify({
649
+ error: "Validation failed",
650
+ details: errors
651
+ })
652
+ }],
653
+ isError: true
654
+ };
655
+ if (input.locale_strategy === "none" && input.i18n !== false) return {
656
+ content: [{
657
+ type: "text",
658
+ text: JSON.stringify({ error: "locale_strategy \"none\" requires i18n:false. The \"none\" strategy stores a single file without locale encoding, which is incompatible with multi-locale content. Use \"file\", \"suffix\", or \"directory\" for i18n models." })
659
+ }],
660
+ isError: true
661
+ };
662
+ const health = await checkBranchHealth(projectRoot);
663
+ if (health.blocked) return {
664
+ content: [{
665
+ type: "text",
666
+ text: JSON.stringify({
667
+ error: health.message,
668
+ action: "blocked",
669
+ hint: "Merge or delete old contentrain/* branches before creating new ones."
670
+ }, null, 2)
671
+ }],
672
+ isError: true
673
+ };
674
+ const action = await readModel(projectRoot, input.id) ? "updated" : "created";
675
+ const model = {
676
+ id: input.id,
677
+ name: input.name,
678
+ kind: input.kind,
679
+ domain: input.domain,
680
+ i18n: input.i18n,
681
+ description: input.description,
682
+ fields: input.fields,
683
+ content_path: input.content_path,
684
+ locale_strategy: input.locale_strategy
685
+ };
686
+ const branch = buildBranchName("model", input.id);
687
+ const tx = await createTransaction(projectRoot, branch);
688
+ try {
689
+ await tx.write(async (wt) => {
690
+ await writeModel(wt, model);
691
+ });
692
+ await tx.commit(`[contentrain] ${action}: ${input.id}`);
693
+ const gitResult = await tx.complete({
694
+ tool: "contentrain_model_save",
695
+ model: input.id
696
+ });
697
+ const defaultLocale = config.locales.default;
698
+ const contentDir = resolveContentDir(projectRoot, model);
699
+ const contentPath = model.content_path ?? `.contentrain/content/${input.domain}/${input.id}`;
700
+ let exampleFilePath;
701
+ if (model.kind === "document") exampleFilePath = resolveMdFilePath(contentDir, model, defaultLocale, "{slug}");
702
+ else exampleFilePath = resolveJsonFilePath(contentDir, model, defaultLocale);
703
+ const displayPath = exampleFilePath.replace(projectRoot + "/", "").replace(projectRoot, "");
704
+ const importSnippet = { generic: `import data from '${displayPath}'` };
705
+ if (config.stack === "nuxt") importSnippet["nuxt"] = model.kind === "document" ? `const { data } = await useAsyncData(() => queryContent('${model.domain}/${model.id}').locale('${defaultLocale}').find())` : `const { data } = await useFetch('/api/content/${model.id}?locale=${defaultLocale}')`;
706
+ return { content: [{
707
+ type: "text",
708
+ text: JSON.stringify({
709
+ status: "committed",
710
+ message: "Model saved and committed to git. Do NOT manually edit .contentrain/ files.",
711
+ action,
712
+ model: input.id,
713
+ validation: {
714
+ valid: true,
715
+ errors: []
716
+ },
717
+ git: {
718
+ branch,
719
+ action: gitResult.action,
720
+ commit: gitResult.commit
721
+ },
722
+ context_updated: true,
723
+ content_path: contentPath + "/",
724
+ example_file: displayPath,
725
+ import_snippet: importSnippet,
726
+ next_steps: ["Add content with contentrain_content_save"]
727
+ }, null, 2)
728
+ }] };
729
+ } catch (error) {
730
+ await tx.cleanup();
731
+ return {
732
+ content: [{
733
+ type: "text",
734
+ text: JSON.stringify({ error: `Model save failed: ${error instanceof Error ? error.message : String(error)}` })
735
+ }],
736
+ isError: true
737
+ };
738
+ } finally {
739
+ await tx.cleanup();
740
+ }
741
+ });
742
+ server.tool("contentrain_model_delete", "Delete a model and its content/meta. Changes are auto-committed to git — do NOT manually edit .contentrain/ files.", {
743
+ model: z.string().describe("Model ID to delete"),
744
+ confirm: z.literal(true).describe("Must be true to confirm deletion")
745
+ }, async ({ model: modelId }) => {
746
+ if (!await readConfig(projectRoot)) return {
747
+ content: [{
748
+ type: "text",
749
+ text: JSON.stringify({ error: "Project not initialized." })
750
+ }],
751
+ isError: true
752
+ };
753
+ if (!await readModel(projectRoot, modelId)) return {
754
+ content: [{
755
+ type: "text",
756
+ text: JSON.stringify({ error: `Model "${modelId}" not found` })
757
+ }],
758
+ isError: true
759
+ };
760
+ const deleteHealth = await checkBranchHealth(projectRoot);
761
+ if (deleteHealth.blocked) return {
762
+ content: [{
763
+ type: "text",
764
+ text: JSON.stringify({
765
+ error: deleteHealth.message,
766
+ action: "blocked",
767
+ hint: "Merge or delete old contentrain/* branches before creating new ones."
768
+ }, null, 2)
769
+ }],
770
+ isError: true
771
+ };
772
+ const refs = await checkReferences(projectRoot, modelId);
773
+ if (refs.length > 0) return { content: [{
774
+ type: "text",
775
+ text: JSON.stringify({
776
+ deleted: false,
777
+ error: "REFERENCED_MODEL",
778
+ referenced_by: refs,
779
+ next_steps: ["Remove relation fields from referencing models first"]
780
+ }, null, 2)
781
+ }] };
782
+ const branch = buildBranchName("model", modelId);
783
+ const tx = await createTransaction(projectRoot, branch);
784
+ try {
785
+ let filesRemoved = [];
786
+ await tx.write(async (wt) => {
787
+ filesRemoved = await deleteModel(wt, modelId);
788
+ });
789
+ await tx.commit(`[contentrain] delete: ${modelId}`);
790
+ const gitResult = await tx.complete({
791
+ tool: "contentrain_model_delete",
792
+ model: modelId
793
+ });
794
+ return { content: [{
795
+ type: "text",
796
+ text: JSON.stringify({
797
+ status: "committed",
798
+ message: "Model deleted and committed to git. Do NOT manually edit .contentrain/ files.",
799
+ deleted: true,
800
+ git: {
801
+ branch,
802
+ action: gitResult.action,
803
+ commit: gitResult.commit
804
+ },
805
+ files_removed: filesRemoved,
806
+ context_updated: true
807
+ }, null, 2)
808
+ }] };
809
+ } catch (error) {
810
+ await tx.cleanup();
811
+ return {
812
+ content: [{
813
+ type: "text",
814
+ text: JSON.stringify({ error: `Delete failed: ${error instanceof Error ? error.message : String(error)}` })
815
+ }],
816
+ isError: true
817
+ };
818
+ } finally {
819
+ await tx.cleanup();
820
+ }
821
+ });
822
+ }
823
+ const validateModel = validateModelDefinition;
824
+ //#endregion
825
+ //#region src/tools/content.ts
826
+ function registerContentTools(server, projectRoot) {
827
+ server.tool("contentrain_content_save", "Save content entries. Entry format varies by model kind: DICTIONARY — provide \"locale\" and \"data\" (flat key-value, all string values); \"id\" and \"slug\" are ignored; data keys are the identities. COLLECTION — provide \"locale\" and \"data\"; \"id\" is optional (auto-generated if omitted); \"slug\" is ignored. DOCUMENT — provide \"slug\" (required), \"locale\", and \"data\"; use the \"body\" key inside data for markdown content. SINGLETON — provide only \"locale\" and \"data\". Changes are auto-committed to git — do NOT manually edit .contentrain/ files after calling this tool.", {
828
+ model: z.string().describe("Model ID"),
829
+ entries: z.array(z.object({
830
+ id: z.string().optional().describe("Entry ID (collection only, auto-generated if omitted)"),
831
+ slug: z.string().optional().describe("Slug (document only)"),
832
+ locale: z.string().optional().describe("Locale code (defaults to config default)"),
833
+ data: z.record(z.string(), z.unknown()).describe("Content data. For documents, include \"body\" key for markdown content."),
834
+ publish_at: z.string().optional().describe("ISO 8601 date for scheduled publishing (stored in meta)"),
835
+ expire_at: z.string().optional().describe("ISO 8601 date for scheduled expiry (stored in meta, must be after publish_at)")
836
+ })).describe("Content entries to save")
837
+ }, async (input) => {
838
+ const config = await readConfig(projectRoot);
839
+ if (!config) return {
840
+ content: [{
841
+ type: "text",
842
+ text: JSON.stringify({ error: "Project not initialized. Run contentrain_init first." })
843
+ }],
844
+ isError: true
845
+ };
846
+ const model = await readModel(projectRoot, input.model);
847
+ if (!model) return {
848
+ content: [{
849
+ type: "text",
850
+ text: JSON.stringify({ error: `Model "${input.model}" not found` })
851
+ }],
852
+ isError: true
853
+ };
854
+ for (const entry of input.entries) {
855
+ if (entry.locale && !config.locales.supported.includes(entry.locale)) return {
856
+ content: [{
857
+ type: "text",
858
+ text: JSON.stringify({ error: `Locale "${entry.locale}" is not supported. Supported: [${config.locales.supported.join(", ")}]` })
859
+ }],
860
+ isError: true
861
+ };
862
+ if (entry.publish_at !== void 0) {
863
+ const d = new Date(entry.publish_at);
864
+ if (Number.isNaN(d.getTime())) return {
865
+ content: [{
866
+ type: "text",
867
+ text: JSON.stringify({ error: `Invalid publish_at date: "${entry.publish_at}". Must be a valid ISO 8601 date string.` })
868
+ }],
869
+ isError: true
870
+ };
871
+ }
872
+ if (entry.expire_at !== void 0) {
873
+ const d = new Date(entry.expire_at);
874
+ if (Number.isNaN(d.getTime())) return {
875
+ content: [{
876
+ type: "text",
877
+ text: JSON.stringify({ error: `Invalid expire_at date: "${entry.expire_at}". Must be a valid ISO 8601 date string.` })
878
+ }],
879
+ isError: true
880
+ };
881
+ }
882
+ if (entry.publish_at !== void 0 && entry.expire_at !== void 0) {
883
+ if (new Date(entry.expire_at) <= new Date(entry.publish_at)) return {
884
+ content: [{
885
+ type: "text",
886
+ text: JSON.stringify({ error: `expire_at ("${entry.expire_at}") must be after publish_at ("${entry.publish_at}").` })
887
+ }],
888
+ isError: true
889
+ };
890
+ }
891
+ if (entry.publish_at !== void 0) entry.data["publish_at"] = entry.publish_at;
892
+ if (entry.expire_at !== void 0) entry.data["expire_at"] = entry.expire_at;
893
+ }
894
+ const health = await checkBranchHealth(projectRoot);
895
+ if (health.blocked) return {
896
+ content: [{
897
+ type: "text",
898
+ text: JSON.stringify({
899
+ error: health.message,
900
+ action: "blocked",
901
+ hint: "Merge or delete old contentrain/* branches before creating new ones."
902
+ }, null, 2)
903
+ }],
904
+ isError: true
905
+ };
906
+ const branch = buildBranchName("content", input.model);
907
+ const tx = await createTransaction(projectRoot, branch);
908
+ try {
909
+ let results;
910
+ let entryIds = [];
911
+ await tx.write(async (wt) => {
912
+ results = await writeContent(wt, model, input.entries, config);
913
+ entryIds = results.map((r) => r.id ?? r.slug ?? r.locale).filter(Boolean);
914
+ });
915
+ await tx.commit(`[contentrain] content: ${input.model}`);
916
+ const gitResult = await tx.complete({
917
+ tool: "contentrain_content_save",
918
+ model: input.model,
919
+ locale: input.entries[0]?.locale,
920
+ entries: entryIds
921
+ });
922
+ const validationResult = await validateProject(projectRoot, { model: input.model });
923
+ return { content: [{
924
+ type: "text",
925
+ text: JSON.stringify({
926
+ status: "committed",
927
+ message: "Content saved and committed to git. Do NOT manually edit .contentrain/ files.",
928
+ results,
929
+ git: {
930
+ branch,
931
+ action: gitResult.action,
932
+ commit: gitResult.commit
933
+ },
934
+ validation: {
935
+ valid: validationResult.valid,
936
+ errors: validationResult.issues.filter((i) => i.severity === "error").map((i) => i.message)
937
+ },
938
+ context_updated: true,
939
+ next_steps: [...!validationResult.valid ? ["WARNING: Content has validation errors — run contentrain_validate for details"] : [], ...model.kind === "collection" ? ["Use contentrain_content_list to verify", "Add more entries or publish"] : ["Use contentrain_content_list to verify"]]
940
+ }, null, 2)
941
+ }] };
942
+ } catch (error) {
943
+ await tx.cleanup();
944
+ return {
945
+ content: [{
946
+ type: "text",
947
+ text: JSON.stringify({ error: `Content save failed: ${error instanceof Error ? error.message : String(error)}` })
948
+ }],
949
+ isError: true
950
+ };
951
+ } finally {
952
+ await tx.cleanup();
953
+ }
954
+ });
955
+ server.tool("contentrain_content_delete", "Delete content entries. For dictionaries, use \"keys\" to remove specific keys (omit to delete entire locale file). Changes are auto-committed to git — do NOT manually edit .contentrain/ files after calling this tool.", {
956
+ model: z.string().describe("Model ID"),
957
+ id: z.string().optional().describe("Entry ID (collection)"),
958
+ slug: z.string().optional().describe("Slug (document)"),
959
+ locale: z.string().optional().describe("Locale code"),
960
+ keys: z.array(z.string()).optional().describe("Dictionary only: specific keys to remove. Omit to delete entire locale file."),
961
+ confirm: z.literal(true).describe("Must be true to confirm deletion")
962
+ }, async (input) => {
963
+ if (!await readConfig(projectRoot)) return {
964
+ content: [{
965
+ type: "text",
966
+ text: JSON.stringify({ error: "Project not initialized." })
967
+ }],
968
+ isError: true
969
+ };
970
+ const model = await readModel(projectRoot, input.model);
971
+ if (!model) return {
972
+ content: [{
973
+ type: "text",
974
+ text: JSON.stringify({ error: `Model "${input.model}" not found` })
975
+ }],
976
+ isError: true
977
+ };
978
+ const deleteHealth = await checkBranchHealth(projectRoot);
979
+ if (deleteHealth.blocked) return {
980
+ content: [{
981
+ type: "text",
982
+ text: JSON.stringify({
983
+ error: deleteHealth.message,
984
+ action: "blocked",
985
+ hint: "Merge or delete old contentrain/* branches before creating new ones."
986
+ }, null, 2)
987
+ }],
988
+ isError: true
989
+ };
990
+ const branch = buildBranchName("content", input.model);
991
+ const tx = await createTransaction(projectRoot, branch);
992
+ try {
993
+ let removed = [];
994
+ await tx.write(async (wt) => {
995
+ removed = await deleteContent(wt, model, {
996
+ id: input.id,
997
+ slug: input.slug,
998
+ locale: input.locale,
999
+ keys: input.keys
1000
+ });
1001
+ });
1002
+ await tx.commit(`[contentrain] delete content: ${input.model}`);
1003
+ const gitResult = await tx.complete({
1004
+ tool: "contentrain_content_delete",
1005
+ model: input.model,
1006
+ locale: input.locale
1007
+ });
1008
+ return { content: [{
1009
+ type: "text",
1010
+ text: JSON.stringify({
1011
+ status: "committed",
1012
+ message: "Content deleted and committed to git. Do NOT manually edit .contentrain/ files.",
1013
+ deleted: true,
1014
+ files_removed: removed,
1015
+ git: {
1016
+ branch,
1017
+ action: gitResult.action,
1018
+ commit: gitResult.commit
1019
+ },
1020
+ context_updated: true
1021
+ }, null, 2)
1022
+ }] };
1023
+ } catch (error) {
1024
+ await tx.cleanup();
1025
+ return {
1026
+ content: [{
1027
+ type: "text",
1028
+ text: JSON.stringify({ error: `Delete failed: ${error instanceof Error ? error.message : String(error)}` })
1029
+ }],
1030
+ isError: true
1031
+ };
1032
+ } finally {
1033
+ await tx.cleanup();
1034
+ }
1035
+ });
1036
+ server.tool("contentrain_content_list", "List content entries (read-only). Returns data from .contentrain/ — do NOT manually create or modify content files.", {
1037
+ model: z.string().describe("Model ID"),
1038
+ locale: z.string().optional().describe("Locale code (defaults to config default)"),
1039
+ filter: z.record(z.string(), z.unknown()).optional().describe("Filter criteria (collection only)"),
1040
+ resolve: z.boolean().optional().describe("Resolve relation fields to actual data"),
1041
+ limit: z.number().optional().describe("Max entries to return"),
1042
+ offset: z.number().optional().describe("Skip N entries")
1043
+ }, async (input) => {
1044
+ const config = await readConfig(projectRoot);
1045
+ if (!config) return {
1046
+ content: [{
1047
+ type: "text",
1048
+ text: JSON.stringify({ error: "Project not initialized." })
1049
+ }],
1050
+ isError: true
1051
+ };
1052
+ const model = await readModel(projectRoot, input.model);
1053
+ if (!model) return {
1054
+ content: [{
1055
+ type: "text",
1056
+ text: JSON.stringify({ error: `Model "${input.model}" not found` })
1057
+ }],
1058
+ isError: true
1059
+ };
1060
+ try {
1061
+ const result = await listContent(projectRoot, model, {
1062
+ locale: input.locale,
1063
+ filter: input.filter,
1064
+ resolve: input.resolve,
1065
+ limit: input.limit,
1066
+ offset: input.offset
1067
+ }, config);
1068
+ return { content: [{
1069
+ type: "text",
1070
+ text: JSON.stringify(result, null, 2)
1071
+ }] };
1072
+ } catch (error) {
1073
+ return {
1074
+ content: [{
1075
+ type: "text",
1076
+ text: JSON.stringify({ error: `List failed: ${error instanceof Error ? error.message : String(error)}` })
1077
+ }],
1078
+ isError: true
1079
+ };
1080
+ }
1081
+ });
1082
+ }
1083
+ //#endregion
1084
+ //#region src/tools/workflow.ts
1085
+ function registerWorkflowTools(server, projectRoot) {
1086
+ server.tool("contentrain_validate", "Validate project content against model schemas. Detects required field violations, type mismatches, broken relations, secret leaks, i18n parity issues, and more. If fix:true, auto-fixes structural issues (canonical sort, orphan meta, missing locale files) — do NOT manually edit .contentrain/ files.", {
1087
+ model: z.string().optional().describe("Model ID to validate (omit for all models)"),
1088
+ fix: z.boolean().optional().describe("Auto-fix structural issues (canonical sort, orphan meta, missing locale files). Default: false")
1089
+ }, async (input) => {
1090
+ if (!await readConfig(projectRoot)) return {
1091
+ content: [{
1092
+ type: "text",
1093
+ text: JSON.stringify({ error: "Project not initialized. Run contentrain_init first." })
1094
+ }],
1095
+ isError: true
1096
+ };
1097
+ try {
1098
+ let result;
1099
+ if (input.fix) {
1100
+ const fixHealth = await checkBranchHealth(projectRoot);
1101
+ if (fixHealth.blocked) return {
1102
+ content: [{
1103
+ type: "text",
1104
+ text: JSON.stringify({
1105
+ error: fixHealth.message,
1106
+ action: "blocked",
1107
+ hint: "Merge or delete old contentrain/* branches before auto-fixing."
1108
+ }, null, 2)
1109
+ }],
1110
+ isError: true
1111
+ };
1112
+ const branch = buildBranchName("fix", "validate");
1113
+ const tx = await createTransaction(projectRoot, branch);
1114
+ try {
1115
+ await tx.write(async (wt) => {
1116
+ result = await validateProject(wt, {
1117
+ model: input.model,
1118
+ fix: true
1119
+ });
1120
+ });
1121
+ if (result.fixed > 0) {
1122
+ await tx.commit(`[contentrain] validate: auto-fix ${result.fixed} issue(s)`);
1123
+ const gitResult = await tx.complete({
1124
+ tool: "contentrain_validate",
1125
+ model: input.model ?? "*"
1126
+ });
1127
+ const nextSteps = [];
1128
+ if (result.summary.errors > 0) nextSteps.push("Fix remaining errors manually");
1129
+ if (result.summary.warnings > 0) nextSteps.push("Review warnings");
1130
+ nextSteps.push("Run contentrain_validate again to verify");
1131
+ return { content: [{
1132
+ type: "text",
1133
+ text: JSON.stringify({
1134
+ status: "committed",
1135
+ message: `Validation complete. ${result.fixed} issue(s) auto-fixed and committed to git. Do NOT manually edit .contentrain/ files.`,
1136
+ ...result,
1137
+ git: {
1138
+ branch,
1139
+ action: gitResult.action,
1140
+ commit: gitResult.commit
1141
+ },
1142
+ context_updated: true,
1143
+ next_steps: nextSteps
1144
+ }, null, 2)
1145
+ }] };
1146
+ } else await tx.cleanup();
1147
+ } catch (error) {
1148
+ await tx.cleanup();
1149
+ throw error;
1150
+ } finally {
1151
+ await tx.cleanup();
1152
+ }
1153
+ }
1154
+ if (!result) result = await validateProject(projectRoot, {
1155
+ model: input.model,
1156
+ fix: false
1157
+ });
1158
+ const nextSteps = [];
1159
+ if (result.summary.errors > 0) nextSteps.push("Fix errors in content using contentrain_content_save");
1160
+ if (result.summary.warnings > 0) nextSteps.push("Review warnings");
1161
+ if (result.valid) nextSteps.push("Run contentrain_submit to push changes");
1162
+ return { content: [{
1163
+ type: "text",
1164
+ text: JSON.stringify({
1165
+ status: "validated",
1166
+ message: result.valid ? "All validation checks passed." : `Validation found ${result.summary.errors} error(s) and ${result.summary.warnings} warning(s).`,
1167
+ ...result,
1168
+ next_steps: nextSteps
1169
+ }, null, 2)
1170
+ }] };
1171
+ } catch (error) {
1172
+ return {
1173
+ content: [{
1174
+ type: "text",
1175
+ text: JSON.stringify({ error: `Validation failed: ${error instanceof Error ? error.message : String(error)}` })
1176
+ }],
1177
+ isError: true
1178
+ };
1179
+ }
1180
+ });
1181
+ server.tool("contentrain_submit", "Push contentrain/* branches to remote. MCP is push-only — PR creation is handled by the platform. Do NOT manually push or create PRs.", {
1182
+ branches: z.array(z.string()).optional().describe("Specific branch names to push (omit for all contentrain/* branches)"),
1183
+ message: z.string().optional().describe("Optional message for the push operation")
1184
+ }, async (input) => {
1185
+ const config = await readConfig(projectRoot);
1186
+ if (!config) return {
1187
+ content: [{
1188
+ type: "text",
1189
+ text: JSON.stringify({ error: "Project not initialized. Run contentrain_init first." })
1190
+ }],
1191
+ isError: true
1192
+ };
1193
+ const git = simpleGit(projectRoot);
1194
+ const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
1195
+ let hasRemote = false;
1196
+ try {
1197
+ hasRemote = (await git.getRemotes()).some((r) => r.name === remoteName);
1198
+ } catch {
1199
+ hasRemote = false;
1200
+ }
1201
+ if (!hasRemote) return {
1202
+ content: [{
1203
+ type: "text",
1204
+ text: JSON.stringify({
1205
+ error: `No remote "${remoteName}" found. Configure a git remote first.`,
1206
+ next_steps: [`git remote add ${remoteName} <url>`]
1207
+ })
1208
+ }],
1209
+ isError: true
1210
+ };
1211
+ try {
1212
+ const baseBranch = config.repository?.default_branch ?? process.env["CONTENTRAIN_BRANCH"] ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
1213
+ const branchSummary = await git.branchLocal();
1214
+ let unmergedRaw = "";
1215
+ try {
1216
+ unmergedRaw = await git.raw([
1217
+ "branch",
1218
+ "--no-merged",
1219
+ baseBranch
1220
+ ]);
1221
+ } catch {
1222
+ unmergedRaw = branchSummary.all.join("\n");
1223
+ }
1224
+ const unmergedSet = new Set(unmergedRaw.split("\n").map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean));
1225
+ let branchesToPush;
1226
+ if (input.branches && input.branches.length > 0) {
1227
+ branchesToPush = input.branches.filter((b) => branchSummary.all.includes(b));
1228
+ const missing = input.branches.filter((b) => !branchSummary.all.includes(b));
1229
+ if (missing.length > 0) return {
1230
+ content: [{
1231
+ type: "text",
1232
+ text: JSON.stringify({ error: `Branches not found: ${missing.join(", ")}` })
1233
+ }],
1234
+ isError: true
1235
+ };
1236
+ branchesToPush = branchesToPush.filter((b) => unmergedSet.has(b));
1237
+ } else branchesToPush = branchSummary.all.filter((b) => b.startsWith("contentrain/") && unmergedSet.has(b));
1238
+ if (branchesToPush.length === 0) return {
1239
+ content: [{
1240
+ type: "text",
1241
+ text: JSON.stringify({
1242
+ error: "No unmerged contentrain/* branches found to push.",
1243
+ next_steps: ["Make changes first with contentrain_content_save or contentrain_model_save"]
1244
+ })
1245
+ }],
1246
+ isError: true
1247
+ };
1248
+ const pushed = [];
1249
+ const errors = [];
1250
+ for (const branch of branchesToPush) try {
1251
+ await git.push(remoteName, branch);
1252
+ pushed.push(branch);
1253
+ } catch (error) {
1254
+ errors.push({
1255
+ branch,
1256
+ error: error instanceof Error ? error.message : String(error)
1257
+ });
1258
+ }
1259
+ const cleanup = await cleanupMergedBranches(projectRoot);
1260
+ const nextSteps = [];
1261
+ if (pushed.length > 0) nextSteps.push("Create PRs on your git platform for review");
1262
+ if (errors.length > 0) nextSteps.push("Fix push errors and retry");
1263
+ if (cleanup.remaining >= 50) nextSteps.push(`Warning: ${cleanup.remaining} active contentrain branches. Consider reviewing old branches.`);
1264
+ return { content: [{
1265
+ type: "text",
1266
+ text: JSON.stringify({
1267
+ status: "committed",
1268
+ message: `Pushed ${pushed.length} branch(es) to ${remoteName}.`,
1269
+ pushed,
1270
+ errors: errors.length > 0 ? errors : void 0,
1271
+ remote: remoteName,
1272
+ cleanup: cleanup.deleted > 0 ? {
1273
+ deleted: cleanup.deleted,
1274
+ remaining: cleanup.remaining
1275
+ } : void 0,
1276
+ next_steps: nextSteps
1277
+ }, null, 2)
1278
+ }] };
1279
+ } catch (error) {
1280
+ return {
1281
+ content: [{
1282
+ type: "text",
1283
+ text: JSON.stringify({ error: `Submit failed: ${error instanceof Error ? error.message : String(error)}` })
1284
+ }],
1285
+ isError: true
1286
+ };
1287
+ }
1288
+ });
1289
+ }
1290
+ //#endregion
1291
+ //#region src/tools/normalize.ts
1292
+ function registerNormalizeTools(server, projectRoot) {
1293
+ server.tool("contentrain_scan", "Scan project source code for content strings. Three modes: \"graph\" builds import/component graph for project intelligence, \"candidates\" extracts string literals with pre-filtering and pagination, \"summary\" provides quick overview stats. Read-only — no changes to disk or git. MCP finds strings deterministically; the agent decides what is content. Recommended workflow: start with \"summary\" or \"graph\" for orientation, then paginate through \"candidates\" to evaluate strings.", {
1294
+ mode: z.enum([
1295
+ "graph",
1296
+ "candidates",
1297
+ "summary"
1298
+ ]).optional().describe("Scan mode. Default: candidates"),
1299
+ paths: z.array(z.string()).optional().describe("Directories to scan (relative to project root). Default: auto-detect"),
1300
+ include: z.array(z.string()).optional().describe("File extensions to include. Default: .tsx, .jsx, .vue, .ts, .js, .mjs, .astro, .svelte"),
1301
+ exclude: z.array(z.string()).optional().describe("Additional directory names to exclude"),
1302
+ limit: z.number().optional().describe("Candidates mode: batch size. Default: 50"),
1303
+ offset: z.number().optional().describe("Candidates mode: pagination offset. Default: 0"),
1304
+ min_length: z.number().optional().describe("Candidates mode: minimum string length. Default: 2"),
1305
+ max_length: z.number().optional().describe("Candidates mode: maximum string length. Default: 500")
1306
+ }, async (input) => {
1307
+ if (!await readConfig(projectRoot)) return {
1308
+ content: [{
1309
+ type: "text",
1310
+ text: JSON.stringify({ error: "Project not initialized. Run contentrain_init first." })
1311
+ }],
1312
+ isError: true
1313
+ };
1314
+ const mode = input.mode ?? "candidates";
1315
+ try {
1316
+ switch (mode) {
1317
+ case "graph": {
1318
+ const graph = await buildGraph(projectRoot, {
1319
+ paths: input.paths,
1320
+ include: input.include,
1321
+ exclude: input.exclude
1322
+ });
1323
+ return { content: [{
1324
+ type: "text",
1325
+ text: JSON.stringify({
1326
+ mode: "graph",
1327
+ ...graph,
1328
+ next_steps: ["Use mode:candidates to scan specific files/directories for strings", "Focus on pages and components with high string counts first"]
1329
+ }, null, 2)
1330
+ }] };
1331
+ }
1332
+ case "candidates": {
1333
+ const result = await scanCandidates(projectRoot, {
1334
+ paths: input.paths,
1335
+ include: input.include,
1336
+ exclude: input.exclude,
1337
+ limit: input.limit,
1338
+ offset: input.offset,
1339
+ min_length: input.min_length,
1340
+ max_length: input.max_length
1341
+ });
1342
+ const nextSteps = [];
1343
+ if (result.stats.has_more) nextSteps.push(`Use offset:${(input.offset ?? 0) + (input.limit ?? 50)} for next batch`);
1344
+ nextSteps.push("Evaluate each candidate: is it user-facing content? Which domain/model?");
1345
+ if (result.duplicates.length > 0) nextSteps.push("Consider deduplicating repeated strings into shared models");
1346
+ return { content: [{
1347
+ type: "text",
1348
+ text: JSON.stringify({
1349
+ mode: "candidates",
1350
+ ...result,
1351
+ next_steps: nextSteps
1352
+ }, null, 2)
1353
+ }] };
1354
+ }
1355
+ case "summary": {
1356
+ const result = await scanSummary(projectRoot, {
1357
+ paths: input.paths,
1358
+ include: input.include,
1359
+ exclude: input.exclude,
1360
+ min_length: input.min_length,
1361
+ max_length: input.max_length
1362
+ });
1363
+ return { content: [{
1364
+ type: "text",
1365
+ text: JSON.stringify({
1366
+ mode: "summary",
1367
+ ...result,
1368
+ next_steps: ["Use mode:graph for project structure analysis", "Use mode:candidates to scan directories with most candidates"]
1369
+ }, null, 2)
1370
+ }] };
1371
+ }
1372
+ default: return {
1373
+ content: [{
1374
+ type: "text",
1375
+ text: JSON.stringify({ error: `Unknown mode: ${mode}` })
1376
+ }],
1377
+ isError: true
1378
+ };
1379
+ }
1380
+ } catch (error) {
1381
+ return {
1382
+ content: [{
1383
+ type: "text",
1384
+ text: JSON.stringify({ error: `Scan failed: ${error instanceof Error ? error.message : String(error)}` })
1385
+ }],
1386
+ isError: true
1387
+ };
1388
+ }
1389
+ });
1390
+ server.tool("contentrain_apply", "Apply normalize operations. Two modes: \"extract\" writes agent-approved strings to Contentrain content files (source untouched), \"reuse\" patches source files with agent-provided replacement expressions. DRY RUN (default, dry_run:true): validates inputs, resolves conflicts, and returns a full preview — NO changes to disk or git. EXECUTE (dry_run:false): writes files to disk, commits to a branch, and requires branch health check to pass. Recommended workflow: always run dry_run first, review the preview, then call again with dry_run:false to execute. Normalize operations always use review workflow (never auto-merge).", {
1391
+ mode: z.enum(["extract", "reuse"]).describe("Apply mode: extract (content creation) or reuse (source patching)"),
1392
+ dry_run: z.boolean().optional().default(true).describe("Defaults to preview mode (dry_run:true). Set dry_run:false to execute after reviewing the preview."),
1393
+ extractions: z.array(z.object({
1394
+ model: z.string().describe("Model ID (e.g. \"ui-texts\", \"hero-section\")"),
1395
+ kind: z.enum([
1396
+ "singleton",
1397
+ "collection",
1398
+ "dictionary",
1399
+ "document"
1400
+ ]).describe("Model kind"),
1401
+ domain: z.string().describe("Content domain (e.g. \"marketing\", \"app\")"),
1402
+ i18n: z.boolean().optional().describe("Enable i18n. Default: true"),
1403
+ fields: fieldDefZodSchema.optional().describe("Field definitions — shared schema with model_save for full parity"),
1404
+ entries: z.array(z.object({
1405
+ locale: z.string().optional().describe("Locale. Default: project default"),
1406
+ slug: z.string().optional().describe("Document slug"),
1407
+ data: z.record(z.any()).describe("Content data"),
1408
+ source: z.object({
1409
+ file: z.string().describe("Source file path"),
1410
+ line: z.number().describe("Source line number"),
1411
+ value: z.string().describe("Original string value")
1412
+ }).optional().describe("Source tracking for traceability"),
1413
+ sources: z.array(z.object({
1414
+ file: z.string().describe("Source file path"),
1415
+ line: z.number().describe("Source line number"),
1416
+ key: z.string().describe("Dictionary key this source maps to"),
1417
+ value: z.string().describe("Original string value")
1418
+ })).optional().describe("Per-key source tracking for dictionary models")
1419
+ })).describe("Content entries to create")
1420
+ })).optional().describe("Extract mode: content extractions"),
1421
+ scope: z.object({
1422
+ model: z.string().optional().describe("Target model ID"),
1423
+ domain: z.string().optional().describe("Target domain")
1424
+ }).optional().describe("Reuse mode: scope (model or domain required)"),
1425
+ patches: z.array(z.object({
1426
+ file: z.string().describe("Relative file path to patch"),
1427
+ line: z.number().describe("Line number hint (±10 line search)"),
1428
+ old_value: z.string().describe("Original string to replace"),
1429
+ new_expression: z.string().describe("Replacement expression (agent-determined)"),
1430
+ import_statement: z.string().optional().describe("Import to add if needed")
1431
+ })).optional().describe("Reuse mode: patches to apply (max 100)")
1432
+ }, async (input) => {
1433
+ if (!await readConfig(projectRoot)) return {
1434
+ content: [{
1435
+ type: "text",
1436
+ text: JSON.stringify({ error: "Project not initialized. Run contentrain_init first." })
1437
+ }],
1438
+ isError: true
1439
+ };
1440
+ try {
1441
+ switch (input.mode) {
1442
+ case "extract": {
1443
+ if (!input.extractions || input.extractions.length === 0) return {
1444
+ content: [{
1445
+ type: "text",
1446
+ text: JSON.stringify({ error: "Extract mode requires extractions array" })
1447
+ }],
1448
+ isError: true
1449
+ };
1450
+ const result = await applyExtract(projectRoot, {
1451
+ extractions: input.extractions.map((e) => ({
1452
+ ...e,
1453
+ fields: e.fields
1454
+ })),
1455
+ dry_run: input.dry_run
1456
+ });
1457
+ if (result.error !== void 0) return {
1458
+ content: [{
1459
+ type: "text",
1460
+ text: JSON.stringify({
1461
+ mode: "extract",
1462
+ ...result
1463
+ }, null, 2)
1464
+ }],
1465
+ isError: true
1466
+ };
1467
+ return { content: [{
1468
+ type: "text",
1469
+ text: JSON.stringify({
1470
+ mode: "extract",
1471
+ ...result
1472
+ }, null, 2)
1473
+ }] };
1474
+ }
1475
+ case "reuse": {
1476
+ if (!input.scope) return {
1477
+ content: [{
1478
+ type: "text",
1479
+ text: JSON.stringify({ error: "Reuse mode requires scope (model or domain)" })
1480
+ }],
1481
+ isError: true
1482
+ };
1483
+ if (!input.patches || input.patches.length === 0) return {
1484
+ content: [{
1485
+ type: "text",
1486
+ text: JSON.stringify({ error: "Reuse mode requires patches array" })
1487
+ }],
1488
+ isError: true
1489
+ };
1490
+ const result = await applyReuse(projectRoot, {
1491
+ scope: input.scope,
1492
+ patches: input.patches,
1493
+ dry_run: input.dry_run
1494
+ });
1495
+ if (result.error !== void 0) return {
1496
+ content: [{
1497
+ type: "text",
1498
+ text: JSON.stringify({
1499
+ mode: "reuse",
1500
+ ...result
1501
+ }, null, 2)
1502
+ }],
1503
+ isError: true
1504
+ };
1505
+ return { content: [{
1506
+ type: "text",
1507
+ text: JSON.stringify({
1508
+ mode: "reuse",
1509
+ ...result
1510
+ }, null, 2)
1511
+ }] };
1512
+ }
1513
+ default: return {
1514
+ content: [{
1515
+ type: "text",
1516
+ text: JSON.stringify({ error: `Unknown mode: ${input.mode}` })
1517
+ }],
1518
+ isError: true
1519
+ };
1520
+ }
1521
+ } catch (error) {
1522
+ return {
1523
+ content: [{
1524
+ type: "text",
1525
+ text: JSON.stringify({ error: `Apply failed: ${error instanceof Error ? error.message : String(error)}` })
1526
+ }],
1527
+ isError: true
1528
+ };
1529
+ }
1530
+ });
1531
+ }
1532
+ //#endregion
1533
+ //#region src/tools/bulk.ts
1534
+ function registerBulkTools(server, projectRoot) {
1535
+ server.tool("contentrain_bulk", "Batch operations on content entries. All operations are auto-committed to git.", {
1536
+ operation: z.enum([
1537
+ "copy_locale",
1538
+ "update_status",
1539
+ "delete_entries"
1540
+ ]),
1541
+ model: z.string().describe("Model ID"),
1542
+ source_locale: z.string().optional().describe("Source locale for copy_locale operation"),
1543
+ target_locale: z.string().optional().describe("Target locale for copy_locale operation"),
1544
+ entry_ids: z.array(z.string()).optional().describe("Entry IDs for update_status or delete_entries"),
1545
+ status: z.enum([
1546
+ "draft",
1547
+ "in_review",
1548
+ "published",
1549
+ "rejected",
1550
+ "archived"
1551
+ ]).optional().describe("New status for update_status"),
1552
+ confirm: z.boolean().optional().describe("Must be true for delete_entries")
1553
+ }, async (input) => {
1554
+ const config = await readConfig(projectRoot);
1555
+ if (!config) return {
1556
+ content: [{
1557
+ type: "text",
1558
+ text: JSON.stringify({ error: "Project not initialized. Run contentrain_init first." })
1559
+ }],
1560
+ isError: true
1561
+ };
1562
+ const model = await readModel(projectRoot, input.model);
1563
+ if (!model) return {
1564
+ content: [{
1565
+ type: "text",
1566
+ text: JSON.stringify({ error: `Model "${input.model}" not found` })
1567
+ }],
1568
+ isError: true
1569
+ };
1570
+ const health = await checkBranchHealth(projectRoot);
1571
+ if (health.blocked) return {
1572
+ content: [{
1573
+ type: "text",
1574
+ text: JSON.stringify({
1575
+ error: health.message,
1576
+ action: "blocked",
1577
+ hint: "Merge or delete old contentrain/* branches before creating new ones."
1578
+ }, null, 2)
1579
+ }],
1580
+ isError: true
1581
+ };
1582
+ switch (input.operation) {
1583
+ case "copy_locale": {
1584
+ if (!input.source_locale || !input.target_locale) return {
1585
+ content: [{
1586
+ type: "text",
1587
+ text: JSON.stringify({ error: "copy_locale requires source_locale and target_locale" })
1588
+ }],
1589
+ isError: true
1590
+ };
1591
+ if (!config.locales.supported.includes(input.source_locale)) return {
1592
+ content: [{
1593
+ type: "text",
1594
+ text: JSON.stringify({ error: `Source locale "${input.source_locale}" is not supported. Supported: [${config.locales.supported.join(", ")}]` })
1595
+ }],
1596
+ isError: true
1597
+ };
1598
+ if (!config.locales.supported.includes(input.target_locale)) return {
1599
+ content: [{
1600
+ type: "text",
1601
+ text: JSON.stringify({ error: `Target locale "${input.target_locale}" is not supported. Supported: [${config.locales.supported.join(", ")}]` })
1602
+ }],
1603
+ isError: true
1604
+ };
1605
+ if (!model.i18n) return {
1606
+ content: [{
1607
+ type: "text",
1608
+ text: JSON.stringify({ error: `Model "${input.model}" has i18n disabled. copy_locale only works with i18n-enabled models. Enable i18n first with contentrain_model_save.` })
1609
+ }],
1610
+ isError: true
1611
+ };
1612
+ if (model.kind !== "collection" && model.kind !== "singleton" && model.kind !== "dictionary") return {
1613
+ content: [{
1614
+ type: "text",
1615
+ text: JSON.stringify({ error: "copy_locale is only supported for collection, singleton, and dictionary models" })
1616
+ }],
1617
+ isError: true
1618
+ };
1619
+ const branch = buildBranchName("bulk", input.model, input.target_locale);
1620
+ const tx = await createTransaction(projectRoot, branch);
1621
+ try {
1622
+ let copiedCount = 0;
1623
+ await tx.write(async (wt) => {
1624
+ const cDir = resolveContentDir(wt, model);
1625
+ const sourceFile = resolveJsonFilePath(cDir, model, input.source_locale);
1626
+ const targetFile = resolveJsonFilePath(cDir, model, input.target_locale);
1627
+ const sourceData = await readJson(sourceFile);
1628
+ if (!sourceData) throw new Error(`No content found for locale "${input.source_locale}" in model "${input.model}"`);
1629
+ await writeJson(targetFile, sourceData);
1630
+ if (model.kind === "collection") {
1631
+ const entries = sourceData;
1632
+ const entryIds = Object.keys(entries);
1633
+ const metaOps = entryIds.map((entryId) => writeMeta(wt, model, {
1634
+ locale: input.target_locale,
1635
+ entryId
1636
+ }, {
1637
+ status: "draft",
1638
+ source: "agent",
1639
+ updated_by: "contentrain-mcp"
1640
+ }));
1641
+ await Promise.all(metaOps);
1642
+ copiedCount = entryIds.length;
1643
+ } else {
1644
+ await writeMeta(wt, model, { locale: input.target_locale }, {
1645
+ status: "draft",
1646
+ source: "agent",
1647
+ updated_by: "contentrain-mcp"
1648
+ });
1649
+ copiedCount = 1;
1650
+ }
1651
+ });
1652
+ await tx.commit(`[contentrain] bulk: copy ${input.source_locale} → ${input.target_locale} for ${input.model}`);
1653
+ const gitResult = await tx.complete({
1654
+ tool: "contentrain_bulk",
1655
+ model: input.model,
1656
+ locale: input.target_locale
1657
+ });
1658
+ return { content: [{
1659
+ type: "text",
1660
+ text: JSON.stringify({
1661
+ status: "committed",
1662
+ operation: "copy_locale",
1663
+ message: `Copied ${copiedCount} entries from ${input.source_locale} to ${input.target_locale}.`,
1664
+ copied: copiedCount,
1665
+ git: {
1666
+ branch,
1667
+ action: gitResult.action,
1668
+ commit: gitResult.commit
1669
+ },
1670
+ context_updated: true
1671
+ }, null, 2)
1672
+ }] };
1673
+ } catch (error) {
1674
+ await tx.cleanup();
1675
+ return {
1676
+ content: [{
1677
+ type: "text",
1678
+ text: JSON.stringify({ error: `copy_locale failed: ${error instanceof Error ? error.message : String(error)}` })
1679
+ }],
1680
+ isError: true
1681
+ };
1682
+ } finally {
1683
+ await tx.cleanup();
1684
+ }
1685
+ }
1686
+ case "update_status": {
1687
+ if (!input.entry_ids || input.entry_ids.length === 0) return {
1688
+ content: [{
1689
+ type: "text",
1690
+ text: JSON.stringify({ error: "update_status requires entry_ids" })
1691
+ }],
1692
+ isError: true
1693
+ };
1694
+ if (!input.status) return {
1695
+ content: [{
1696
+ type: "text",
1697
+ text: JSON.stringify({ error: "update_status requires status" })
1698
+ }],
1699
+ isError: true
1700
+ };
1701
+ if (model.kind !== "collection") return {
1702
+ content: [{
1703
+ type: "text",
1704
+ text: JSON.stringify({ error: "update_status with entry_ids is only supported for collection models" })
1705
+ }],
1706
+ isError: true
1707
+ };
1708
+ const branch = buildBranchName("bulk", input.model);
1709
+ const tx = await createTransaction(projectRoot, branch);
1710
+ try {
1711
+ let updatedCount = 0;
1712
+ const notFound = [];
1713
+ await tx.write(async (wt) => {
1714
+ for (const locale of config.locales.supported) {
1715
+ const metaData = await readMeta(wt, model, { locale });
1716
+ const updateOps = [];
1717
+ for (const entryId of input.entry_ids) {
1718
+ const existing = metaData?.[entryId];
1719
+ if (existing) {
1720
+ updateOps.push(writeMeta(wt, model, {
1721
+ locale,
1722
+ entryId
1723
+ }, {
1724
+ ...existing,
1725
+ status: input.status,
1726
+ updated_by: "contentrain-mcp"
1727
+ }));
1728
+ updatedCount++;
1729
+ } else if (locale === config.locales.default) notFound.push(entryId);
1730
+ }
1731
+ await Promise.all(updateOps);
1732
+ }
1733
+ });
1734
+ await tx.commit(`[contentrain] bulk: update status → ${input.status} for ${input.model}`);
1735
+ const gitResult = await tx.complete({
1736
+ tool: "contentrain_bulk",
1737
+ model: input.model,
1738
+ entries: input.entry_ids
1739
+ });
1740
+ return { content: [{
1741
+ type: "text",
1742
+ text: JSON.stringify({
1743
+ status: "committed",
1744
+ operation: "update_status",
1745
+ message: `Updated ${updatedCount} meta entries to status "${input.status}".`,
1746
+ updated: updatedCount,
1747
+ not_found: notFound.length > 0 ? notFound : void 0,
1748
+ git: {
1749
+ branch,
1750
+ action: gitResult.action,
1751
+ commit: gitResult.commit
1752
+ },
1753
+ context_updated: true
1754
+ }, null, 2)
1755
+ }] };
1756
+ } catch (error) {
1757
+ await tx.cleanup();
1758
+ return {
1759
+ content: [{
1760
+ type: "text",
1761
+ text: JSON.stringify({ error: `update_status failed: ${error instanceof Error ? error.message : String(error)}` })
1762
+ }],
1763
+ isError: true
1764
+ };
1765
+ } finally {
1766
+ await tx.cleanup();
1767
+ }
1768
+ }
1769
+ case "delete_entries": {
1770
+ if (!input.entry_ids || input.entry_ids.length === 0) return {
1771
+ content: [{
1772
+ type: "text",
1773
+ text: JSON.stringify({ error: "delete_entries requires entry_ids" })
1774
+ }],
1775
+ isError: true
1776
+ };
1777
+ if (input.confirm !== true) return {
1778
+ content: [{
1779
+ type: "text",
1780
+ text: JSON.stringify({ error: "delete_entries requires confirm:true" })
1781
+ }],
1782
+ isError: true
1783
+ };
1784
+ if (model.kind !== "collection") return {
1785
+ content: [{
1786
+ type: "text",
1787
+ text: JSON.stringify({ error: "delete_entries with entry_ids is only supported for collection models" })
1788
+ }],
1789
+ isError: true
1790
+ };
1791
+ const branch = buildBranchName("bulk", input.model);
1792
+ const tx = await createTransaction(projectRoot, branch);
1793
+ try {
1794
+ const allRemoved = [];
1795
+ await tx.write(async (wt) => {
1796
+ for (const entryId of input.entry_ids) {
1797
+ const removed = await deleteContent(wt, model, { id: entryId });
1798
+ allRemoved.push(...removed);
1799
+ }
1800
+ });
1801
+ await tx.commit(`[contentrain] bulk: delete ${input.entry_ids.length} entries from ${input.model}`);
1802
+ const gitResult = await tx.complete({
1803
+ tool: "contentrain_bulk",
1804
+ model: input.model,
1805
+ entries: input.entry_ids
1806
+ });
1807
+ return { content: [{
1808
+ type: "text",
1809
+ text: JSON.stringify({
1810
+ status: "committed",
1811
+ operation: "delete_entries",
1812
+ message: `Deleted ${input.entry_ids.length} entries.`,
1813
+ deleted: input.entry_ids.length,
1814
+ files_removed: allRemoved,
1815
+ git: {
1816
+ branch,
1817
+ action: gitResult.action,
1818
+ commit: gitResult.commit
1819
+ },
1820
+ context_updated: true
1821
+ }, null, 2)
1822
+ }] };
1823
+ } catch (error) {
1824
+ await tx.cleanup();
1825
+ return {
1826
+ content: [{
1827
+ type: "text",
1828
+ text: JSON.stringify({ error: `delete_entries failed: ${error instanceof Error ? error.message : String(error)}` })
1829
+ }],
1830
+ isError: true
1831
+ };
1832
+ } finally {
1833
+ await tx.cleanup();
1834
+ }
1835
+ }
1836
+ default: return {
1837
+ content: [{
1838
+ type: "text",
1839
+ text: JSON.stringify({ error: `Unknown operation: ${input.operation}` })
1840
+ }],
1841
+ isError: true
1842
+ };
1843
+ }
1844
+ });
1845
+ }
1846
+ //#endregion
1847
+ //#region package.json
1848
+ var version = "1.0.3";
1849
+ //#endregion
1850
+ //#region src/server.ts
1851
+ function createServer(projectRoot) {
1852
+ const server = new McpServer({
1853
+ name: "contentrain-mcp",
1854
+ version
1855
+ });
1856
+ registerContextTools(server, projectRoot);
1857
+ registerSetupTools(server, projectRoot);
1858
+ registerModelTools(server, projectRoot);
1859
+ registerContentTools(server, projectRoot);
1860
+ registerWorkflowTools(server, projectRoot);
1861
+ registerNormalizeTools(server, projectRoot);
1862
+ registerBulkTools(server, projectRoot);
1863
+ return server;
1864
+ }
1865
+ //#endregion
1866
+ export { createServer as t };
1867
+
1868
+ //# sourceMappingURL=server-CC12z_xq.mjs.map