Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,28 @@ Normalize is intentionally split into two phases:

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:

```json
{
"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.

## 📦 Core Exports

The package also exposes low-level modules for embedding and advanced use:
Expand Down
34 changes: 5 additions & 29 deletions packages/mcp/src/git/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto'
import { readConfig } from '../core/config.js'
import { writeContext } from '../core/context.js'
import { branchTimestamp } from '../util/id.js'
import { migrateLegacyBranches } from '../providers/local/migration.js'
import type { SyncResult } from '@contentrain/types'
import { CONTENTRAIN_BRANCH } from '@contentrain/types'

Expand Down Expand Up @@ -38,35 +39,10 @@ export async function ensureContentBranch(projectRoot: string): Promise<void> {
|| (await git.raw(['branch', '--show-current'])).trim()
|| 'main'

// Migration: old system used `contentrain/*` feature branches which conflict
// with creating a `contentrain` branch (git ref namespace: path can't be both
// file and directory). Clean up merged old-prefix branches before creating.
const oldPrefixBranches = branches.all.filter(b => b.startsWith('contentrain/'))
if (oldPrefixBranches.length > 0) {
// Get merged branches (safe to delete)
let mergedBranches: string[] = []
try {
const mergedOutput = await git.raw(['branch', '--merged', baseBranch])
mergedBranches = mergedOutput.split('\n')
.map(b => b.trim().replace(/^\*\s*/, ''))
.filter(b => b.startsWith('contentrain/'))
} catch { /* ignore */ }

// Delete merged old-prefix branches
for (const b of mergedBranches) {
try { await git.raw(['branch', '-d', b]) } catch { /* skip if protected */ }
}

// Check if unmerged old-prefix branches still block creation
const remaining = (await git.branchLocal()).all.filter(b => b.startsWith('contentrain/'))
if (remaining.length > 0) {
// Force-delete remaining old-prefix branches (they use the old system,
// content is already on main via previous auto-merges)
for (const b of remaining) {
try { await git.raw(['branch', '-D', b]) } catch { /* skip */ }
}
}
}
// Clean up legacy `contentrain/*` feature branches so the singleton
// `contentrain` ref can be created. Idempotent — safe to call even
// when no legacy branches exist.
await migrateLegacyBranches(git, baseBranch)

// Create contentrain branch from base
await git.branch([CONTENTRAIN_BRANCH, baseBranch])
Expand Down
67 changes: 67 additions & 0 deletions packages/mcp/src/providers/local/migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { SimpleGit } from 'simple-git'

/**
* Migration: the first MCP release used `contentrain/*` feature branches.
* Once we introduced the singleton `contentrain` branch (tracking the
* committed content state), those old feature branches became a ref-
* namespace conflict — git cannot hold both `contentrain` (a leaf ref)
* and `contentrain/foo` (implying a directory) simultaneously.
*
* `migrateLegacyBranches` removes the old-prefix branches so the
* singleton `contentrain` ref can be created. It is idempotent and
* safe to call before every `ensureContentBranch` run.
*
* Strategy:
* 1. Delete merged `contentrain/*` branches first (`-d`). Their commits
* are already on the base branch via the old auto-merge flow.
* 2. Force-delete whatever remains (`-D`). Any unmerged leftover is
* from an abandoned or partially-committed legacy branch — content
* on `main` always wins, and the singleton `contentrain` branch is
* about to be created from `main`/`baseBranch` anyway.
*
* Returns the number of branches that were deleted. Callers may log it;
* the git transaction layer does not need the count for correctness.
*/
export async function migrateLegacyBranches(
git: SimpleGit,
baseBranch: string,
): Promise<number> {
const branches = await git.branchLocal()
const oldPrefixBranches = branches.all.filter(b => b.startsWith('contentrain/'))
if (oldPrefixBranches.length === 0) return 0

let deleted = 0

// 1) Delete merged legacy branches first — the safe path.
let mergedLegacy: string[] = []
try {
const mergedOutput = await git.raw(['branch', '--merged', baseBranch])
mergedLegacy = mergedOutput.split('\n')
.map(b => b.trim().replace(/^\*\s*/, ''))
.filter(b => b.startsWith('contentrain/'))
} catch {
// `branch --merged` fails before baseBranch exists — fall through.
}

for (const b of mergedLegacy) {
try {
await git.raw(['branch', '-d', b])
deleted++
} catch {
// Branch may be protected or already gone — safe to skip.
}
}

// 2) Force-delete any unmerged legacy branches still present.
const remaining = (await git.branchLocal()).all.filter(b => b.startsWith('contentrain/'))
for (const b of remaining) {
try {
await git.raw(['branch', '-D', b])
deleted++
} catch {
// Skip — best-effort cleanup.
}
}

return deleted
}
15 changes: 11 additions & 4 deletions packages/mcp/src/tools/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { capabilityError } from './guards.js'

