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.
interface RepoReader {
readFile(path: string, ref?: string): Promise<string>
listDirectory(path: string, ref?: string): Promise<string[]>
fileExists(path: string, ref?: string): Promise<boolean>
}Error semantics:
readFilethrows when the file is missing. Callers opt into tolerance with an explicit try/catch.listDirectoryreturns[]for a missing directory. The empty case is the common, uninteresting one.
RepoWriter
Write surface — one method, one atomic commit per call.
interface RepoWriter {
applyPlan(input: ApplyPlanInput): Promise<Commit>
}
interface ApplyPlanInput {
branch: string
changes: FileChange[]
message: string
author: CommitAuthor
base?: string // Defaults to CONTENTRAIN_BRANCH ('contentrain')
}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 operations
Providers extend RepoReader and RepoWriter with branch / merge / diff operations to form the full RepoProvider:
interface RepoProvider extends RepoReader, RepoWriter {
readonly capabilities: ProviderCapabilities
listBranches(prefix?: string): Promise<Branch[]>
createBranch(name: string, fromRef?: string): Promise<void>
deleteBranch(name: string): Promise<void>
getBranchDiff(branch: string, base?: string): Promise<FileDiff[]>
mergeBranch(branch: string, into: string): Promise<MergeResult>
isMerged(branch: string, into?: string): Promise<boolean>
getDefaultBranch(): Promise<string>
}MergeResult is { merged, sha, pullRequestUrl, sync? }. GitHubProvider fills sha on direct merges; GitLabProvider fills both sha and pullRequestUrl (merges via MR). LocalProvider populates sync: SyncResult to describe file syncing to the working tree. When branch protection blocks a direct merge, any provider may return merged: false with a pullRequestUrl fallback.
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.
interface ProviderCapabilities {
localWorktree: boolean
sourceRead: boolean
sourceWrite: boolean
pushRemote: boolean
branchProtection: boolean
pullRequestFallback: boolean
astScan: boolean
}Capability meanings:
| Capability | Purpose |
|---|---|
localWorktree | Provider backs onto a local filesystem worktree and can selectively sync changes to developer's working tree |
sourceRead | Provider can read arbitrary source files outside .contentrain/ (required for normalize extract) |
sourceWrite | Provider can write arbitrary source files outside .contentrain/ (required for normalize reuse) |
pushRemote | Provider can push commits to a remote repository (required for submit) |
branchProtection | Provider detects branch protection rules on the remote |
pullRequestFallback | Provider can open a pull request as a fallback when direct merge is blocked |
astScan | Provider can execute AST scanners against source files (implies local disk access) |
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:
export const LOCAL_CAPABILITIES: ProviderCapabilities = {
localWorktree: true,
sourceRead: true,
sourceWrite: true,
pushRemote: true,
branchProtection: false,
pullRequestFallback: false,
astScan: true,
}Supporting types
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
sync?: SyncResult // Only populated by LocalProvider
}
interface SyncResult {
synced: string[] // Files successfully synced to working tree
skipped: string[] // Files skipped due to uncommitted local changes
warning?: string // Human-readable warning if files were skipped
}Implementing a custom provider
Minimum viable provider:
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<string> { /* ... */ }
async listDirectory(path: string, ref?: string): Promise<string[]> { /* ... */ }
async fileExists(path: string, ref?: string): Promise<boolean> { /* ... */ }
async applyPlan(input): Promise<Commit> { /* 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 + worktreepackages/mcp/src/providers/github/— Octokit over the Git Data + Repos APIspackages/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.