Contentrain MCP

Extrahiere, verwalte und versende strukturierte Inhalte aus deiner Codebasis.

Dokumentation

@contentrain/mcp

npm version GitHub source Docs

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/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/{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_BRANCH env → 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 contentrain tip: JSON changes are carried over key by key, so entries another writer pushed are kept; a same-value conflict writes nothing and returns CONTENT_WORKING_TREE_STALE
  • on a checked-out feature branch, which writes do not update, .contentrain/** is read from the contentrain ref instead (source files still from the working tree); read tools report it as content_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: false in config.json
  • local mode (git.push: false or CONTENTRAIN_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 git block names the resolved base_branch; when it is not the checked-out branch, the result's warning says your working tree was left alone
  • commit hooks: skipped on machine commits by default; git.verify: true or CONTENTRAIN_VERIFY=1 runs 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 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

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).

ToolPurposeRead-onlyDestructive
contentrain_statusProject status, config, models, branch health, contextYes—
contentrain_describeFull schema and sample data for a modelYes—
contentrain_describe_formatFile-format and storage contract referenceYes—
contentrain_doctorProject health report (env, structure, models, orphans, local + remote branches, SDK)Yes—
contentrain_initCreate .contentrain/ structure and base config——
contentrain_scaffoldApply a starter template such as blog, docs, landing, saas——
contentrain_model_saveCreate or update a model definition——
contentrain_model_deleteDelete a model definition—Yes
contentrain_content_saveSave content entries for any model kind——
contentrain_content_deleteDelete content entries—Yes
contentrain_content_listRead content entriesYes—
contentrain_validateValidate project content, optionally auto-fix structural issues——
contentrain_submitPush cr/* branches to remote, then lazily prune merged local + remote leftovers——
contentrain_mergeMerge a review-mode branch into contentrain locally (by exact branch or model); deletes its remote copy—Yes
contentrain_reconcileContent-aware three-way merge of a diverged contentrain ↔ base pair (dry_run first, resolutions second)——
contentrain_branch_listList pending cr/* branches with merge status (remote: true adds remote view)Yes—
contentrain_branch_deleteDelete a stale/failed cr/* branch locally and on the remote (contentrain branch protected)—Yes
contentrain_scanGraph- and candidate-based hardcoded string scanYes—
contentrain_applyNormalize extract/reuse execution with dry-run support——
contentrain_bulkBulk locale copy, status updates, and deletes (dry_run previews)——
contentrain_vocabulary_saveAdd or update canonical vocabulary terms (merges with what exists)——
contentrain_vocabulary_deleteRemove canonical vocabulary terms by slug (confirm: true)—Yes
contentrain_media_listList media assets (search, tag filter, cursor pagination)Yes—
contentrain_media_getGet one media asset by idYes—
contentrain_media_ingestIngest an asset from a source URL (provider fetches server-side)——
contentrain_media_updateUpdate asset metadata (alt, tags, filename)——
contentrain_media_deleteDelete 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):

VariableDefaultEffect
CONTENTRAIN_BRANCHunsetBase 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_REMOTEoriginRemote used for every fetch and push (and remote branch cleanup)
CONTENTRAIN_NO_PUSHunset1/true turns local mode on; 0/false turns it off. Any other value is ignored
CONTENTRAIN_AUTHOR_NAME / CONTENTRAIN_AUTHOR_EMAILContentrain / ai@contentrain.ioAuthor identity on Contentrain's commits
CONTENTRAIN_GIT_BINARYunsetAbsolute path of the git binary to use
CONTENTRAIN_VERIFYunset1/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.json commit (the content is already on contentrain) is reported in the result's warning, not swallowed;
  • the hooks must work in a worktree under the system temp directory — a hook that runs npx lint-staged needs 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:

  1. Call contentrain_status
  2. If needed, call contentrain_init
  3. Create models with contentrain_model_save or contentrain_scaffold
  4. Save content with contentrain_content_save
  5. Validate with contentrain_validate
  6. For hardcoded strings, use contentrain_scan then contentrain_apply
  7. 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/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. 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 (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/base-branch — resolveBaseBranch, the one base-branch resolver every MCP write and contentrain status / serve use (env → repository.default_branch → <remote>/HEAD → main → master → checked-out branch → main); resolveBaseBranchSource (the same, plus which rung answered); resolveInitDefaultBranch (what init records as repository.default_branch, or null)
  • @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:

ScopeOnly 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
ConsentOne approval, bound to the exact scope by scope_hash. An approval of src/** cannot be replayed against a scope that also has .github/workflows/**
BranchA migration/* ref. The contentrain branch is never a target and never a base — including through ApplyPlanInput's default, which a migration must not inherit
TrailOne MigrationAuditEntry per file: step, path, action, the pattern that allowed it, actor, timestamp, commit
UndoOne 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 RepoProvider contract (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_REQUIREMENTS is 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.