export function registerNormalizeTools(
server: McpServer,
_provider: ToolProvider,
provider: ToolProvider,
projectRoot: string | undefined,
): void {
// ─── contentrain_scan ───
Expand All @@ -31,7 +31,11 @@ export function registerNormalizeTools(
},
TOOL_ANNOTATIONS['contentrain_scan']!,
async (input) => {
if (!projectRoot) return capabilityError('contentrain_scan', 'astScan')
// AST scans require local disk access — GitHubProvider et al. expose
// `astScan: false`, so this rejects with a uniform capability error.
if (!provider.capabilities.astScan || !projectRoot) {
return capabilityError('contentrain_scan', 'astScan')
}
const config = await readConfig(projectRoot)
if (!config) {
return {
Expand Down Expand Up @@ -178,8 +182,11 @@ export function registerNormalizeTools(
},
TOOL_ANNOTATIONS['contentrain_apply']!,
async (input) => {
if (!projectRoot) {
const capability = input.mode === 'reuse' ? 'sourceWrite' : 'sourceRead'
// Normalize extract needs to read source files; reuse needs to write
// them back. Remote providers expose both capabilities as `false` and
// get rejected before any work starts.
const capability = input.mode === 'reuse' ? 'sourceWrite' : 'sourceRead'
if (!provider.capabilities[capability] || !projectRoot) {
return capabilityError('contentrain_apply', capability)
}
const config = await readConfig(projectRoot)
Expand Down
81 changes: 81 additions & 0 deletions packages/mcp/tests/providers/local/migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { simpleGit } from 'simple-git'
import { migrateLegacyBranches } from '../../../src/providers/local/migration.js'

vi.setConfig({ testTimeout: 30_000, hookTimeout: 30_000 })

let testDir: string

beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), 'cr-migration-'))
const git = simpleGit(testDir)
await git.init()
await git.addConfig('user.name', 'Test')
await git.addConfig('user.email', 'test@contentrain.io')
await writeFile(join(testDir, 'README.md'), 'seed\n')
await git.add('.')
await git.commit('initial')
})

afterEach(async () => {
await rm(testDir, { recursive: true, force: true })
})

describe('migrateLegacyBranches', () => {
it('deletes merged contentrain/* branches and clears the namespace', async () => {
const git = simpleGit(testDir)
// Create two merged legacy branches (no divergence from main).
await git.branch(['contentrain/legacy-merged-1'])
await git.branch(['contentrain/legacy-merged-2'])

const baseBranch = (await git.raw(['branch', '--show-current'])).trim() || 'main'
const deleted = await migrateLegacyBranches(git, baseBranch)

expect(deleted).toBe(2)
const remaining = (await git.branchLocal()).all.filter(b => b.startsWith('contentrain/'))
expect(remaining).toEqual([])
})

it('force-deletes unmerged contentrain/* branches so the namespace clears', async () => {
const git = simpleGit(testDir)
// Diverge a legacy branch so `branch -d` would refuse it.
await git.checkoutBranch('contentrain/legacy-unmerged', 'HEAD')
await writeFile(join(testDir, 'diverged.txt'), 'diverged\n')
await git.add('.')
await git.commit('diverged commit')
// Return to base so `branch -D` can delete the current branch target.
await git.checkout('main').catch(async () => {
// Some environments default to `master`.
await git.checkout('master')
})

const baseBranch = (await git.raw(['branch', '--show-current'])).trim()
const deleted = await migrateLegacyBranches(git, baseBranch)

expect(deleted).toBeGreaterThanOrEqual(1)
const remaining = (await git.branchLocal()).all.filter(b => b.startsWith('contentrain/'))
expect(remaining).toEqual([])
})

it('is a no-op when there are no legacy branches', async () => {
const git = simpleGit(testDir)
const baseBranch = (await git.raw(['branch', '--show-current'])).trim() || 'main'
const deleted = await migrateLegacyBranches(git, baseBranch)
expect(deleted).toBe(0)
})

it('is idempotent — a second run after cleanup returns 0', async () => {
const git = simpleGit(testDir)
await git.branch(['contentrain/legacy-a'])
const baseBranch = (await git.raw(['branch', '--show-current'])).trim() || 'main'

const first = await migrateLegacyBranches(git, baseBranch)
const second = await migrateLegacyBranches(git, baseBranch)

expect(first).toBe(1)
expect(second).toBe(0)
})
})
Loading