@contentrain/mcp

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:
Contentrain is AI-generated content governance infrastructure:
- agent produces content decisions
- MCP applies deterministic filesystem and git workflow
- humans review and merge
- the system keeps schema, locale, and serialization consistent
This package is the runtime core behind Contentrain's MCP integration. It can be used as:
- a stdio MCP server (
contentrain-mcp)
- an embeddable server (
createServer(projectRoot))
- a low-level toolkit for config, models, content, validation, scanning, and git transaction flow
Install
pnpm add @contentrain/mcp
Requirements:
- Node.js
22+
- git available on the machine
Optional parser support for higher-quality source scanning:
@vue/compiler-sfc
@astrojs/compiler
svelte
They are listed as optional dependencies. The scanner still works without them, but Vue/Astro/Svelte detection is stronger when they are installed.
What It Does
@contentrain/mcp manages a .contentrain/ directory in your project and exposes MCP tools for:
- project initialization
- model creation and deletion
- content save, delete, and list
- validation and auto-fix
- normalize scan and apply flows
- bulk operations
- branch submission, review-mode merge, and branch-health awareness
- project health checking (doctor)
All write operations are designed around git-backed safety:
- a dedicated
contentrain branch serves as the content state single source of truth
- each write creates a temporary worktree on a feature branch forked from
contentrain (branch name: cr/{operation}/{model}/{locale}/{timestamp}-{suffix})
- auto-merge: feature merges into
contentrain, baseBranch advanced via update-ref, .contentrain/ files selectively synced to developer's working tree
- review: feature branch pushed to remote for team review; once merged (or deleted), its remote copy is removed too — best-effort, opt out with
remoteBranchCleanup: false in config.json
- merged-branch detection survives base-history rewrites (ancestry check with a patch-id fallback), so rebases/squashes don't strand stale branches
- developer's working tree is never mutated during MCP git operations (no stash, no checkout, no merge)
- context.json never lands on feature branches — it is regenerated on the
contentrain branch after merge (locally by the transaction layer; in remote flows by the orchestrator that owns the merge)
- canonical JSON output — sorted keys, 2-space indent, trailing newline
- validation + next-step hints surfaced to the caller
27 MCP tools — 22 core + 5 media — with annotations (readOnlyHint, destructiveHint, idempotentHint, and openWorldHint: false everywhere except contentrain_media_ingest, which fetches a caller-supplied URL server-side) for client safety hints.
Tool listing is capability-aware. tools/list only advertises tools the resolved provider + projectRoot pair can actually satisfy. A local stdio server lists the 22 core tools; a session driven by a remote provider (GitHub/GitLab, no local checkout) lists only the remote-safe subset — status, describe, describe_format, model_save, model_delete, content_save, content_delete, content_list, validate. The requirement map lives in TOOL_REQUIREMENTS (@contentrain/mcp/tools/availability).
| Tool | Purpose | Read-only | Destructive |
|---|
contentrain_status | Project status, config, models, branch health, context | Yes | — |
contentrain_describe | Full schema and sample data for a model | Yes | — |
contentrain_describe_format | File-format and storage contract reference | Yes | — |
contentrain_doctor | Project health report (env, structure, models, orphans, local + remote branches, SDK) | Yes | — |
contentrain_init | Create .contentrain/ structure and base config | — | — |
contentrain_scaffold | Apply a starter template such as blog, docs, landing, saas | — | — |
contentrain_model_save | Create or update a model definition | — | — |
contentrain_model_delete | Delete a model definition | — | Yes |
contentrain_content_save | Save content entries for any model kind | — | — |
contentrain_content_delete | Delete content entries | — | Yes |
contentrain_content_list | Read content entries | Yes | — |
contentrain_validate | Validate project content, optionally auto-fix structural issues | — | — |
contentrain_submit | Push cr/* branches to remote, then lazily prune merged local + remote leftovers | — | — |
contentrain_merge | Merge a review-mode branch into contentrain locally (by exact branch or model); deletes its remote copy | — | — |
contentrain_reconcile | Content-aware three-way merge of a diverged contentrain ↔ base pair (dry_run first, resolutions second) | — | — |
contentrain_branch_list | List pending cr/* branches with merge status (remote: true adds remote view) | Yes | — |
contentrain_branch_delete | Delete a stale/failed cr/* branch locally and on the remote (contentrain branch protected) | — | Yes |
contentrain_scan | Graph- and candidate-based hardcoded string scan | Yes | — |
contentrain_apply | Normalize extract/reuse execution with dry-run support | — | — |
contentrain_bulk | Bulk locale copy, status updates, and deletes (dry_run previews) | — | — |
contentrain_media_list | List media assets (search, tag filter, cursor pagination) | Yes | — |
contentrain_media_get | Get one media asset by id | Yes | — |
contentrain_media_ingest | Ingest an asset from a source URL (provider fetches server-side) | — | — |
contentrain_media_update | Update asset metadata (alt, tags, filename) | — | — |
contentrain_media_delete | Delete an asset from the media stack | — | Yes |
The five contentrain_media_* tools are a deterministic passthrough to the provider's optional media facet (RepoProvider.media) and are registered only when the provider exposes one (e.g. Studio MCP Cloud). Local stdio servers and plain GitHub/GitLab providers never list them. Ingest is URL-based (MCP has no binary channel); the provider implementation owns SSRF/MIME/size policy for the fetch.
Quick Start
npx contentrain setup claude-code
This auto-creates the correct MCP config file for your IDE. See CLI docs for details.
Run as a standalone MCP server
CONTENTRAIN_PROJECT_ROOT=/path/to/project npx contentrain-mcp
If CONTENTRAIN_PROJECT_ROOT is omitted, the current working directory is used.
Embed the server in your own process
import { createServer } from '@contentrain/mcp/server'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
const server = createServer(process.cwd())
const transport = new StdioServerTransport()
await server.connect(transport)
createServer also accepts an options object: { provider, projectRoot?, instructions? }. instructions sets the MCP instructions string clients receive at initialize (defaults to a built-in DEFAULT_INSTRUCTIONS, kept under 512 characters; pass '' to omit).
Example MCP Flow
Typical agent workflow:
- Call
contentrain_status
- If needed, call
contentrain_init
- Create models with
contentrain_model_save or contentrain_scaffold
- Save content with
contentrain_content_save
- Validate with
contentrain_validate
- For hardcoded strings, use
contentrain_scan then contentrain_apply
- Push review branches with
contentrain_submit
Normalize Flow
Normalize is intentionally split into two phases:
contentrain_scan finds candidate strings.
contentrain_apply with mode: "extract":
- creates or updates models
- writes content entries
- records source tracking
- creates a review branch (
cr/normalize/extract/{domain}/{timestamp})
2. Reuse
contentrain_apply with mode: "reuse":
- patches source files using agent-provided expressions
- adds imports when needed
- enforces patch path safety and scope checks
- creates a separate review branch (
cr/normalize/reuse/{model}/{locale}/{timestamp})
This split keeps content extraction separate from source rewriting.
Transport / provider 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 HTTP transport configured with a LocalProvider).
Remote providers such as GitHubProvider expose astScan: false, sourceRead: false, and sourceWrite: false. Calling these tools over a remote provider returns a uniform capability error:
{
"error": "contentrain_scan requires local filesystem access.",
"capability_required": "astScan",
"hint": "This tool is unavailable when MCP is driven by a remote provider (e.g. GitHubProvider). Use a LocalProvider or the stdio transport."
}
Agents driving a remote transport should fall back to a local transport (or a local checkout) before invoking normalize.
Remote Providers
MCP supports three backends behind the same RepoProvider contract:
- LocalProvider — simple-git + worktree. Every tool (normalize included) works on it. Stdio transport defaults to this.
- GitHubProvider — Octokit over the Git Data + Repos APIs. No clone, no worktree.
@octokit/rest ships as an optional peer dependency.
- GitLabProvider — gitbeaker over the GitLab REST API. No clone, no worktree.
@gitbeaker/rest ships as an optional peer dependency. Supports gitlab.com and self-hosted CE / EE.
Each remote provider implements the same surface: reader (readFile / listDirectory / fileExists), writer (applyPlan — one atomic commit), branch ops (list / create / delete / diff / merge / isMerged / getDefaultBranch). mergeBranch goes straight through on GitHub; on GitLab it opens an MR and immediately accepts it so the final MergeResult shape matches either way.
GitLab — installation & usage
import { createGitLabProvider } from '@contentrain/mcp/providers/gitlab'
import { createServer } from '@contentrain/mcp/server'
const provider = await createGitLabProvider({
auth: { type: 'pat', token: process.env.GITLAB_TOKEN! },
project: {
projectId: 'acme/site',
host: 'https://gitlab.company.com',
},
})
const server = createServer({ provider })
Capabilities: sourceRead, sourceWrite, astScan, localWorktree are all false; pushRemote, branchProtection, pullRequestFallback are true. Normalize / scan / apply reject with a capability error on GitLabProvider — fall back to a local transport for those flows.
Bitbucket — coming soon
Bitbucket Cloud + Data Center support is on the roadmap. Until the provider ships, use the contentrain_describe_format tool to drive Contentrain content operations manually from a Bitbucket checkout via the LocalProvider path.
Core Exports
The package also exposes low-level modules for embedding and advanced use:
@contentrain/mcp/server
@contentrain/mcp/server/http
@contentrain/mcp/core/config
@contentrain/mcp/core/context
@contentrain/mcp/core/model-manager
@contentrain/mcp/core/content-manager
@contentrain/mcp/core/validator
@contentrain/mcp/core/scanner
@contentrain/mcp/core/graph-builder
@contentrain/mcp/core/apply-manager
@contentrain/mcp/core/scan-config
@contentrain/mcp/core/doctor
@contentrain/mcp/core/contracts
@contentrain/mcp/core/ops — plan APIs (including planReconcile and its bindRef reader adapter) plus content-root-relative path helpers: contentDirPath, contentFilePath, documentFilePath, metaFilePath
@contentrain/mcp/core/overlay-reader
@contentrain/mcp/core/migration — the allowlisted migration write path: createMigrationWriter, scopeHash, decide. See Migration writes below
@contentrain/mcp/util/detect
@contentrain/mcp/util/fs
@contentrain/mcp/git/transaction
@contentrain/mcp/git/branch-lifecycle — branch health/cleanup plus the remote cr/* lifecycle: deleteRemoteBranch, listRemoteCrBranches, pruneMergedRemoteBranches, isRefMerged, classifyMergedBranches
@contentrain/mcp/git/errors
@contentrain/mcp/git/reconcile — reconcileBranches, the local reconcile executor
@contentrain/mcp/tools/annotations
@contentrain/mcp/templates
@contentrain/mcp/providers/local
@contentrain/mcp/providers/github
@contentrain/mcp/providers/gitlab
These are intended for Contentrain tooling and advanced integrations, not for direct manual editing of .contentrain/ files.
Migration Writes
A migration does not deliver content, it delivers a codebase: src/, public/,
package.json, a lockfile, deploy configuration. The content write path was
never built for any of that, and widening it would quietly move a security
boundary the whole product rests on — "Contentrain only writes .contentrain/"
is a promise, not an implementation detail.
@contentrain/mcp/core/migration is the gate instead. It wraps a provider whose
applyPlan can already write any path, and puts consent in front of it.
import { createMigrationWriter, scopeHash } from '@contentrain/mcp/core/migration'
const scope = {
allow: ['src/**', 'public/**', 'package.json', 'astro.config.mjs'],
branch: 'migration/acme',
base: 'main',
}
const approval = {
scope_hash: await scopeHash(scope),
approver: { kind: 'human', id: userId, role: 'owner' },
approved_at: new Date().toISOString(),
}
const writer = createMigrationWriter(provider, scope, approval)
await writer.applyPlan({ ...plan, step: 'emit', actor })
writer.audit
What it guarantees:
| |
|---|
| Scope | Only paths an allowlist pattern covers. .contentrain/** is not implicit — a migration that wants the store asks for it, and the person approving sees that it does |
| Consent | One approval, bound to the exact scope by scope_hash. An approval of src/** cannot be replayed against a scope that also has .github/workflows/** |
| Branch | A migration/* ref. The contentrain branch is never a target and never a base — including through ApplyPlanInput's default, which a migration must not inherit |
| Trail | One MigrationAuditEntry per file: step, path, action, the pattern that allowed it, actor, timestamp, commit |
| Undo | One branch, delivered as one PR, revertable with one git revert |
Every refusal happens before the provider is called, and a plan is refused
whole: a plan with one path outside the scope writes nothing at all, not even
its allowed part. A half-applied migration is harder to recover from than one
that never started.
The path matcher is a security control and is written to fail closed. Traversal,
absolute paths, backslash separators, percent-encoding, empty segments and
control characters are refused before any pattern is consulted; patterns are
anchored at both ends, so src/* does not cover src/a/b and nothing matches
by being a prefix; and regex metacharacters in a pattern are literal, so an
allowlist entry cannot silently cover more than it reads.
Design Constraints
Key design decisions in this package:
- local-first by default — stdio transport + LocalProvider works without any network dependency
- provider-agnostic engine — the same core tools run over LocalProvider, GitHubProvider, or GitLabProvider behind a single
RepoProvider contract; media tools ride the provider's optional media facet
- 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 (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, doctor) reject with a uniform
capability_required error on remote providers
Development
From the monorepo root:
pnpm --filter @contentrain/mcp build
pnpm --filter @contentrain/mcp test
pnpm --filter @contentrain/mcp typecheck
pnpm exec oxlint packages/mcp/src packages/mcp/tests
contentrain — CLI and local review tooling
@contentrain/query — generated runtime query SDK
@contentrain/rules — IDE/agent rules and prompts
@contentrain/types — shared schema and model types
Documentation
Full documentation at ai.contentrain.io/packages/mcp.
License
MIT
Locale coverage (model.locales)
contentrain_validate checks an i18n: true model's parity against every locale
in config.locales.supported. A model that declares locales — a subset of that
list — is checked against the subset instead, which is how a partially-translated
site states the truth rather than failing on translations it never had. Absent, as
on every model that predates the field, means the whole project list.
Severity is unchanged and still follows the kind: a missing translation is a
warning on a document model and an error on a collection. Every message
names the list it was evaluated against, so the two cases read apart:
Locale file missing: tr.json (checked against the model's own locales [en, tr])
Entry parity: entry "a1b2c3" exists in en but missing in tr (checked against the project's supported locales [en, tr, da])
contentrain_model_save accepts locales and rejects a locale outside
config.locales.supported. contentrain_validate fix:true reports a broken
declaration but never invents one: narrowing a model's coverage is a content
decision, and the only value the tool could derive — the locales that happen to
have files today — would write the current gaps into the schema and silence the
errors that reveal them.
Runtime model configuration
Structural contentrain_model_save edits preserve existing top-level form and
comments blocks. These blocks belong to the runtime provider; the structural
tool does not enable or change them. Its response lists preserved_blocks when
configuration was carried forward.