@contentrain/mcp 1.4.0 → 1.5.1

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 (46) hide show
  1. package/README.md +6 -4
  2. package/dist/{annotations-Clhbw2lh.mjs → annotations-BFJbfSKe.mjs} +13 -1
  3. package/dist/{annotations-Clhbw2lh.mjs.map → annotations-BFJbfSKe.mjs.map} +1 -1
  4. package/dist/{apply-manager-D5ym5Jba.mjs → apply-manager-CDss8ZIr.mjs} +5 -5
  5. package/dist/{apply-manager-D5ym5Jba.mjs.map → apply-manager-CDss8ZIr.mjs.map} +1 -1
  6. package/dist/{branch-lifecycle-CFVpTpNT.mjs → branch-lifecycle-DjX3MXNG.mjs} +10 -7
  7. package/dist/{branch-lifecycle-CFVpTpNT.mjs.map → branch-lifecycle-DjX3MXNG.mjs.map} +1 -1
  8. package/dist/{config-BIl5Ybj0.mjs → config-Lw7vpK9n.mjs} +4 -2
  9. package/dist/config-Lw7vpK9n.mjs.map +1 -0
  10. package/dist/{context-J0OU9Nfp.mjs → context-DS40WOdw.mjs} +2 -2
  11. package/dist/{context-J0OU9Nfp.mjs.map → context-DS40WOdw.mjs.map} +1 -1
  12. package/dist/core/apply-manager.mjs +5 -5
  13. package/dist/core/config.d.mts.map +1 -1
  14. package/dist/core/config.mjs +1 -1
  15. package/dist/core/context.mjs +2 -2
  16. package/dist/core/doctor.mjs +3 -3
  17. package/dist/core/overlay-reader.mjs +1 -1
  18. package/dist/core/validator/index.d.mts.map +1 -1
  19. package/dist/core/validator/index.mjs +2 -2
  20. package/dist/{doctor-CgBoT6h1.mjs → doctor-Dxu_SzZy.mjs} +3 -3
  21. package/dist/{doctor-CgBoT6h1.mjs.map → doctor-Dxu_SzZy.mjs.map} +1 -1
  22. package/dist/git/branch-lifecycle.d.mts.map +1 -1
  23. package/dist/git/branch-lifecycle.mjs +2 -2
  24. package/dist/git/transaction.d.mts.map +1 -1
  25. package/dist/git/transaction.mjs +3 -3
  26. package/dist/index.mjs +10 -10
  27. package/dist/{local-DvP4Y4T-.mjs → local-BEl5L7p5.mjs} +4 -4
  28. package/dist/{local-DvP4Y4T-.mjs.map → local-BEl5L7p5.mjs.map} +1 -1
  29. package/dist/{overlay-reader-DOdyDRIv.mjs → overlay-reader-CUs9jgqk.mjs} +1 -1
  30. package/dist/{overlay-reader-DOdyDRIv.mjs.map → overlay-reader-CUs9jgqk.mjs.map} +1 -1
  31. package/dist/providers/local/index.mjs +4 -4
  32. package/dist/server/http/index.mjs +10 -10
  33. package/dist/{server-BkhhtEE2.mjs → server-BpQvNZke.mjs} +266 -55
  34. package/dist/server-BpQvNZke.mjs.map +1 -0
  35. package/dist/server.mjs +10 -10
  36. package/dist/tools/annotations.d.mts.map +1 -1
  37. package/dist/tools/annotations.mjs +1 -1
  38. package/dist/{transaction-DgkNgWSI.mjs → transaction-0D_edyh0.mjs} +48 -5
  39. package/dist/transaction-0D_edyh0.mjs.map +1 -0
  40. package/dist/{validator-B0nNRFeU.mjs → validator-Bo06QPVB.mjs} +46 -17
  41. package/dist/validator-Bo06QPVB.mjs.map +1 -0
  42. package/package.json +2 -2
  43. package/dist/config-BIl5Ybj0.mjs.map +0 -1
  44. package/dist/server-BkhhtEE2.mjs.map +0 -1
  45. package/dist/transaction-DgkNgWSI.mjs.map +0 -1
  46. package/dist/validator-B0nNRFeU.mjs.map +0 -1
@@ -1,22 +1,22 @@
1
1
  import { i as planContentDelete, n as planModelDelete, r as planContentSave, t as planModelSave } from "./ops-DjEWi5Wa.mjs";
