From cb8f65e1dfa049d7adb8469ebf4f294ea5bf8399 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Fri, 17 Apr 2026 18:56:03 +0300 Subject: [PATCH 1/2] =?UTF-8?q?refactor(mcp,types):=20phase=2010=20?= =?UTF-8?q?=E2=80=94=20review=20alignment=20+=20P1/P2=20bug=20fixes=20+=20?= =?UTF-8?q?docs=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single PR that closes the gaps a four-agent review surfaced after phases 0-9 landed. "Hiçbir şeyi atlamadan" — every finding is addressed in this change, from cohesion polish to the docs drift the refactor left in its wake. ### Provider contracts move to @contentrain/types - packages/types/src/provider.ts (new) — RepoReader, RepoWriter, RepoProvider, ProviderCapabilities, LOCAL_CAPABILITIES, FileChange, Branch, Commit, CommitAuthor, FileDiff, MergeResult, ApplyPlanInput. Third-party tools can now implement a custom RepoProvider without a runtime dependency on @contentrain/mcp. - packages/types/src/index.ts — repository.provider widened from `'github'` to `'github' | 'gitlab'`; all provider contracts re-exported from the root. - packages/mcp/src/core/contracts/index.ts — now a thin barrel that re-exports from @contentrain/types. The per-file contract modules (branch.ts / capabilities.ts / file-change.ts / provider.ts / repo-reader.ts / repo-writer.ts) are deleted; existing MCP imports through `core/contracts` continue to work unchanged. ### P1 — remote write base branch invariant - packages/mcp/src/tools/commit-plan.ts (new) extracts the LocalProvider vs remote RepoProvider dispatch from four repeated blocks in content.ts / model.ts into `commitThroughProvider`. - Remote writes now always fork from CONTENTRAIN_BRANCH, matching the local transaction flow. Previously they used `config.repository.default_branch ?? 'contentrain'` — in any project that set `repository.default_branch: 'main'`, feature branches were silently forked from `main`, breaking the `contentrain` singleton invariant. ### P1 — stale remote context.json + validation - packages/mcp/src/core/overlay-reader.ts (new) — OverlayReader wraps a RepoReader and layers pending FileChanges on top. Semantics: pending adds → visible; pending deletes → missing; base falls through for everything else. listDirectory merges additions into the base listing, removes deletes, and surfaces nested subdirectories for deeper pending paths. - commit-plan.ts: `buildContextChange` is now called against an OverlayReader over the pending plan, so `stats.entries` / `stats.models` reflect the state the new commit produces instead of the pre-change base branch. - tools/content.ts: post-save validateProject runs against the same overlay on remote, so the validation envelope matches the new content state. Local flow still uses projectRoot-based validation — the transaction layer has already written the worktree. ### P2 — read-only tools work over remote providers - tools/context.ts (`contentrain_status`, `contentrain_describe`) and tools/content.ts (`contentrain_content_list`) no longer gate on `!projectRoot`. They route through `provider` and degrade gracefully when no project root is available: stack detection, branch health, and `content_list --resolve` stay local-only and are skipped / rejected with a descriptive error on remote. - core/content-manager.ts: listContent gains a reader overload (`listContentViaReader`) that handles all four kinds (singleton, collection, document, dictionary). Relation hydration (`opts.resolve`) still requires local filesystem access because the walk touches other models. ### P2 — public export surface unbroken - packages/mcp/package.json: adds `./core/contracts` and `./providers/local` to `exports` and the build targets. Both paths were referenced in package docstrings since phase 5 but were never publishable — external imports failed with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Now they resolve. ### Cohesion — provider cleanup - packages/mcp/src/providers/shared/ (new) consolidates helpers that were duplicated across GitHub + GitLab: - `errors.ts:isNotFoundError` — unified strict-status-based check. Removes four per-provider `isNotFound` helpers and GitLab's lenient description-match fallback that risked masking non-404 failures silently. - `paths.ts:normaliseContentRoot + resolveRepoPath` — one copy instead of two identical files under github/ and gitlab/. - tools/workflow.ts: `contentrain_submit` and `contentrain_merge` now gate on explicit `provider.capabilities.X` (pushRemote / localWorktree) instead of the `!projectRoot` proxy. Matches the pattern `tools/normalize.ts` adopted in phase 6. ### Tool surface No changes. Same 16 tools, same parameters, same response JSON. ### Docs Tool-count drift (15 → 16) fixed in root README, CLAUDE.md, docs/packages/mcp.md frontmatter, docs/concepts.md. Tool lists updated to include `contentrain_merge`. "Local-first only / no GitHub API" framing rewritten in four spots (packages/mcp/README.md opening + design constraints, docs/packages/mcp.md frontmatter + body section). New framing: "provider-agnostic, local-first by default, optional GitHub and GitLab backends". New VitePress pages (sidebar + nav updated): - docs/guides/providers.md — Local / GitHub / GitLab overview and capability matrix - docs/guides/http-transport.md — HTTP deployment, Bearer auth, Studio / CI / remote-agent patterns - docs/reference/providers.md — RepoProvider contract reference Existing pages updated: getting-started (HTTP transport tip), concepts (provider concept + capability gates), studio (content engine is MCP over HTTP + RepoProvider), normalize (LocalProvider warning), config reference (widened provider type). CLI: - packages/cli/src/commands/serve.ts description now says "stdio + HTTP" - packages/cli/README.md adds a "MCP HTTP (Studio, CI, remote drivers)" subsection under `serve` modes and updates branch references from `contentrain/*` to `cr/*`. Rules and skills: - packages/rules/shared/mcp-usage.md — new "Transport and Provider Capabilities" section with the capability matrix; `contentrain_merge` added to the tool catalogue; branch naming corrected to `cr/*`; `capability_required` entry added to the common-errors table. - packages/rules/shared/{workflow,normalize}-rules.md — branch examples migrated to `cr/*`. - packages/skills/skills/contentrain-normalize/SKILL.md — new "Transport Requirements" section documenting the LocalProvider constraint. - packages/skills/workflows/{contentrain-normalize,translate}.md — branch references migrated to `cr/*`. ### Tests - packages/mcp/tests/providers/local/reader.test.ts (new, 11 cases) — LocalReader was previously untested at the unit level. - packages/mcp/tests/core/overlay-reader.test.ts (new, 11 cases) — exercises the primitive behind both P1 fixes. - packages/mcp/tests/server/fixtures/github-mock.ts (new) — shared mock for GitHubProvider HTTP E2Es; previously inlined in each case. - packages/mcp/tests/server/http.test.ts — five new E2Es (content_delete, model_save, model_delete, validate read-only, status read-only) plus an updated capability-error test that exercises `contentrain_submit` (genuinely local-only) instead of the now-remote-capable `contentrain_status`. ### Changesets - `.changeset/types-provider-alignment.md` — @contentrain/types minor (provider widened + contracts exposed). - `.changeset/mcp-phase-10-alignment.md` — @contentrain/mcp minor (new subpath exports + P1 bug fixes + read-only-over-remote feature additions). ### Verification - pnpm -r typecheck (8 packages) → 0 errors - npx oxlint (monorepo, 397 files) → 0 warnings - pnpm --filter @contentrain/mcp build → clean, 130 files packed - vitest run tests/core tests/conformance tests/serialization-parity tests/git tests/providers tests/server tests/util → 440/440 green, 2 skipped (22 new tests land in this pass: 11 LocalReader + 11 OverlayReader, plus 5 new HTTP E2Es replacing one rewritten case). - vitest run tests/tools → 90/91 on parallel; 1 pre-existing flaky timeout on `content.test.ts > blocks new writes when 80 active contentrain branches exist` (80 sequential git checkouts inside a 120s budget, unrelated to phase 10). Re-run in isolation: 17/17 green in 411s. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/mcp-phase-10-alignment.md | 82 +++++ .changeset/types-provider-alignment.md | 18 ++ CLAUDE.md | 4 +- README.md | 6 +- docs/.vitepress/config.ts | 6 + docs/concepts.md | 6 +- docs/getting-started.md | 10 + docs/guides/http-transport.md | 123 ++++++++ docs/guides/normalize.md | 6 + docs/guides/providers.md | 102 +++++++ docs/packages/mcp.md | 53 +++- docs/reference/config.md | 2 +- docs/reference/providers.md | 172 +++++++++++ docs/studio.md | 8 +- packages/cli/README.md | 37 ++- packages/cli/src/commands/serve.ts | 2 +- packages/mcp/README.md | 12 +- packages/mcp/package.json | 12 +- packages/mcp/src/core/content-manager.ts | 168 +++++++++++ packages/mcp/src/core/contracts/branch.ts | 18 -- .../mcp/src/core/contracts/capabilities.ts | 37 --- .../mcp/src/core/contracts/file-change.ts | 14 - packages/mcp/src/core/contracts/index.ts | 28 +- packages/mcp/src/core/contracts/provider.ts | 27 -- .../mcp/src/core/contracts/repo-reader.ts | 30 -- .../mcp/src/core/contracts/repo-writer.ts | 39 --- packages/mcp/src/core/overlay-reader.ts | 97 ++++++ .../mcp/src/providers/github/apply-plan.ts | 8 +- packages/mcp/src/providers/github/paths.ts | 19 -- packages/mcp/src/providers/github/reader.ts | 10 +- .../mcp/src/providers/gitlab/apply-plan.ts | 14 +- packages/mcp/src/providers/gitlab/reader.ts | 23 +- packages/mcp/src/providers/shared/errors.ts | 25 ++ packages/mcp/src/providers/shared/index.ts | 2 + .../src/providers/{gitlab => shared}/paths.ts | 15 +- packages/mcp/src/tools/commit-plan.ts | 102 +++++++ packages/mcp/src/tools/content.ts | 101 +++---- packages/mcp/src/tools/context.ts | 113 ++++--- packages/mcp/src/tools/model.ts | 68 ++--- packages/mcp/src/tools/workflow.ts | 15 +- .../mcp/tests/core/overlay-reader.test.ts | 113 +++++++ .../mcp/tests/providers/local/reader.test.ts | 102 +++++++ .../mcp/tests/server/fixtures/github-mock.ts | 117 ++++++++ packages/mcp/tests/server/http.test.ts | 280 +++++++++++++++++- packages/rules/shared/mcp-usage.md | 44 ++- packages/rules/shared/normalize-rules.md | 2 +- packages/rules/shared/workflow-rules.md | 13 +- .../skills/contentrain-normalize/SKILL.md | 24 ++ .../skills/workflows/contentrain-normalize.md | 6 +- .../skills/workflows/contentrain-translate.md | 2 +- packages/types/src/index.ts | 29 +- packages/types/src/provider.ts | 190 ++++++++++++ 52 files changed, 2093 insertions(+), 463 deletions(-) create mode 100644 .changeset/mcp-phase-10-alignment.md create mode 100644 .changeset/types-provider-alignment.md create mode 100644 docs/guides/http-transport.md create mode 100644 docs/guides/providers.md create mode 100644 docs/reference/providers.md delete mode 100644 packages/mcp/src/core/contracts/branch.ts delete mode 100644 packages/mcp/src/core/contracts/capabilities.ts delete mode 100644 packages/mcp/src/core/contracts/file-change.ts delete mode 100644 packages/mcp/src/core/contracts/provider.ts delete mode 100644 packages/mcp/src/core/contracts/repo-reader.ts delete mode 100644 packages/mcp/src/core/contracts/repo-writer.ts create mode 100644 packages/mcp/src/core/overlay-reader.ts delete mode 100644 packages/mcp/src/providers/github/paths.ts create mode 100644 packages/mcp/src/providers/shared/errors.ts create mode 100644 packages/mcp/src/providers/shared/index.ts rename packages/mcp/src/providers/{gitlab => shared}/paths.ts (55%) create mode 100644 packages/mcp/src/tools/commit-plan.ts create mode 100644 packages/mcp/tests/core/overlay-reader.test.ts create mode 100644 packages/mcp/tests/providers/local/reader.test.ts create mode 100644 packages/mcp/tests/server/fixtures/github-mock.ts create mode 100644 packages/types/src/provider.ts diff --git a/.changeset/mcp-phase-10-alignment.md b/.changeset/mcp-phase-10-alignment.md new file mode 100644 index 0000000..cec515e --- /dev/null +++ b/.changeset/mcp-phase-10-alignment.md @@ -0,0 +1,82 @@ +--- +"@contentrain/mcp": minor +--- + +chore(mcp): phase 10 alignment — docs parity, cohesion fixes, P1 bug fixes, new subpath exports + +Follow-up to the phase-0-through-9 provider-agnostic refactor. Ships +in one PR because the pieces are interlocking: the bug fixes rely on +new primitives, the new primitives unlock the feature gaps the docs +claimed were already covered. + +### New public subpath exports + +- `@contentrain/mcp/core/contracts` — `RepoProvider` / `RepoReader` / + `RepoWriter` / capabilities / file-change / branch / commit types, + re-exported from `@contentrain/types` for backward compat. +- `@contentrain/mcp/providers/local` — `LocalProvider`, `LocalReader`, + related types. Previously internal-only. + +These paths were referenced in package documentation since phase 5 +but had no `exports` entry, so external imports failed with +`ERR_PACKAGE_PATH_NOT_EXPORTED`. Now callable. + +### Bug fixes (P1) + +- **Remote write base branch invariant** — `commit-plan.ts` and its + call sites now always fork remote feature branches from the + `contentrain` singleton branch, matching the local flow. Previous + behaviour forked from `config.repository.default_branch` (usually + `main`), breaking the single-source-of-truth invariant in any + project that set the repository's default branch explicitly. +- **Stale remote context / validation** — the new `OverlayReader` + primitive layers pending `FileChange`s on top of the underlying + reader. `buildContextChange` and post-save `validateProject` now + see the state the pending commit produces instead of the + pre-change base branch. Fixes commits whose context.json entry + counts or validation result reflected the old state. + +### Read-only tools on HTTP + remote providers (P2) + +- `contentrain_status`, `contentrain_describe`, and + `contentrain_content_list` no longer gate on `!projectRoot`. They + work against any `ToolProvider` — `LocalProvider`, `GitHubProvider`, + `GitLabProvider` — through the reader surface. Branch health + stack + detection (local-only) are skipped gracefully when no project root + is available. +- `contentrain_content_list` with `resolve: true` still requires local + disk (cross-model relation hydration walks other models' content + files); the reader path rejects it with a descriptive error. + +### Cohesion + +- `commitThroughProvider` — shared helper that encapsulates the + `LocalProvider` vs remote `RepoProvider` dispatch. The eight + repeated `if (provider instanceof LocalProvider) { … } else { … }` + blocks across `content.ts` / `model.ts` collapse to single calls. + Uniform `{ commitSha, workflowAction, sync? }` return shape. +- `providers/shared/{errors,paths}.ts` — consolidated + `isNotFoundError` + `normaliseContentRoot` / `resolveRepoPath` used + by both GitHub and GitLab providers. Removes four duplicate + `isNotFound` helpers with asymmetric semantics (GitLab's lenient + description-match fallback is gone — status-based check only). +- `workflow.ts` — `contentrain_submit` and `contentrain_merge` now + gate on explicit `provider.capabilities.X` instead of the + `!projectRoot` proxy. Matches the pattern `normalize.ts` adopted in + phase 6. +- `util/serializer.ts` was previously dead-code removed in phase 9. + +### Test coverage + +- `tests/providers/local/reader.test.ts` — new, 11 cases +- `tests/core/overlay-reader.test.ts` — new, 11 cases +- `tests/server/http.test.ts` — +5 cases (content_delete, model_save, + model_delete, validate, status remote) and an updated + capability-error test that now exercises `contentrain_submit` + (genuinely local-only) instead of `contentrain_status`. + +### Tool surface + +No changes. Same 16 tools, same parameters, same response shapes. +Stdio + LocalProvider flows behave identically to the previous +release. diff --git a/.changeset/types-provider-alignment.md b/.changeset/types-provider-alignment.md new file mode 100644 index 0000000..41bc89c --- /dev/null +++ b/.changeset/types-provider-alignment.md @@ -0,0 +1,18 @@ +--- +"@contentrain/types": minor +--- + +feat(types): RepoProvider contracts + widened `repository.provider` + +- `ContentrainConfig.repository.provider` is now `'github' | 'gitlab'` (was a hardcoded `'github'`). Reflects the two remote providers `@contentrain/mcp` ships today. +- The provider-agnostic engine contracts used by `@contentrain/mcp` are now exposed directly from `@contentrain/types`: + - `RepoReader`, `RepoWriter`, `RepoProvider` + - `ProviderCapabilities`, `LOCAL_CAPABILITIES` + - `ApplyPlanInput`, `Commit`, `CommitAuthor` + - `FileChange`, `Branch`, `FileDiff`, `MergeResult` + +Third-party tools can now implement a custom `RepoProvider` without +taking a runtime dependency on `@contentrain/mcp`. + +`@contentrain/mcp/core/contracts` keeps re-exporting every symbol, so +existing MCP-based imports are unchanged. diff --git a/CLAUDE.md b/CLAUDE.md index fd854cb..21a00a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ MIT-licensed monorepo for Contentrain's open-source packages: MCP tools, CLI, Ty ``` contentrain-ai/ ├── packages/ -│ ├── mcp/ — 15 MCP tools (simple-git + zod + MCP SDK) +│ ├── mcp/ — 16 MCP tools, stdio + HTTP transports, Local / GitHub / GitLab providers (simple-git + zod + MCP SDK) │ ├── cli/ — citty + tsdown (init/serve/validate/normalize/connect) │ ├── types/ — Shared TypeScript types (@contentrain/types) │ ├── rules/ — AI agent quality rules & conventions @@ -90,7 +90,7 @@ When working with Contentrain content operations (models, content, normalize, va | Package | Name | Description | |---|---|---| -| packages/mcp | @contentrain/mcp | 15 MCP tools | +| packages/mcp | @contentrain/mcp | 16 MCP tools, stdio + HTTP transports, Local / GitHub / GitLab providers | | packages/cli | contentrain | CLI (npx contentrain) | | packages/types | @contentrain/types | Shared TypeScript types | | packages/rules | @contentrain/rules | AI agent quality rules & conventions | diff --git a/README.md b/README.md index d8635b6..1f44bfb 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ This is the strongest entry point into the product: ``` ┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ -│ AI Agent │────▶│ MCP (15 tools) │────▶│ .contentrain/│ +│ AI Agent │────▶│ MCP (16 tools) │────▶│ .contentrain/│ │ (decides) │ │ (enforces) │ │ (stores) │ └─────────────┘ └──────────────────┘ └──────┬───────┘ │ @@ -145,7 +145,7 @@ Works with Nuxt, Next.js, Astro, SvelteKit, Vue, React, Node, Go, Python, Swift, - **Git-native** — every write goes through worktree isolation + review branches - **Normalize flow** — scan codebase for hardcoded strings → extract → create i18n-ready content → patch source files -- **Local-first MCP** — 15 tools, stdio transport, works with Claude Code, Cursor, Windsurf, or any MCP client +- **MCP engine** — 16 tools over stdio or HTTP transport, works with Claude Code, Cursor, Windsurf, or any MCP client - **Provider-agnostic engine** — the same tool surface runs over a local worktree, GitHub, or GitLab (self-hosted included) with zero tool-code changes. HTTP transport available for remote drivers such as Studio. - **Canonical serialization** — sorted keys, deterministic output, clean git diffs, conflict-free parallel edits - **Agent rules & skills** — behavioral policies and step-by-step workflows ship as npm packages @@ -176,7 +176,7 @@ See [`AGENTS.md`](AGENTS.md) for the full skill catalog and agent guidance. | Package | npm | Role | |---|---|---| -| [`@contentrain/mcp`](packages/mcp) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Fmcp)](https://www.npmjs.com/package/@contentrain/mcp) | 15 MCP tools + stdio / HTTP transport + Local / GitHub / GitLab providers | +| [`@contentrain/mcp`](packages/mcp) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Fmcp)](https://www.npmjs.com/package/@contentrain/mcp) | 16 MCP tools + stdio / HTTP transport + Local / GitHub / GitLab providers | | [`contentrain`](packages/cli) | [![npm](https://img.shields.io/npm/v/contentrain)](https://www.npmjs.com/package/contentrain) | CLI + Serve UI + MCP stdio entrypoint | | [`@contentrain/query`](packages/sdk/js) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Fquery)](https://www.npmjs.com/package/@contentrain/query) | Generated TypeScript query SDK | | [`@contentrain/types`](packages/types) | [![npm](https://img.shields.io/npm/v/%40contentrain%2Ftypes)](https://www.npmjs.com/package/@contentrain/types) | Shared type definitions + constants | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5791ec3..f254ea2 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -47,6 +47,8 @@ export default defineConfig({ { text: 'Types', link: '/packages/types' }, ]}, { text: 'Guides', items: [ + { text: 'Providers & Transports', link: '/guides/providers' }, + { text: 'HTTP Transport', link: '/guides/http-transport' }, { text: 'Normalize Flow', link: '/guides/normalize' }, { text: 'Framework Integration', link: '/guides/frameworks' }, { text: 'i18n Workflow', link: '/guides/i18n' }, @@ -56,6 +58,7 @@ export default defineConfig({ { text: 'Model Kinds', link: '/reference/model-kinds' }, { text: 'Field Types', link: '/reference/field-types' }, { text: 'Configuration', link: '/reference/config' }, + { text: 'RepoProvider', link: '/reference/providers' }, ]}, { text: 'Starters', link: 'https://github.com/orgs/Contentrain/repositories?q=contentrain-starter&type=template' }, ], @@ -84,6 +87,8 @@ export default defineConfig({ { text: 'Guides', items: [ + { text: 'Providers & Transports', link: '/guides/providers' }, + { text: 'HTTP Transport', link: '/guides/http-transport' }, { text: 'Normalize Flow', link: '/guides/normalize' }, { text: 'Framework Integration', link: '/guides/frameworks' }, { text: 'i18n Workflow', link: '/guides/i18n' }, @@ -96,6 +101,7 @@ export default defineConfig({ { text: 'Model Kinds', link: '/reference/model-kinds' }, { text: 'Field Types', link: '/reference/field-types' }, { text: 'Configuration', link: '/reference/config' }, + { text: 'RepoProvider', link: '/reference/providers' }, ], }, ], diff --git a/docs/concepts.md b/docs/concepts.md index 49a48b3..44336bb 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -43,16 +43,18 @@ Contentrain AI inverts the traditional CMS workflow: ### 1. MCP (Infrastructure) -15 tools that AI agents call to manage content: +16 tools that AI agents call to manage content: - **Read:** `contentrain_status`, `contentrain_describe`, `contentrain_describe_format`, `contentrain_content_list` - **Project setup:** `contentrain_init`, `contentrain_scaffold` - **Content and schema writes:** `contentrain_model_save`, `contentrain_model_delete`, `contentrain_content_save`, `contentrain_content_delete` - **Normalize:** `contentrain_scan`, `contentrain_apply` -- **Workflow and operations:** `contentrain_validate`, `contentrain_submit`, `contentrain_bulk` +- **Workflow and operations:** `contentrain_validate`, `contentrain_submit`, `contentrain_merge`, `contentrain_bulk` MCP is **deterministic infrastructure** — it doesn't make content decisions. The agent decides what to create; MCP executes it. +MCP runs over two transports (stdio for IDE agents, HTTP for Studio / CI / remote drivers) and three provider backends: **Local** (simple-git + worktree), **GitHub** (Octokit over the Git Data API), and **GitLab** (gitbeaker over the REST API). The tool surface is identical across all three; some tools require `LocalProvider` — see [Providers and transports](/guides/providers) for the capability matrix. + ### 2. Agent (Intelligence) The AI agent (Claude, GPT, etc.) is the intelligence layer: diff --git a/docs/getting-started.md b/docs/getting-started.md index 9d3a308..c6bd28d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -105,6 +105,16 @@ If your IDE is detected during `contentrain init`, the MCP config is created aut +::: tip HTTP Transport +In addition to stdio, MCP also serves over HTTP at `POST /mcp`. Useful for Studio / CI runners / remote agents that drive Contentrain operations without a local IDE: + +```bash +npx contentrain serve --mcpHttp --authToken $(openssl rand -hex 32) +``` + +See the [HTTP Transport guide](/guides/http-transport) for auth, deployment patterns, and programmatic embedding. +::: + ### 3. Create a content model Tell your agent: diff --git a/docs/guides/http-transport.md b/docs/guides/http-transport.md new file mode 100644 index 0000000..3a77b49 --- /dev/null +++ b/docs/guides/http-transport.md @@ -0,0 +1,123 @@ +--- +title: HTTP Transport +description: Run the Contentrain MCP server over HTTP for Studio, CI runners, and remote agent drivers — with Bearer authentication. +slug: http-transport +--- + +# HTTP Transport + +The MCP server ships two transports. The default — stdio — is for IDE integration. The HTTP transport serves tool calls at `POST /mcp` so agents running on a different machine can drive Contentrain operations against a project. + +Typical drivers: + +- **Contentrain Studio** — hosts an agent that talks to MCP over HTTP, backed by a GitHubProvider or GitLabProvider pointed at a team's content repo. +- **CI runners** — deterministic content operations as part of a pipeline (scaffold, validate, submit). +- **Remote agents** — any MCP client that wants to operate a Contentrain project without a local checkout. + +All three tunnel the same 16 tools through the same `RepoProvider` contract. Which backend answers depends on how the server is wired — see [Providers & Transports](/guides/providers) for the capability matrix. + +## Starting the HTTP server + +The simplest path is the CLI: + +```bash +contentrain serve --mcpHttp --authToken $(openssl rand -hex 32) +``` + +This binds to `localhost:3333` by default, uses the current working directory as the project root, and wraps it in a `LocalProvider`. Flags: + +- `--port ` (`CONTENTRAIN_PORT`) — listen port +- `--host ` (`CONTENTRAIN_HOST`) — bind address. Default `localhost`; set to `0.0.0.0` to accept remote connections +- `--authToken ` (`CONTENTRAIN_AUTH_TOKEN`) — Bearer token required for every request +- `--root ` (`CONTENTRAIN_PROJECT_ROOT`) — project root when not the cwd + +MCP tool calls land at `POST :/mcp`. Any other path returns 404. + +## Authentication + +When `--authToken` is set (or `CONTENTRAIN_AUTH_TOKEN` is exported), every request must carry `Authorization: Bearer `. Missing or mismatched tokens get `401 Unauthorized` before the MCP session initialises. + +```bash +curl -X POST http://localhost:3333/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"contentrain_describe_format","arguments":{}}}' +``` + +Auth is optional in dev — omit `--authToken` when binding to `localhost`. Production deployments should always set one. + +## Programmatic embedding + +CLI-wrapped HTTP always uses `LocalProvider`. To run HTTP against a remote provider (GitHub, GitLab) embed the server programmatically: + +```ts +import { createGitHubProvider } from '@contentrain/mcp/providers/github' +import { startHttpMcpServerWith } from '@contentrain/mcp/server/http' + +const provider = await createGitHubProvider({ + auth: { type: 'pat', token: process.env.GITHUB_TOKEN! }, + repo: { owner: 'acme', name: 'site' }, +}) + +const handle = await startHttpMcpServerWith({ + provider, + port: 3333, + host: '0.0.0.0', + authToken: process.env.MCP_BEARER_TOKEN, +}) + +// handle.url contains the fully-qualified URL, e.g. http://0.0.0.0:3333/mcp +// handle.close() stops the server +``` + +The same pattern works for `createGitLabProvider` with a `GitLabProvider`. Both require their respective optional peers (`@octokit/rest`, `@gitbeaker/rest`). + +## Deployment patterns + +### Studio (hosted agent) + +Studio's agent builds a GitHubProvider or GitLabProvider per tenant, points it at the tenant's content repo, and talks to an embedded MCP server over HTTP+LocalProvider-style wiring but with a remote provider. Each session is ephemeral. + +### CI + +A GitHub Actions job can: + +1. Check out the repository +2. `pnpm install` +3. Start `contentrain serve --mcpHttp --authToken $CI_TOKEN &` +4. Drive it with an MCP client (Claude, Cursor-headless, or a custom JSON-RPC client) +5. Let `contentrain_submit` push the review branch + +Because `contentrain serve --mcpHttp` uses `LocalProvider`, every tool — including normalize and submit — is available. + +### Remote agent + +An agent running on a laptop can drive a Contentrain project that lives on a server by connecting to the server's HTTP MCP endpoint (over a VPN or behind a reverse proxy with TLS + Bearer auth). The agent sees the full tool surface; capability gates still apply based on the backing provider. + +## Capability gates over HTTP + +Not every tool works on every provider. A tool driven by an HTTP client against a remote provider returns a structured capability error when the required capability is missing: + +```json +{ + "error": "contentrain_scan requires local filesystem access.", + "capability_required": "astScan", + "hint": "This tool is unavailable when MCP is driven by a remote provider. Use a LocalProvider or the stdio transport." +} +``` + +Agent drivers should treat `capability_required` as a retry signal — prompt the user to switch transports, or fall back to a local-checkout session for that specific tool. + +## Security notes + +- Never expose HTTP MCP without `--authToken` on a non-`localhost` bind. +- Rotate tokens regularly; MCP does not support ACLs at the tool level, so a token is full project access. +- When Studio connects, the token is managed per workspace — see the Studio docs for the rotation workflow. +- All writes create feature branches from `contentrain`; the singleton source-of-truth branch is protected from direct pushes in team configurations. + +## Next steps + +- [Providers & Transports](/guides/providers) — deeper reference for each backend. +- [MCP package docs](/packages/mcp) — tool catalogue and response shapes. +- [Contentrain Studio](/studio) — the hosted surface that drives HTTP MCP. diff --git a/docs/guides/normalize.md b/docs/guides/normalize.md index 1e168af..62e954a 100644 --- a/docs/guides/normalize.md +++ b/docs/guides/normalize.md @@ -9,6 +9,12 @@ slug: normalize The normalize flow is Contentrain's **primary value proposition** — the fastest path from "500 hardcoded strings scattered across my codebase" to "structured, translatable, manageable content." It runs in three phases: **Extract**, **Reuse**, and **Translate**. +::: warning LocalProvider required +Normalize (`contentrain_scan` and `contentrain_apply`) requires local disk access — AST scanners walk the source tree and patch files in place. It runs only on a `LocalProvider` (stdio or HTTP+LocalProvider). + +Remote providers (`GitHubProvider`, `GitLabProvider`) reject these calls with `capability_required: astScan / sourceRead / sourceWrite`. Run normalize in a local checkout, then push the extracted content branch. See [Providers & Transports](/guides/providers) for the capability matrix. +::: + ## The Problem A typical SaaS landing page has 40-60 components with 300-800 hardcoded strings. Nobody notices until someone asks for a second language or a copy change across 12 pages. diff --git a/docs/guides/providers.md b/docs/guides/providers.md new file mode 100644 index 0000000..dad345e --- /dev/null +++ b/docs/guides/providers.md @@ -0,0 +1,102 @@ +--- +title: Providers & Transports +description: How Contentrain MCP's provider-agnostic engine works — LocalProvider, GitHubProvider, GitLabProvider — and which capabilities each exposes. +slug: providers +--- + +# Providers & Transports + +Contentrain MCP runs the same 16 tools over three backends: + +- **LocalProvider** — simple-git + a temporary worktree on your disk. Default for `npx contentrain serve --stdio` and the HTTP transport when driven by the CLI. +- **GitHubProvider** — Octokit over the GitHub Git Data + Repos APIs. No clone, no worktree. +- **GitLabProvider** — gitbeaker over the GitLab REST API. No clone, no worktree. Works with gitlab.com and self-hosted CE / EE. + +All three implement the same `RepoProvider` contract from `@contentrain/types`. Tool handlers route through the contract, never the concrete provider, so a change of backend never changes the tool surface an agent sees. + +Bitbucket support is on the roadmap — see the README for the current status. + +## Transport matrix + +| Transport | Typical driver | Provider used | Notes | +|---|---|---|---| +| `stdio` | Claude Code, Cursor, Windsurf, any MCP client | LocalProvider | Ships inside `contentrain` CLI (`contentrain serve --stdio`) | +| HTTP (`POST /mcp`) with LocalProvider | Local CI runner, Studio when pointed at a working tree | LocalProvider | `contentrain serve --mcpHttp --authToken …` | +| HTTP with GitHubProvider | Studio's hosted agent, CI against a GitHub repo | GitHubProvider | Embedders construct the provider with `createGitHubProvider({ auth, repo })` | +| HTTP with GitLabProvider | Studio's hosted agent, CI against a GitLab repo | GitLabProvider | Embedders construct the provider with `createGitLabProvider({ auth, project })` | + +## Capability matrix + +Some tools need more than a git provider can offer — normalize has to walk your source tree with an AST parser, submit has to invoke `git push`. Each provider advertises a capability set; tools gate on the capabilities they need and reject over HTTP with a uniform `capability_required` error when the active provider can't satisfy them. + +| Capability | LocalProvider | GitHubProvider | GitLabProvider | Tools that require it | +|---|---|---|---|---| +| `localWorktree` | ✓ | — | — | `init`, `scaffold`, `validate --fix`, `submit`, `merge`, `bulk` | +| `sourceRead` | ✓ | — | — | `apply` (extract mode) | +| `sourceWrite` | ✓ | — | — | `apply` (reuse mode) | +| `astScan` | ✓ | — | — | `scan` | +| `pushRemote` | ✓ | ✓ | ✓ | `submit` | +| `branchProtection` | — | ✓ | ✓ | merge fallback detection | +| `pullRequestFallback` | — | ✓ | ✓ | merge fallback creation | + +Read-only tools (`status`, `describe`, `describe_format`, `content_list`, `validate` without `--fix`) work on every provider. Write tools (`content_save`, `content_delete`, `model_save`, `model_delete`) work over any provider too — a remote provider posts the changes as a single atomic commit and always returns `action: pending-review` so Studio (or whoever orchestrates the server) drives the merge. + +`content_list` with `resolve: true` requires local filesystem access today because relation hydration walks other models' content files. The reader-backed path rejects it with a descriptive error. + +## When to use which provider + +- **`LocalProvider`** — Day-to-day development from an IDE. Offline-capable, zero API keys, full source-tree access for normalize. +- **`GitHubProvider`** — CI-driven content operations, Studio's hosted agent, or any automation that should push directly to a GitHub repository without a clone. Requires `@octokit/rest` (optional peer dependency) and a personal access token or GitHub App installation. +- **`GitLabProvider`** — Same as above for GitLab (SaaS or self-hosted). Requires `@gitbeaker/rest` (optional peer dependency) and a PAT / OAuth / job token. + +The choice is operational, not commercial. All three providers live in MIT; enterprise features are on top of Contentrain Studio, not behind provider gates. See [Ecosystem Map](/ecosystem) for the full package-to-product relationship. + +## Installation + +Base install: + +```bash +pnpm add @contentrain/mcp +``` + +Remote provider peers — install only when the backend is used: + +```bash +# GitHub +pnpm add @octokit/rest + +# GitLab +pnpm add @gitbeaker/rest +``` + +stdio + `LocalProvider` flows (the default) need neither peer. + +## Wiring a remote provider + +```ts +import { createServer } from '@contentrain/mcp/server' +import { createGitHubProvider } from '@contentrain/mcp/providers/github' +// or: import { createGitLabProvider } from '@contentrain/mcp/providers/gitlab' +import { startHttpMcpServerWith } from '@contentrain/mcp/server/http' + +const provider = await createGitHubProvider({ + auth: { type: 'pat', token: process.env.GITHUB_TOKEN! }, + repo: { owner: 'acme', name: 'site' }, +}) + +const handle = await startHttpMcpServerWith({ + provider, + port: 3333, + authToken: process.env.MCP_BEARER_TOKEN, +}) + +console.log(`MCP server at ${handle.url}`) +``` + +The server is MCP-compliant — any MCP client (including Studio) can talk to it over Streamable HTTP. + +## Next steps + +- See the [HTTP Transport guide](/guides/http-transport) for deployment patterns, auth, and the Studio use case. +- The [MCP package reference](/packages/mcp) documents every tool's parameters and return shape. +- [Normalize Flow](/guides/normalize) explains why normalize is local-only. diff --git a/docs/packages/mcp.md b/docs/packages/mcp.md index ae28b58..e055f3b 100644 --- a/docs/packages/mcp.md +++ b/docs/packages/mcp.md @@ -1,6 +1,6 @@ --- title: MCP Tools -description: Complete reference for @contentrain/mcp — the local-first MCP server powering AI content governance with 15 deterministic tools +description: Complete reference for @contentrain/mcp — the provider-agnostic MCP engine powering AI content governance with 16 deterministic tools over stdio or HTTP order: 1 slug: mcp --- @@ -124,21 +124,56 @@ Always call write tools with `dry_run: true` first. This is not optional — it ### 3. Git-Native Workflow -All write operations create or update `contentrain/*` branches: +All write operations create or update `cr/*` branches: -- Content changes go to isolated branches +- Content changes go to isolated branches (`cr/{scope}/{target}[/{locale}]/{timestamp}-{suffix}`) - Humans review via `contentrain diff` or the serve UI - Approved changes merge into the `contentrain` branch, baseBranch is advanced via update-ref - Branch health is tracked and surfaced via `contentrain_status` +- Legacy `contentrain/*` branches are auto-migrated on first init -### 4. Local-First, No API Dependencies +### 4. Local-First by Default, Remote Providers Opt-In -MCP operates entirely on the local filesystem. There is no GitHub API, no cloud service, no external dependency. This means: +The default shape — stdio transport + `LocalProvider` — operates entirely on the local filesystem. No GitHub API, no cloud service, no external dependency. -- Works offline -- Works with any git provider -- No API keys or authentication needed -- Full data sovereignty +Remote providers (`GitHubProvider` via `@octokit/rest`, `GitLabProvider` via `@gitbeaker/rest`) are **optional peer dependencies**. They are installed only when Studio, CI, or a remote agent needs to drive MCP over an HTTP transport against a hosted git repo. A session that uses `LocalProvider` never loads these SDKs. + +That means: + +- Default install works offline and needs no API keys +- Optional remote backends ship on the same tool contract — see [Providers and transports](/guides/providers) for the full capability matrix +- Normalize, scan, and apply always need a `LocalProvider` — they return a `capability_required` error on remote providers + +### 5. Capability Gates + +Every tool declares the capabilities it needs. Tools that require `astScan`, `sourceRead`, `sourceWrite`, or `localWorktree` reject on providers that do not expose them with a uniform error: + +```json +{ + "error": "contentrain_scan requires local filesystem access.", + "capability_required": "astScan", + "hint": "This tool is unavailable when MCP is driven by a remote provider. Use a LocalProvider or the stdio transport." +} +``` + +Agent drivers treat `capability_required` as a retry signal. See [Providers & Transports](/guides/providers) for the full capability matrix. + +## Transports + +- **stdio** — `contentrain serve --stdio` or `npx contentrain-mcp`. IDE agents (Claude Code, Cursor, Windsurf) connect over stdin/stdout. +- **HTTP** — `contentrain serve --mcpHttp --authToken $TOKEN` or the programmatic `startHttpMcpServer({...})` / `startHttpMcpServerWith({ provider })` exports. Streamable HTTP at `POST /mcp` with optional Bearer auth. See the [HTTP Transport guide](/guides/http-transport). + +Both transports serve the same 16 tools and the same JSON response shapes. + +## Providers + +`@contentrain/mcp` ships three `RepoProvider` implementations behind a single contract: + +- **`LocalProvider`** — simple-git + temporary worktree on your disk +- **`GitHubProvider`** — Octokit over the Git Data + Repos APIs (no clone) +- **`GitLabProvider`** — gitbeaker over the GitLab REST API (no clone; supports self-hosted) + +Bitbucket is on the roadmap. See [Providers & Transports](/guides/providers) for the capability matrix and [RepoProvider Reference](/reference/providers) for the interface definitions. ## Studio Bridge diff --git a/docs/reference/config.md b/docs/reference/config.md index 78ea398..649fbef 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -67,7 +67,7 @@ interface ContentrainConfig { stack: StackType // Framework/stack identifier workflow: WorkflowMode // Content workflow mode repository?: { // Git repository info (optional) - provider: 'github' + provider: 'github' | 'gitlab' // Git host (Bitbucket coming) owner: string name: string default_branch: string diff --git a/docs/reference/providers.md b/docs/reference/providers.md new file mode 100644 index 0000000..0cecac4 --- /dev/null +++ b/docs/reference/providers.md @@ -0,0 +1,172 @@ +--- +title: RepoProvider Reference +description: The RepoProvider contract — RepoReader, RepoWriter, branch operations, and the capability manifest — as exposed from @contentrain/types. +slug: providers +--- + +# RepoProvider Reference + +Contentrain's provider-agnostic engine is defined by a small set of interfaces in `@contentrain/types`. Third-party tools can implement a custom `RepoProvider` (for a private git host, an internal service, a test harness) without taking a dependency on `@contentrain/mcp`. + +The canonical source lives in `packages/types/src/provider.ts`. `@contentrain/mcp/core/contracts` re-exports every symbol for backward compatibility. + +## RepoReader + +Read-only surface — three methods. Paths are content-root relative; `ref` is a branch name, tag, or commit SHA. + +```ts +interface RepoReader { + readFile(path: string, ref?: string): Promise + listDirectory(path: string, ref?: string): Promise + fileExists(path: string, ref?: string): Promise +} +``` + +Error semantics: + +- `readFile` **throws** when the file is missing. Callers opt into tolerance with an explicit try/catch. +- `listDirectory` returns `[]` for a missing directory. The empty case is the common, uninteresting one. + +## RepoWriter + +Write surface — one method, one atomic commit per call. + +```ts +interface RepoWriter { + applyPlan(input: ApplyPlanInput): Promise +} + +interface ApplyPlanInput { + branch: string + changes: FileChange[] + message: string + author: CommitAuthor + base?: string // Defaults to provider's content-tracking branch +} +``` + +`changes` entries are `{ path, content }`; `content: null` means delete. Providers are responsible for resolving paths against their backing store and translating the change set into whatever commit primitive the backend supports. + +## Branch ops + +Providers extend `RepoReader` and `RepoWriter` with branch / merge / diff operations to form the full `RepoProvider`: + +```ts +interface RepoProvider extends RepoReader, RepoWriter { + readonly capabilities: ProviderCapabilities + + listBranches(prefix?: string): Promise + createBranch(name: string, fromRef?: string): Promise + deleteBranch(name: string): Promise + getBranchDiff(branch: string, base?: string): Promise + mergeBranch(branch: string, into: string): Promise + isMerged(branch: string, into?: string): Promise + getDefaultBranch(): Promise +} +``` + +`MergeResult` is `{ merged, sha, pullRequestUrl }`. GitHub's `repos.merge` fills `sha`; GitLab's MR-based flow fills both `sha` and `pullRequestUrl`; a provider that hits branch protection returns `merged: false` with a `pullRequestUrl` so the caller can delegate. + +## Capabilities + +Every provider advertises what it can do. Tools gate on capabilities and reject with `capability_required` when the active provider can't satisfy them. + +```ts +interface ProviderCapabilities { + localWorktree: boolean + sourceRead: boolean + sourceWrite: boolean + pushRemote: boolean + branchProtection: boolean + pullRequestFallback: boolean + astScan: boolean +} +``` + +Built-in capability sets: + +| Capability | LocalProvider | GitHubProvider | GitLabProvider | +|---|---|---|---| +| `localWorktree` | ✓ | — | — | +| `sourceRead` | ✓ | — | — | +| `sourceWrite` | ✓ | — | — | +| `pushRemote` | ✓ | ✓ | ✓ | +| `branchProtection` | — | ✓ | ✓ | +| `pullRequestFallback` | — | ✓ | ✓ | +| `astScan` | ✓ | — | — | + +`LOCAL_CAPABILITIES` is exported from `@contentrain/types` for ergonomic use in custom providers that back onto the local filesystem. + +## Supporting types + +```ts +interface FileChange { path: string; content: string | null } + +interface CommitAuthor { name: string; email: string } + +interface Commit { + sha: string + message: string + author: CommitAuthor + timestamp: string // ISO 8601 +} + +interface Branch { name: string; sha: string; protected?: boolean } + +interface FileDiff { + path: string + status: 'added' | 'modified' | 'removed' + before: string | null + after: string | null +} + +interface MergeResult { + merged: boolean + sha: string | null + pullRequestUrl: string | null +} +``` + +## Implementing a custom provider + +Minimum viable provider: + +```ts +import type { RepoProvider, ProviderCapabilities } from '@contentrain/types' +import { LOCAL_CAPABILITIES } from '@contentrain/types' + +class MyProvider implements RepoProvider { + readonly capabilities: ProviderCapabilities = { + ...LOCAL_CAPABILITIES, + // override what your backend actually supports + astScan: false, + } + + async readFile(path: string, ref?: string): Promise { /* ... */ } + async listDirectory(path: string, ref?: string): Promise { /* ... */ } + async fileExists(path: string, ref?: string): Promise { /* ... */ } + async applyPlan(input): Promise { /* one atomic commit */ } + + async listBranches(prefix?: string) { /* ... */ } + async createBranch(name, fromRef?) { /* ... */ } + async deleteBranch(name) { /* ... */ } + async getBranchDiff(branch, base?) { /* ... */ } + async mergeBranch(branch, into) { /* ... */ } + async isMerged(branch, into?) { /* ... */ } + async getDefaultBranch() { /* ... */ } +} + +// Plug it in: +import { createServer } from '@contentrain/mcp/server' +const server = createServer({ provider: new MyProvider() }) +``` + +Any custom provider slots straight into the MCP server and the HTTP transport with no further wiring. + +## Reference implementations + +- `packages/mcp/src/providers/local/` — simple-git + worktree +- `packages/mcp/src/providers/github/` — Octokit over the Git Data + Repos APIs +- `packages/mcp/src/providers/gitlab/` — gitbeaker over the GitLab REST API + +Each is ~400–500 lines; they're small enough to read end-to-end and mirror each other's structure. They're the recommended starting point for a new backend. diff --git a/docs/studio.md b/docs/studio.md index d8e2f3f..a1c7220 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -150,9 +150,9 @@ So the chat surface is an operations UI, not a generic assistant shell. ## Content Engine -The content engine is the execution core behind Studio. +The content engine is the execution core behind Studio. It is a [`@contentrain/mcp`](/packages/mcp) server driven over HTTP (`POST /mcp`) with a `GitHubProvider` or `GitLabProvider` pointed at the team's content repository. Studio never talks to Git directly — every operation flows through MCP's `RepoProvider` contract and lands as an atomic commit on a `cr/*` feature branch. -It is responsible for: +The engine is responsible for: - reading model definitions - loading current content @@ -173,7 +173,9 @@ Supported operations include: - project init - branch merge / reject -Neither the UI nor the AI writes directly to Git. The content engine does. +Studio uses the same tool contract the stdio-driven IDE agents use. A new git host support added to MCP (today: Local / GitHub / GitLab; Bitbucket coming) becomes a Studio connector automatically. + +See [Providers & Transports](/guides/providers) for the capability matrix and [HTTP Transport](/guides/http-transport) for the deployment pattern. ## Review, Branches, and Diffs diff --git a/packages/cli/README.md b/packages/cli/README.md index ef6767b..3adad1c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,9 +17,9 @@ Start here: - initialize `.contentrain/` in an existing repo - inspect project health and validation state - generate the typed `#contentrain` SDK client -- review pending `contentrain/*` branches +- review pending `cr/*` branches - run the local review UI -- expose the MCP server over stdio for IDE agents +- expose the MCP server over stdio (IDE agents) or HTTP (Studio, CI, remote drivers) This package is the human-facing companion to: @@ -56,8 +56,8 @@ Requirements: | `contentrain doctor` | Check setup health, SDK freshness, orphan content, and branch limits | | `contentrain validate` | Validate content against schemas, optionally create review-branch fixes | | `contentrain generate` | Generate `.contentrain/client/` and `#contentrain` package imports | -| `contentrain diff` | Review and merge or reject pending `contentrain/*` branches | -| `contentrain serve` | Start the local review UI or the MCP stdio server | +| `contentrain diff` | Review and merge or reject pending `cr/*` branches | +| `contentrain serve` | Start the local review UI, the MCP stdio server (`--stdio`), or the MCP HTTP server (`--mcpHttp`) | | `contentrain studio connect` | Connect a repository to a Studio project | | `contentrain studio login` | Authenticate with Contentrain Studio | | `contentrain studio logout` | Log out from Studio | @@ -113,22 +113,18 @@ contentrain serve ## 🖥 `serve` Modes -`contentrain serve` has two roles. +`contentrain serve` has three roles. -Start the local review UI: +### Local review UI (default) ```bash contentrain serve contentrain serve --port 3333 --host localhost ``` -This serves: +Serves the REST endpoints for status / content / validation / branches / normalize, a WebSocket stream for live updates, and the embedded Vue `serve-ui` app bundled with the CLI. -- REST endpoints for status, content, validation, branches, and normalize data -- a WebSocket stream for live updates -- the embedded Vue `serve-ui` app bundled with the CLI - -Start the MCP server for IDE integration: +### MCP stdio (IDE agents) ```bash contentrain serve --stdio @@ -136,6 +132,21 @@ contentrain serve --stdio Use stdio mode when connecting Claude Code, Cursor, Windsurf, or another MCP client to the local project. +### MCP HTTP (Studio, CI, remote drivers) + +```bash +contentrain serve --mcpHttp --authToken $(openssl rand -hex 32) +contentrain serve --mcpHttp --port 3333 --host 0.0.0.0 --authToken $TOKEN +``` + +Spins up a [Streamable HTTP MCP](https://modelcontextprotocol.io) server at `POST /mcp`. Bearer auth is enforced when `--authToken` is set (or `CONTENTRAIN_AUTH_TOKEN` is exported). Use HTTP mode when: + +- Studio's agent drives MCP remotely +- a CI runner needs deterministic content operations +- an agent on another machine orchestrates content changes + +HTTP sessions use the same `LocalProvider` backing as stdio — the transport differs, the behaviour does not. Remote git-host providers (`GitHubProvider`, `GitLabProvider`) are constructed by embedders who instantiate the MCP server programmatically; see the MCP package docs for that flow. + ## 📦 `generate` and `#contentrain` `contentrain generate` writes a typed client to `.contentrain/client/` and injects `#contentrain` imports into your `package.json`. @@ -165,7 +176,7 @@ contentrain diff to understand: -- how many active review branches exist on the `contentrain` branch +- how many active `cr/*` review branches exist on the `contentrain` branch - whether branch health is blocking new writes - what changed before merging or deleting a branch diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 306db83..02d4956 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -6,7 +6,7 @@ import consola from 'consola' export default defineCommand({ meta: { name: 'serve', - description: 'Start local content viewer or MCP stdio server', + description: 'Start the local content viewer or the MCP server (stdio for IDE agents, --mcpHttp for Studio / CI / remote drivers)', }, args: { root: { type: 'string', description: 'Project root path (env: CONTENTRAIN_PROJECT_ROOT)', required: false }, diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 957124f..cfc541d 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -4,7 +4,7 @@ [![GitHub source](https://img.shields.io/badge/source-Contentrain%2Fai-181717?logo=github)](https://github.com/Contentrain/ai/tree/main/packages/mcp) [![Docs](https://img.shields.io/badge/docs-ai.contentrain.io-0f172a)](https://ai.contentrain.io/packages/mcp) -Local-first MCP server and core primitives for Contentrain. +Provider-agnostic MCP engine for Contentrain — local-first by default, with optional GitHub and GitLab backends and an HTTP transport for remote drivers such as Studio. Start here: @@ -262,13 +262,15 @@ These are intended for Contentrain tooling and advanced integrations, not for di Key design decisions in this package: -- local-first, filesystem-based MCP -- no GitHub API dependency in MCP +- local-first **by default** — stdio transport + LocalProvider works without any network dependency +- provider-agnostic engine — the same 16 tools run over LocalProvider, GitHubProvider, or GitLabProvider behind a single `RepoProvider` contract +- remote provider SDKs (`@octokit/rest`, `@gitbeaker/rest`) are optional peer dependencies — pulled in only when their provider is used - JSON-only content storage -- git-backed write workflow -- canonical serialization +- git-backed write workflow (worktree transaction locally, single atomic commit over the Git Data / REST APIs remotely) +- canonical serialization — byte-deterministic output, sorted keys, trailing newline - framework-agnostic MCP layer - agent decides content semantics, MCP enforces deterministic execution +- capability gates — tools that need source-tree access (normalize, scan, apply) reject with a uniform `capability_required` error on remote providers ## 🛠 Development diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 66fe5cb..1cac406 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -84,6 +84,10 @@ "types": "./dist/core/scan-config.d.mts", "import": "./dist/core/scan-config.mjs" }, + "./core/contracts": { + "types": "./dist/core/contracts/index.d.mts", + "import": "./dist/core/contracts/index.mjs" + }, "./util/detect": { "types": "./dist/util/detect.d.mts", "import": "./dist/util/detect.mjs" @@ -104,6 +108,10 @@ "types": "./dist/templates/index.d.mts", "import": "./dist/templates/index.mjs" }, + "./providers/local": { + "types": "./dist/providers/local/index.d.mts", + "import": "./dist/providers/local/index.mjs" + }, "./providers/github": { "types": "./dist/providers/github/index.d.mts", "import": "./dist/providers/github/index.mjs" @@ -119,8 +127,8 @@ "dist" ], "scripts": { - "build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript", - "dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch", + "build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript", + "dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch", "test": "vitest run", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" diff --git a/packages/mcp/src/core/content-manager.ts b/packages/mcp/src/core/content-manager.ts index 46ed581..adafd76 100644 --- a/packages/mcp/src/core/content-manager.ts +++ b/packages/mcp/src/core/content-manager.ts @@ -5,6 +5,8 @@ import { rm } from 'node:fs/promises' import { contentrainDir, readDir, readJson, readText, writeJson, writeText } from '../util/fs.js' import { writeMeta, deleteMeta } from './meta-manager.js' import { readModel } from './model-manager.js' +import type { RepoReader } from './contracts/index.js' +import { contentDirPath, contentFilePath, documentFilePath } from './ops/paths.js' // Re-export for backward compatibility (MCP internal consumers) export { validateSlug, validateEntryId, validateLocale } @@ -362,7 +364,46 @@ export async function deleteContent( // ─── listContent ─── +/** + * List content entries for a model. Dual signature: + * + * - `listContent(projectRoot, model, opts, config)` — legacy local flow, + * uses direct filesystem reads and supports `opts.resolve` for + * cross-model relation hydration. + * - `listContent(reader, model, opts, config)` — reader-backed flow for + * remote providers. Basic list works across all four model kinds. + * `opts.resolve: true` is rejected with an error on remote readers + * because the cross-model walk requires local filesystem access. + * + * The reader-based function lives in {@link listContentViaReader}; the + * projectRoot-based entry point continues to call the legacy body to + * preserve bit-for-bit behaviour for every existing caller. + */ +export function listContent( + projectRoot: string, + model: ModelDefinition, + opts: ListOpts, + config: ContentrainConfig, +): Promise +export function listContent( + reader: RepoReader, + model: ModelDefinition, + opts: ListOpts, + config: ContentrainConfig, +): Promise export async function listContent( + input: string | RepoReader, + model: ModelDefinition, + opts: ListOpts, + config: ContentrainConfig, +): Promise { + if (typeof input !== 'string') { + return listContentViaReader(input, model, opts, config) + } + return listContentLocal(input, model, opts, config) +} + +async function listContentLocal( projectRoot: string, model: ModelDefinition, opts: ListOpts, @@ -486,6 +527,133 @@ export async function listContent( } } +async function tryReadJsonViaReader(reader: RepoReader, path: string): Promise { + try { + return JSON.parse(await reader.readFile(path)) as T + } catch { + return null + } +} + +async function tryReadTextViaReader(reader: RepoReader, path: string): Promise { + try { + return await reader.readFile(path) + } catch { + return null + } +} + +async function listContentViaReader( + reader: RepoReader, + model: ModelDefinition, + opts: ListOpts, + config: ContentrainConfig, +): Promise { + if (opts.resolve) { + throw new Error( + 'contentrain_content_list with resolve:true requires local filesystem access. ' + + 'Use a LocalProvider (stdio or HTTP+LocalProvider) or omit resolve:true.', + ) + } + + const cDir = contentDirPath(model) + const locale = opts.locale ?? config.locales.default + + switch (model.kind) { + case 'singleton': { + const data = await tryReadJsonViaReader>(reader, contentFilePath(model, locale)) + return { kind: 'singleton', data: data ?? {}, locale } + } + + case 'collection': { + const data = await tryReadJsonViaReader>>( + reader, + contentFilePath(model, locale), + ) ?? {} + let entries: Array> = Object.entries(data).map(([id, fields]) => { + const entry: Record = { id } + Object.assign(entry, fields) + return entry + }) + + if (opts.filter) { + entries = entries.filter(entry => { + for (const [key, value] of Object.entries(opts.filter!)) { + if (entry[key] !== value) return false + } + return true + }) + } + + const total = entries.length + const offset = opts.offset ?? 0 + const limit = opts.limit ?? entries.length + entries = entries.slice(offset, offset + limit) + + return { kind: 'collection', data: entries, total, locale, offset, limit } + } + + case 'document': { + const entries: DocumentEntry[] = [] + const strategy = resolveLocaleStrategy(model) + + const collectEntry = async (relPath: string, slug: string): Promise => { + const raw = await tryReadTextViaReader(reader, relPath) + if (!raw) return + const { frontmatter, body } = parseMarkdownFrontmatter(raw) + entries.push({ slug, frontmatter, body }) + } + + if (!model.i18n) { + const files = await reader.listDirectory(cDir) + for (const f of files) { + if (!f.endsWith('.md')) continue + await collectEntry(documentFilePath(model, locale, f.replace(/\.md$/u, '')), f.replace(/\.md$/u, '')) + } + } else if (strategy === 'file') { + const slugDirs = await reader.listDirectory(cDir) + for (const slug of slugDirs) { + await collectEntry(documentFilePath(model, locale, slug), slug) + } + } else if (strategy === 'suffix') { + const files = await reader.listDirectory(cDir) + const suffix = `.${locale}.md` + for (const f of files) { + if (!f.endsWith(suffix)) continue + const slug = f.slice(0, -suffix.length) + await collectEntry(documentFilePath(model, locale, slug), slug) + } + } else if (strategy === 'directory') { + const files = await reader.listDirectory(`${cDir}/${locale}`) + for (const f of files) { + if (!f.endsWith('.md')) continue + const slug = f.replace(/\.md$/u, '') + await collectEntry(documentFilePath(model, locale, slug), slug) + } + } else { + const files = await reader.listDirectory(cDir) + for (const f of files) { + if (!f.endsWith('.md')) continue + const slug = f.replace(/\.md$/u, '') + await collectEntry(documentFilePath(model, locale, slug), slug) + } + } + + const total = entries.length + const offset = opts.offset ?? 0 + const limit = opts.limit ?? entries.length + const paged = entries.slice(offset, offset + limit) + + return { kind: 'document', data: paged, total, locale, offset, limit } + } + + case 'dictionary': { + const data = await tryReadJsonViaReader>(reader, contentFilePath(model, locale)) ?? {} + return { kind: 'dictionary', data, total_keys: Object.keys(data).length, locale } + } + } +} + // ─── readContent (for external use / relation resolving) ─── export async function readContent( diff --git a/packages/mcp/src/core/contracts/branch.ts b/packages/mcp/src/core/contracts/branch.ts deleted file mode 100644 index 5d81013..0000000 --- a/packages/mcp/src/core/contracts/branch.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface Branch { - name: string - sha: string - protected?: boolean -} - -export interface FileDiff { - path: string - status: 'added' | 'modified' | 'removed' - before: string | null - after: string | null -} - -export interface MergeResult { - merged: boolean - sha: string | null - pullRequestUrl: string | null -} diff --git a/packages/mcp/src/core/contracts/capabilities.ts b/packages/mcp/src/core/contracts/capabilities.ts deleted file mode 100644 index 86cd655..0000000 --- a/packages/mcp/src/core/contracts/capabilities.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Capabilities describe what a provider can and cannot do. Operations check - * required capabilities before running. Normalize extract needs `sourceRead`; - * normalize reuse needs `sourceWrite`; submit needs `pushRemote`; AST scans - * need `astScan` (which implies a local working tree). - * - * The consistent position is: all git hosts are commodity MIT providers; the - * distinction between providers is operational (how they read/write), not - * commercial. Enterprise features live in Studio, not in capability gates. - */ -export interface ProviderCapabilities { - /** Provider backs onto a local worktree and can selectively sync changes into the developer's working tree. */ - localWorktree: boolean - /** Provider can read arbitrary source files outside `.contentrain/`. Required for normalize extract. */ - sourceRead: boolean - /** Provider can write arbitrary source files outside `.contentrain/`. Required for normalize reuse. */ - sourceWrite: boolean - /** Provider can push commits to a remote. Required for submit. */ - pushRemote: boolean - /** Provider detects branch protection rules on the remote. */ - branchProtection: boolean - /** Provider can open a pull request as a merge fallback when branch protection blocks direct merge. */ - pullRequestFallback: boolean - /** Provider can execute AST scanners against source files. Implies local disk access. */ - astScan: boolean -} - -/** Capability set for the LocalProvider (simple-git + worktree). */ -export const LOCAL_CAPABILITIES: ProviderCapabilities = { - localWorktree: true, - sourceRead: true, - sourceWrite: true, - pushRemote: true, - branchProtection: false, - pullRequestFallback: false, - astScan: true, -} diff --git a/packages/mcp/src/core/contracts/file-change.ts b/packages/mcp/src/core/contracts/file-change.ts deleted file mode 100644 index 280f87c..0000000 --- a/packages/mcp/src/core/contracts/file-change.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * A single file change within a plan. - * - * - `content: string` — write or overwrite the file with this UTF-8 content. - * - `content: null` — delete the file. - * - * Paths are content-root relative, use forward slashes, and must not contain - * `..` segments or absolute anchors. Providers are responsible for resolving - * paths against their backing store (worktree, git tree, etc.). - */ -export interface FileChange { - path: string - content: string | null -} diff --git a/packages/mcp/src/core/contracts/index.ts b/packages/mcp/src/core/contracts/index.ts index e8a6beb..5612f53 100644 --- a/packages/mcp/src/core/contracts/index.ts +++ b/packages/mcp/src/core/contracts/index.ts @@ -1,7 +1,21 @@ -export type { Branch, FileDiff, MergeResult } from './branch.js' -export type { ProviderCapabilities } from './capabilities.js' -export { LOCAL_CAPABILITIES } from './capabilities.js' -export type { FileChange } from './file-change.js' -export type { RepoProvider } from './provider.js' -export type { RepoReader } from './repo-reader.js' -export type { ApplyPlanInput, Commit, CommitAuthor, RepoWriter } from './repo-writer.js' +// ─── RepoProvider contracts ─── +// +// The canonical definitions live in `@contentrain/types/provider` so +// third-party tools can implement a custom provider without depending on +// @contentrain/mcp. This barrel re-exports them for MCP's internal +// imports and for consumers who have been using +// `@contentrain/mcp/core/contracts` directly. +export type { + ApplyPlanInput, + Branch, + Commit, + CommitAuthor, + FileChange, + FileDiff, + MergeResult, + ProviderCapabilities, + RepoProvider, + RepoReader, + RepoWriter, +} from '@contentrain/types' +export { LOCAL_CAPABILITIES } from '@contentrain/types' diff --git a/packages/mcp/src/core/contracts/provider.ts b/packages/mcp/src/core/contracts/provider.ts deleted file mode 100644 index d15753b..0000000 --- a/packages/mcp/src/core/contracts/provider.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Branch, FileDiff, MergeResult } from './branch.js' -import type { ProviderCapabilities } from './capabilities.js' -import type { RepoReader } from './repo-reader.js' -import type { RepoWriter } from './repo-writer.js' - -/** - * A content repository provider — the unified surface that MCP tools (Phase 2+) - * drive. Implementations wrap a git backend: - * - * - `LocalProvider` (Phase 3) — simple-git + temp worktree + selective sync - * - `GitHubProvider` (Phase 5) — Octokit Git Data API (no clone) - * - `GitLabProvider` (Phase 8) — gitbeaker REST client - * - `BitbucketProvider` (Phase 8) — Bitbucket REST v2 - * - * Providers are commodity and all live in MIT — see `.internal/refactor/00-principles.md`. - */ -export interface RepoProvider extends RepoReader, RepoWriter { - readonly capabilities: ProviderCapabilities - - listBranches(prefix?: string): Promise - createBranch(name: string, fromRef?: string): Promise - deleteBranch(name: string): Promise - getBranchDiff(branch: string, base?: string): Promise - mergeBranch(branch: string, into: string): Promise - isMerged(branch: string, into?: string): Promise - getDefaultBranch(): Promise -} diff --git a/packages/mcp/src/core/contracts/repo-reader.ts b/packages/mcp/src/core/contracts/repo-reader.ts deleted file mode 100644 index 4802c81..0000000 --- a/packages/mcp/src/core/contracts/repo-reader.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Read-only interface to a content repository. - * - * Paths are relative to the repository's content root (e.g. `.contentrain/config.json`). - * The `ref` parameter is a branch name, tag, or commit SHA. Providers that - * operate on a single working tree (LocalReader) ignore `ref`; API-backed - * providers use it to resolve the correct revision. - * - * `readFile` and `listDirectory` deliberately have different error semantics: - * - `readFile` THROWS when the file is missing so callers must opt into - * tolerance explicitly (typically with a try/catch returning a default). - * - `listDirectory` returns `[]` for a missing directory because the empty - * case is the common, uninteresting one. - */ -export interface RepoReader { - /** - * Read a file's contents as UTF-8. - * @throws when the file does not exist or cannot be read. - */ - readFile(path: string, ref?: string): Promise - - /** - * List file and directory names directly under `path`. Does not recurse. - * Returns an empty array when the directory does not exist. - */ - listDirectory(path: string, ref?: string): Promise - - /** Check whether a file or directory exists at `path`. */ - fileExists(path: string, ref?: string): Promise -} diff --git a/packages/mcp/src/core/contracts/repo-writer.ts b/packages/mcp/src/core/contracts/repo-writer.ts deleted file mode 100644 index 724f0c8..0000000 --- a/packages/mcp/src/core/contracts/repo-writer.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { FileChange } from './file-change.js' - -export interface CommitAuthor { - name: string - email: string -} - -export interface Commit { - sha: string - message: string - author: CommitAuthor - timestamp: string -} - -/** - * Input to `applyPlan`. Represents a single atomic commit: all changes land - * in one commit on `branch`, created from `base` if it does not yet exist. - */ -export interface ApplyPlanInput { - /** Branch name to commit to. Created from `base` if missing. */ - branch: string - /** File additions, modifications and deletions to apply in a single commit. */ - changes: FileChange[] - /** Commit message. */ - message: string - /** Commit author. */ - author: CommitAuthor - /** Optional base branch. Defaults to provider's content-tracking branch. */ - base?: string -} - -/** - * Write-side interface. Providers implement this to persist a set of file - * changes as a single atomic commit. LocalProvider writes through a worktree - * and `git commit`; API-backed providers post to the Git Data API or equivalent. - */ -export interface RepoWriter { - applyPlan(input: ApplyPlanInput): Promise -} diff --git a/packages/mcp/src/core/overlay-reader.ts b/packages/mcp/src/core/overlay-reader.ts new file mode 100644 index 0000000..483ac3f --- /dev/null +++ b/packages/mcp/src/core/overlay-reader.ts @@ -0,0 +1,97 @@ +import type { FileChange, RepoReader } from './contracts/index.js' + +/** + * A RepoReader that overlays a set of pending FileChanges on top of an + * underlying reader. Used in the remote-provider write path so helpers + * like {@link import('./context.js').buildContextChange} and + * {@link import('./validator/project.js').validateProject} see the + * post-change state — the state the pending commit is about to produce + * — rather than the pre-change base branch. + * + * Semantics: + * + * - `readFile(path)` — returns pending `content` when the overlay maps + * the path; falls back to the base reader otherwise. A pending delete + * (`content: null`) surfaces as "missing" (throws, matching + * `RepoReader.readFile`'s missing-file contract). + * + * - `listDirectory(path)` — merges the base directory listing with + * pending additions that live directly in the same folder, removes + * entries whose pending change is a delete, and de-duplicates the + * result. Pending paths in nested subdirectories surface only at + * their own listings. + * + * - `fileExists(path)` — pending adds → `true`, pending deletes → + * `false`, otherwise delegates. + * + * The overlay keys are canonicalised to match the FileChange contract: + * forward slashes, no leading `/`, no `..` segments (FileChanges are + * required to respect these invariants). + */ +export class OverlayReader implements RepoReader { + private readonly overlay: Map + + constructor( + private readonly base: RepoReader, + pendingChanges: FileChange[], + ) { + this.overlay = new Map() + for (const change of pendingChanges) { + this.overlay.set(normalise(change.path), change) + } + } + + async readFile(path: string, ref?: string): Promise { + const key = normalise(path) + const pending = this.overlay.get(key) + if (pending) { + if (pending.content === null) { + throw new Error(`OverlayReader: "${path}" is marked for deletion`) + } + return pending.content + } + return this.base.readFile(path, ref) + } + + async listDirectory(path: string, ref?: string): Promise { + const baseEntries = await this.base.listDirectory(path, ref) + const dirKey = normalise(path) + const prefix = dirKey === '' ? '' : `${dirKey}/` + + const direct = [...this.overlay.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([key, change]) => ({ + name: key.slice(prefix.length).split('/')[0] ?? '', + isNested: key.slice(prefix.length).includes('/'), + deleted: change.content === null, + })) + .filter(entry => entry.name.length > 0) + + // For nested pending paths (e.g. overlay at `dir/sub/a.json` when + // listing `dir`), the immediate child directory `sub` must surface + // even though no pending change targets it directly. + const deleted = new Set( + direct.filter(e => e.deleted && !e.isNested).map(e => e.name), + ) + const added = direct + .filter(e => !e.deleted) + .map(e => e.name) + + const result = baseEntries.filter(n => !deleted.has(n)) + for (const name of added) { + if (!result.includes(name)) result.push(name) + } + return result + } + + async fileExists(path: string, ref?: string): Promise { + const key = normalise(path) + const pending = this.overlay.get(key) + if (pending) return pending.content !== null + return this.base.fileExists(path, ref) + } +} + +function normalise(path: string): string { + return path.replace(/^\/+/, '') +} diff --git a/packages/mcp/src/providers/github/apply-plan.ts b/packages/mcp/src/providers/github/apply-plan.ts index 6226236..cb870c0 100644 --- a/packages/mcp/src/providers/github/apply-plan.ts +++ b/packages/mcp/src/providers/github/apply-plan.ts @@ -1,6 +1,6 @@ import type { ApplyPlanInput, Commit, FileChange } from '../../core/contracts/index.js' +import { isNotFoundError, resolveRepoPath } from '../shared/index.js' import type { GitHubClient } from './client.js' -import { resolveRepoPath } from './paths.js' import type { RepoRef } from './types.js' interface TreeEntry { @@ -127,7 +127,7 @@ async function resolveBaseSha( }) return { baseSha: ref.data.object.sha, branchExists: true } } catch (error) { - if (!isNotFound(error)) throw error + if (!isNotFoundError(error)) throw error } const baseRefName = base ?? (await client.rest.repos.get({ @@ -142,7 +142,3 @@ async function resolveBaseSha( }) return { baseSha: baseRef.data.object.sha, branchExists: false } } - -function isNotFound(error: unknown): boolean { - return typeof error === 'object' && error !== null && (error as { status?: number }).status === 404 -} diff --git a/packages/mcp/src/providers/github/paths.ts b/packages/mcp/src/providers/github/paths.ts deleted file mode 100644 index 4b66624..0000000 --- a/packages/mcp/src/providers/github/paths.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Normalise an optional contentRoot — strip leading/trailing slashes, - * treat `''`, `/` and `undefined` as "no prefix". - */ -export function normaliseContentRoot(raw?: string): string { - if (!raw || raw === '/' || raw === '') return '' - return raw.replace(/^\/+|\/+$/g, '') -} - -/** - * Resolve a content-root-relative path to a repo-relative path. The - * provider always stores paths in forward-slash, no leading slash form - * because that is what the GitHub Git Data and Repos APIs consume. - */ -export function resolveRepoPath(contentRoot: string | undefined, relativePath: string): string { - const prefix = normaliseContentRoot(contentRoot) - const cleanPath = relativePath.replace(/^\/+/, '') - return prefix ? `${prefix}/${cleanPath}` : cleanPath -} diff --git a/packages/mcp/src/providers/github/reader.ts b/packages/mcp/src/providers/github/reader.ts index 04423a0..de20ea1 100644 --- a/packages/mcp/src/providers/github/reader.ts +++ b/packages/mcp/src/providers/github/reader.ts @@ -1,6 +1,6 @@ import type { RepoReader } from '../../core/contracts/index.js' +import { isNotFoundError, resolveRepoPath } from '../shared/index.js' import type { GitHubClient } from './client.js' -import { resolveRepoPath } from './paths.js' import type { RepoRef } from './types.js' /** @@ -70,7 +70,7 @@ export class GitHubReader implements RepoReader { if (!Array.isArray(data)) return [] return data.map(entry => entry.name) } catch (error) { - if (isNotFound(error)) return [] + if (isNotFoundError(error)) return [] throw error } } @@ -86,12 +86,8 @@ export class GitHubReader implements RepoReader { }) return true } catch (error) { - if (isNotFound(error)) return false + if (isNotFoundError(error)) return false throw error } } } - -function isNotFound(error: unknown): boolean { - return typeof error === 'object' && error !== null && (error as { status?: number }).status === 404 -} diff --git a/packages/mcp/src/providers/gitlab/apply-plan.ts b/packages/mcp/src/providers/gitlab/apply-plan.ts index 71e6c51..57c2752 100644 --- a/packages/mcp/src/providers/gitlab/apply-plan.ts +++ b/packages/mcp/src/providers/gitlab/apply-plan.ts @@ -1,6 +1,6 @@ import type { ApplyPlanInput, Commit, FileChange } from '../../core/contracts/index.js' +import { isNotFoundError, resolveRepoPath } from '../shared/index.js' import type { GitLabClient } from './client.js' -import { resolveRepoPath } from './paths.js' import type { ProjectRef } from './types.js' type CommitActionType = 'create' | 'update' | 'delete' @@ -128,7 +128,7 @@ async function branchHasRef( await client.Branches.show(project.projectId, branch) return true } catch (error) { - if (isNotFound(error)) return false + if (isNotFoundError(error)) return false throw error } } @@ -143,7 +143,7 @@ async function fileExistsAtRef( await client.RepositoryFiles.show(project.projectId, filePath, ref) return true } catch (error) { - if (isNotFound(error)) return false + if (isNotFoundError(error)) return false throw error } } @@ -163,11 +163,3 @@ function toIsoTimestamp(raw: unknown): string | null { return date.toISOString() } -function isNotFound(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false - const err = error as { cause?: { response?: { status?: number } }, description?: unknown } - const status = err.cause?.response?.status - if (status === 404) return true - const description = typeof err.description === 'string' ? err.description : '' - return /not found/i.test(description) -} diff --git a/packages/mcp/src/providers/gitlab/reader.ts b/packages/mcp/src/providers/gitlab/reader.ts index 004b106..df484ed 100644 --- a/packages/mcp/src/providers/gitlab/reader.ts +++ b/packages/mcp/src/providers/gitlab/reader.ts @@ -1,6 +1,6 @@ import type { RepoReader } from '../../core/contracts/index.js' +import { isNotFoundError, resolveRepoPath } from '../shared/index.js' import type { GitLabClient } from './client.js' -import { resolveRepoPath } from './paths.js' import type { ProjectRef } from './types.js' /** @@ -52,7 +52,7 @@ export class GitLabReader implements RepoReader { ) return Array.isArray(entries) ? entries.map(e => e.name) : [] } catch (error) { - if (isNotFound(error)) return [] + if (isNotFoundError(error)) return [] throw error } } @@ -70,7 +70,7 @@ export class GitLabReader implements RepoReader { ) return true } catch (error) { - if (!isNotFound(error)) throw error + if (!isNotFoundError(error)) throw error } // 2. Fall back to a tree listing — directories and empty dirs show @@ -82,7 +82,7 @@ export class GitLabReader implements RepoReader { ) return Array.isArray(entries) && entries.length > 0 } catch (error) { - if (isNotFound(error)) return false + if (isNotFoundError(error)) return false throw error } } @@ -93,18 +93,3 @@ export class GitLabReader implements RepoReader { } } -/** - * Gitbeaker surfaces HTTP errors as a plain `Error` whose `.cause` - * includes `response.status`. 404 semantics are important enough to - * justify this dedicated check — we collapse them into "missing" - * instead of bubbling up. - */ -function isNotFound(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false - const err = error as { cause?: { response?: { status?: number } }, description?: unknown } - const status = err.cause?.response?.status - if (status === 404) return true - // Fallback — some gitbeaker versions expose `description` with "Not Found". - const description = typeof err.description === 'string' ? err.description : '' - return /not found/i.test(description) -} diff --git a/packages/mcp/src/providers/shared/errors.ts b/packages/mcp/src/providers/shared/errors.ts new file mode 100644 index 0000000..622011e --- /dev/null +++ b/packages/mcp/src/providers/shared/errors.ts @@ -0,0 +1,25 @@ +/** + * Unified "is this a 404?" helper for API-backed providers. + * + * Two common error shapes in the provider SDKs we use: + * + * - **Octokit** (`@octokit/rest`) — rejects with an `Error` that has a + * top-level `.status` number set to the HTTP status code. + * - **Gitbeaker** (`@gitbeaker/rest`) — rejects with a plain `Error` whose + * `.cause` includes `{ response: { status } }`. + * + * Both forms converge on `404` meaning "resource missing", so we check + * both shapes strictly. We deliberately do NOT fall back to substring + * matching on the error message — that leniency can silently mask other + * 404-like errors (forbidden repo, deleted project, rate limits) and + * produce the wrong answer. If either SDK ever stops populating the + * status field on a legitimate 404, the regression will surface in tests + * rather than being papered over at the reader layer. + */ +export function isNotFoundError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const direct = (error as { status?: number }).status + if (direct === 404) return true + const nested = (error as { cause?: { response?: { status?: number } } }).cause?.response?.status + return nested === 404 +} diff --git a/packages/mcp/src/providers/shared/index.ts b/packages/mcp/src/providers/shared/index.ts new file mode 100644 index 0000000..5de16c6 --- /dev/null +++ b/packages/mcp/src/providers/shared/index.ts @@ -0,0 +1,2 @@ +export { isNotFoundError } from './errors.js' +export { normaliseContentRoot, resolveRepoPath } from './paths.js' diff --git a/packages/mcp/src/providers/gitlab/paths.ts b/packages/mcp/src/providers/shared/paths.ts similarity index 55% rename from packages/mcp/src/providers/gitlab/paths.ts rename to packages/mcp/src/providers/shared/paths.ts index 37d15fb..4e3f6e4 100644 --- a/packages/mcp/src/providers/gitlab/paths.ts +++ b/packages/mcp/src/providers/shared/paths.ts @@ -1,8 +1,9 @@ /** * Normalise an optional contentRoot — strip leading/trailing slashes, - * treat `''`, `/` and `undefined` as "no prefix". Mirrors the GitHub - * provider helper; kept in this package so the GitLab implementation - * is self-contained. + * treat `''`, `/` and `undefined` as "no prefix". Used by API-backed + * providers (GitHub, GitLab, future Bitbucket) to anchor content-relative + * paths against a repo subdirectory when Contentrain lives under a + * monorepo path like `apps/web/.contentrain/`. */ export function normaliseContentRoot(raw?: string): string { if (!raw || raw === '/' || raw === '') return '' @@ -10,10 +11,10 @@ export function normaliseContentRoot(raw?: string): string { } /** - * Resolve a content-root-relative path to a repo-relative path. Paths - * always use forward slashes and never lead with `/`, because that is - * what the GitLab REST API consumes for `file_path` and `path` query - * parameters. + * Resolve a content-root-relative path to a repo-relative path. The result + * always uses forward slashes and has no leading slash — the form every + * REST git API consumes for `file_path` / `path` query parameters and the + * Git Data API tree entries. */ export function resolveRepoPath(contentRoot: string | undefined, relativePath: string): string { const prefix = normaliseContentRoot(contentRoot) diff --git a/packages/mcp/src/tools/commit-plan.ts b/packages/mcp/src/tools/commit-plan.ts new file mode 100644 index 0000000..42deede --- /dev/null +++ b/packages/mcp/src/tools/commit-plan.ts @@ -0,0 +1,102 @@ +import { CONTENTRAIN_BRANCH } from '@contentrain/types' +import type { FileChange } from '../core/contracts/index.js' +import { buildContextChange } from '../core/context.js' +import { OverlayReader } from '../core/overlay-reader.js' +import { LocalProvider } from '../providers/local/index.js' +import type { ToolProvider } from '../server.js' + +/** + * Context payload written into `.contentrain/context.json` as part of the + * same commit. Kept as a loose object so tool-specific payloads can add + * optional fields (`locale`, `entries`) without churning the helper's + * signature. + */ +export interface CommitContextPayload { + tool: string + model: string + locale?: string + entries?: string[] +} + +export interface CommitThroughProviderInput { + branch: string + changes: FileChange[] + message: string + contextPayload: CommitContextPayload +} + +export interface CommitThroughProviderResult { + commitSha: string + workflowAction: 'auto-merged' | 'pending-review' + sync?: unknown +} + +/** + * Commit a plan's changes through whichever provider is wired into the + * tool handler. Encapsulates the LocalProvider vs remote RepoProvider + * dispatch that every write-side tool (content_save, content_delete, + * model_save, model_delete) used to inline: + * + * - **LocalProvider** — goes through the worktree-backed transaction. + * `context` payload is threaded as an extra write-through and the + * transaction layer decides `auto-merged` vs `pending-review` based on + * the project's configured workflow. Selective-sync result is surfaced + * to the caller via `sync`. + * + * - **Any other RepoProvider** — the context.json write becomes an extra + * `FileChange` bundled into the plan so the whole commit lands + * atomically through the generic `RepoWriter.applyPlan`. Remote flows + * always report `pending-review`; Studio (or whatever orchestrator is + * driving the server) owns the merge. + * + * The return shape is deliberately uniform so callers don't have to + * branch on provider type again. + */ +export async function commitThroughProvider( + provider: ToolProvider, + input: CommitThroughProviderInput, +): Promise { + const { branch, changes, message, contextPayload } = input + + if (provider instanceof LocalProvider) { + const result = await provider.applyPlan({ + branch, + changes, + message, + context: contextPayload, + }) + return { + commitSha: result.sha, + workflowAction: result.workflowAction, + sync: result.sync, + } + } + + // Build context.json against an overlay of the pending FileChanges so + // `stats.entries` / `stats.models` reflect the state *after* this + // commit lands, not the pre-change base branch. Without the overlay + // the committed context.json would be stale — a new entry added in + // this commit would not appear in the entry count until the next + // write. + const overlay = new OverlayReader(provider, changes) + const contextChange = await buildContextChange(overlay, contextPayload) + const allChanges = [...changes, contextChange] + .toSorted((a, b) => a.path.localeCompare(b.path)) + // Feature branches ALWAYS fork from the `contentrain` branch — that's + // the single source of truth the local transaction flow enforces, and + // the remote flow must match it so Studio's cr/* → contentrain → + // defaultBranch model stays consistent. `config.repository.default_branch` + // names the repo's primary branch (main / master / trunk) — that is + // NOT the content tracking branch, only the downstream target. + const commit = await provider.applyPlan({ + branch, + changes: allChanges, + message, + author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, + base: CONTENTRAIN_BRANCH, + }) + return { + commitSha: commit.sha, + workflowAction: 'pending-review', + } +} diff --git a/packages/mcp/src/tools/content.ts b/packages/mcp/src/tools/content.ts index 2abebfc..acdecdc 100644 --- a/packages/mcp/src/tools/content.ts +++ b/packages/mcp/src/tools/content.ts @@ -1,7 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import type { ToolProvider } from '../server.js' -import { buildContextChange } from '../core/context.js' import { readConfig, readVocabulary } from '../core/config.js' import { readModel } from '../core/model-manager.js' import { listContent } from '../core/content-manager.js' @@ -10,8 +9,9 @@ import { LocalProvider } from '../providers/local/index.js' import { buildBranchName } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' import { validateProject } from '../core/validator/index.js' +import { OverlayReader } from '../core/overlay-reader.js' import { TOOL_ANNOTATIONS } from './annotations.js' -import { capabilityError } from './guards.js' +import { commitThroughProvider } from './commit-plan.js' export function registerContentTools( server: McpServer, @@ -153,34 +153,15 @@ export function registerContentTools( let sync: unknown try { - if (provider instanceof LocalProvider) { - // Legacy flow — preserves selective sync + auto-merge workflow. - const result = await provider.applyPlan({ - branch, - changes: plan.changes, - message, - context: contextPayload, - }) - commitSha = result.sha - workflowAction = result.workflowAction - sync = result.sync - } else { - // Remote provider — bundle context as a FileChange and commit via - // the generic RepoWriter interface. Remote writes are always - // pending-review; Studio (or the caller) orchestrates the merge. - const contextChange = await buildContextChange(provider, contextPayload) - const allChanges = [...plan.changes, contextChange] - .toSorted((a, b) => a.path.localeCompare(b.path)) - const commit = await provider.applyPlan({ - branch, - changes: allChanges, - message, - author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, - base: config.repository?.default_branch ?? 'contentrain', - }) - commitSha = commit.sha - workflowAction = 'pending-review' - } + const result = await commitThroughProvider(provider, { + branch, + changes: plan.changes, + message, + contextPayload, + }) + commitSha = result.commitSha + workflowAction = result.workflowAction + sync = result.sync } catch (error) { return { content: [{ type: 'text' as const, text: JSON.stringify({ @@ -191,12 +172,15 @@ export function registerContentTools( } // Post-save validation — runs against whichever provider backed the - // write. LocalProvider gets filesystem parity by walking `projectRoot`; - // remote providers (GitHubProvider et al.) walk their read surface via - // the shared `RepoReader` so the validation envelope matches. + // write. LocalProvider sees the post-commit state via its + // filesystem (the worktree transaction has already landed the + // files). Remote providers see the pre-commit state in their + // reader; wrap the reader in an OverlayReader so the validator + // evaluates the committed-but-not-yet-visible state instead of + // the pre-change base branch. const validationResult = projectRoot ? await validateProject(projectRoot, { model: input.model }) - : await validateProject(provider, { model: input.model }) + : await validateProject(new OverlayReader(provider, plan.changes), { model: input.model }) const allAdvisories = plan.result.flatMap(r => r.advisories ?? []) @@ -302,30 +286,15 @@ export function registerContentTools( let sync: unknown try { - if (provider instanceof LocalProvider) { - const result = await provider.applyPlan({ - branch, - changes: deletePlan.changes, - message, - context: contextPayload, - }) - commitSha = result.sha - workflowAction = result.workflowAction - sync = result.sync - } else { - const contextChange = await buildContextChange(provider, contextPayload) - const allChanges = [...deletePlan.changes, contextChange] - .toSorted((a, b) => a.path.localeCompare(b.path)) - const commit = await provider.applyPlan({ - branch, - changes: allChanges, - message, - author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, - base: config.repository?.default_branch ?? 'contentrain', - }) - commitSha = commit.sha - workflowAction = 'pending-review' - } + const result = await commitThroughProvider(provider, { + branch, + changes: deletePlan.changes, + message, + contextPayload, + }) + commitSha = result.commitSha + workflowAction = result.workflowAction + sync = result.sync return { content: [{ type: 'text' as const, text: JSON.stringify({ @@ -362,8 +331,7 @@ export function registerContentTools( }, TOOL_ANNOTATIONS['contentrain_content_list']!, async (input) => { - if (!projectRoot) return capabilityError('contentrain_content_list', 'localWorktree') - const config = await readConfig(projectRoot) + const config = await readConfig(provider) if (!config) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Project not initialized.' }) }], @@ -371,7 +339,7 @@ export function registerContentTools( } } - const model = await readModel(projectRoot, input.model) + const model = await readModel(provider, input.model) if (!model) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Model "${input.model}" not found` }) }], @@ -380,13 +348,20 @@ export function registerContentTools( } try { - const result = await listContent(projectRoot, model, { + // LocalProvider path keeps the legacy filesystem implementation + // (with full `resolve:true` relation hydration). Remote providers + // get the reader-based path — `resolve:true` is rejected there + // because cross-model relation walks need local disk today. + const listOpts = { locale: input.locale, filter: input.filter as Record, resolve: input.resolve, limit: input.limit, offset: input.offset, - }, config) + } + const result = projectRoot + ? await listContent(projectRoot, model, listOpts, config) + : await listContent(provider, model, listOpts, config) return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], diff --git a/packages/mcp/src/tools/context.ts b/packages/mcp/src/tools/context.ts index 1449b47..9c2173c 100644 --- a/packages/mcp/src/tools/context.ts +++ b/packages/mcp/src/tools/context.ts @@ -4,17 +4,17 @@ import type { ToolProvider } from '../server.js' import { readConfig, readVocabulary } from '../core/config.js' import { readContext } from '../core/context.js' import { countEntries, listModels, readModel } from '../core/model-manager.js' -import { resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy } from '../core/content-manager.js' +import { resolveLocaleStrategy } from '../core/content-manager.js' +import { contentDirPath, contentFilePath } from '../core/ops/paths.js' import { detectStack } from '../util/detect.js' import { join } from 'node:path' -import { contentrainDir, pathExists } from '../util/fs.js' +import { pathExists } from '../util/fs.js' import { checkBranchHealth, cleanupMergedBranches } from '../git/branch-lifecycle.js' import { TOOL_ANNOTATIONS } from './annotations.js' -import { capabilityError } from './guards.js' export function registerContextTools( server: McpServer, - _provider: ToolProvider, + provider: ToolProvider, projectRoot: string | undefined, ): void { // ─── contentrain_status ─── @@ -24,18 +24,19 @@ export function registerContextTools( {}, TOOL_ANNOTATIONS['contentrain_status']!, async () => { - if (!projectRoot) return capabilityError('contentrain_status', 'localWorktree') - const crDir = contentrainDir(projectRoot) - const initialized = await pathExists(join(crDir, 'config.json')) + // Read-only status works over any provider. Stack detection and + // branch health are projectRoot-only — they degrade gracefully + // when the session is driven by a remote provider. + const initialized = await provider.fileExists('.contentrain/config.json') if (!initialized) { - const detectedStack = await detectStack(projectRoot) + const detectedStack = projectRoot ? await detectStack(projectRoot) : null return { content: [{ type: 'text' as const, text: JSON.stringify({ initialized: false, - detected_stack: detectedStack, + ...(detectedStack ? { detected_stack: detectedStack } : {}), suggestion: 'Run contentrain_init to set up .contentrain/ structure', next_steps: ['Run contentrain_init'], }, null, 2), @@ -43,10 +44,10 @@ export function registerContextTools( } } - const config = await readConfig(projectRoot) - const models = await listModels(projectRoot) - const context = await readContext(projectRoot) - const vocabulary = await readVocabulary(projectRoot) + const config = await readConfig(provider) + const models = await listModels(provider) + const context = projectRoot ? await readContext(projectRoot) : null + const vocabulary = await readVocabulary(provider) const errors: string[] = [] if (!config) errors.push('.contentrain/config.json missing') @@ -70,26 +71,30 @@ export function registerContextTools( : { size: 0 }, } - // Branch lifecycle: lazy cleanup + health check (run BEFORE validation summary) - const hasGitRepo = await pathExists(join(projectRoot, '.git')) - if (hasGitRepo) { - try { - const cleanup = await cleanupMergedBranches(projectRoot) - const health = await checkBranchHealth(projectRoot) - result['branches'] = { - total: health.total, - merged: health.merged, - unmerged: health.unmerged, - cleaned_up: cleanup.deleted, + // Branch lifecycle: lazy cleanup + health check. Local-only, uses + // simple-git against the working tree. Skipped on remote providers + // where branch state is managed by Studio / the platform. + if (projectRoot) { + const hasGitRepo = await pathExists(join(projectRoot, '.git')) + if (hasGitRepo) { + try { + const cleanup = await cleanupMergedBranches(projectRoot) + const health = await checkBranchHealth(projectRoot) + result['branches'] = { + total: health.total, + merged: health.merged, + unmerged: health.unmerged, + cleaned_up: cleanup.deleted, + } + if (health.message) { + result['branch_warning'] = health.message + } + if (health.blocked) { + errors.push(health.message!) + } + } catch { + // Branch health check is best-effort — don't fail status } - if (health.message) { - result['branch_warning'] = health.message - } - if (health.blocked) { - errors.push(health.message!) - } - } catch { - // Branch health check is best-effort — don't fail status } } @@ -120,8 +125,7 @@ export function registerContextTools( }, TOOL_ANNOTATIONS['contentrain_describe']!, async ({ model: modelId, include_sample, locale }) => { - if (!projectRoot) return capabilityError('contentrain_describe', 'localWorktree') - const modelDef = await readModel(projectRoot, modelId) + const modelDef = await readModel(provider, modelId) if (!modelDef) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Model "${modelId}" not found` }) }], @@ -129,9 +133,9 @@ export function registerContextTools( } } - const config = await readConfig(projectRoot) + const config = await readConfig(provider) const effectiveLocale = locale ?? config?.locales.default ?? 'en' - const stats = await countEntries(projectRoot, modelDef) + const stats = await countEntries(provider, modelDef) const result: Record = { id: modelDef.id, @@ -145,7 +149,7 @@ export function registerContextTools( } if (include_sample) { - const sample = await getSample(projectRoot, modelDef, effectiveLocale) + const sample = await getSample(provider, modelDef, effectiveLocale) if (sample) result['sample'] = sample } @@ -155,7 +159,7 @@ export function registerContextTools( // Vocabulary hint for dictionary models if (modelDef.kind === 'dictionary') { - const vocabulary = await readVocabulary(projectRoot) + const vocabulary = await readVocabulary(provider) if (vocabulary && Object.keys(vocabulary.terms).length > 0) { result['vocabulary_hint'] = { note: 'Check these approved terms before creating new dictionary keys', @@ -311,52 +315,54 @@ export function registerContextTools( } async function getSample( - projectRoot: string, + reader: import('../core/contracts/index.js').RepoReader, model: import('@contentrain/types').ModelDefinition, locale: string, ): Promise { - const { readJson, readDir } = await import('../util/fs.js') - const cDir = resolveContentDir(projectRoot, model) + const cDir = contentDirPath(model) if (model.kind === 'collection') { - const data = await readJson>>(resolveJsonFilePath(cDir, model, locale)) + const data = await tryReadJson>>( + reader, + contentFilePath(model, locale), + ) if (!data) return null const firstKey = Object.keys(data)[0] return firstKey ? { id: firstKey, ...data[firstKey] } : null } if (model.kind === 'singleton' || model.kind === 'dictionary') { - return readJson(resolveJsonFilePath(cDir, model, locale)) + return tryReadJson(reader, contentFilePath(model, locale)) } if (model.kind === 'document') { const strategy = resolveLocaleStrategy(model) if (!model.i18n) { - const entry = (await readDir(cDir)).find(item => item.endsWith('.md')) + const entry = (await reader.listDirectory(cDir)).find(item => item.endsWith('.md')) const slug = entry?.replace(/\.md$/u, '') return slug ? { slug, locale } : null } if (strategy === 'file') { - const slug = (await readDir(cDir))[0] + const slug = (await reader.listDirectory(cDir))[0] return slug ? { slug, locale } : null } if (strategy === 'suffix') { const suffix = `.${locale}.md` - const entry = (await readDir(cDir)).find(file => file.endsWith(suffix)) + const entry = (await reader.listDirectory(cDir)).find(file => file.endsWith(suffix)) const slug = entry?.slice(0, -suffix.length) return slug ? { slug, locale } : null } if (strategy === 'directory') { - const entry = (await readDir(join(cDir, locale))).find(item => item.endsWith('.md')) + const entry = (await reader.listDirectory(`${cDir}/${locale}`)).find(item => item.endsWith('.md')) const slug = entry?.replace(/\.md$/u, '') return slug ? { slug, locale } : null } - const entry = (await readDir(cDir)).find(item => item.endsWith('.md')) + const entry = (await reader.listDirectory(cDir)).find(item => item.endsWith('.md')) const slug = entry?.replace(/\.md$/u, '') return slug ? { slug, locale } : null } @@ -364,6 +370,17 @@ async function getSample( return null } +async function tryReadJson( + reader: import('../core/contracts/index.js').RepoReader, + path: string, +): Promise { + try { + return JSON.parse(await reader.readFile(path)) as T + } catch { + return null + } +} + function buildContentPath( model: import('@contentrain/types').ModelDefinition, locale: string, diff --git a/packages/mcp/src/tools/model.ts b/packages/mcp/src/tools/model.ts index 7603164..7de0f6f 100644 --- a/packages/mcp/src/tools/model.ts +++ b/packages/mcp/src/tools/model.ts @@ -3,7 +3,6 @@ import type { ModelDefinition } from '@contentrain/types' import { z } from 'zod' import type { ToolProvider } from '../server.js' import { readConfig } from '../core/config.js' -import { buildContextChange } from '../core/context.js' import { resolveContentDir, resolveJsonFilePath, resolveMdFilePath } from '../core/content-manager.js' import { checkReferences, readModel, validateModelDefinition, fieldDefZodSchema } from '../core/model-manager.js' @@ -12,6 +11,7 @@ import { LocalProvider } from '../providers/local/index.js' import { buildBranchName } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' import { TOOL_ANNOTATIONS } from './annotations.js' +import { commitThroughProvider } from './commit-plan.js' // Shared field definition schema — single source of truth with normalize extract const fieldDefSchema = fieldDefZodSchema @@ -100,30 +100,15 @@ export function registerModelTools( let sync: unknown try { - if (provider instanceof LocalProvider) { - const result = await provider.applyPlan({ - branch, - changes: savePlan.changes, - message, - context: contextPayload, - }) - commitSha = result.sha - workflowAction = result.workflowAction - sync = result.sync - } else { - const contextChange = await buildContextChange(provider, contextPayload) - const allChanges = [...savePlan.changes, contextChange] - .toSorted((a, b) => a.path.localeCompare(b.path)) - const commit = await provider.applyPlan({ - branch, - changes: allChanges, - message, - author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, - base: config.repository?.default_branch ?? 'contentrain', - }) - commitSha = commit.sha - workflowAction = 'pending-review' - } + const result = await commitThroughProvider(provider, { + branch, + changes: savePlan.changes, + message, + contextPayload, + }) + commitSha = result.commitSha + workflowAction = result.workflowAction + sync = result.sync } catch (error) { return { content: [{ type: 'text' as const, text: JSON.stringify({ @@ -238,30 +223,15 @@ export function registerModelTools( let sync: unknown try { - if (provider instanceof LocalProvider) { - const result = await provider.applyPlan({ - branch, - changes: deletePlan.changes, - message, - context: contextPayload, - }) - commitSha = result.sha - workflowAction = result.workflowAction - sync = result.sync - } else { - const contextChange = await buildContextChange(provider, contextPayload) - const allChanges = [...deletePlan.changes, contextChange] - .toSorted((a, b) => a.path.localeCompare(b.path)) - const commit = await provider.applyPlan({ - branch, - changes: allChanges, - message, - author: { name: 'Contentrain', email: 'mcp@contentrain.io' }, - base: config.repository?.default_branch ?? 'contentrain', - }) - commitSha = commit.sha - workflowAction = 'pending-review' - } + const result = await commitThroughProvider(provider, { + branch, + changes: deletePlan.changes, + message, + contextPayload, + }) + commitSha = result.commitSha + workflowAction = result.workflowAction + sync = result.sync return { content: [{ type: 'text' as const, text: JSON.stringify({ diff --git a/packages/mcp/src/tools/workflow.ts b/packages/mcp/src/tools/workflow.ts index dcb1d1a..7a44a3b 100644 --- a/packages/mcp/src/tools/workflow.ts +++ b/packages/mcp/src/tools/workflow.ts @@ -147,7 +147,13 @@ export function registerWorkflowTools( }, TOOL_ANNOTATIONS['contentrain_submit']!, async (input) => { - if (!projectRoot) return capabilityError('contentrain_submit', 'localWorktree') + // Submit pushes a branch to origin via simple-git — needs both a + // local worktree (to enumerate cr/* branches) and pushRemote + // (semantic capability name, even though the underlying code is + // simple-git rather than a provider call). + if (!provider.capabilities.localWorktree || !provider.capabilities.pushRemote || !projectRoot) { + return capabilityError('contentrain_submit', 'localWorktree') + } const config = await readConfig(projectRoot) if (!config) { return { @@ -292,7 +298,12 @@ export function registerWorkflowTools( }, TOOL_ANNOTATIONS['contentrain_merge']!, async (input) => { - if (!projectRoot) return capabilityError('contentrain_merge', 'localWorktree') + // Merge runs a local git transaction (worktree + update-ref + + // selective sync). Remote providers do not expose this today; use + // provider.mergeBranch directly from a Studio-style driver instead. + if (!provider.capabilities.localWorktree || !projectRoot) { + return capabilityError('contentrain_merge', 'localWorktree') + } const config = await readConfig(projectRoot) if (!config) { return { diff --git a/packages/mcp/tests/core/overlay-reader.test.ts b/packages/mcp/tests/core/overlay-reader.test.ts new file mode 100644 index 0000000..6a0bc87 --- /dev/null +++ b/packages/mcp/tests/core/overlay-reader.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RepoReader } from '../../src/core/contracts/index.js' +import { OverlayReader } from '../../src/core/overlay-reader.js' + +/** + * OverlayReader is the primitive that guarantees the remote write path + * commits a consistent context.json / validation result for the state + * the pending commit produces (not the state of the pre-change base + * branch). The tests cover the three surfaces — readFile, listDirectory, + * fileExists — against synthesized base readers. + */ + +function mockReader(overrides: Partial = {}): RepoReader { + return { + readFile: overrides.readFile ?? vi.fn(async () => { throw new Error('no read') }), + listDirectory: overrides.listDirectory ?? vi.fn(async () => []), + fileExists: overrides.fileExists ?? vi.fn(async () => false), + } +} + +describe('OverlayReader.readFile', () => { + it('returns pending content for an added path', async () => { + const base = mockReader() + const overlay = new OverlayReader(base, [ + { path: 'a.json', content: '{"pending":true}' }, + ]) + expect(await overlay.readFile('a.json')).toBe('{"pending":true}') + expect(base.readFile).not.toHaveBeenCalled() + }) + + it('falls through to base reader when path is not in overlay', async () => { + const readFile = vi.fn(async () => 'base content') + const overlay = new OverlayReader(mockReader({ readFile }), [ + { path: 'other.json', content: '{}' }, + ]) + expect(await overlay.readFile('a.json')).toBe('base content') + expect(readFile).toHaveBeenCalledWith('a.json', undefined) + }) + + it('throws for a path marked for deletion in the overlay', async () => { + const overlay = new OverlayReader(mockReader(), [ + { path: 'gone.json', content: null }, + ]) + await expect(overlay.readFile('gone.json')).rejects.toThrow(/marked for deletion/) + }) + + it('normalises leading slashes when matching overlay keys', async () => { + const overlay = new OverlayReader(mockReader(), [ + { path: 'a.json', content: 'X' }, + ]) + expect(await overlay.readFile('/a.json')).toBe('X') + }) +}) + +describe('OverlayReader.listDirectory', () => { + it('returns base entries plus pending adds in the same directory', async () => { + const listDirectory = vi.fn(async () => ['keep.json']) + const overlay = new OverlayReader(mockReader({ listDirectory }), [ + { path: 'dir/new.json', content: '{}' }, + ]) + const names = (await overlay.listDirectory('dir')).toSorted() + expect(names).toEqual(['keep.json', 'new.json']) + }) + + it('removes entries whose pending change is a delete', async () => { + const listDirectory = vi.fn(async () => ['keep.json', 'gone.json']) + const overlay = new OverlayReader(mockReader({ listDirectory }), [ + { path: 'dir/gone.json', content: null }, + ]) + expect(await overlay.listDirectory('dir')).toEqual(['keep.json']) + }) + + it('surfaces nested subdirectories when a pending change targets a file inside them', async () => { + const listDirectory = vi.fn(async () => []) + const overlay = new OverlayReader(mockReader({ listDirectory }), [ + { path: 'dir/sub/a.json', content: '{}' }, + ]) + expect(await overlay.listDirectory('dir')).toEqual(['sub']) + }) + + it('does not duplicate entries that exist both in base and overlay', async () => { + const listDirectory = vi.fn(async () => ['shared.json']) + const overlay = new OverlayReader(mockReader({ listDirectory }), [ + { path: 'dir/shared.json', content: '{"pending":true}' }, + ]) + expect(await overlay.listDirectory('dir')).toEqual(['shared.json']) + }) +}) + +describe('OverlayReader.fileExists', () => { + it('returns true for a pending add even when base reader returns false', async () => { + const base = mockReader({ fileExists: vi.fn(async () => false) }) + const overlay = new OverlayReader(base, [ + { path: 'a.json', content: '{}' }, + ]) + expect(await overlay.fileExists('a.json')).toBe(true) + }) + + it('returns false for a pending delete even when base reader returns true', async () => { + const base = mockReader({ fileExists: vi.fn(async () => true) }) + const overlay = new OverlayReader(base, [ + { path: 'gone.json', content: null }, + ]) + expect(await overlay.fileExists('gone.json')).toBe(false) + }) + + it('delegates to the base reader for paths not in overlay', async () => { + const fileExists = vi.fn(async () => true) + const overlay = new OverlayReader(mockReader({ fileExists }), []) + expect(await overlay.fileExists('any.json')).toBe(true) + expect(fileExists).toHaveBeenCalledWith('any.json', undefined) + }) +}) diff --git a/packages/mcp/tests/providers/local/reader.test.ts b/packages/mcp/tests/providers/local/reader.test.ts new file mode 100644 index 0000000..0b9d822 --- /dev/null +++ b/packages/mcp/tests/providers/local/reader.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { LocalReader } from '../../../src/providers/local/reader.js' + +/** + * LocalReader exercises the filesystem directly, so the tests materialise + * real files under `mkdtemp` rather than mocking node:fs. That keeps the + * suite honest about encoding, empty-dir semantics, and missing-path + * handling while staying fast. + */ + +let tmpRoot: string + +beforeEach(async () => { + tmpRoot = await mkdtemp(join(tmpdir(), 'cr-local-reader-')) +}) + +afterEach(async () => { + await rm(tmpRoot, { recursive: true, force: true }) +}) + +describe('LocalReader.readFile', () => { + it('reads a relative path as UTF-8', async () => { + await mkdir(join(tmpRoot, '.contentrain'), { recursive: true }) + await writeFile(join(tmpRoot, '.contentrain/config.json'), '{"a":1}\n') + const reader = new LocalReader(tmpRoot) + + const content = await reader.readFile('.contentrain/config.json') + expect(content).toBe('{"a":1}\n') + }) + + it('also accepts absolute paths via node:path/resolve', async () => { + const abs = join(tmpRoot, 'abs.txt') + await writeFile(abs, 'hello\n') + const reader = new LocalReader(tmpRoot) + + expect(await reader.readFile(abs)).toBe('hello\n') + }) + + it('throws when the file is missing — explicit tolerance required', async () => { + const reader = new LocalReader(tmpRoot) + await expect(reader.readFile('missing.json')).rejects.toThrow() + }) + + it('ignores the ref parameter (local worktree is the only revision)', async () => { + await writeFile(join(tmpRoot, 'a.txt'), 'A\n') + const reader = new LocalReader(tmpRoot) + // Same content regardless of ref. + expect(await reader.readFile('a.txt', 'contentrain')).toBe('A\n') + expect(await reader.readFile('a.txt', 'main')).toBe('A\n') + }) +}) + +describe('LocalReader.listDirectory', () => { + it('returns entry names for an existing directory', async () => { + await mkdir(join(tmpRoot, 'dir'), { recursive: true }) + await writeFile(join(tmpRoot, 'dir/a.json'), '{}') + await writeFile(join(tmpRoot, 'dir/b.md'), '# b') + const reader = new LocalReader(tmpRoot) + + const names = await reader.listDirectory('dir') + expect(names.toSorted()).toEqual(['a.json', 'b.md']) + }) + + it('returns [] for a missing directory', async () => { + const reader = new LocalReader(tmpRoot) + expect(await reader.listDirectory('nonexistent')).toEqual([]) + }) + + it('returns [] when the path is a file, not a directory', async () => { + await writeFile(join(tmpRoot, 'f.txt'), 'x') + const reader = new LocalReader(tmpRoot) + expect(await reader.listDirectory('f.txt')).toEqual([]) + }) +}) + +describe('LocalReader.fileExists', () => { + it('returns true for an existing file', async () => { + await writeFile(join(tmpRoot, 'x.json'), '{}') + const reader = new LocalReader(tmpRoot) + expect(await reader.fileExists('x.json')).toBe(true) + }) + + it('returns true for an existing directory', async () => { + await mkdir(join(tmpRoot, 'subdir'), { recursive: true }) + const reader = new LocalReader(tmpRoot) + expect(await reader.fileExists('subdir')).toBe(true) + }) + + it('returns false for a missing path', async () => { + const reader = new LocalReader(tmpRoot) + expect(await reader.fileExists('nope')).toBe(false) + }) + + it('returns true for a nested directory that exists', async () => { + await mkdir(join(tmpRoot, '.contentrain/content/blog/m'), { recursive: true }) + const reader = new LocalReader(tmpRoot) + expect(await reader.fileExists('.contentrain/content/blog/m')).toBe(true) + }) +}) diff --git a/packages/mcp/tests/server/fixtures/github-mock.ts b/packages/mcp/tests/server/fixtures/github-mock.ts new file mode 100644 index 0000000..4453941 --- /dev/null +++ b/packages/mcp/tests/server/fixtures/github-mock.ts @@ -0,0 +1,117 @@ +import { vi } from 'vitest' + +/** + * Minimal mock of the Octokit surface `GitHubProvider` actually touches. + * Shared across the HTTP E2E tests so each test case focuses on its + * specific tool behaviour rather than re-implementing the mock. + * + * Callers seed `filesOnHead` with `{ 'repo-relative/path': 'utf8 content' }` + * to control what `getContent` returns. Any path not in the map yields a + * 404. The `createTree` / `createCommit` payloads are captured so tests + * can assert on the tree entries, deleted paths (sha: null), parent SHA, + * and message. + */ + +export interface GitHubMockFixture { + client: unknown + capturedTree: () => { tree: Array<{ path: string, sha: string | null, mode: string, type: string }> } | undefined + capturedCommit: () => { message: string, parents: string[], tree: string } | undefined + createdBlobs: () => Array<{ content: string, encoding: string }> +} + +export function makeGitHubMock(filesOnHead: Record): GitHubMockFixture { + const createdBlobs: Array<{ content: string, encoding: string }> = [] + let capturedTree: { tree: Array<{ path: string, sha: string | null, mode: string, type: string }> } | undefined + let capturedCommit: { message: string, parents: string[], tree: string } | undefined + + const client = { + paginate: { + iterator: () => ({ + [Symbol.asyncIterator]() { + return { next: async () => ({ done: true, value: undefined as never }) } + }, + }), + }, + rest: { + repos: { + get: vi.fn().mockResolvedValue({ data: { default_branch: 'contentrain' } }), + async getContent({ path }: { path: string }) { + if (filesOnHead[path] !== undefined) { + return { + data: { + type: 'file', + encoding: 'base64', + content: Buffer.from(filesOnHead[path]!).toString('base64'), + size: filesOnHead[path]!.length, + sha: `sha-${path}`, + }, + } + } + // Directory listings — return the files whose full path starts + // with `{path}/` as `[{ name, type: 'file' }]`. + const prefix = `${path}/` + const children = Object.keys(filesOnHead) + .filter(p => p.startsWith(prefix)) + .map(p => p.slice(prefix.length).split('/')[0]!) + const unique = [...new Set(children)] + if (unique.length > 0) { + return { data: unique.map(name => ({ name, type: 'file' })) } + } + const err = Object.assign(new Error('Not Found'), { status: 404 }) + throw err + }, + }, + git: { + async getRef({ ref }: { ref: string }) { + if (ref === 'heads/contentrain') return { data: { object: { sha: 'base-sha' } } } + const err = Object.assign(new Error('Not Found'), { status: 404 }) + throw err + }, + async getCommit() { return { data: { tree: { sha: 'base-tree-sha' } } } }, + async createBlob({ content, encoding }: { content: string, encoding: string }) { + createdBlobs.push({ content, encoding }) + return { data: { sha: `blob-${createdBlobs.length}` } } + }, + async createTree(input: unknown) { + capturedTree = input as typeof capturedTree + return { data: { sha: 'new-tree-sha' } } + }, + async createCommit(input: unknown) { + capturedCommit = input as typeof capturedCommit + return { + data: { + sha: 'new-commit-sha', + message: (input as { message: string }).message, + author: { name: 'Contentrain', email: 'mcp@contentrain.io', date: '2026-04-17T12:00:00Z' }, + }, + } + }, + async createRef() { return {} }, + async updateRef() { return {} }, + }, + }, + } + + return { + client, + capturedTree: () => capturedTree, + capturedCommit: () => capturedCommit, + createdBlobs: () => createdBlobs, + } +} + +/** + * Standard Contentrain config used across E2E tests. Caller can override + * fields by spreading. + */ +export function makeConfig(overrides: Record = {}): Record { + return { + version: 1, + stack: 'vue-nuxt', + workflow: 'review', + locales: { default: 'en', supported: ['en', 'tr'] }, + domains: ['marketing'], + repository: { provider: 'github', owner: 'acme', name: 'site', default_branch: 'contentrain' }, + ...overrides, + } +} diff --git a/packages/mcp/tests/server/http.test.ts b/packages/mcp/tests/server/http.test.ts index 06d57af..59c112c 100644 --- a/packages/mcp/tests/server/http.test.ts +++ b/packages/mcp/tests/server/http.test.ts @@ -380,7 +380,242 @@ describe('startHttpMcpServer', () => { } }) - it('returns capability error for write tools when projectRoot is absent', async () => { + it('commits content_delete through a GitHubProvider-like remote provider', async () => { + const { makeGitHubMock, makeConfig } = await import('./fixtures/github-mock.js') + const { GitHubProvider } = await import('../../src/providers/github/index.js') + const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') + + const model = { + id: 'blog', + name: 'Blog', + kind: 'collection', + domain: 'marketing', + i18n: true, + fields: { title: { type: 'string', required: true } }, + } + const config = makeConfig() + const filesOnHead: Record = { + '.contentrain/config.json': JSON.stringify(config), + '.contentrain/models/blog.json': JSON.stringify(model), + '.contentrain/content/marketing/blog/en.json': JSON.stringify({ + abc123def456: { title: 'Hello' }, + }), + '.contentrain/meta/blog/en.json': JSON.stringify({ + abc123def456: { status: 'draft' }, + }), + } + const fixture = makeGitHubMock(filesOnHead) + + const provider = new GitHubProvider( + fixture.client as unknown as import('../../src/providers/github/client.js').GitHubClient, + { owner: 'acme', name: 'site' }, + ) + const handle = await startHttpMcpServerWith({ provider, port: 0 }) + try { + const mcpClient = new Client({ name: 'test-http-client', version: '1.0.0' }) + const transport = new StreamableHTTPClientTransport(new URL(handle.url)) + await mcpClient.connect(transport) + + try { + const result = await mcpClient.callTool({ + name: 'contentrain_content_delete', + arguments: { + model: 'blog', + id: 'abc123def456', + locale: 'en', + confirm: true, + }, + }) + const parsed = parseResult(result) + expect(parsed['status']).toBe('committed') + const git = parsed['git'] as Record + expect(git['action']).toBe('pending-review') + expect((git['branch'] as string).startsWith('cr/content/blog')).toBe(true) + + const tree = fixture.capturedTree()!.tree + const paths = tree.map(t => t.path) + expect(paths).toContain('.contentrain/context.json') + // The content entry is removed from the object-map; the file is + // rewritten (not deleted) because other entries may still live + // there in real flows. The meta file loses the corresponding + // entry too. + const contentEntry = tree.find(t => t.path === '.contentrain/content/marketing/blog/en.json') + expect(contentEntry).toBeDefined() + expect(contentEntry!.sha).not.toBeNull() + } finally { + await mcpClient.close() + } + } finally { + await handle.close() + } + }) + + it('commits model_save through a GitHubProvider-like remote provider', async () => { + const { makeGitHubMock, makeConfig } = await import('./fixtures/github-mock.js') + const { GitHubProvider } = await import('../../src/providers/github/index.js') + const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') + + const filesOnHead: Record = { + '.contentrain/config.json': JSON.stringify(makeConfig()), + } + const fixture = makeGitHubMock(filesOnHead) + + const provider = new GitHubProvider( + fixture.client as unknown as import('../../src/providers/github/client.js').GitHubClient, + { owner: 'acme', name: 'site' }, + ) + const handle = await startHttpMcpServerWith({ provider, port: 0 }) + try { + const mcpClient = new Client({ name: 'test-http-client', version: '1.0.0' }) + const transport = new StreamableHTTPClientTransport(new URL(handle.url)) + await mcpClient.connect(transport) + + try { + const result = await mcpClient.callTool({ + name: 'contentrain_model_save', + arguments: { + id: 'hero', + name: 'Hero', + kind: 'singleton', + domain: 'marketing', + i18n: true, + fields: { title: { type: 'string', required: true } }, + }, + }) + const parsed = parseResult(result) + expect(parsed['status']).toBe('committed') + expect(parsed['model']).toBe('hero') + const git = parsed['git'] as Record + expect(git['action']).toBe('pending-review') + expect((git['branch'] as string).startsWith('cr/model/hero')).toBe(true) + + const tree = fixture.capturedTree()!.tree + const paths = tree.map(t => t.path) + expect(paths).toContain('.contentrain/models/hero.json') + expect(paths).toContain('.contentrain/context.json') + } finally { + await mcpClient.close() + } + } finally { + await handle.close() + } + }) + + it('commits model_delete through a GitHubProvider-like remote provider', async () => { + const { makeGitHubMock, makeConfig } = await import('./fixtures/github-mock.js') + const { GitHubProvider } = await import('../../src/providers/github/index.js') + const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') + + const model = { + id: 'blog', + name: 'Blog', + kind: 'collection', + domain: 'marketing', + i18n: true, + fields: { title: { type: 'string', required: true } }, + } + const filesOnHead: Record = { + '.contentrain/config.json': JSON.stringify(makeConfig()), + '.contentrain/models/blog.json': JSON.stringify(model), + '.contentrain/content/marketing/blog/en.json': '{}', + } + const fixture = makeGitHubMock(filesOnHead) + + const provider = new GitHubProvider( + fixture.client as unknown as import('../../src/providers/github/client.js').GitHubClient, + { owner: 'acme', name: 'site' }, + ) + const handle = await startHttpMcpServerWith({ provider, port: 0 }) + try { + const mcpClient = new Client({ name: 'test-http-client', version: '1.0.0' }) + const transport = new StreamableHTTPClientTransport(new URL(handle.url)) + await mcpClient.connect(transport) + + try { + const result = await mcpClient.callTool({ + name: 'contentrain_model_delete', + arguments: { model: 'blog', confirm: true }, + }) + const parsed = parseResult(result) + expect(parsed['status']).toBe('committed') + expect(parsed['deleted']).toBe(true) + const git = parsed['git'] as Record + expect(git['action']).toBe('pending-review') + expect((git['branch'] as string).startsWith('cr/model/blog')).toBe(true) + + const tree = fixture.capturedTree()!.tree + const modelEntry = tree.find(t => t.path === '.contentrain/models/blog.json') + // Deletion is expressed as sha: null in the GitHub Git Data API. + expect(modelEntry).toBeDefined() + expect(modelEntry!.sha).toBeNull() + } finally { + await mcpClient.close() + } + } finally { + await handle.close() + } + }) + + it('runs contentrain_validate read-only over a GitHubProvider-like remote provider', async () => { + const { makeGitHubMock, makeConfig } = await import('./fixtures/github-mock.js') + const { GitHubProvider } = await import('../../src/providers/github/index.js') + const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') + + const model = { + id: 'blog', + name: 'Blog', + kind: 'collection', + domain: 'marketing', + i18n: true, + fields: { title: { type: 'string', required: true } }, + } + const filesOnHead: Record = { + '.contentrain/config.json': JSON.stringify(makeConfig()), + '.contentrain/models/blog.json': JSON.stringify(model), + '.contentrain/content/marketing/blog/en.json': JSON.stringify({ + abc123def456: { title: 'Hello' }, + }), + '.contentrain/content/marketing/blog/tr.json': JSON.stringify({ + abc123def456: { title: 'Merhaba' }, + }), + '.contentrain/meta/blog/en.json': JSON.stringify({ + abc123def456: { status: 'draft' }, + }), + '.contentrain/meta/blog/tr.json': JSON.stringify({ + abc123def456: { status: 'draft' }, + }), + } + const fixture = makeGitHubMock(filesOnHead) + + const provider = new GitHubProvider( + fixture.client as unknown as import('../../src/providers/github/client.js').GitHubClient, + { owner: 'acme', name: 'site' }, + ) + const handle = await startHttpMcpServerWith({ provider, port: 0 }) + try { + const mcpClient = new Client({ name: 'test-http-client', version: '1.0.0' }) + const transport = new StreamableHTTPClientTransport(new URL(handle.url)) + await mcpClient.connect(transport) + + try { + const result = await mcpClient.callTool({ + name: 'contentrain_validate', + arguments: {}, + }) + const parsed = parseResult(result) + expect(parsed['status']).toBe('validated') + expect(parsed).toHaveProperty('summary') + // Read-only: no commit was produced. + expect(fixture.capturedCommit()).toBeUndefined() + } finally { + await mcpClient.close() + } + } finally { + await handle.close() + } + }) + + it('returns capability error for local-only tools on a remote provider (submit requires localWorktree)', async () => { const readOnlyProvider = { capabilities: { localWorktree: false, @@ -404,8 +639,11 @@ describe('startHttpMcpServer', () => { await client.connect(transport) try { + // contentrain_submit ships feature branches to remote via simple-git + // — it truly needs a local worktree and rejects uniformly when the + // session's provider can't offer one. const result = await client.callTool({ - name: 'contentrain_status', + name: 'contentrain_submit', arguments: {}, }) const parsed = parseResult(result) @@ -418,4 +656,42 @@ describe('startHttpMcpServer', () => { await handle.close() } }) + + it('status works read-only over a remote provider (no localWorktree rejection)', async () => { + const { makeGitHubMock, makeConfig } = await import('./fixtures/github-mock.js') + const { GitHubProvider } = await import('../../src/providers/github/index.js') + const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') + + const filesOnHead: Record = { + '.contentrain/config.json': JSON.stringify(makeConfig()), + } + const fixture = makeGitHubMock(filesOnHead) + const provider = new GitHubProvider( + fixture.client as unknown as import('../../src/providers/github/client.js').GitHubClient, + { owner: 'acme', name: 'site' }, + ) + const handle = await startHttpMcpServerWith({ provider, port: 0 }) + try { + const mcpClient = new Client({ name: 'test-http-client', version: '1.0.0' }) + const transport = new StreamableHTTPClientTransport(new URL(handle.url)) + await mcpClient.connect(transport) + + try { + const result = await mcpClient.callTool({ + name: 'contentrain_status', + arguments: {}, + }) + const parsed = parseResult(result) + expect(parsed['initialized']).toBe(true) + // No capability_required key — status worked through the reader. + expect(parsed).not.toHaveProperty('capability_required') + // Branch health is local-only and is skipped for remote providers. + expect(parsed).not.toHaveProperty('branches') + } finally { + await mcpClient.close() + } + } finally { + await handle.close() + } + }) }) diff --git a/packages/rules/shared/mcp-usage.md b/packages/rules/shared/mcp-usage.md index a8c604d..f73955a 100644 --- a/packages/rules/shared/mcp-usage.md +++ b/packages/rules/shared/mcp-usage.md @@ -156,7 +156,8 @@ Each entry in the `entries` array has this shape: | Tool | Purpose | Parameters | |------|---------|------------| | `contentrain_validate` | Validate project content against model schemas | `model?`, `fix?` (bool) | -| `contentrain_submit` | Push contentrain/* branches to remote | `branches?` (string[]), `message?` | +| `contentrain_submit` | Push `cr/*` branches to the remote | `branches?` (string[]), `message?` | +| `contentrain_merge` | Merge a review-mode branch into `contentrain` and fast-forward the base | `branch`, `confirm: true` | ### 2.7 Bulk Tools @@ -184,9 +185,15 @@ Each entry in the `entries` array has this shape: #### contentrain_submit parameters -- `branches`: specific branch names to push (omit for all contentrain/* branches). +- `branches`: specific branch names to push (omit for all `cr/*` branches). - `message`: optional message for the push operation. +#### contentrain_merge parameters + +- `branch`: feature branch to merge (e.g. `cr/content/blog/...`). +- `confirm`: must be `true` to execute. +- Local-only: MCP runs the merge through a worktree + `update-ref` + selective sync. Use it when the workflow is `review` and the change has been approved. + --- ## 3. Calling Sequences (Pipelines) @@ -287,7 +294,7 @@ contentrain_apply(mode: "reuse", scope: {model: "model-id"}, dry_run: true) (pre - A dedicated `contentrain` branch is the single source of truth for content state, created at init and protected from deletion. - Every write operation creates a temporary worktree on a new feature branch forked from `contentrain`. -- Branch naming: `contentrain/{operation}/{model}/{timestamp}` (locale included when applicable). +- Branch naming: `cr/{operation}/{model}[/{locale}]/{timestamp}-{suffix}` (locale included when applicable; legacy `contentrain/*` branches are auto-migrated on first init). - You do not create branches manually. MCP handles Git transactions. - Developer's working tree is never mutated during MCP operations (no stash, no checkout, no merge on the developer's tree). - context.json is committed together with content changes, not as a separate commit. @@ -297,9 +304,35 @@ contentrain_apply(mode: "reuse", scope: {model: "model-id"}, dry_run: true) (pre ### 4.7 Branch Health - MCP enforces branch health limits: 50+ active branches triggers a warning, 80+ blocks new write operations. -- If blocked, merge or delete old `contentrain/*` branches before proceeding. +- If blocked, merge or delete old `cr/*` branches before proceeding. - `contentrain_status` reports branch health automatically. +### 4.8 Transport and Provider Capabilities + +MCP runs over three transports / provider combinations: **LocalProvider** (stdio or HTTP), **GitHubProvider** (HTTP only), and **GitLabProvider** (HTTP only). Each provider advertises a capability set that gates which tools are available. + +| Capability | Local | GitHub | GitLab | Tools affected | +|---|---|---|---|---| +| `localWorktree` | ✓ | — | — | `init`, `scaffold`, `validate --fix`, `submit`, `merge`, `bulk` | +| `sourceRead` | ✓ | — | — | `apply` (extract mode) | +| `sourceWrite` | ✓ | — | — | `apply` (reuse mode) | +| `astScan` | ✓ | — | — | `scan` | +| `pushRemote` | ✓ | ✓ | ✓ | `submit` | +| `branchProtection` | — | ✓ | ✓ | merge fallback detection | +| `pullRequestFallback` | — | ✓ | ✓ | merge fallback creation | + +When a tool is called on a provider that lacks the required capability, MCP returns a uniform error: + +```json +{ + "error": "contentrain_scan requires local filesystem access.", + "capability_required": "astScan", + "hint": "This tool is unavailable when MCP is driven by a remote provider. Use a LocalProvider or the stdio transport." +} +``` + +If the agent is driving a remote-only session (e.g. Studio-hosted MCP over HTTP + GitHubProvider), normalize and other local-only tools must run in a separate local checkout before content is pushed. + --- ## 5. Tool Details @@ -401,7 +434,8 @@ Keep suggestions brief and contextual. Do not repeat them if already mentioned. | `VALIDATION_FAILED` | Content does not match schema | Fix errors reported by `contentrain_validate`, then retry | | `REFERENCED_MODEL` | Attempting to delete a model referenced by others | Remove relation fields from referencing models first | | `LOCALE_MISMATCH` | Locale not in supported list | Check `config.locales.supported`, add locale or use a supported one | -| `BRANCH_BLOCKED` | Too many active contentrain/* branches (80+) | Merge or delete old branches before creating new ones | +| `BRANCH_BLOCKED` | Too many active `cr/*` branches (80+) | Merge or delete old branches before creating new ones | +| `capability_required` | Tool requires a capability the active provider does not expose (e.g. `astScan` on a GitHubProvider) | Switch to a LocalProvider session, or run the tool in a local checkout before returning to the remote flow | ### Rule: Always Check Status After Errors diff --git a/packages/rules/shared/normalize-rules.md b/packages/rules/shared/normalize-rules.md index a6fdf25..f4f438e 100644 --- a/packages/rules/shared/normalize-rules.md +++ b/packages/rules/shared/normalize-rules.md @@ -22,7 +22,7 @@ Each phase produces a separate branch for independent review. This separation en | Purpose | Pull content from source to `.contentrain/` | Patch source files with content references | | Scope | Full project scan | Per model or per domain | | Source files modified | No | Yes | -| Branch pattern | `contentrain/normalize/extract/{domain}/{timestamp}` | `contentrain/normalize/reuse/{model}/{locale}/{timestamp}` | +| Branch pattern | `cr/normalize/extract/{domain}/{timestamp}-{suffix}` | `cr/normalize/reuse/{model}/{locale}/{timestamp}-{suffix}` | | Prerequisite | Initialized `.contentrain/` | Completed extraction (content exists in `.contentrain/`) | | Workflow mode | Always `review` | Always `review` | | Standalone value | Yes -- content is manageable in Studio immediately | Depends on Phase 1 | diff --git a/packages/rules/shared/workflow-rules.md b/packages/rules/shared/workflow-rules.md index 7b0d581..7b6fa53 100644 --- a/packages/rules/shared/workflow-rules.md +++ b/packages/rules/shared/workflow-rules.md @@ -50,18 +50,19 @@ contentrain/{operation}/{model}/{locale}/{timestamp} | Scenario | Branch Name | |----------|-------------| -| Content update | `contentrain/content/blog-post/en/1710300000` | -| Model creation | `contentrain/model/team-member/1710300000` | -| Normalize extraction | `contentrain/normalize/extract/blog/1710300000` | -| Normalize reuse | `contentrain/normalize/reuse/marketing-hero/en/1710300000` | -| Scaffold | `contentrain/new/scaffold-landing/en/1710300000` | +| Content update | `cr/content/blog-post/en/1710300000-a1b2` | +| Model creation | `cr/model/team-member/1710300000-c3d4` | +| Normalize extraction | `cr/normalize/extract/blog/1710300000-e5f6` | +| Normalize reuse | `cr/normalize/reuse/marketing-hero/en/1710300000-0789` | +| Scaffold | `cr/new/scaffold-landing/en/1710300000-1234` | ### Rules - Branches are created automatically by MCP tools. Do NOT create them manually. -- The `{timestamp}` component ensures uniqueness. +- The `{timestamp}-{suffix}` component ensures uniqueness across concurrent writes. - `{locale}` is included when the operation is locale-specific. - `{model}` is included when the operation targets a specific model. +- Legacy `contentrain/*` branches from pre-`cr/*` installations are auto-migrated on first init. --- diff --git a/packages/skills/skills/contentrain-normalize/SKILL.md b/packages/skills/skills/contentrain-normalize/SKILL.md index 9c12d5d..391bec7 100644 --- a/packages/skills/skills/contentrain-normalize/SKILL.md +++ b/packages/skills/skills/contentrain-normalize/SKILL.md @@ -27,6 +27,30 @@ Phase 1 alone is valuable: content becomes manageable in Studio, translatable, a - MUST NOT patch `.contentrain/` files via reuse (content files are read-only for reuse) - MUST NOT exceed 100 patches per `contentrain_apply` call +## Transport Requirements + +Normalize (`contentrain_scan` and `contentrain_apply`) requires **local +disk access** — AST scanners walk the source tree and patch files in +place. It runs only on a `LocalProvider` (stdio transport, or an HTTP +transport configured with a `LocalProvider`). + +Remote providers (`GitHubProvider`, `GitLabProvider`, future +`BitbucketProvider`) expose `astScan: false`, `sourceRead: false`, and +`sourceWrite: false`. Calling these tools over a remote provider +returns a uniform capability error: + +```json +{ + "error": "contentrain_scan requires local filesystem access.", + "capability_required": "astScan", + "hint": "This tool is unavailable when MCP is driven by a remote provider. Use a LocalProvider or the stdio transport." +} +``` + +If the agent is driving a remote-only MCP session, normalize must run +in a separate local-checkout session before the extracted content +branch is pushed. + --- ## Two-Phase Architecture diff --git a/packages/skills/workflows/contentrain-normalize.md b/packages/skills/workflows/contentrain-normalize.md index ffd9d5d..2fb38c8 100644 --- a/packages/skills/workflows/contentrain-normalize.md +++ b/packages/skills/workflows/contentrain-normalize.md @@ -134,7 +134,7 @@ Do NOT proceed to apply unless the user approves (either via UI or explicit conf 4. **Detect the user's decision.** After directing the user to the UI, check the plan file to determine the outcome: - **Plan file exists** (`status: "pending"`) → user has not decided yet — wait - - **Plan file deleted + new `contentrain/normalize/extract/*` branch exists** → user approved, extraction applied + - **Plan file deleted + new `cr/normalize/extract/*` branch exists** → user approved, extraction applied - **Plan file deleted + no new branch** → user rejected the plan If rejected, ask the user what to change and iterate from Step 4 (re-evaluate candidates). @@ -161,7 +161,7 @@ After user approval (via UI or chat), call `contentrain_apply(mode: "extract", d Note: `dry_run` defaults to `true`, so you MUST explicitly set `dry_run: false` to execute. -This creates model definitions and content files in `.contentrain/` on a `contentrain/normalize/extract/{timestamp}` branch. Source files are NOT modified. +This creates model definitions and content files in `.contentrain/` on a `cr/normalize/extract/{timestamp}-{suffix}` branch. Source files are NOT modified. If approved via UI, the UI calls this automatically — no additional agent action needed. @@ -230,7 +230,7 @@ After user confirmation, call `contentrain_apply(mode: "reuse", scope: { model: Note: `dry_run` defaults to `true`, so you MUST explicitly set `dry_run: false` to execute. -This patches source files and creates a `contentrain/normalize/reuse/{model}/{timestamp}` branch. +This patches source files and creates a `cr/normalize/reuse/{model}/{timestamp}-{suffix}` branch. ### Step 5. Validate and Submit diff --git a/packages/skills/workflows/contentrain-translate.md b/packages/skills/workflows/contentrain-translate.md index 65b89e8..70eb5cf 100644 --- a/packages/skills/workflows/contentrain-translate.md +++ b/packages/skills/workflows/contentrain-translate.md @@ -154,7 +154,7 @@ If validation fails, fix issues and re-save. Call `contentrain_submit` to commit the translations: -- Branch: `contentrain/content/{model}/{targetLocale}/{timestamp}`. +- Branch: `cr/content/{model}/{targetLocale}/{timestamp}-{suffix}`. - Each locale can be submitted independently. ### 11. Final Summary diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7e9c068..67d5d24 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -79,7 +79,14 @@ export interface ContentrainConfig { stack: StackType workflow: WorkflowMode repository?: { - provider: 'github' + /** + * Git host backing this project's remote. Widens as new providers ship + * in @contentrain/mcp — `github` and `gitlab` are currently supported. + * The value is informational for tooling: the concrete provider the + * MCP server talks to is chosen at server construction time, not from + * this config. + */ + provider: 'github' | 'gitlab' owner: string name: string default_branch: string @@ -819,3 +826,23 @@ export function serializeMarkdownFrontmatter(data: Record, body } return lines.join('\n') } + +// ─── Repository provider contracts ─── +// +// Provider-agnostic engine contracts used by @contentrain/mcp. Exposed from +// @contentrain/types so third-party tools can implement a custom +// RepoProvider without taking a dependency on @contentrain/mcp. +export type { + ApplyPlanInput, + Branch, + Commit, + CommitAuthor, + FileChange, + FileDiff, + MergeResult, + ProviderCapabilities, + RepoProvider, + RepoReader, + RepoWriter, +} from './provider.js' +export { LOCAL_CAPABILITIES } from './provider.js' diff --git a/packages/types/src/provider.ts b/packages/types/src/provider.ts new file mode 100644 index 0000000..3937c70 --- /dev/null +++ b/packages/types/src/provider.ts @@ -0,0 +1,190 @@ +// ─── Repository Provider Contracts ─── +// +// Shared interfaces for the provider-agnostic content repository model used +// by @contentrain/mcp. They live in @contentrain/types so third-party tools +// can implement a custom RepoProvider (e.g. for a private git host, an +// internal service, a mock in a test suite) without depending on MCP +// internals. +// +// @contentrain/mcp re-exports every symbol here from +// @contentrain/mcp/core/contracts so existing consumers do not have to +// migrate imports. + +// ─── File change ─── + +/** + * A single file change within a plan. + * + * - `content: string` — write or overwrite the file with this UTF-8 content. + * - `content: null` — delete the file. + * + * Paths are content-root relative, use forward slashes, and must not contain + * `..` segments or absolute anchors. Providers are responsible for resolving + * paths against their backing store (worktree, git tree, etc.). + */ +export interface FileChange { + path: string + content: string | null +} + +// ─── Capabilities ─── + +/** + * Capabilities describe what a provider can and cannot do. Operations check + * required capabilities before running. Normalize extract needs `sourceRead`; + * normalize reuse needs `sourceWrite`; submit needs `pushRemote`; AST scans + * need `astScan` (which implies a local working tree). + * + * The consistent position is: all git hosts are commodity MIT providers; the + * distinction between providers is operational (how they read/write), not + * commercial. Enterprise features live in Studio, not in capability gates. + */ +export interface ProviderCapabilities { + /** Provider backs onto a local worktree and can selectively sync changes into the developer's working tree. */ + localWorktree: boolean + /** Provider can read arbitrary source files outside `.contentrain/`. Required for normalize extract. */ + sourceRead: boolean + /** Provider can write arbitrary source files outside `.contentrain/`. Required for normalize reuse. */ + sourceWrite: boolean + /** Provider can push commits to a remote. Required for submit. */ + pushRemote: boolean + /** Provider detects branch protection rules on the remote. */ + branchProtection: boolean + /** Provider can open a pull request as a merge fallback when branch protection blocks direct merge. */ + pullRequestFallback: boolean + /** Provider can execute AST scanners against source files. Implies local disk access. */ + astScan: boolean +} + +/** Capability set for the LocalProvider (simple-git + worktree). */ +export const LOCAL_CAPABILITIES: ProviderCapabilities = { + localWorktree: true, + sourceRead: true, + sourceWrite: true, + pushRemote: true, + branchProtection: false, + pullRequestFallback: false, + astScan: true, +} + +// ─── Reader ─── + +/** + * Read-only interface to a content repository. + * + * Paths are relative to the repository's content root (e.g. + * `.contentrain/config.json`). The `ref` parameter is a branch name, tag, + * or commit SHA. Providers that operate on a single working tree + * (LocalReader) ignore `ref`; API-backed providers use it to resolve the + * correct revision. + * + * `readFile` and `listDirectory` deliberately have different error semantics: + * - `readFile` THROWS when the file is missing so callers must opt into + * tolerance explicitly (typically with a try/catch returning a default). + * - `listDirectory` returns `[]` for a missing directory because the empty + * case is the common, uninteresting one. + */ +export interface RepoReader { + /** + * Read a file's contents as UTF-8. + * @throws when the file does not exist or cannot be read. + */ + readFile(path: string, ref?: string): Promise + + /** + * List file and directory names directly under `path`. Does not recurse. + * Returns an empty array when the directory does not exist. + */ + listDirectory(path: string, ref?: string): Promise + + /** Check whether a file or directory exists at `path`. */ + fileExists(path: string, ref?: string): Promise +} + +// ─── Writer ─── + +export interface CommitAuthor { + name: string + email: string +} + +export interface Commit { + sha: string + message: string + author: CommitAuthor + timestamp: string +} + +/** + * Input to `applyPlan`. Represents a single atomic commit: all changes land + * in one commit on `branch`, created from `base` if it does not yet exist. + */ +export interface ApplyPlanInput { + /** Branch name to commit to. Created from `base` if missing. */ + branch: string + /** File additions, modifications and deletions to apply in a single commit. */ + changes: FileChange[] + /** Commit message. */ + message: string + /** Commit author. */ + author: CommitAuthor + /** Optional base branch. Defaults to provider's content-tracking branch. */ + base?: string +} + +/** + * Write-side interface. Providers implement this to persist a set of file + * changes as a single atomic commit. LocalProvider writes through a worktree + * and `git commit`; API-backed providers post to the Git Data API or + * equivalent. + */ +export interface RepoWriter { + applyPlan(input: ApplyPlanInput): Promise +} + +// ─── Branch / diff / merge ─── + +export interface Branch { + name: string + sha: string + protected?: boolean +} + +export interface FileDiff { + path: string + status: 'added' | 'modified' | 'removed' + before: string | null + after: string | null +} + +export interface MergeResult { + merged: boolean + sha: string | null + pullRequestUrl: string | null +} + +// ─── Provider (full surface) ─── + +/** + * A content repository provider — the unified surface that MCP tools drive. + * + * Implementations wrap a git backend: + * + * - `LocalProvider` — simple-git + temp worktree + selective sync + * - `GitHubProvider` — Octokit Git Data API (no clone) + * - `GitLabProvider` — gitbeaker REST client (no clone) + * - `BitbucketProvider` — planned, coming soon + * + * Providers are commodity and all live in MIT. + */ +export interface RepoProvider extends RepoReader, RepoWriter { + readonly capabilities: ProviderCapabilities + + listBranches(prefix?: string): Promise + createBranch(name: string, fromRef?: string): Promise + deleteBranch(name: string): Promise + getBranchDiff(branch: string, base?: string): Promise + mergeBranch(branch: string, into: string): Promise + isMerged(branch: string, into?: string): Promise + getDefaultBranch(): Promise +} From 0c6125b9c5714bece85005ec8eb9a37cf8f88617 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Fri, 17 Apr 2026 19:44:23 +0300 Subject: [PATCH 2/2] feat(mcp,docs): studio handoff + embedding guide + two P2 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the integration gap between the finished MCP refactor and the consumers that want to embed it (Studio first, but the public guide extends to any host). Also fixes two bugs surfaced while writing the handoff. ### Studio handoff (internal) `.internal/refactor/02-studio-handoff.md` was written at the start of the refactor (Phase 2) and had drifted on every subsequent phase. Updated to the finished-refactor state: - Preconditions now list the actual shipped subpath exports (`core/ops`, `core/overlay-reader`, `core/contracts`, `providers/local`, etc.), not the Phase-2-era stubs. - Contracts canonical source is `@contentrain/types`, not `@contentrain/mcp/core/contracts` (which re-exports for back-compat). - S2 (Content Engine Sökümü) code samples updated for today's APIs: `buildContextChange(reader, operation)` with `OverlayReader` wrapping, `provider.applyPlan({..., base: CONTENTRAIN_BRANCH})` invariant, no `.reader()` accessor (didn't exist then, doesn't now). - New "Phase 10 tuzakları" section spelling out the three traps Studio should avoid: fork from contentrain, OverlayReader before context / validation, `isNotFoundError` is internal. - S6 (MCP Cloud Endpoint) reframed around `startHttpMcpServerWith` rather than a hand-rolled JSON-RPC layer — Studio can reuse the MCP package's HTTP primitive and put Bearer / quota / metering in a Nitro middleware in front of it. - Removed the hypothetical "core branch-policy module" placeholder (Phase 3.5 that never shipped separately — the helper logic lives inline in `providers/local/migration.ts` now). - Provider selection matrix clarifying that Studio never uses LocalProvider, so normalize / scan / apply / init / scaffold / submit / merge / validate --fix / bulk are always `capability_required` on Studio's Cloud endpoint — not something Studio has to filter client-side, the capability gate handles it. ### Public embedding guide (public docs) New `docs/guides/embedding-mcp.md` — a consumer-agnostic guide that sits under the existing Providers / HTTP Transport guides. Documents four construction recipes (stdio+Local, HTTP+Local, HTTP+Remote, programmatic no-transport), the three primitives every integrator must understand (`CONTENTRAIN_BRANCH` as fork point, `OverlayReader` for post-commit consistency, `capability_required` as a structured error), auth model, and an extension point for custom providers. Studio is called out as a reference integration alongside CI and scripted automation. Sidebar + top nav updated. ### P2 — `ApplyPlanInput.base` contract vs implementation The `@contentrain/types` docstring said "defaults to provider's content-tracking branch", but both `GitHubProvider` and `GitLabProvider` defaulted to the repository's default branch (main / master / trunk). Tests locked in that wrong behaviour. MCP's own tool-level write path (`commit-plan.ts`) bypassed the issue by always passing `base: CONTENTRAIN_BRANCH` explicitly, but any Studio-style direct `provider.applyPlan` call with `base` omitted would silently fork from `main`. Both implementations now default to `CONTENTRAIN_BRANCH`, matching the documented contract and the local-transaction behaviour. Tests rewritten to assert: - `github.apply-plan.test.ts > defaults to the contentrain branch when no base is provided` — `repos.get` is NOT called, `getRef` resolves `heads/contentrain`. - `gitlab.apply-plan.test.ts > defaults to the contentrain branch when input.base is absent` — `Projects.show` is NOT called, `startBranch` is `'contentrain'`. Docstring tightened in `packages/types/src/provider.ts` to leave no room for a split-brain reinterpretation. `commit-plan.ts` can now omit `base` entirely (provider default is correct), but keeps it explicit for readability. ### P2 — `contentrain_status` context field on remote Remote `contentrain_status` returned `context: null` unconditionally, even though remote writes commit `.contentrain/context.json` through the overlay reader. Studio's surface reported "no last operation" and "stats.entries: null" forever. `readContext` gains a reader overload in `core/context.ts`. `tools/context.ts` uses the reader form for remote sessions. The HTTP E2E now seeds `.contentrain/context.json` and asserts the `lastOperation` + `stats` surface from the remote provider. ### New public subpath exports - `@contentrain/mcp/core/ops` — plan helpers + path helpers, exactly what an embedder needs to compose custom write paths against any `RepoProvider`. - `@contentrain/mcp/core/overlay-reader` — the `OverlayReader` primitive. Required by any integrator writing to a remote provider so `buildContextChange` / `validateProject` see post-commit state. Both paths are referenced from the embedding guide and the Studio handoff; without them, a from-scratch Studio-style integration would require reaching into `dist/` which is explicitly blocked by `package.json#exports`. ### Verification - `pnpm -r typecheck` → 0 errors across 8 packages. - `oxlint` → 0 warnings on 397 files. - `pnpm --filter @contentrain/mcp build` → clean; dist contains `core/ops/`, `core/overlay-reader.{d.mts,mjs}`, `core/contracts/`, `providers/local/`. - `vitest run tests/core tests/conformance tests/serialization-parity tests/git tests/providers tests/server tests/util` → 440/440 green, 2 skipped. - `vitest run tests/providers/github tests/providers/gitlab tests/server/http.test.ts` → 60/60 green (includes the rewritten base-default tests and the augmented remote-context status E2E). - `node -e import('@contentrain/mcp/core/ops')` + overlay-reader + contracts + providers/local → all four subpaths resolve. ### Changesets `.changeset/mcp-phase-11-studio-handoff.md` — `@contentrain/mcp` minor bump (new subpath exports, two P2 bug fixes). Stacks with the existing Phase 10 changesets into a single minor release. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/mcp-phase-11-studio-handoff.md | 47 +++ docs/.vitepress/config.ts | 2 + docs/guides/embedding-mcp.md | 282 ++++++++++++++++++ packages/mcp/package.json | 12 +- packages/mcp/src/core/context.ts | 24 +- .../mcp/src/providers/github/apply-plan.ts | 12 +- .../mcp/src/providers/gitlab/apply-plan.ts | 13 +- packages/mcp/src/tools/context.ts | 6 +- .../tests/providers/github/apply-plan.test.ts | 13 +- .../tests/providers/gitlab/apply-plan.test.ts | 8 +- packages/mcp/tests/server/http.test.ts | 26 ++ packages/types/src/provider.ts | 8 +- 12 files changed, 429 insertions(+), 24 deletions(-) create mode 100644 .changeset/mcp-phase-11-studio-handoff.md create mode 100644 docs/guides/embedding-mcp.md diff --git a/.changeset/mcp-phase-11-studio-handoff.md b/.changeset/mcp-phase-11-studio-handoff.md new file mode 100644 index 0000000..9acaf4a --- /dev/null +++ b/.changeset/mcp-phase-11-studio-handoff.md @@ -0,0 +1,47 @@ +--- +"@contentrain/mcp": minor +--- + +feat(mcp): phase 11 — embedding surface + two more P2 fixes + +Follow-up to phase 10. Extends the public surface Studio (and any +third-party integrator) consumes, and closes two additional bugs +surfaced while writing the handoff documentation. + +### New public subpath exports + +- `@contentrain/mcp/core/ops` — plan helpers (`planContentSave`, + `planContentDelete`, `planModelSave`, `planModelDelete`) plus the + path helpers (`contentDirPath`, `contentFilePath`, + `documentFilePath`, `metaFilePath`) integrators need to compose + their own write paths against a `RepoProvider`. +- `@contentrain/mcp/core/overlay-reader` — the `OverlayReader` + primitive required by any non-local write path that needs + `buildContextChange` / `validateProject` to see post-commit state. + +### Bug fixes (P2) + +- **`ApplyPlanInput.base` contract alignment.** `GitHubProvider` and + `GitLabProvider` previously fell back to the repository's default + branch (main / master / trunk) when `base` was omitted — in direct + conflict with the docstring that said "defaults to provider's + content-tracking branch". Both implementations now default to + `CONTENTRAIN_BRANCH`, matching the documented contract and the + `LocalProvider` transaction behaviour. Tests that locked in the old + behaviour are rewritten; the docstring is tightened to be + unambiguous. +- **`contentrain_status` context field on remote.** When the session + has no `projectRoot`, `contentrain_status` previously returned + `context: null` unconditionally, even though remote writes do + commit `.contentrain/context.json`. `readContext` gains a reader + overload; `tools/context.ts` uses it for remote flows. Remote + `status` calls now surface the last operation + stats. + +### Tests + +- `tests/server/http.test.ts` — `status works read-only over a + remote provider` now seeds `.contentrain/context.json` and asserts + the committed `lastOperation` + `stats` propagate. +- `tests/providers/{github,gitlab}/apply-plan.test.ts` — the + old "falls back to repo default branch" cases are rewritten to + assert the new CONTENTRAIN_BRANCH default. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f254ea2..571f8e4 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -49,6 +49,7 @@ export default defineConfig({ { text: 'Guides', items: [ { text: 'Providers & Transports', link: '/guides/providers' }, { text: 'HTTP Transport', link: '/guides/http-transport' }, + { text: 'Embedding MCP', link: '/guides/embedding-mcp' }, { text: 'Normalize Flow', link: '/guides/normalize' }, { text: 'Framework Integration', link: '/guides/frameworks' }, { text: 'i18n Workflow', link: '/guides/i18n' }, @@ -89,6 +90,7 @@ export default defineConfig({ items: [ { text: 'Providers & Transports', link: '/guides/providers' }, { text: 'HTTP Transport', link: '/guides/http-transport' }, + { text: 'Embedding MCP', link: '/guides/embedding-mcp' }, { text: 'Normalize Flow', link: '/guides/normalize' }, { text: 'Framework Integration', link: '/guides/frameworks' }, { text: 'i18n Workflow', link: '/guides/i18n' }, diff --git a/docs/guides/embedding-mcp.md b/docs/guides/embedding-mcp.md new file mode 100644 index 0000000..09b3b11 --- /dev/null +++ b/docs/guides/embedding-mcp.md @@ -0,0 +1,282 @@ +--- +title: Embedding MCP in a Host Application +description: How to consume @contentrain/mcp from inside another application — transports, providers, authentication, capability gating, and extension points. Includes Studio as a reference integration. +slug: embedding-mcp +--- + +# Embedding MCP in a Host Application + +`@contentrain/mcp` is distributed as a standalone package so it can be embedded in any Node.js host — a hosted CMS, a CI runner, a custom agent driver, an internal tool. This guide walks through the shapes that integration can take and the primitives you'll touch. + +Studio (`contentrain.io`) is the canonical consumer; the patterns below describe what Studio does and what third parties should do to match it. + +## What you're embedding + +`@contentrain/mcp` ships three pieces you plug together: + +1. **A `RepoProvider`** — Local / GitHub / GitLab (or your own). Wraps whatever git backend you're targeting. +2. **An `McpServer`** — the MCP JSON-RPC surface with all 16 Contentrain tools registered. +3. **A transport** — stdio (for IDE agents) or HTTP (for hosted / remote drivers). + +The three are orthogonal. Mix them freely. + +## Installation + +```bash +pnpm add @contentrain/mcp @contentrain/types +``` + +Remote providers ship as optional peers — install only the ones you'll use: + +```bash +# For GitHub-backed sessions +pnpm add @octokit/rest + +# For GitLab-backed sessions +pnpm add @gitbeaker/rest +``` + +A pure-LocalProvider embedding (just wrapping a working tree) needs neither peer. + +## Construction recipes + +### 1. Stdio + LocalProvider (IDE agents) + +```ts +import { createServer } from '@contentrain/mcp/server' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' + +const server = createServer('/path/to/project') +const transport = new StdioServerTransport() +await server.connect(transport) +``` + +This is what `contentrain serve --stdio` does internally. Every IDE that speaks MCP (Claude Code, Cursor, Windsurf) talks to this shape. + +### 2. HTTP + LocalProvider (local CI, Studio-like hosting pointed at a working tree) + +```ts +import { startHttpMcpServer } from '@contentrain/mcp/server/http' + +const handle = await startHttpMcpServer({ + projectRoot: '/path/to/project', + port: 3333, + host: '0.0.0.0', + authToken: process.env.MCP_BEARER_TOKEN, +}) + +// handle.url — "http://0.0.0.0:3333/mcp" +// handle.close() — shuts down when you're done +``` + +CLI equivalent: `contentrain serve --mcpHttp --authToken $TOKEN`. + +### 3. HTTP + Remote Provider (Studio's pattern) + +```ts +import { createGitHubProvider } from '@contentrain/mcp/providers/github' +import { startHttpMcpServerWith } from '@contentrain/mcp/server/http' + +const provider = await createGitHubProvider({ + auth: { type: 'pat', token: await exchangeInstallationToken(installationId) }, + repo: { owner: 'acme', name: 'site' }, +}) + +const handle = await startHttpMcpServerWith({ + provider, + port: 3333, + authToken: workspaceBearerToken, +}) +``` + +Swap in `createGitLabProvider({ auth, project })` for GitLab. Self-hosted GitLab instances pass `project.host`. + +### 4. Programmatic tool calls (no transport at all) + +If you want to run a Contentrain tool inside your own Node.js process without MCP's JSON-RPC layer: + +```ts +import { planContentSave } from '@contentrain/mcp/core/ops' +import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' +import { buildContextChange } from '@contentrain/mcp/core/context' +import { validateProject } from '@contentrain/mcp/core/validator' +import { CONTENTRAIN_BRANCH } from '@contentrain/types' + +const plan = await planContentSave(provider, { model, entries, config, vocabulary }) +if (plan.result.some(r => r.error)) throw new Error('plan invalid') + +const overlay = new OverlayReader(provider, plan.changes) +const contextChange = await buildContextChange(overlay, { + tool: 'save_content', + model: model.id, + locale: entries[0].locale, +}) + +const allChanges = [...plan.changes, contextChange] + .toSorted((a, b) => a.path.localeCompare(b.path)) + +const commit = await provider.applyPlan({ + branch: 'cr/content/blog/2026-04-17-abcd', + changes: allChanges, + message: 'content: save blog', + author: { name: 'Your Bot', email: 'bot@example.com' }, + // base omitted → defaults to CONTENTRAIN_BRANCH +}) + +const validation = await validateProject(overlay, { model: model.id }) +``` + +Use this shape when you want tool-level control without the JSON-RPC envelope. + +## Critical primitives + +Three primitives matter when you build a non-local write path. Studio ran into all three during integration; get them right up front. + +### `CONTENTRAIN_BRANCH` is the fork point, always + +Every feature branch (`cr/content/...`, `cr/model/...`, `cr/normalize/...`) forks from the singleton `contentrain` branch. NOT from the repo's default branch (`main` / `master` / `trunk`). The content-tracking branch is the SSOT; the default branch is downstream. + +`provider.applyPlan({ ..., base })` defaults to `CONTENTRAIN_BRANCH` when `base` is omitted. That's the contract. Pass `base` explicitly only when you know you're opting out of the invariant. + +### `OverlayReader` for post-commit consistency + +`buildContextChange` and `validateProject` take a reader. If you pass the raw provider, they read the pre-change base branch — so your committed `context.json` reports stale entry counts and post-save validation evaluates the wrong state. + +Wrap the reader with `OverlayReader(reader, plan.changes)`. It layers pending `FileChange`s on top: adds become visible, deletes look missing, everything else falls through. The resulting context + validation reflect the state your commit is about to produce. + +This is only needed for remote / reader-based flows. `LocalProvider`'s transaction writes the worktree before context/validation run, so the filesystem itself is the overlay. + +### `capability_required` is a structured error + +Tools that need capabilities the active provider doesn't expose return: + +```json +{ + "error": "contentrain_scan requires local filesystem access.", + "capability_required": "astScan", + "hint": "This tool is unavailable when MCP is driven by a remote provider. Use a LocalProvider or the stdio transport." +} +``` + +Treat `capability_required` as a retry signal at the client. Typical fallback: prompt the user to switch to a local checkout, or downgrade the request (e.g. `content_list` with `resolve: true` → `resolve: false`). + +See [Providers & Transports](/guides/providers) for the full capability matrix. + +## Authentication + +- **Stdio** — no authentication. Transport is localhost pipes; security boundary is the OS. +- **HTTP** — optional Bearer token. Set `authToken` on `startHttpMcpServer` / `startHttpMcpServerWith`, and require clients to send `Authorization: Bearer `. Missing or mismatched tokens get `401` before any MCP session initialises. +- **Upstream git hosts** — provider auth (PAT / OAuth / GitHub App installation token / GitLab job token) is scoped per-provider. See the provider's factory docstring. + +Rotate Bearer tokens regularly. MCP does not support per-tool ACLs; a valid token is full project access. + +## Capability gating + +Each provider advertises a `ProviderCapabilities` manifest. Tools gate on capabilities and reject uniformly when the active provider can't satisfy them. + +| Capability | Local | GitHub | GitLab | Gated tools | +|---|---|---|---|---| +| `localWorktree` | ✓ | — | — | `init`, `scaffold`, `validate --fix`, `submit`, `merge`, `bulk` | +| `sourceRead` | ✓ | — | — | `apply` (extract) | +| `sourceWrite` | ✓ | — | — | `apply` (reuse) | +| `astScan` | ✓ | — | — | `scan` | +| `pushRemote` | ✓ | ✓ | ✓ | `submit` | +| `branchProtection` | — | ✓ | ✓ | merge fallback | +| `pullRequestFallback` | — | ✓ | ✓ | merge fallback | + +Read-only tools (`status`, `describe`, `describe_format`, `content_list`, `validate` without `--fix`) work on every provider — they use only the reader surface. + +## Extension: custom providers + +If your host doesn't use Local / GitHub / GitLab, implement `RepoProvider` directly: + +```ts +import type { RepoProvider, ProviderCapabilities } from '@contentrain/types' + +class MyProvider implements RepoProvider { + readonly capabilities: ProviderCapabilities = { + localWorktree: false, + sourceRead: false, + sourceWrite: false, + pushRemote: true, + branchProtection: false, + pullRequestFallback: false, + astScan: false, + } + + async readFile(path, ref?) { /* your backend */ } + async listDirectory(path, ref?) { /* your backend */ } + async fileExists(path, ref?) { /* your backend */ } + + async applyPlan(input) { + // Single atomic commit. Honour input.base (default CONTENTRAIN_BRANCH). + } + + async listBranches(prefix?) { /* ... */ } + async createBranch(name, fromRef?) { /* ... */ } + async deleteBranch(name) { /* ... */ } + async getBranchDiff(branch, base?) { /* ... */ } + async mergeBranch(branch, into) { /* ... */ } + async isMerged(branch, into?) { /* ... */ } + async getDefaultBranch() { /* ... */ } +} + +const server = createServer({ provider: new MyProvider() }) +``` + +The reference implementations under `packages/mcp/src/providers/{local,github,gitlab}/` are ~500 lines each and mirror the same structure. Start there. + +For the full contract see [RepoProvider Reference](/reference/providers). + +## Reference integrations + +### Contentrain Studio + +Studio is the canonical hosted integration. It hosts an HTTP MCP server per workspace, backed by `GitHubProvider` or `GitLabProvider` pointing at the customer's content repo. Bearer tokens are managed per workspace; quota and plan gates sit in a thin middleware in front of MCP. Studio never runs local-only tools (normalize, submit, etc.) — those delegate to the customer's own local checkout. + +See [Studio Overview](/studio) for the product surface and `.internal/refactor/02-studio-handoff.md` in the monorepo for the detailed Studio-side integration plan (Studio-repo-specific). + +### CI runners + +A GitHub Actions job: + +1. `actions/checkout@v4` +2. `pnpm install` +3. Start `contentrain serve --mcpHttp --authToken $CI_TOKEN &` (LocalProvider under the hood) +4. Drive it with an MCP client +5. Let `contentrain_submit` push the `cr/*` branch + +All 16 tools are available because the runner has `LocalProvider`. + +### Scripted automation + +A nightly script that regenerates translation stubs via `contentrain_content_save` over GitHub: + +```ts +const provider = await createGitHubProvider({ + auth: { type: 'pat', token: process.env.GH_TOKEN! }, + repo: { owner: 'acme', name: 'site' }, +}) +// run planContentSave + commit directly (recipe 4 above) +``` + +No HTTP, no MCP JSON-RPC — just the core primitives. + +## Going deeper + +- [Providers & Transports](/guides/providers) — capability matrix, when to use which provider +- [HTTP Transport](/guides/http-transport) — deployment patterns, Bearer auth +- [RepoProvider Reference](/reference/providers) — contract definitions +- [MCP package reference](/packages/mcp) — full tool catalogue + +## Troubleshooting + +**`ERR_PACKAGE_PATH_NOT_EXPORTED`** — you're reaching into a subpath that isn't in `package.json#exports`. Known good subpaths: `/server`, `/server/http`, `/core/config`, `/core/context`, `/core/contracts`, `/core/model-manager`, `/core/content-manager`, `/core/validator`, `/core/ops`, `/core/overlay-reader`, `/core/scanner`, `/core/graph-builder`, `/core/apply-manager`, `/core/scan-config`, `/providers/local`, `/providers/github`, `/providers/gitlab`, `/util/detect`, `/util/fs`, `/git/transaction`, `/git/branch-lifecycle`, `/templates`. + +**Stale context.json stats after a remote commit** — you forgot `OverlayReader`. `buildContextChange(provider, op)` reads the pre-change branch; wrap with `new OverlayReader(provider, plan.changes)`. + +**Feature branch forked from main instead of contentrain** — you passed `base: config.repository?.default_branch` somewhere. Remove it; the default is `CONTENTRAIN_BRANCH`. + +**`@octokit/rest` / `@gitbeaker/rest` not found at runtime** — optional peer isn't installed. Add it to your host's dependencies; the factory throws with this hint. + +**Validation passes but the commit still contains bad data** — you're running `validateProject(provider, ...)` without the overlay on a remote path. Same fix: wrap the reader. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 1cac406..5e229c4 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -88,6 +88,14 @@ "types": "./dist/core/contracts/index.d.mts", "import": "./dist/core/contracts/index.mjs" }, + "./core/ops": { + "types": "./dist/core/ops/index.d.mts", + "import": "./dist/core/ops/index.mjs" + }, + "./core/overlay-reader": { + "types": "./dist/core/overlay-reader.d.mts", + "import": "./dist/core/overlay-reader.mjs" + }, "./util/detect": { "types": "./dist/util/detect.d.mts", "import": "./dist/util/detect.mjs" @@ -127,8 +135,8 @@ "dist" ], "scripts": { - "build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript", - "dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch", + "build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript", + "dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch", "test": "vitest run", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" diff --git a/packages/mcp/src/core/context.ts b/packages/mcp/src/core/context.ts index a92710c..0993883 100644 --- a/packages/mcp/src/core/context.ts +++ b/packages/mcp/src/core/context.ts @@ -8,8 +8,28 @@ import { readConfig } from './config.js' const CONTEXT_PATH = '.contentrain/context.json' -export async function readContext(projectRoot: string): Promise { - return readJson(join(contentrainDir(projectRoot), 'context.json')) +/** + * Read the committed `.contentrain/context.json` payload written by the + * most recent content or model operation. Returns `null` when the file + * does not exist (fresh project, or reader lookup failure). + * + * Dual signature — the local flow passes a `projectRoot` string, remote + * flows (GitHubProvider, GitLabProvider, any custom `RepoReader`) pass + * the reader directly so `contentrain_status` over HTTP can still + * report the last operation + stats. + */ +export function readContext(projectRoot: string): Promise +export function readContext(reader: RepoReader): Promise +export async function readContext(input: string | RepoReader): Promise { + if (typeof input === 'string') { + return readJson(join(contentrainDir(input), 'context.json')) + } + try { + const raw = await input.readFile(CONTEXT_PATH) + return JSON.parse(raw) as ContextJson + } catch { + return null + } } function resolveSource(explicit?: ContextSource): ContextSource { diff --git a/packages/mcp/src/providers/github/apply-plan.ts b/packages/mcp/src/providers/github/apply-plan.ts index cb870c0..5460527 100644 --- a/packages/mcp/src/providers/github/apply-plan.ts +++ b/packages/mcp/src/providers/github/apply-plan.ts @@ -1,3 +1,4 @@ +import { CONTENTRAIN_BRANCH } from '@contentrain/types' import type { ApplyPlanInput, Commit, FileChange } from '../../core/contracts/index.js' import { isNotFoundError, resolveRepoPath } from '../shared/index.js' import type { GitHubClient } from './client.js' @@ -130,10 +131,13 @@ async function resolveBaseSha( if (!isNotFoundError(error)) throw error } - const baseRefName = base ?? (await client.rest.repos.get({ - owner: repo.owner, - repo: repo.name, - })).data.default_branch + // Invariant: feature branches always fork from the Contentrain + // content-tracking branch. Callers that genuinely want to bypass this + // must pass `base` explicitly. The repository's default branch + // (main / master / trunk) is NOT the fallback — that would create a + // split-brain where remote writes derive from a different ref than + // local writes, which the LocalProvider transaction path forbids. + const baseRefName = base ?? CONTENTRAIN_BRANCH const baseRef = await client.rest.git.getRef({ owner: repo.owner, diff --git a/packages/mcp/src/providers/gitlab/apply-plan.ts b/packages/mcp/src/providers/gitlab/apply-plan.ts index 57c2752..545bb97 100644 --- a/packages/mcp/src/providers/gitlab/apply-plan.ts +++ b/packages/mcp/src/providers/gitlab/apply-plan.ts @@ -1,3 +1,4 @@ +import { CONTENTRAIN_BRANCH } from '@contentrain/types' import type { ApplyPlanInput, Commit, FileChange } from '../../core/contracts/index.js' import { isNotFoundError, resolveRepoPath } from '../shared/index.js' import type { GitLabClient } from './client.js' @@ -37,7 +38,10 @@ export async function applyPlanToGitLab( input: ApplyPlanInput, ): Promise { const branchExists = await branchHasRef(client, project, input.branch) - const baseBranch = input.base ?? await resolveDefaultBranch(client, project) + // Invariant: fork from the Contentrain content-tracking branch, not + // the repo's default branch. See ApplyPlanInput.base docstring in + // @contentrain/types. + const baseBranch = input.base ?? CONTENTRAIN_BRANCH // Actions are computed against the HEAD of the feature branch when it // exists, otherwise against the fork point (baseBranch). GitLab @@ -148,13 +152,6 @@ async function fileExistsAtRef( } } -async function resolveDefaultBranch( - client: GitLabClient, - project: ProjectRef, -): Promise { - const p = await client.Projects.show(project.projectId) as { default_branch?: string } - return p.default_branch ?? 'main' -} function toIsoTimestamp(raw: unknown): string | null { if (typeof raw !== 'string') return null diff --git a/packages/mcp/src/tools/context.ts b/packages/mcp/src/tools/context.ts index 9c2173c..fca2b0b 100644 --- a/packages/mcp/src/tools/context.ts +++ b/packages/mcp/src/tools/context.ts @@ -46,7 +46,11 @@ export function registerContextTools( const config = await readConfig(provider) const models = await listModels(provider) - const context = projectRoot ? await readContext(projectRoot) : null + // Local flow uses the filesystem helper for byte-parity; remote + // flows read the committed `.contentrain/context.json` through + // the provider. Either way, the context block surfaces the last + // operation + stats that were written alongside the last commit. + const context = projectRoot ? await readContext(projectRoot) : await readContext(provider) const vocabulary = await readVocabulary(provider) const errors: string[] = [] diff --git a/packages/mcp/tests/providers/github/apply-plan.test.ts b/packages/mcp/tests/providers/github/apply-plan.test.ts index 740709d..5cc4afe 100644 --- a/packages/mcp/tests/providers/github/apply-plan.test.ts +++ b/packages/mcp/tests/providers/github/apply-plan.test.ts @@ -107,10 +107,15 @@ describe('applyPlanToGitHub', () => { expect(getRef).toHaveBeenNthCalledWith(2, { owner: 'o', repo: 'r', ref: 'heads/main' }) }) - it('falls back to repo default branch when no base is provided', async () => { + it('defaults to the contentrain branch when no base is provided', async () => { + // Invariant: feature branches always fork from the content-tracking + // branch, never from the repo's default branch. Asserting against + // `heads/contentrain` and that `repos.get` is NOT called locks this + // in for both the GitHub implementation and the public contract + // documented on ApplyPlanInput.base. const getRef = vi.fn() .mockRejectedValueOnce(notFound()) - .mockResolvedValueOnce({ data: { object: { sha: 'default-sha' } } }) + .mockResolvedValueOnce({ data: { object: { sha: 'contentrain-sha' } } }) const repoGet = vi.fn().mockResolvedValue({ data: { default_branch: 'main' } }) const getCommit = vi.fn().mockResolvedValue({ data: { tree: { sha: 't' } } }) const createBlob = vi.fn().mockResolvedValue({ data: { sha: 'b' } }) @@ -132,8 +137,8 @@ describe('applyPlanToGitHub', () => { author: AUTHOR, }) - expect(repoGet).toHaveBeenCalledWith({ owner: 'o', repo: 'r' }) - expect(getRef).toHaveBeenNthCalledWith(2, { owner: 'o', repo: 'r', ref: 'heads/main' }) + expect(repoGet).not.toHaveBeenCalled() + expect(getRef).toHaveBeenNthCalledWith(2, { owner: 'o', repo: 'r', ref: 'heads/contentrain' }) }) it('emits null-sha tree entries for deletions', async () => { diff --git a/packages/mcp/tests/providers/gitlab/apply-plan.test.ts b/packages/mcp/tests/providers/gitlab/apply-plan.test.ts index 2c9089d..6e33293 100644 --- a/packages/mcp/tests/providers/gitlab/apply-plan.test.ts +++ b/packages/mcp/tests/providers/gitlab/apply-plan.test.ts @@ -182,7 +182,10 @@ describe('applyPlanToGitLab', () => { expect(actions[0].filePath).toBe('apps/web/.contentrain/context.json') }) - it('resolves the project default branch when input.base is absent', async () => { + it('defaults to the contentrain branch when input.base is absent', async () => { + // Invariant — same as the GitHub path: forks from the content-tracking + // branch, not the GitLab project's default branch. `Projects.show` must + // NOT be consulted for base resolution. const projectShow = vi.fn().mockResolvedValue({ default_branch: 'trunk' }) const commitsCreate = vi.fn().mockResolvedValue({ id: 'sha-5', @@ -201,7 +204,8 @@ describe('applyPlanToGitLab', () => { }) const [, , , , options] = commitsCreate.mock.calls[0]! - expect(options.startBranch).toBe('trunk') + expect(options.startBranch).toBe('contentrain') + expect(projectShow).not.toHaveBeenCalled() }) it('throws when the plan reduces to zero actions', async () => { diff --git a/packages/mcp/tests/server/http.test.ts b/packages/mcp/tests/server/http.test.ts index 59c112c..9e92434 100644 --- a/packages/mcp/tests/server/http.test.ts +++ b/packages/mcp/tests/server/http.test.ts @@ -662,8 +662,25 @@ describe('startHttpMcpServer', () => { const { GitHubProvider } = await import('../../src/providers/github/index.js') const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') + const committedContext = { + version: '1', + lastOperation: { + tool: 'contentrain_content_save', + model: 'blog', + locale: 'en', + timestamp: '2026-04-17T12:00:00.000Z', + source: 'mcp-studio', + }, + stats: { + models: 1, + entries: 3, + locales: ['en', 'tr'], + lastSync: '2026-04-17T12:00:00.000Z', + }, + } const filesOnHead: Record = { '.contentrain/config.json': JSON.stringify(makeConfig()), + '.contentrain/context.json': JSON.stringify(committedContext), } const fixture = makeGitHubMock(filesOnHead) const provider = new GitHubProvider( @@ -687,6 +704,15 @@ describe('startHttpMcpServer', () => { expect(parsed).not.toHaveProperty('capability_required') // Branch health is local-only and is skipped for remote providers. expect(parsed).not.toHaveProperty('branches') + // Remote reads pick up the committed .contentrain/context.json + // through the provider — no longer hardcoded to null. + const context = parsed['context'] as Record + expect(context).toBeDefined() + const lastOp = context['lastOperation'] as Record + expect(lastOp['tool']).toBe('contentrain_content_save') + expect(lastOp['model']).toBe('blog') + const stats = context['stats'] as Record + expect(stats['entries']).toBe(3) } finally { await mcpClient.close() } diff --git a/packages/types/src/provider.ts b/packages/types/src/provider.ts index 3937c70..b2b1d0d 100644 --- a/packages/types/src/provider.ts +++ b/packages/types/src/provider.ts @@ -128,7 +128,13 @@ export interface ApplyPlanInput { message: string /** Commit author. */ author: CommitAuthor - /** Optional base branch. Defaults to provider's content-tracking branch. */ + /** + * Optional base branch. Defaults to the Contentrain content-tracking + * branch (`CONTENTRAIN_BRANCH` — the `contentrain` ref) — NOT the + * repository's default branch. This is the single source of truth for + * content state; every feature branch forks from it. Pass an explicit + * `base` only when you know you want to bypass the invariant. + */ base?: string }