diff --git a/packages/mcp/README.md b/packages/mcp/README.md index e5b31e9..252f799 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -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: diff --git a/packages/mcp/src/git/transaction.ts b/packages/mcp/src/git/transaction.ts index 047e96a..fbebe3a 100644 --- a/packages/mcp/src/git/transaction.ts +++ b/packages/mcp/src/git/transaction.ts @@ -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' @@ -38,35 +39,10 @@ export async function ensureContentBranch(projectRoot: string): Promise { || (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]) diff --git a/packages/mcp/src/providers/local/migration.ts b/packages/mcp/src/providers/local/migration.ts new file mode 100644 index 0000000..8d16dc6 --- /dev/null +++ b/packages/mcp/src/providers/local/migration.ts @@ -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 { + 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 +} diff --git a/packages/mcp/src/tools/normalize.ts b/packages/mcp/src/tools/normalize.ts index 1647d66..5af4a58 100644 --- a/packages/mcp/src/tools/normalize.ts +++ b/packages/mcp/src/tools/normalize.ts @@ -12,7 +12,7 @@ import { capabilityError } from './guards.js' export function registerNormalizeTools( server: McpServer, - _provider: ToolProvider, + provider: ToolProvider, projectRoot: string | undefined, ): void { // ─── contentrain_scan ─── @@ -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 { @@ -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) diff --git a/packages/mcp/tests/providers/local/migration.test.ts b/packages/mcp/tests/providers/local/migration.test.ts new file mode 100644 index 0000000..7f922d0 --- /dev/null +++ b/packages/mcp/tests/providers/local/migration.test.ts @@ -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) + }) +})