Skip to content

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<string>
  listDirectory(path: string, ref?: string): Promise<string[]>
  fileExists(path: string, ref?: string): Promise<boolean>
}

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<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:

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

ts
interface ProviderCapabilities {
  localWorktree: boolean
  sourceRead: boolean
  sourceWrite: boolean
  pushRemote: boolean
  branchProtection: boolean
  pullRequestFallback: boolean
  astScan: boolean
}

Capability meanings:

CapabilityPurpose
localWorktreeProvider backs onto a local filesystem worktree and can selectively sync changes to developer's working tree
sourceReadProvider can read arbitrary source files outside .contentrain/ (required for normalize extract)
sourceWriteProvider can write arbitrary source files outside .contentrain/ (required for normalize reuse)
pushRemoteProvider can push commits to a remote repository (required for submit)
branchProtectionProvider detects branch protection rules on the remote
pullRequestFallbackProvider can open a pull request as a fallback when direct merge is blocked
astScanProvider can execute AST scanners against source files (implies local disk access)

Built-in capability sets:

CapabilityLocalProviderGitHubProviderGitLabProvider
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:

ts
export const LOCAL_CAPABILITIES: ProviderCapabilities = {
  localWorktree: true,
  sourceRead: true,
  sourceWrite: true,
  pushRemote: true,
  branchProtection: false,
  pullRequestFallback: false,
  astScan: true,
}

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
  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:

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