Contentrain MCP
Extrahiere, verwalte und versende strukturierte Inhalte aus deiner Codebasis.
Dokumentation
@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 a content governance ecosystem for teams and AI agents. In it, this engine works like this:
- 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/compilersvelte
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
contentrainbranch 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/{scope}/{target}/{timestamp}-{suffix}, e.g.cr/content/blog-post/1730000000-a1b2; bulk locale copies and scaffold add a{locale}segment before the timestamp) - auto-merge: feature merges into
contentrain, baseBranch advanced via update-ref,.contentrain/files selectively synced to developer's working tree when the base branch is the one checked out - baseBranch =
CONTENTRAIN_BRANCHenv →repository.default_branch→ remote HEAD →main→master→ checked-out branch →main(fallback). A write made on a feature branch never merges, moves or pushes that branch - plans are read from the working tree but committed on the fetched
contentraintip: JSON changes are carried over key by key, so entries another writer pushed are kept; a same-value conflict writes nothing and returnsCONTENT_WORKING_TREE_STALE - on a checked-out feature branch, which writes do not update,
.contentrain/**is read from thecontentrainref instead (source files still from the working tree); read tools report it ascontent_source - 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: falsein config.json - local mode (
git.push: falseorCONTENTRAIN_NO_PUSH=1): nothing is ever pushed or deleted on the remote, and every result says so — see Git environment and local mode - every local write's
gitblock names the resolvedbase_branch; when it is not the checked-out branch, the result'swarningsays your working tree was left alone - commit hooks: skipped on machine commits by default;
git.verify: trueorCONTENTRAIN_VERIFY=1runs them, and a rejecting hook fails the write with its output — see Commit hooks - 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
contentrainbranch 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
Tool Surface
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, vocabulary_save, vocabulary_delete, 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 | — | Yes |
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_vocabulary_save | Add or update canonical vocabulary terms (merges with what exists) | — | — |
contentrain_vocabulary_delete | Remove canonical vocabulary terms by slug (confirm: true) | — | Yes |
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
Configure via CLI (recommended)
npx contentrain setup claude-code # or: cursor, vscode, windsurf, copilot
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 -y @contentrain/mcp
If CONTENTRAIN_PROJECT_ROOT is omitted, the current working directory is used.
Git environment and local mode
These apply to the local provider (stdio, LocalProvider):
| Variable | Default | Effect |
|---|---|---|
CONTENTRAIN_BRANCH | unset | Base branch the contentrain branch advances. Wins over repository.default_branch, then remote HEAD → main → master → the checked-out branch → main. Per process; never written to config |
CONTENTRAIN_REMOTE | origin | Remote used for every fetch and push (and remote branch cleanup) |
CONTENTRAIN_NO_PUSH | unset | 1/true turns local mode on; 0/false turns it off. Any other value is ignored |
CONTENTRAIN_AUTHOR_NAME / CONTENTRAIN_AUTHOR_EMAIL | Contentrain / ai@contentrain.io | Author identity on Contentrain's commits |
CONTENTRAIN_GIT_BINARY | unset | Absolute path of the git binary to use |
CONTENTRAIN_VERIFY | unset | 1/true runs the repository's commit hooks on Contentrain's commits; 0/false skips them. Wins over git.verify. Any other value is ignored |
Local mode keeps every write on local branches: no branch push, no review push, no push after an auto-merge, approve or reconcile, and no remote branch delete or prune. Fetching is not a push and still happens when a remote exists. Turn it on for a project with "git": { "push": false } in .contentrain/config.json, or for one run with CONTENTRAIN_NO_PUSH=1.
Precedence: the env wins over the config in both directions. CONTENTRAIN_NO_PUSH=1 turns pushing off even when config allows it; CONTENTRAIN_NO_PUSH=0 turns it back on over git.push: false. With neither, pushing is on.
It is never a silent success. Each write's git block reports remote_push: "disabled" with remote_note: "not pushed (local mode)", and next_steps carries a LOCAL MODE line. contentrain_submit returns an error instead of pushing; land review branches with contentrain_merge. contentrain_merge and contentrain_branch_delete delete the local branch as usual and leave any remote copy untouched; contentrain_branch_delete reports remote_skipped: "local-mode", contentrain_merge reports remote: { deleted: false, skipped: "local-mode" }.
Commit hooks
By default Contentrain's machine commits (the cr/* content commit, the context.json commit, a reconcile merge commit, the seed commit of an empty repo) skip the repository's commit hooks (pre-commit, prepare-commit-msg, commit-msg). That keeps existing projects working: these commits are made in a temporary worktree without your node_modules, and their [contentrain] … subject fails a strict commitlint. Merges still run the merge hooks and pushes still run pre-push, as before.
To run commit hooks on every machine commit, set "git": { "verify": true } in .contentrain/config.json, or CONTENTRAIN_VERIFY=1 for one run (CONTENTRAIN_VERIFY=0 skips them over the config). With hooks on:
- a hook that rejects the content commit fails the write: nothing lands, and the tool error carries the hook's output. Nothing is retried without hooks;
- a hook that rejects only the follow-up
context.jsoncommit (the content is already oncontentrain) is reported in the result'swarning, not swallowed; - the hooks must work in a worktree under the system temp directory — a hook that runs
npx lint-stagedneeds the tool available there.
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 defaultInstructions for the tools the server registers — with every tool available, DEFAULT_INSTRUCTIONS — kept under 512 characters; pass '' to omit). The HTTP entries (startHttpMcpServer, startHttpMcpServerWith) take the same instructions option.
Example MCP Flow
Typical agent workflow:
- Call
contentrain_status - If needed, call
contentrain_init - Create models with
contentrain_model_saveorcontentrain_scaffold - Save content with
contentrain_content_save - Validate with
contentrain_validate - For hardcoded strings, use
contentrain_scanthencontentrain_apply - Push review branches with
contentrain_submit
Normalize Flow
Normalize is intentionally split into two phases:
1. Extract
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/{timestamp}-{suffix})
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 or domain}/{timestamp}-{suffix})
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, so a server built on one does not register contentrain_scan or contentrain_apply at all (see TOOL_REQUIREMENTS). A provider that can read the source tree but not write it lists contentrain_apply; its reuse mode then 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 core tool (normalize included) works on it; the media tools need a provider with a media facet. Stdio transport defaults to this.
- GitHubProvider — Octokit over the Git Data + Repos APIs. No clone, no worktree.
@octokit/restships as an optional peer dependency. - GitLabProvider — gitbeaker over the GitLab REST API. No clone, no worktree.
@gitbeaker/restships 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. The GitHub provider also takes a full commit SHA as applyPlan's base (and createBranch's fromRef) for compare-and-set writes: a missing branch forks from that commit, and a branch that has moved away from it refuses the write with PROVIDER_CONFLICT.
GitLab — installation & usage
pnpm add @gitbeaker/rest
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', // or numeric project ID
host: 'https://gitlab.company.com', // omit for gitlab.com
},
})
const server = createServer({ provider })
// serve over stdio or the HTTP transport from @contentrain/mcp/server/http
Capabilities: sourceRead, sourceWrite, astScan, localWorktree are all false; pushRemote, branchProtection, pullRequestFallback are true. Normalize (scan / apply) is not registered on a GitLabProvider server — fall back to a local transport for those flows.
Bitbucket
There is no Bitbucket provider. A Bitbucket checkout works through the LocalProvider path like any other local Git repository.
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 (includingplanReconcileand itsbindRefreader 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/base-branch—resolveBaseBranch, the one base-branch resolver every MCP write andcontentrain status/serveuse (env →repository.default_branch→<remote>/HEAD→main→master→ checked-out branch →main);resolveBaseBranchSource(the same, plus which rung answered);resolveInitDefaultBranch(whatinitrecords asrepository.default_branch, ornull)@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',
}
// Show the user `scope`, not the hash. Then bind their consent to it:
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 // which file, in which step, by which actor, in which commit
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 core tools run over LocalProvider, GitHubProvider, or GitLabProvider behind a single
RepoProvidercontract (remote providers get the remote-safe subset); 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 a local checkout or source-tree access (scan, apply, doctor, init, scaffold, bulk, submit, merge, reconcile, branch tools) are not registered on remote providers;
TOOL_REQUIREMENTSis the map
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
Related Packages
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.
Part of the Contentrain ecosystem
Contentrain is a content governance ecosystem for teams and AI agents. This package is one of the open-source tools (docs). Studio is the team surface for review and roles; it drives this engine over the HTTP transport, and the contentrain_media_* tools need a provider with a media stack such as Studio's. Migrate moves WordPress sites to Astro, and Bridge is a free WordPress plugin that exports a site as a Contentrain store.
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.
A translation that was never written is a warning: an entry of a collection
that one locale has and another lacks (Entry parity), like a document's missing
translation. A dictionary key missing from a locale (Key parity) is a warning
too. A locale with no content file (Locale file missing) is an error. 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.