2
2
  import { n as contentFilePath, t as contentDirPath } from "./paths-CYl5vCBD.mjs";
3
3
  import { a as readJson, n as ensureDir, r as pathExists, s as writeJson, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
4
- import { n as readVocabulary, t as readConfig } from "./config-BIl5Ybj0.mjs";
4
+ import { n as readVocabulary, t as readConfig } from "./config-Lw7vpK9n.mjs";
5
5
  import { S as writeContent, _ as resolveMdFilePath, a as fieldDefZodSchema, c as validateModelDefinition, d as listContent, g as resolveLocaleStrategy, h as resolveJsonFilePath, l as writeModel, m as resolveContentDir, n as checkReferences, o as listModels, r as countEntries, s as readModel, u as deleteContent } from "./model-manager-BhLsUgaB.mjs";
6
6
  import { n as readMeta, r as writeMeta } from "./meta-manager-C900rEyG.mjs";
7
- import { n as readContext, t as buildContextChange } from "./context-J0OU9Nfp.mjs";
8
- import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "./transaction-DgkNgWSI.mjs";
9
- import { t as LocalProvider } from "./local-DvP4Y4T-.mjs";
7
+ import { n as readContext } from "./context-DS40WOdw.mjs";
8
+ import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "./transaction-0D_edyh0.mjs";
9
+ import { n as isMerged, t as LocalProvider } from "./local-BEl5L7p5.mjs";
10
10
  import { t as detectStack } from "./detect-DL9mFmaS.mjs";
11
- import { n as checkBranchHealth, r as cleanupMergedBranches } from "./branch-lifecycle-CFVpTpNT.mjs";
12
- import { t as TOOL_ANNOTATIONS } from "./annotations-Clhbw2lh.mjs";
11
+ import { n as checkBranchHealth, r as cleanupMergedBranches } from "./branch-lifecycle-DjX3MXNG.mjs";
12
+ import { t as TOOL_ANNOTATIONS } from "./annotations-BFJbfSKe.mjs";
13
13
  import { n as listTemplates, t as getTemplate } from "./templates-VrLVqfe5.mjs";
14
- import { t as OverlayReader } from "./overlay-reader-DOdyDRIv.mjs";
15
- import { t as validateProject } from "./validator-B0nNRFeU.mjs";
14
+ import { t as validateProject } from "./validator-Bo06QPVB.mjs";
15
+ import { t as OverlayReader } from "./overlay-reader-CUs9jgqk.mjs";
16
16
  import { t as buildGraph } from "./graph-builder-DXv2JEp-.mjs";
17
17
  import { n as scanSummary, t as scanCandidates } from "./scanner-DKMwc03k.mjs";
18
- import { n as applyExtract, r as applyReuse } from "./apply-manager-D5ym5Jba.mjs";
19
- import { t as runDoctor } from "./doctor-CgBoT6h1.mjs";
18
+ import { n as applyExtract, r as applyReuse } from "./apply-manager-CDss8ZIr.mjs";
19
+ import { t as runDoctor } from "./doctor-Dxu_SzZy.mjs";
20
20
  import { join } from "node:path";
21
21
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
22
  import { CONTENTRAIN_BRANCH } from "@contentrain/types";
@@ -42,6 +42,17 @@ function registerContextTools(server, provider, projectRoot) {
42
42
  const models = await listModels(provider);
43
43
  const context = projectRoot ? await readContext(projectRoot) : await readContext(provider);
44
44
  const vocabulary = await readVocabulary(provider);
45
+ let stats = context?.stats ?? null;
46
+ if (projectRoot) {
47
+ const fullModels = await Promise.all(models.map((m) => readModel(provider, m.id)));
48
+ const entryCounts = await Promise.all(fullModels.filter((m) => m !== null).map((m) => countEntries(provider, m)));
49
+ stats = {
50
+ models: models.length,
51
+ entries: entryCounts.reduce((acc, c) => acc + c.total, 0),
52
+ locales: config?.locales.supported ?? context?.stats?.locales ?? ["en"],
53
+ ...context?.stats?.lastSync ? { lastSync: context.stats.lastSync } : {}
54
+ };
55
+ }
45
56
  const errors = [];
46
57
  if (!config) errors.push(".contentrain/config.json missing");
47
58
  const result = {
@@ -54,9 +65,9 @@ function registerContextTools(server, provider, projectRoot) {
54
65
  repository: config.repository
55
66
  } : null,
56
67
  models,
57
- context: context ? {
58
- lastOperation: context.lastOperation,
59
- stats: context.stats
68
+ context: context || stats ? {
69
+ lastOperation: context?.lastOperation ?? null,
70
+ stats
60
71
  } : null,
61
72
  vocabulary: vocabulary && Object.keys(vocabulary.terms).length > 0 ? {
62
73
  size: Object.keys(vocabulary.terms).length,
@@ -362,6 +373,47 @@ function generateImportSnippet(model, stack, locale) {
362
373
  return snippets;
363
374
  }
364
375
  //#endregion
376
+ //#region src/git/errors.ts
377
+ /**
378
+ * Error normalization for git-backed MCP operations.
379
+ *
380
+ * `simple-git` surfaces a failed `git commit`/`push`/`merge` as a `GitError`
381
+ * whose `message` is the raw subprocess stderr — which, for hook tooling
382
+ * (commitlint, husky, lefthook), is colorized multi-line text. Returning that
383
+ * verbatim inside a JSON error produces an unreadable ANSI-escaped blob.
384
+ *
385
+ * This helper strips ANSI, preserves any structured fields the thrown error
386
+ * already carries (`code`/`agent_hint`/`developer_action`), and detects git
387
+ * hook rejections so the agent gets an actionable, structured envelope.
388
+ */
389
+ const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
390
+ function stripAnsi(input) {
391
+ return input.replace(ANSI_RE, "");
392
+ }
393
+ /**
394
+ * Normalize any thrown error into a structured, ANSI-free envelope for MCP
395
+ * tool responses. `stage` labels where it happened (e.g. 'content_save').
396
+ */
397
+ function normalizeOperationError(err, stage) {
398
+ const e = err;
399
+ const message = stripAnsi(e?.message ?? String(err)).trim();
400
+ const out = { error: message };
401
+ if (stage) out.stage = stage;
402
+ if (e?.code) out.code = e.code;
403
+ if (e?.agent_hint) out.agent_hint = e.agent_hint;
404
+ if (e?.developer_action) out.developer_action = e.developer_action;
405
+ if (!out.code) {
406
+ const hookName = /\b(commit-msg|pre-commit|prepare-commit-msg|pre-push)\b/i.exec(message)?.[1];
407
+ const hookTool = /\b(commitlint|husky|lefthook)\b/i.exec(message)?.[1];
408
+ if (hookName || hookTool) {
409
+ out.stage = out.stage ?? "git-commit";
410
+ out.hook = hookName ?? hookTool;
411
+ out.agent_hint = out.agent_hint ?? "A git hook rejected the Contentrain commit. Contentrain commits with --no-verify, but a wrapping CLI or server-side hook may still run. Ask the developer to allow machine-generated \"[contentrain]\" commits or to exclude cr/* branches from the hook.";
412
+ }
413
+ }
414
+ return out;
415
+ }
416
+ //#endregion
365
417
  //#region src/tools/guards.ts
366
418
  /**
367
419
  * Emit a uniform "capability not available" response for tools that
@@ -413,7 +465,10 @@ function registerSetupTools(server, _provider, projectRoot) {
413
465
  "-n",
414
466
  "1",
415
467
  "--all"
416
- ]).then((out) => out.trim().length > 0).catch(() => false)) await git.commit("initial commit", { "--allow-empty": null });
468
+ ]).then((out) => out.trim().length > 0).catch(() => false)) await git.commit("initial commit", {
469
+ "--allow-empty": null,
470
+ "--no-verify": null
471
+ });
417
472
  const initHealth = await checkBranchHealth(projectRoot);
418
473
  if (initHealth.blocked) return {
419
474
  content: [{
@@ -513,7 +568,7 @@ function registerSetupTools(server, _provider, projectRoot) {
513
568
  return {
514
569
  content: [{
515
570
  type: "text",
516
- text: JSON.stringify({ error: `Init failed: ${error instanceof Error ? error.message : String(error)}` })
571
+ text: JSON.stringify({ ...normalizeOperationError(error, "init") })
517
572
  }],
518
573
  isError: true
519
574
  };
@@ -608,7 +663,7 @@ function registerSetupTools(server, _provider, projectRoot) {
608
663
  return {
609
664
  content: [{
610
665
  type: "text",
611
- text: JSON.stringify({ error: `Scaffold failed: ${error instanceof Error ? error.message : String(error)}` })
666
+ text: JSON.stringify({ ...normalizeOperationError(error, "scaffold") })
612
667
  }],
613
668
  isError: true
614
669
  };
@@ -695,11 +750,17 @@ async function mergeVocabulary(projectRoot, newTerms, _locales) {
695
750
  * the project's configured workflow. Selective-sync result is surfaced
696
751
  * to the caller via `sync`.
697
752
  *
698
- * - **Any other RepoProvider** — the context.json write becomes an extra
699
- * `FileChange` bundled into the plan so the whole commit lands
700
- * atomically through the generic `RepoWriter.applyPlan`. Remote flows
753
+ * - **Any other RepoProvider** — only the plan's own changes are
754
+ * committed. Feature branches NEVER carry `.contentrain/context.json`:
755
+ * the file embeds timestamps, so two parallel cr/* branches forked from
756
+ * the same contentrain commit would always conflict on it and the
757
+ * second merge would fail permanently. This mirrors the local
758
+ * transaction flow, which regenerates context.json on the contentrain
759
+ * branch after merge (single-threaded, deterministic). Remote flows
701
760
  * always report `pending-review`; Studio (or whatever orchestrator is
702
- * driving the server) owns the merge.
761
+ * driving the server) owns the merge AND the post-merge context
762
+ * regeneration on the contentrain branch (`buildContextChange` from
763
+ * `@contentrain/mcp/core/context` is exported for exactly that).
703
764
  *
704
765
  * The return shape is deliberately uniform so callers don't have to
705
766
  * branch on provider type again.
@@ -719,8 +780,7 @@ async function commitThroughProvider(provider, input) {
719
780
  sync: result.sync
720
781
  };
721
782
  }
722
- const contextChange = await buildContextChange(new OverlayReader(provider, changes), contextPayload);
723
- const allChanges = [...changes, contextChange].toSorted((a, b) => a.path.localeCompare(b.path));
783
+ const allChanges = changes.toSorted((a, b) => a.path.localeCompare(b.path));
724
784
  return {
725
785
  commitSha: (await provider.applyPlan({
726
786
  branch,
@@ -836,7 +896,7 @@ function registerModelTools(server, provider, projectRoot) {
836
896
  return {
837
897
  content: [{
838
898
  type: "text",
839
- text: JSON.stringify({ error: `Model save failed: ${error instanceof Error ? error.message : String(error)}` })
899
+ text: JSON.stringify({ ...normalizeOperationError(error, "model_save") })
840
900
  }],
841
901
  isError: true
842
902
  };
@@ -959,7 +1019,7 @@ function registerModelTools(server, provider, projectRoot) {
959
1019
  return {
960
1020
  content: [{
961
1021
  type: "text",
962
- text: JSON.stringify({ error: `Delete failed: ${error instanceof Error ? error.message : String(error)}` })
1022
+ text: JSON.stringify({ ...normalizeOperationError(error, "model_delete") })
963
1023
  }],
964
1024
  isError: true
965
1025
  };
@@ -1064,7 +1124,7 @@ function registerContentTools(server, provider, projectRoot) {
1064
1124
  return {
1065
1125
  content: [{
1066
1126
  type: "text",
1067
- text: JSON.stringify({ error: `Content save failed: ${error instanceof Error ? error.message : String(error)}` })
1127
+ text: JSON.stringify({ ...normalizeOperationError(error, "content_save") })
1068
1128
  }],
1069
1129
  isError: true
1070
1130
  };
@@ -1095,12 +1155,12 @@ function registerContentTools(server, provider, projectRoot) {
1095
1155
  return {
1096
1156
  content: [{
1097
1157
  type: "text",
1098
- text: JSON.stringify({ error: `Content save failed: ${error instanceof Error ? error.message : String(error)}` })
1158
+ text: JSON.stringify({ ...normalizeOperationError(error, "content_save") })
1099
1159
  }],
1100
1160
  isError: true
1101
1161
  };
1102
1162
  }
1103
- const validationResult = projectRoot ? await validateProject(projectRoot, { model: input.model }) : await validateProject(new OverlayReader(provider, plan.changes), { model: input.model });
1163
+ const validationResult = await validateProject(new OverlayReader(provider, plan.changes), { model: input.model });
1104
1164
  const allAdvisories = plan.result.flatMap((r) => r.advisories ?? []);
1105
1165
  return { content: [{
1106
1166
  type: "text",
@@ -1181,7 +1241,7 @@ function registerContentTools(server, provider, projectRoot) {
1181
1241
  return {
1182
1242
  content: [{
1183
1243
  type: "text",
1184
- text: JSON.stringify({ error: `Delete failed: ${error instanceof Error ? error.message : String(error)}` })
1244
+ text: JSON.stringify({ ...normalizeOperationError(error, "content_delete") })
1185
1245
  }],
1186
1246
  isError: true
1187
1247
  };
@@ -1226,7 +1286,7 @@ function registerContentTools(server, provider, projectRoot) {
1226
1286
  return {
1227
1287
  content: [{
1228
1288
  type: "text",
1229
- text: JSON.stringify({ error: `Delete failed: ${error instanceof Error ? error.message : String(error)}` })
1289
+ text: JSON.stringify({ ...normalizeOperationError(error, "content_delete") })
1230
1290
  }],
1231
1291
  isError: true
1232
1292
  };
@@ -1273,7 +1333,7 @@ function registerContentTools(server, provider, projectRoot) {
1273
1333
  return {
1274
1334
  content: [{
1275
1335
  type: "text",
1276
- text: JSON.stringify({ error: `List failed: ${error instanceof Error ? error.message : String(error)}` })
1336
+ text: JSON.stringify({ ...normalizeOperationError(error, "content_list") })
1277
1337
  }],
1278
1338
  isError: true
1279
1339
  };
@@ -1282,6 +1342,49 @@ function registerContentTools(server, provider, projectRoot) {
1282
1342
  }
1283
1343
  //#endregion
1284
1344
  //#region src/tools/workflow.ts
1345
+ /**
1346
+ * Resolve a merge target from either an exact branch name or a
1347
+ * model/locale/latest selector. Returns the concrete branch, or an error with
1348
+ * candidate branches when the selector is ambiguous or matches nothing.
1349
+ */
1350
+ async function resolveMergeBranch(git, input) {
1351
+ const summary = await git.branchLocal();
1352
+ const crBranches = summary.all.filter((b) => b.startsWith("cr/") && b !== CONTENTRAIN_BRANCH);
1353
+ if (input.branch) {
1354
+ if (summary.all.includes(input.branch)) return { branch: input.branch };
1355
+ return {
1356
+ error: `Branch "${input.branch}" not found locally.`,
1357
+ candidates: crBranches
1358
+ };
1359
+ }
1360
+ if (!input.model) return {
1361
+ error: "Provide either \"branch\" (exact name) or \"model\" (to resolve the branch).",
1362
+ candidates: crBranches
1363
+ };
1364
+ const prefix = input.locale ? `cr/content/${input.model}/${input.locale}/` : `cr/content/${input.model}/`;
1365
+ let matches = crBranches.filter((b) => b.startsWith(prefix));
1366
+ if (matches.length === 0) matches = crBranches.filter((b) => b.split("/").includes(input.model));
1367
+ if (matches.length === 0) return {
1368
+ error: `No pending cr/* branch found for model "${input.model}".`,
1369
+ candidates: crBranches
1370
+ };
1371
+ if (matches.length === 1) return { branch: matches[0] };
1372
+ if (!input.latest) return {
1373
+ error: `Multiple branches match model "${input.model}". Pass latest:true or an exact branch.`,
1374
+ candidates: matches
1375
+ };
1376
+ return { branch: (await Promise.all(matches.map(async (b) => {
1377
+ return {
1378
+ b,
1379
+ t: await git.raw([
1380
+ "log",
1381
+ "-1",
1382
+ "--format=%ct",
1383
+ b
1384
+ ]).then((s) => Number(s.trim())).catch(() => 0)
1385
+ };
1386
+ }))).toSorted((left, right) => right.t - left.t)[0].b };
1387
+ }
1285
1388
  function registerWorkflowTools(server, provider, projectRoot) {
1286
1389
  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.", {
1287
1390
  model: z.string().optional().describe("Model ID to validate (omit for all models)"),
@@ -1311,7 +1414,7 @@ function registerWorkflowTools(server, provider, projectRoot) {
1311
1414
  isError: true
1312
1415
  };
1313
1416
  const branch = buildBranchName("fix", "validate");
1314
- const tx = await createTransaction(projectRoot, branch);
1417
+ const tx = await createTransaction(projectRoot, branch, { workflowOverride: "auto-merge" });
1315
1418
  try {
1316
1419
  await tx.write(async (wt) => {
1317
1420
  result = await validateProject(wt, {
@@ -1404,16 +1507,22 @@ function registerWorkflowTools(server, provider, projectRoot) {
1404
1507
  } catch {
1405
1508
  hasRemote = false;
1406
1509
  }
1407
- if (!hasRemote) return {
1408
- content: [{
1409
- type: "text",
1410
- text: JSON.stringify({
1411
- error: `No remote "${remoteName}" found. Configure a git remote first.`,
1412
- next_steps: [`git remote add ${remoteName} <url>`]
1413
- })
1414
- }],
1415
- isError: true
1416
- };
1510
+ if (!hasRemote) {
1511
+ const pending = (await git.branchLocal().catch(() => ({ all: [] }))).all.filter((b) => b.startsWith("cr/") && b !== CONTENTRAIN_BRANCH);
1512
+ return {
1513
+ content: [{
1514
+ type: "text",
1515
+ text: JSON.stringify({
1516
+ error: `No remote "${remoteName}" configured — contentrain_submit (push) is unavailable.`,
1517
+ stage: "submit",
1518
+ agent_hint: "This project has no git remote. In a local/solo workflow, land pending review branches with contentrain_merge instead of submit, or set workflow:\"auto-merge\" in config.json so saves merge locally without a remote.",
1519
+ pending_branches: pending,
1520
+ next_steps: [pending.length > 0 ? `Merge a pending branch locally: contentrain_merge { branch: "${pending[0]}", confirm: true }` : "Make changes with contentrain_content_save — auto-merge lands them locally without a remote", `Or add a remote to enable submit: git remote add ${remoteName} <url>`]
1521
+ }, null, 2)
1522
+ }],
1523
+ isError: true
1524
+ };
1525
+ }
1417
1526
  try {
1418
1527
  const baseBranch = config.repository?.default_branch ?? process.env["CONTENTRAIN_BRANCH"] ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
1419
1528
  const branchSummary = await git.branchLocal();
@@ -1496,8 +1605,11 @@ function registerWorkflowTools(server, provider, projectRoot) {
1496
1605
  };
1497
1606
  }
1498
1607
  });
1499
- server.tool("contentrain_merge", "Merge a review-mode branch into contentrain. Local git operation — no external platform needed. Merges the feature branch into the contentrain branch, advances the base branch via update-ref, and selectively syncs .contentrain/ files to the working tree.", {
1500
- branch: z.string().describe("Branch name to merge (e.g. cr/normalize/extract/...)"),
1608
+ server.tool("contentrain_merge", "Merge a review-mode branch into contentrain. Local git operation — no external platform needed. Merges the feature branch into the contentrain branch, advances the base branch via update-ref, selectively syncs .contentrain/ files to the working tree, and prunes the merged branch. Target by exact \"branch\" name, or resolve by \"model\" (+ optional \"locale\"/\"latest\").", {
1609
+ branch: z.string().optional().describe("Exact branch name to merge (e.g. cr/content/blog-post/...). Omit to resolve by model."),
1610
+ model: z.string().optional().describe("Resolve the branch by model id (e.g. \"blog-post\")."),
1611
+ locale: z.string().optional().describe("Narrow model resolution to a locale."),
1612
+ latest: z.boolean().optional().describe("When multiple branches match the model, merge the most recently committed one."),
1501
1613
  confirm: z.literal(true).describe("Must be true to confirm the merge")
1502
1614
  }, TOOL_ANNOTATIONS["contentrain_merge"], async (input) => {
1503
1615
  if (!provider.capabilities.localWorktree || !projectRoot) return capabilityError("contentrain_merge", "localWorktree");
@@ -1509,26 +1621,30 @@ function registerWorkflowTools(server, provider, projectRoot) {
1509
1621
  isError: true
1510
1622
  };
1511
1623
  try {
1512
- if (!(await simpleGit(projectRoot).branchLocal()).all.includes(input.branch)) return {
1624
+ const resolved = await resolveMergeBranch(simpleGit(projectRoot), input);
1625
+ if ("error" in resolved) return {
1513
1626
  content: [{
1514
1627
  type: "text",
1515
1628
  text: JSON.stringify({
1516
- error: `Branch "${input.branch}" not found locally.`,
1517
- next_steps: ["Check branch name with git branch -l", "Use contentrain_submit to push first if needed"]
1518
- })
1629
+ error: resolved.error,
1630
+ candidates: resolved.candidates,
1631
+ next_steps: ["Pass an exact { branch } or { model, latest: true }", "List pending branches with contentrain_branch_list"]
1632
+ }, null, 2)
1519
1633
  }],
1520
1634
  isError: true
1521
1635
  };
1522
- const result = await mergeBranch(projectRoot, input.branch);
1636
+ const targetBranch = resolved.branch;
1637
+ const result = await mergeBranch(projectRoot, targetBranch);
1523
1638
  return { content: [{
1524
1639
  type: "text",
1525
1640
  text: JSON.stringify({
1526
1641
  status: "merged",
1642
+ branch: targetBranch,
1527
1643
  action: result.action,
1528
1644
  commit: result.commit,
1529
1645
  sync: result.sync,
1530
1646
  next_steps: [
1531
- "Run `npx contentrain generate` to update SDK client",
1647
+ "Run `contentrain generate` (or `npx contentrain-query generate`) to update the SDK client",
1532
1648
  "Run contentrain_validate to verify content integrity",
1533
1649
  result.sync.skipped.length > 0 ? `${result.sync.skipped.length} file(s) skipped due to local changes — resolve manually` : ""
1534
1650
  ].filter(Boolean)
@@ -1538,7 +1654,102 @@ function registerWorkflowTools(server, provider, projectRoot) {
1538
1654
  return {
1539
1655
  content: [{
1540
1656
  type: "text",
1541
- text: JSON.stringify({ error: `Merge failed: ${error instanceof Error ? error.message : String(error)}` })
1657
+ text: JSON.stringify(normalizeOperationError(error, "merge"), null, 2)
1658
+ }],
1659
+ isError: true
1660
+ };
1661
+ }
1662
+ });
1663
+ server.tool("contentrain_branch_list", "List pending contentrain (cr/*) branches with their merge status against the contentrain branch. Use this to discover branch names for contentrain_merge / contentrain_branch_delete, and to monitor branch-health limits (warning at 50, blocked at 80 unmerged).", { unmerged_only: z.boolean().optional().describe("Only list branches not yet merged into contentrain. Default: false") }, TOOL_ANNOTATIONS["contentrain_branch_list"], async (input) => {
1664
+ if (!provider.capabilities.localWorktree || !projectRoot) return capabilityError("contentrain_branch_list", "localWorktree");
1665
+ try {
1666
+ const git = simpleGit(projectRoot);
1667
+ const summary = await git.branchLocal();
1668
+ const crBranches = summary.all.filter((b) => b.startsWith("cr/") && b !== CONTENTRAIN_BRANCH);
1669
+ const ordered = (await Promise.all(crBranches.map(async (name) => {
1670
+ const [merged, ts] = await Promise.all([isMerged(projectRoot, name, CONTENTRAIN_BRANCH), git.raw([
1671
+ "log",
1672
+ "-1",
1673
+ "--format=%cI",
1674
+ name
1675
+ ]).then((s) => s.trim()).catch(() => "")]);
1676
+ return {
1677
+ name,
1678
+ sha: summary.branches[name]?.commit ?? "",
1679
+ merged,
1680
+ lastCommit: ts
1681
+ };
1682
+ }))).toSorted((left, right) => right.lastCommit.localeCompare(left.lastCommit));
1683
+ const filtered = input.unmerged_only ? ordered.filter((b) => !b.merged) : ordered;
1684
+ const health = await checkBranchHealth(projectRoot);
1685
+ return { content: [{
1686
+ type: "text",
1687
+ text: JSON.stringify({
1688
+ total: ordered.length,
1689
+ unmerged: ordered.filter((b) => !b.merged).length,
1690
+ branches: filtered,
1691
+ health: {
1692
+ warning: health.warning,
1693
+ blocked: health.blocked,
1694
+ message: health.message
1695
+ },
1696
+ next_steps: ["Merge a branch: contentrain_merge { branch: \"...\", confirm: true }", "Delete a stale branch: contentrain_branch_delete { branch: \"...\", confirm: true }"]
1697
+ }, null, 2)
1698
+ }] };
1699
+ } catch (error) {
1700
+ return {
1701
+ content: [{
1702
+ type: "text",
1703
+ text: JSON.stringify(normalizeOperationError(error, "branch_list"), null, 2)
1704
+ }],
1705
+ isError: true
1706
+ };
1707
+ }
1708
+ });
1709
+ server.tool("contentrain_branch_delete", "Delete a pending contentrain (cr/*) branch that will not be merged — e.g. a branch left behind by a failed operation, or a superseded draft. Only cr/* branches can be deleted; the contentrain branch is protected. This is destructive: the branch and its unmerged commits are removed.", {
1710
+ branch: z.string().describe("The cr/* branch to delete"),
1711
+ confirm: z.literal(true).describe("Must be true to confirm deletion")
1712
+ }, TOOL_ANNOTATIONS["contentrain_branch_delete"], async (input) => {
1713
+ if (!provider.capabilities.localWorktree || !projectRoot) return capabilityError("contentrain_branch_delete", "localWorktree");
1714
+ if (!input.branch.startsWith("cr/") || input.branch === CONTENTRAIN_BRANCH) return {
1715
+ content: [{
1716
+ type: "text",
1717
+ text: JSON.stringify({ error: `Refusing to delete "${input.branch}". Only cr/* feature branches can be deleted (the contentrain branch is protected).` })
1718
+ }],
1719
+ isError: true
1720
+ };
1721
+ try {
1722
+ const git = simpleGit(projectRoot);
1723
+ if (!(await git.branchLocal()).all.includes(input.branch)) return {
1724
+ content: [{
1725
+ type: "text",
1726
+ text: JSON.stringify({
1727
+ error: `Branch "${input.branch}" not found locally.`,
1728
+ next_steps: ["List branches with contentrain_branch_list"]
1729
+ })
1730
+ }],
1731
+ isError: true
1732
+ };
1733
+ const merged = await isMerged(projectRoot, input.branch, CONTENTRAIN_BRANCH);
1734
+ await git.raw([
1735
+ "branch",
1736
+ "-D",
1737
+ input.branch
1738
+ ]);
1739
+ return { content: [{
1740
+ type: "text",
1741
+ text: JSON.stringify({
1742
+ status: "deleted",
1743
+ branch: input.branch,
1744
+ was_merged: merged,
1745
+ warning: merged ? void 0 : "Branch was not merged — its commits were discarded."
1746
+ }, null, 2)
1747
+ }] };
1748
+ } catch (error) {
1749
+ return {
1750
+ content: [{
1751
+ type: "text",
1752
+ text: JSON.stringify(normalizeOperationError(error, "branch_delete"), null, 2)
1542
1753
  }],
1543
1754
  isError: true
1544
1755
  };
@@ -1938,7 +2149,7 @@ function registerBulkTools(server, _provider, projectRoot) {
1938
2149
  return {
1939
2150
  content: [{
1940
2151
  type: "text",
1941
- text: JSON.stringify({ error: `copy_locale failed: ${error instanceof Error ? error.message : String(error)}` })
2152
+ text: JSON.stringify({ ...normalizeOperationError(error, "bulk_copy_locale") })
1942
2153
  }],
1943
2154
  isError: true
1944
2155
  };
@@ -2022,7 +2233,7 @@ function registerBulkTools(server, _provider, projectRoot) {
2022
2233
  return {
2023
2234
  content: [{
2024
2235
  type: "text",
2025
- text: JSON.stringify({ error: `update_status failed: ${error instanceof Error ? error.message : String(error)}` })
2236
+ text: JSON.stringify({ ...normalizeOperationError(error, "bulk_update_status") })
2026
2237
  }],
2027
2238
  isError: true
2028
2239
  };
@@ -2090,7 +2301,7 @@ function registerBulkTools(server, _provider, projectRoot) {
2090
2301
  return {
2091
2302
  content: [{
2092
2303
  type: "text",
2093
- text: JSON.stringify({ error: `delete_entries failed: ${error instanceof Error ? error.message : String(error)}` })
2304
+ text: JSON.stringify({ ...normalizeOperationError(error, "bulk_delete_entries") })
2094
2305
  }],
2095
2306
  isError: true
2096
2307
  };
@@ -2122,7 +2333,7 @@ function registerDoctorTools(server, _provider, projectRoot) {
2122
2333
  }
2123
2334
  //#endregion
2124
2335
  //#region package.json
2125
- var version = "1.4.0";
2336
+ var version = "1.5.1";
2126
2337
  //#endregion
2127
2338
  //#region src/server.ts
2128
2339
  function createServer(input) {
@@ -2163,4 +2374,4 @@ function resolveServerContext(input) {
2163
2374
  //#endregion
2164
2375
  export { createServer as t };
2165
2376
 
2166
- //# sourceMappingURL=server-BkhhtEE2.mjs.map
2377
+ //# sourceMappingURL=server-BpQvNZke.mjs.map