From cb8f65e1dfa049d7adb8469ebf4f294ea5bf8399 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Fri, 17 Apr 2026 18:56:03 +0300 Subject: [PATCH] =?UTF-8?q?refactor(mcp,types):=20phase=2010=20=E2=80=94?= =?UTF-8?q?=20review=20alignment=20+=20P1/P2=20bug=20fixes=20+=20docs=20pa?= =?UTF-8?q?rity?= 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 +}