diff --git a/.mcp.json b/.mcp.json index ed95798b..5ba95d7f 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,7 +3,8 @@ "contentrain": { "command": "npx", "args": [ - "@contentrain/mcp" + "-y", + "@contentrain/mcp@1.5.0" ] }, "nuxt": { diff --git a/CLAUDE.md b/CLAUDE.md index aa08c1c8..bc0abed9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,13 +237,23 @@ itself. Every save / delete op composes: (`status: 'draft'`, `updated_by: 'contentrain-mcp'`) with Studio's `autoPublish` + existing-status preservation + per-user `updated_by` semantics. -4. `OverlayReader` + `buildContextChange` — wraps the plan changes - so `context.json` stats (entries per model, last-sync) reflect - the post-commit state, not the pre-change base branch. -5. `provider.applyPlan({ branch, changes, message, author, base: 'contentrain' })` +4. `provider.applyPlan({ branch, changes, message, author, base: 'contentrain' })` — atomic branch+commit via the GitHub Data API. `createBranch` is no longer called separately; `applyPlan` forks `base` when the - branch is missing. + branch is missing. **`context.json` is NOT part of `changes`** — + see the context.json invariant below. + +**`context.json` lifecycle (MCP 1.5.0 model)** — feature branches +**never** carry `context.json`. Committing it per-save caused merge +conflicts when parallel `cr/*` branches landed (each mutated the same +file from the same base). Instead it is regenerated deterministically +on the `contentrain` branch **after a merge**, in +`branch-ops.ts:mergeBranch` → `regenerateContextOnContentrain` +(`buildContextChange` over the merged tree + a dedicated +`applyPlan` commit onto `contentrain`, best-effort). The seed +`context.json` is still written once at `initProject` time. Brain cache +and external readers only ever read it from `contentrain`, so post-merge +regeneration is the single point it needs to be accurate. **Invariants to preserve** when touching this path: @@ -251,20 +261,54 @@ itself. Every save / delete op composes: fork from it via `applyPlan`'s default `base`. `config.repository .default_branch` (`main` / `master`) is informational — never the fork point. -- Post-change reads (for validation or context) go through - `OverlayReader(reader, pendingChanges)` — raw reader shows the - pre-change tree and will emit stale stats. +- Never add `context.json` to a feature-branch `applyPlan`. Only + `initProject` (seed) and `regenerateContextOnContentrain` (post-merge, + on `contentrain`) may write it. - Studio's `pinReaderToContentrain` wrapper defaults ref to `CONTENTRAIN_BRANCH` for every MCP read (MCP's helpers call `reader.readFile(path)` without a ref). +## MCP Cloud — HTTP MCP server for external agents + +Studio boots a real MCP server (`@contentrain/mcp/server/http` +`startHttpMcpServerWith`) on a loopback port at Nitro startup +(`server/plugins/mcp-cloud-server.ts`). The authenticated public entry is +`server/api/mcp/v1/[projectId]/[...slug].ts` — Bearer key validation + +project match + `api.mcp_cloud` plan gate + per-key rate limit + atomic +monthly quota (`increment_mcp_cloud_usage_if_allowed`) + usage metering + +header strip + proxy to the loopback server + brain-cache invalidation on +write tools. Keys live in `mcp_cloud_keys` (SHA-256 hashed); UI is +`WorkspaceMcpCloudPanel.vue`. The whole path is implemented — **not** a stub. + +**Two deliberate boundaries — keep them in mind when changing this path:** + +- **Reduced tool surface.** The loopback server runs against Studio's + `GitHubProvider` (`localWorktree: false`). So MCP's local-git tools — + `contentrain_merge`, `contentrain_branch_list`, `contentrain_branch_delete`, + `contentrain_submit` — return a capability error over MCP Cloud. External + agents can author (content / model save+delete, list, describe, validate, + status, init, bulk, scaffold) but the merge/review lifecycle is + Studio-owned. Do **not** add these to `WRITE_TOOL_NAMES` — they neither + reach the provider nor mutate the content branch. + +- **pending-review by contract, reconciled by workflow.** MCP's remote write + path hardcodes `workflowAction: "pending-review"` and leaves the merge to + Studio. So MCP Cloud writes land as `cr/*` branches. To stay consistent + with the native write paths, `reconcileMcpCloudAutoMerge` + (`server/utils/mcp-cloud-automerge.ts`) lands those branches **only** when + the project's effective workflow is auto-merge — resolved with the same + rule everywhere: `review` requires both the `workflow.review` plan feature + **and** `config.workflow === 'review'`; otherwise auto-merge. It runs + fire-and-forget after a write so it can never affect the external caller's + response, and is a no-op on review-gated projects. + ## Deferred TODOs Medium: - Mobile shell: hamburger + slide-over (button exists, handler + drawer missing) -- Branch health: no 80+ branch threshold, no auto-delete merged cr/* branches +- Branch health: warn/block thresholds (default 50/80, config-driven via `branchWarnLimit`/`branchBlockLimit` since MCP 1.5.0) + merged `cr/*` auto-delete are implemented (`branch-health.ts`, `branch-cleanup.ts`). Remaining: surface health status in the UI - Brain cache: no GitHub webhook-triggered invalidation for external pushes (TTL-only, 10min) -- MCP Cloud endpoint: `server/api/mcp/v1/[projectId]/[...].ts` awaits `@contentrain/mcp` `resolveProvider` callback (per-request provider resolution). Foundations (license entries, `mcp_cloud_keys` table, usage RPC) shipped in Faz S6 — route implementation pending. +- MCP Cloud: integration-test coverage for the proxy route (`/api/mcp/v1/...`) and key endpoints is still thin (logic is covered, full HTTP path is not) ## Branch Model & Deploy Flow — CRITICAL diff --git a/package.json b/package.json index 3c1a18f6..eb0b5053 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,9 @@ "dependencies": { "@anthropic-ai/sdk": "^0.80.0", "@aws-sdk/client-s3": "^3.1014.0", - "@contentrain/mcp": "1.4.0", - "@contentrain/query": "^5.1.2", - "@contentrain/types": "0.5.0", + "@contentrain/mcp": "1.5.0", + "@contentrain/query": "^6.0.0", + "@contentrain/types": "0.5.1", "@gitbeaker/rest": "^43.8.0", "@nuxt/eslint": "1.15.2", "@nuxt/image": "2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0eb2fe66..db7a66da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,14 +15,14 @@ importers: specifier: ^3.1014.0 version: 3.1014.0 '@contentrain/mcp': - specifier: 1.4.0 - version: 1.4.0(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1) + specifier: 1.5.0 + version: 1.5.0(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1) '@contentrain/query': - specifier: ^5.1.2 - version: 5.1.2 + specifier: ^6.0.0 + version: 6.0.0 '@contentrain/types': - specifier: 0.5.0 - version: 0.5.0 + specifier: 0.5.1 + version: 0.5.1 '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 @@ -565,8 +565,8 @@ packages: resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} engines: {node: '>=v18'} - '@contentrain/mcp@1.4.0': - resolution: {integrity: sha512-wIZVaquWzwxKnFtoIwaKThje7jMzQ37SDsBptxTVrTZxjef8LniYhYt/uMSQWy4pSDTGV6B9/jQrWnvoH40q5g==} + '@contentrain/mcp@1.5.0': + resolution: {integrity: sha512-PV+Pwb4o384Jb0joJP0eb2deLaZtWX8L346hBlapPVcGIuDUdzRhTXrI3jHV6b5wz+SiAF9Z0NUDI0mSdU95Sw==} hasBin: true peerDependencies: '@gitbeaker/rest': '>=43.0.0' @@ -577,15 +577,12 @@ packages: '@octokit/rest': optional: true - '@contentrain/query@5.1.2': - resolution: {integrity: sha512-t4ogva5SYJXsRJNDhCpkDURoipmDlDEB6tsOIi697fuOpVKi60c9sKchdDYUEd7zNqcgx0d2dZGKae8qLfVMng==} + '@contentrain/query@6.0.0': + resolution: {integrity: sha512-qiTB/K4VPRO18TA1TlcEK1oPD7jEkPm6qYESyqnZLnE/heuBai2AS8xO3M32dflS89Zi3JXNaoIMI3c6hSHoQg==} hasBin: true - '@contentrain/types@0.4.0': - resolution: {integrity: sha512-saZ1qgdOfDmoNN2/KwUV59HEvcpOPtwdgZtmEWFFTxtJ1Cze1FZQCVElQfocikseNh5dE/rO/f/oVi/H1W/usw==} - - '@contentrain/types@0.5.0': - resolution: {integrity: sha512-VWU2EERn3zVHYcDWyINqjiTM9p2kKl5H965qHRmWYlif+fWyzLUqsb7JzqOtvmGhsDSQfhT+8SLjs9duoLO6jg==} + '@contentrain/types@0.5.1': + resolution: {integrity: sha512-G8rlZFjtdSTVNVJVSsbO93pkmtVmxwXsRYSGmHK9q0Op672FEqSMHXNVCo+sF0+N/JYs+6kiW0GnsQOIV1v4Pg==} '@conventional-changelog/git-client@2.6.0': resolution: {integrity: sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==} @@ -5478,11 +5475,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - srvx@0.11.12: - resolution: {integrity: sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA==} - engines: {node: '>=20.16.0'} - hasBin: true - srvx@0.11.15: resolution: {integrity: sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==} engines: {node: '>=20.16.0'} @@ -7015,9 +7007,9 @@ snapshots: conventional-commits-parser: 6.3.0 picocolors: 1.1.1 - '@contentrain/mcp@1.4.0(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1)': + '@contentrain/mcp@1.5.0(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1)': dependencies: - '@contentrain/types': 0.5.0 + '@contentrain/types': 0.5.1 '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) simple-git: 3.33.0 typescript: 5.9.3 @@ -7032,13 +7024,11 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@contentrain/query@5.1.2': + '@contentrain/query@6.0.0': dependencies: - '@contentrain/types': 0.4.0 + '@contentrain/types': 0.5.1 - '@contentrain/types@0.4.0': {} - - '@contentrain/types@0.5.0': {} + '@contentrain/types@0.5.1': {} '@conventional-changelog/git-client@2.6.0(conventional-commits-parser@6.3.0)': dependencies: @@ -7539,7 +7529,7 @@ snapshots: pkg-types: 2.3.0 scule: 1.3.0 semver: 7.7.4 - srvx: 0.11.12 + srvx: 0.11.15 std-env: 3.10.0 tinyclip: 0.1.12 tinyexec: 1.1.1 @@ -12482,8 +12472,6 @@ snapshots: sprintf-js@1.0.3: {} - srvx@0.11.12: {} - srvx@0.11.15: {} stable-hash-x@0.2.0: {} diff --git a/server/api/mcp/v1/[projectId]/[...slug].ts b/server/api/mcp/v1/[projectId]/[...slug].ts index d9dc4b41..1620ec36 100644 --- a/server/api/mcp/v1/[projectId]/[...slug].ts +++ b/server/api/mcp/v1/[projectId]/[...slug].ts @@ -29,6 +29,7 @@ import { useDatabaseProvider } from '~~/server/utils/providers' import { checkRateLimit } from '~~/server/utils/rate-limit' import { getPlanLimit, getWorkspacePlan, hasFeature } from '~~/server/utils/license' import { getEffectiveLimit } from '~~/server/utils/overage' +import { reconcileMcpCloudAutoMerge } from '~~/server/utils/mcp-cloud-automerge' const WRITE_TOOL_NAMES = new Set([ 'contentrain_content_save', @@ -202,6 +203,20 @@ export default defineEventHandler(async (event) => { if (shouldInvalidateBrain) { invalidateBrainCache(keyData.projectId) + + // MCP's remote write path always reports `pending-review` and delegates + // the merge to Studio. Land those branches when the project's effective + // workflow is auto-merge (plan + config aware), matching the native + // write paths. Fire-and-forget — it can never affect the response the + // external agent already received. + void reconcileMcpCloudAutoMerge({ + workspaceId: keyData.workspaceId, + projectId: keyData.projectId, + installationId: workspace.github_installation_id as number, + repoFullName: project.repo_full_name as string, + contentRoot: (project.content_root as string | null) ?? '', + plan, + }).catch(() => {}) } return response diff --git a/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/cleanup.post.ts b/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/cleanup.post.ts index 93ff69dd..c58c4c04 100644 --- a/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/cleanup.post.ts +++ b/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/cleanup.post.ts @@ -14,8 +14,8 @@ export default defineEventHandler(async (event) => { const db = useDatabaseProvider() await db.requireWorkspaceRole(session.accessToken, session.user.id, workspaceId, ['owner', 'admin']) - const { git } = await resolveProjectContext(workspaceId, projectId) - const report = await cleanupMergedBranches(git, projectId) + const { git, contentRoot } = await resolveProjectContext(workspaceId, projectId) + const report = await cleanupMergedBranches(git, projectId, undefined, contentRoot) return report }) diff --git a/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/health.get.ts b/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/health.get.ts index 60b061db..57df275f 100644 --- a/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/health.get.ts +++ b/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/health.get.ts @@ -15,6 +15,6 @@ export default defineEventHandler(async (event) => { const cached = await getHealthStatus(projectId) if (cached) return cached - const { git } = await resolveProjectContext(workspaceId, projectId) - return checkBranchHealth(git, projectId) + const { git, contentRoot } = await resolveProjectContext(workspaceId, projectId) + return checkBranchHealth(git, projectId, contentRoot) }) diff --git a/server/plugins/branch-cleanup.ts b/server/plugins/branch-cleanup.ts index 73ead067..fa60515e 100644 --- a/server/plugins/branch-cleanup.ts +++ b/server/plugins/branch-cleanup.ts @@ -27,7 +27,7 @@ export default defineNitroPlugin((nitroApp) => { async function runBranchCleanup() { const db = useDatabaseProvider() - const projects = await db.listAllActiveProjects('id, repo_full_name, workspace_id') + const projects = await db.listAllActiveProjects('id, repo_full_name, workspace_id, content_root') for (const project of projects) { try { @@ -44,7 +44,8 @@ async function runBranchCleanup() { repo, }) - const report = await cleanupMergedBranches(git, project.id as string) + const contentRoot = normalizeContentRoot(project.content_root as string) + const report = await cleanupMergedBranches(git, project.id as string, undefined, contentRoot) if (report.deleted.length > 0) { // eslint-disable-next-line no-console console.info(`[branch-cleanup] ${owner}/${repo}: deleted ${report.deleted.length} merged branches, ${report.remaining} remaining`) diff --git a/server/utils/branch-health.ts b/server/utils/branch-health.ts index d33072ca..a018f73f 100644 --- a/server/utils/branch-health.ts +++ b/server/utils/branch-health.ts @@ -1,16 +1,24 @@ /** * Branch health — monitors cr/* branch count and cleans up merged branches. * - * Per git-architecture.md §8.2: + * Per git-architecture.md §8.2 (default thresholds): * - 0–49 unmerged cr/*: OK — operations proceed * - 50–79 unmerged cr/*: WARNING — operations proceed, user alerted * - 80+ unmerged cr/*: BLOCKED — new write operations rejected * + * As of @contentrain/mcp 1.5.0 the warn/block thresholds are configurable + * via `config.json` (`branchWarnLimit` / `branchBlockLimit`). Studio honors + * those when present and falls back to 50/80 otherwise — see + * {@link resolveBranchLimits}. + * * Merged branches are auto-deleted after branchRetention days (default 30). * * Cache: Redis when available (multi-instance safe), in-memory Map fallback. */ +import type { ContentrainConfig } from '@contentrain/types' +import { CONTENTRAIN_BRANCH } from '@contentrain/types' import type { GitProvider } from '../providers/git' +import { resolveConfigPath } from './content-paths' import { getRedis } from './redis' // ── Types ──────────────────────────────────────────── @@ -29,6 +37,12 @@ export interface CleanupReport { status: BranchHealthStatus } +/** Resolved warn/block thresholds for the unmerged cr/* branch count. */ +export interface BranchLimits { + warn: number + block: number +} + // ── Constants ──────────────────────────────────────── const WARNING_THRESHOLD = 50 @@ -105,11 +119,45 @@ export async function clearHealthCache(): Promise { } } +// ── Threshold resolution (config-driven, MCP 1.5.0) ── + +const DEFAULT_LIMITS: BranchLimits = { warn: WARNING_THRESHOLD, block: BLOCKED_THRESHOLD } + +/** + * Resolve branch-health thresholds. MCP 1.5.0 exposes `branchWarnLimit` + * (default 50) and `branchBlockLimit` (default 80) on `config.json`; Studio + * honors them so operators can tune limits without a code change. Reads + * config from the `contentrain` content branch. + * + * Falls back to the 50/80 defaults when `contentRoot` is omitted, the config + * is unreadable, or the fields are absent/non-numeric. + */ +export async function resolveBranchLimits( + git: GitProvider, + contentRoot?: string, +): Promise { + if (contentRoot === undefined) return DEFAULT_LIMITS + try { + const raw = await git.readFile(resolveConfigPath({ contentRoot }), CONTENTRAIN_BRANCH) + const config = JSON.parse(raw) as ContentrainConfig + return { + warn: typeof config.branchWarnLimit === 'number' ? config.branchWarnLimit : WARNING_THRESHOLD, + block: typeof config.branchBlockLimit === 'number' ? config.branchBlockLimit : BLOCKED_THRESHOLD, + } + } + catch { + return DEFAULT_LIMITS + } +} + // ── Status calculation ─────────────────────────────── -export function calculateStatus(unmergedCount: number): BranchHealthStatus { - if (unmergedCount >= BLOCKED_THRESHOLD) return 'blocked' - if (unmergedCount >= WARNING_THRESHOLD) return 'warning' +export function calculateStatus( + unmergedCount: number, + limits: BranchLimits = DEFAULT_LIMITS, +): BranchHealthStatus { + if (unmergedCount >= limits.block) return 'blocked' + if (unmergedCount >= limits.warn) return 'warning' return 'ok' } @@ -118,6 +166,7 @@ export function calculateStatus(unmergedCount: number): BranchHealthStatus { export async function checkBranchHealth( git: GitProvider, projectId: string, + contentRoot?: string, ): Promise { const branches = await git.listBranches('cr/') let unmergedCount = 0 @@ -127,8 +176,9 @@ export async function checkBranchHealth( if (!merged) unmergedCount++ } + const limits = await resolveBranchLimits(git, contentRoot) const report: BranchHealthReport = { - status: calculateStatus(unmergedCount), + status: calculateStatus(unmergedCount, limits), unmergedCount, lastChecked: new Date().toISOString(), } @@ -142,6 +192,7 @@ export async function cleanupMergedBranches( git: GitProvider, projectId: string, retentionDays: number = DEFAULT_RETENTION_DAYS, + contentRoot?: string, ): Promise { const branches = await git.listBranches('cr/') const deleted: string[] = [] @@ -165,10 +216,11 @@ export async function cleanupMergedBranches( } const remaining = branches.length - deleted.length + const limits = await resolveBranchLimits(git, contentRoot) const report: CleanupReport = { deleted, remaining, - status: calculateStatus(remaining), + status: calculateStatus(remaining, limits), } // Update cache after cleanup diff --git a/server/utils/content-engine/branch-ops.ts b/server/utils/content-engine/branch-ops.ts index 60d89681..3340d034 100644 --- a/server/utils/content-engine/branch-ops.ts +++ b/server/utils/content-engine/branch-ops.ts @@ -1,5 +1,7 @@ +import { buildContextChange } from '@contentrain/mcp/core/context' import type { Branch, EngineInternalContext, MergeResult } from './types' -import { BRANCH_PREFIX, CONTENT_BRANCH } from './types' +import { BOT_AUTHOR, BRANCH_PREFIX, CONTENT_BRANCH } from './types' +import { pinReaderToContentrain } from './helpers' /** * Ensure the dedicated `contentrain` branch exists and is synced with main. @@ -89,6 +91,11 @@ export async function mergeBranch(ctx: EngineInternalContext, branch: string): P // Branch may have been auto-deleted } + // Regenerate context.json on contentrain now that the content has + // landed — feature branches no longer carry it (MCP 1.5.0 model), so + // it is rebuilt here from the merged tree before main is advanced. + await regenerateContextOnContentrain(ctx, branch) + // Step 2: advance contentrain -> main const defaultBranch = await ctx.git.getDefaultBranch() try { @@ -116,3 +123,59 @@ export async function mergeBranch(ctx: EngineInternalContext, branch: string): P export async function rejectBranch(ctx: EngineInternalContext, branch: string): Promise { await ctx.git.deleteBranch(branch) } + +/** + * Regenerate `context.json` deterministically on the `contentrain` branch + * after a feature branch lands (MCP 1.5.0 model). + * + * Feature branches no longer carry `context.json`, which removes the + * merge-conflict surface when parallel `cr/*` saves land. Instead the file + * is rebuilt here from the merged `contentrain` tree so its stats + * (model / entry counts) reflect post-merge reality. The brain cache and + * external readers only ever read `context.json` from `contentrain`, so + * this is the single point where it needs to be accurate. + * + * Best-effort: a failure (transient git error, or a concurrent + * regeneration losing the non-fast-forward ref update) is swallowed — the + * next merge regenerates it correctly. + */ +async function regenerateContextOnContentrain( + ctx: EngineInternalContext, + mergedBranch: string, +): Promise { + try { + const reader = pinReaderToContentrain(ctx.git) + const contextChange = await buildContextChange(reader, parseMergeOperation(mergedBranch), 'mcp-studio') + + // Skip an empty commit when the merged tree already carries an + // identical context.json. + try { + const current = await reader.readFile(contextChange.path) + if (current === contextChange.content) return + } + catch { /* no existing context.json — fall through and write it */ } + + await ctx.git.applyPlan({ + branch: CONTENT_BRANCH, + changes: [contextChange], + message: 'contentrain: regenerate context.json', + author: BOT_AUTHOR, + base: CONTENT_BRANCH, + }) + } + catch { + // Best-effort: context.json self-heals on the next merge. + } +} + +/** + * Derive the `context.json` lastOperation from a merged `cr/*` branch name. + * Format: `cr/{scope}/{target}[/{locale}]/{timestamp}-{suffix}`. + */ +function parseMergeOperation(branch: string): { tool: string, model: string, locale?: string } { + const parts = branch.split('/') + // cr / scope / target / [locale] / timestamp-suffix + const model = parts[2] ?? '' + const locale = parts.length >= 5 ? parts[3] : undefined + return { tool: 'merge', model, locale } +} diff --git a/server/utils/content-engine/delete-content.ts b/server/utils/content-engine/delete-content.ts index 77a5388b..6a30f983 100644 --- a/server/utils/content-engine/delete-content.ts +++ b/server/utils/content-engine/delete-content.ts @@ -1,6 +1,5 @@ import type { FileChange, ModelDefinition, RepoReader } from '@contentrain/types' import { CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH } from '@contentrain/types' -import { buildContextChange } from '@contentrain/mcp/core/context' import { planContentDelete } from '@contentrain/mcp/core/ops' import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' import type { EngineInternalContext, WriteResult } from './types' @@ -43,14 +42,9 @@ export async function deleteContent( const aggregatedChanges = [...changesByPath.values()] - const overlay = new OverlayReader(reader, aggregatedChanges) - const contextChange = await buildContextChange( - overlay, - { tool: 'delete_content', model: modelId, locale, entries: entryIds }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [...aggregatedChanges, contextChange] + // context.json is regenerated on `contentrain` post-merge (MCP 1.5.0 + // model), not committed on the feature branch. + const allChanges: FileChange[] = [...aggregatedChanges] .toSorted((a, b) => a.path.localeCompare(b.path)) const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale) diff --git a/server/utils/content-engine/helpers.ts b/server/utils/content-engine/helpers.ts index 5c160071..a044eae9 100644 --- a/server/utils/content-engine/helpers.ts +++ b/server/utils/content-engine/helpers.ts @@ -36,7 +36,7 @@ export async function createFeatureBranch( ): Promise<{ branchName: string, healthWarning?: string }> { if (ctx.projectId) { const cached = await getHealthStatus(ctx.projectId) - const health = cached ?? await checkBranchHealth(ctx.git, ctx.projectId) + const health = cached ?? await checkBranchHealth(ctx.git, ctx.projectId, ctx.pathCtx.contentRoot) if (health.status === 'blocked') { throw createError({ diff --git a/server/utils/content-engine/save-content.ts b/server/utils/content-engine/save-content.ts index f81909b0..40637832 100644 --- a/server/utils/content-engine/save-content.ts +++ b/server/utils/content-engine/save-content.ts @@ -1,8 +1,6 @@ import type { ContentrainConfig, FileChange, ModelDefinition, ValidationResult, Vocabulary } from '@contentrain/types' import { CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH } from '@contentrain/types' -import { buildContextChange } from '@contentrain/mcp/core/context' import { planContentSave } from '@contentrain/mcp/core/ops' -import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' import type { ValidationContext } from '../content-validation' import type { EngineInternalContext, WriteResult } from './types' import { BOT_AUTHOR, CONTENT_BRANCH } from './types' @@ -26,9 +24,8 @@ import { * - feature-branch lifecycle (`cr/*` name generation, health check) * - commit + diff bookkeeping for the `WriteResult` return shape * - * `OverlayReader` is wrapped around the pinned reader so the committed - * `context.json` reflects post-commit stats (see `.internal/refactor/ - * 02-studio-handoff.md` Faz S2.1 — Phase 10 tuzakları). + * `context.json` is not committed here — it is regenerated on + * `contentrain` post-merge (MCP 1.5.0 model; see `branch-ops.ts`). */ export async function saveContent( ctx: EngineInternalContext, @@ -164,19 +161,11 @@ export async function saveContent( userEmail, }) - const overlay = new OverlayReader(reader, patchedChanges) - const contextChange = await buildContextChange( - overlay, - { - tool: 'save_content', - model: modelId, - locale, - entries: modelDef.kind === 'collection' ? touchedIds : undefined, - }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [...patchedChanges, contextChange] + // context.json is NOT committed on feature branches (MCP 1.5.0 model): + // it is regenerated deterministically on `contentrain` post-merge so + // parallel saves cannot conflict on it. See `regenerateContextOnContentrain` + // in `branch-ops.ts`. + const allChanges: FileChange[] = [...patchedChanges] .toSorted((a, b) => a.path.localeCompare(b.path)) const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale) diff --git a/server/utils/content-engine/save-document.ts b/server/utils/content-engine/save-document.ts index 28d84c27..7f5e9481 100644 --- a/server/utils/content-engine/save-document.ts +++ b/server/utils/content-engine/save-document.ts @@ -1,8 +1,6 @@ import type { ContentrainConfig, FileChange, ModelDefinition, Vocabulary } from '@contentrain/types' import { CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH, validateSlug } from '@contentrain/types' -import { buildContextChange } from '@contentrain/mcp/core/context' import { planContentSave } from '@contentrain/mcp/core/ops' -import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' import type { EngineInternalContext, WriteResult } from './types' import { BOT_AUTHOR, CONTENT_BRANCH } from './types' import { applyStudioMetaOverrides, pinReaderToContentrain, createFeatureBranch } from './helpers' @@ -12,9 +10,8 @@ import { applyStudioMetaOverrides, pinReaderToContentrain, createFeatureBranch } * * Delegates markdown serialization + path resolution to * `planContentSave` (document kind); Studio overrides meta with its - * own status + user-email logic and wires `OverlayReader` around the - * pending changes so the committed `context.json` reflects post-commit - * stats. + * own status + user-email logic. `context.json` is not touched here — + * it is regenerated on `contentrain` post-merge (MCP 1.5.0 model). */ export async function saveDocument( ctx: EngineInternalContext, @@ -104,14 +101,9 @@ export async function saveDocument( userEmail, }) - const overlay = new OverlayReader(reader, patchedChanges) - const contextChange = await buildContextChange( - overlay, - { tool: 'save_content', model: modelId, locale, entries: [safeSlug] }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [...patchedChanges, contextChange] + // context.json is regenerated on `contentrain` post-merge, not committed + // here (MCP 1.5.0 model — see `branch-ops.ts`). + const allChanges: FileChange[] = [...patchedChanges] .toSorted((a, b) => a.path.localeCompare(b.path)) const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale) diff --git a/server/utils/content-engine/save-model.ts b/server/utils/content-engine/save-model.ts index f818a161..0ee156ff 100644 --- a/server/utils/content-engine/save-model.ts +++ b/server/utils/content-engine/save-model.ts @@ -1,8 +1,6 @@ import type { ContentrainConfig, FileChange, ModelDefinition } from '@contentrain/types' import { CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH } from '@contentrain/types' -import { buildContextChange } from '@contentrain/mcp/core/context' import { planModelSave } from '@contentrain/mcp/core/ops' -import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' import type { EngineInternalContext, WriteResult } from './types' import { BOT_AUTHOR, CONTENT_BRANCH } from './types' import { pinReaderToContentrain, createFeatureBranch } from './helpers' @@ -13,8 +11,8 @@ import { pinReaderToContentrain, createFeatureBranch } from './helpers' * Schema validation (Studio-owned today, pending S3 unification with MCP) * runs first; if it passes, file assembly is delegated to * `planModelSave` — it writes `.contentrain/models/{id}.json` in - * canonical form. Studio adds the `context.json` change on top via - * `buildContextChange` wrapped in an `OverlayReader`. + * canonical form. `context.json` is regenerated on `contentrain` + * post-merge (MCP 1.5.0 model), not committed on the feature branch. */ export async function saveModel( ctx: EngineInternalContext, @@ -82,14 +80,7 @@ export async function saveModel( } } - const overlay = new OverlayReader(reader, plan.changes) - const contextChange = await buildContextChange( - overlay, - { tool: 'save_model', model: definition.id, locale: '' }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [...plan.changes, contextChange] + const allChanges: FileChange[] = [...plan.changes] .toSorted((a, b) => a.path.localeCompare(b.path)) const { branchName } = await createFeatureBranch(ctx, 'model', definition.id) diff --git a/server/utils/content-engine/update-status.ts b/server/utils/content-engine/update-status.ts index 478ed49a..694448c0 100644 --- a/server/utils/content-engine/update-status.ts +++ b/server/utils/content-engine/update-status.ts @@ -1,7 +1,5 @@ import type { EntryMeta, FileChange, ModelDefinition } from '@contentrain/types' import { canonicalStringify, CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH } from '@contentrain/types' -import { buildContextChange } from '@contentrain/mcp/core/context' -import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' import type { EngineInternalContext, WriteResult } from './types' import { BOT_AUTHOR, CONTENT_BRANCH } from './types' import { pinReaderToContentrain, createFeatureBranch } from './helpers' @@ -35,7 +33,7 @@ export async function updateEntryStatus( for (const entryId of entryIds) { existingMeta[entryId] = { - ...(existingMeta[entryId] ?? {}), + ...existingMeta[entryId], status, updated_by: userEmail, } as EntryMeta @@ -43,15 +41,9 @@ export async function updateEntryStatus( const metaChange: FileChange = { path: metaPath, content: canonicalStringify(existingMeta) } - const overlay = new OverlayReader(reader, [metaChange]) - const contextChange = await buildContextChange( - overlay, - { tool: 'update_status', model: modelId, locale, entries: entryIds }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [metaChange, contextChange] - .toSorted((a, b) => a.path.localeCompare(b.path)) + // context.json is regenerated on `contentrain` post-merge (MCP 1.5.0 + // model), not committed on the feature branch. + const allChanges: FileChange[] = [metaChange] const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale) @@ -135,14 +127,9 @@ export async function copyLocale( { path: targetMetaPath, content: metaContent }, ] - const overlay = new OverlayReader(reader, copyChanges) - const contextChange = await buildContextChange( - overlay, - { tool: 'copy_locale', model: modelId, locale: toLocale }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [...copyChanges, contextChange] + // context.json is regenerated on `contentrain` post-merge (MCP 1.5.0 + // model), not committed on the feature branch. + const allChanges: FileChange[] = [...copyChanges] .toSorted((a, b) => a.path.localeCompare(b.path)) const { branchName } = await createFeatureBranch(ctx, 'content', modelId) diff --git a/server/utils/mcp-cloud-automerge.ts b/server/utils/mcp-cloud-automerge.ts new file mode 100644 index 00000000..b12cb2f0 --- /dev/null +++ b/server/utils/mcp-cloud-automerge.ts @@ -0,0 +1,97 @@ +/** + * MCP Cloud write reconciliation. + * + * MCP's remote (GitHub) write path always reports `pending-review` and + * leaves the merge to "Studio (or whatever orchestrator is driving the + * server)" — see `commitThroughProvider` in `@contentrain/mcp/server/http`. + * So a content/model write issued by an external agent over MCP Cloud lands + * as a `cr/*` branch and does NOT auto-publish, even on projects configured + * for `workflow: auto-merge`. + * + * To keep MCP Cloud a first-class write path — consistent with Studio's + * native write paths (chat / forms / content API) — this reconciler lands + * those pending branches when, and only when, the project's effective + * workflow is auto-merge. The effective workflow is resolved with the exact + * same rule the native paths use: + * + * review is honored ONLY when the plan grants `workflow.review` AND the + * project opts into it via `config.workflow`; otherwise auto-merge. + * + * So this both honors the project's `config.json` setting and the user's + * plan/package permissions. On review-gated projects it is a no-op — those + * branches stay pending for human review. + * + * Best-effort by contract: every step is guarded so a reconciliation failure + * can never surface to the external caller. The caller invokes it + * fire-and-forget after a successful write tool call. + */ +import type { ContentrainConfig } from '@contentrain/types' +import { CONTENTRAIN_BRANCH } from '@contentrain/types' +import type { Plan } from './license' +import { hasFeature } from './license' +import { createContentEngine } from './content-engine' +import { resolveConfigPath } from './content-paths' +import { invalidateBrainCache } from './brain-cache' +import { useGitProvider } from './providers' + +export interface McpCloudAutoMergeParams { + workspaceId: string + projectId: string + installationId: number + repoFullName: string + contentRoot: string + plan: Plan +} + +/** + * Resolve the effective workflow for a project — identical rule to the + * native chat / content-API handlers: `review` requires both the plan + * feature and the project opt-in; everything else collapses to auto-merge. + */ +function resolveEffectiveWorkflow(plan: Plan, configWorkflow: string | undefined): 'auto-merge' | 'review' { + if (!hasFeature(plan, 'workflow.review')) return 'auto-merge' + return configWorkflow === 'review' ? 'review' : 'auto-merge' +} + +/** + * Land the pending `cr/*` branches an MCP Cloud write produced — but only on + * auto-merge projects. No-op (and never throws) otherwise. + */ +export async function reconcileMcpCloudAutoMerge(params: McpCloudAutoMergeParams): Promise { + const { workspaceId: _workspaceId, projectId, installationId, repoFullName, contentRoot, plan } = params + + try { + const [owner = '', repo = ''] = repoFullName.split('/') + if (!owner || !repo) return + + const git = useGitProvider({ installationId, owner, repo }) + + // Resolve the project workflow from config.json on the content branch. + let configWorkflow: string | undefined + try { + const raw = await git.readFile(resolveConfigPath({ contentRoot }), CONTENTRAIN_BRANCH) + configWorkflow = (JSON.parse(raw) as ContentrainConfig).workflow + } + catch { /* missing/unreadable config → default rule applies below */ } + + if (resolveEffectiveWorkflow(plan, configWorkflow) !== 'auto-merge') return + + const engine = createContentEngine({ git, contentRoot, projectId }) + const branches = await engine.listContentBranches() + + let merged = false + for (const branch of branches) { + const isMerged = await git.isMerged(branch.name).catch(() => true) + if (isMerged) continue + try { + await engine.mergeBranch(branch.name) + merged = true + } + catch { /* best-effort: another path may merge it, or it conflicts */ } + } + + // Refresh the brain cache so the next read reflects the landed content. + if (merged) invalidateBrainCache(projectId) + } + catch { /* best-effort: MCP Cloud writes still succeeded as pending branches */ } +} diff --git a/tests/unit/content-engine.test.ts b/tests/unit/content-engine.test.ts index f4399e29..1ce737a1 100644 --- a/tests/unit/content-engine.test.ts +++ b/tests/unit/content-engine.test.ts @@ -102,6 +102,31 @@ describe('content engine', () => { }) }) + it('regenerates context.json on contentrain after a successful merge', async () => { + const applyPlan = vi.fn().mockResolvedValue(defaultCommit) + const git = createGitProvider({ + getDefaultBranch: vi.fn().mockResolvedValue('main'), + mergeBranch: vi.fn().mockResolvedValue({ merged: true, sha: 'merge-sha', pullRequestUrl: null }), + deleteBranch: vi.fn().mockResolvedValue(undefined), + applyPlan, + }) + const engine = createContentEngine({ git, contentRoot: '' }) + + await engine.mergeBranch('cr/content/faq/en/1234567890-abcd') + + // Feature branches no longer carry context.json (MCP 1.5.0 model); it + // is rebuilt on contentrain post-merge via a dedicated commit. + expect(applyPlan).toHaveBeenCalledWith( + expect.objectContaining({ + branch: 'contentrain', + base: 'contentrain', + changes: expect.arrayContaining([ + expect.objectContaining({ path: '.contentrain/context.json' }), + ]), + }), + ) + }) + it('falls back to PR creation when branch protection blocks step 2 merge', async () => { const git = createGitProvider({ getDefaultBranch: vi.fn().mockResolvedValue('main'), @@ -264,7 +289,7 @@ describe('content engine', () => { expect(result.validation.errors[0]?.message).toBe('Model does not support i18n') }) - it('saves model definitions and emits context change', async () => { + it('saves model definitions without committing context.json on the feature branch', async () => { const applyPlan = vi.fn().mockResolvedValue(defaultCommit) const git = createGitProvider({ readFile: vi.fn(async (path: string) => { @@ -299,10 +324,14 @@ describe('content engine', () => { base: 'contentrain', changes: expect.arrayContaining([ expect.objectContaining({ path: '.contentrain/models/authors.json' }), - expect.objectContaining({ path: '.contentrain/context.json' }), ]), }), ) + + // context.json is regenerated on contentrain post-merge (MCP 1.5.0 + // model), never committed on the feature branch. + const call = applyPlan.mock.calls[0]?.[0] as { changes: Array<{ path: string }> } + expect(call.changes.some(c => c.path.endsWith('context.json'))).toBe(false) }) it('initializes a project with config, models, content, and meta files', async () => { diff --git a/tests/unit/mcp-cloud-automerge.test.ts b/tests/unit/mcp-cloud-automerge.test.ts new file mode 100644 index 00000000..66c6eeb2 --- /dev/null +++ b/tests/unit/mcp-cloud-automerge.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ── Mocks ──────────────────────────────────────────── +// The reconciler wires git + engine + license + brain-cache; mock each so +// the test isolates the workflow/plan decision and the merge fan-out. + +const git = { + readFile: vi.fn(), + isMerged: vi.fn(), +} +const engine = { + listContentBranches: vi.fn(), + mergeBranch: vi.fn(), +} +const invalidateBrainCache = vi.fn() +let hasReviewFeature = true + +vi.mock('../../server/utils/providers', () => ({ + useGitProvider: vi.fn(() => git), + useDatabaseProvider: vi.fn(), +})) +vi.mock('../../server/utils/content-engine', () => ({ + createContentEngine: vi.fn(() => engine), +})) +vi.mock('../../server/utils/license', () => ({ + hasFeature: vi.fn(() => hasReviewFeature), +})) +vi.mock('../../server/utils/brain-cache', () => ({ + invalidateBrainCache, +})) + +const { reconcileMcpCloudAutoMerge } = await import('../../server/utils/mcp-cloud-automerge') + +const baseParams = { + workspaceId: 'ws-1', + projectId: 'proj-1', + installationId: 123, + repoFullName: 'owner/repo', + contentRoot: '', + plan: 'pro' as never, +} + +describe('reconcileMcpCloudAutoMerge', () => { + beforeEach(() => { + vi.clearAllMocks() + hasReviewFeature = true + git.readFile.mockResolvedValue(JSON.stringify({ workflow: 'auto-merge' })) + git.isMerged.mockResolvedValue(false) + engine.listContentBranches.mockResolvedValue([ + { name: 'cr/content/posts/en/1-aa', sha: 's1', protected: false }, + ]) + engine.mergeBranch.mockResolvedValue({ merged: true, sha: 'm', pullRequestUrl: null }) + }) + + it('merges pending branches on an auto-merge project', async () => { + await reconcileMcpCloudAutoMerge(baseParams) + + expect(engine.mergeBranch).toHaveBeenCalledWith('cr/content/posts/en/1-aa') + expect(invalidateBrainCache).toHaveBeenCalledWith('proj-1') + }) + + it('is a no-op on a review project when the plan grants workflow.review', async () => { + hasReviewFeature = true + git.readFile.mockResolvedValue(JSON.stringify({ workflow: 'review' })) + + await reconcileMcpCloudAutoMerge(baseParams) + + expect(engine.mergeBranch).not.toHaveBeenCalled() + expect(invalidateBrainCache).not.toHaveBeenCalled() + }) + + it('forces auto-merge when the plan lacks workflow.review even if config says review', async () => { + hasReviewFeature = false + git.readFile.mockResolvedValue(JSON.stringify({ workflow: 'review' })) + + await reconcileMcpCloudAutoMerge(baseParams) + + expect(engine.mergeBranch).toHaveBeenCalledWith('cr/content/posts/en/1-aa') + }) + + it('skips already-merged branches', async () => { + git.isMerged.mockResolvedValue(true) + + await reconcileMcpCloudAutoMerge(baseParams) + + expect(engine.mergeBranch).not.toHaveBeenCalled() + expect(invalidateBrainCache).not.toHaveBeenCalled() + }) + + it('defaults to auto-merge when config.json is unreadable (plan grants review)', async () => { + git.readFile.mockRejectedValue(new Error('no config')) + + await reconcileMcpCloudAutoMerge(baseParams) + + expect(engine.mergeBranch).toHaveBeenCalled() + }) + + it('no-ops on an invalid repo full name', async () => { + await reconcileMcpCloudAutoMerge({ ...baseParams, repoFullName: 'invalid' }) + + expect(engine.listContentBranches).not.toHaveBeenCalled() + expect(engine.mergeBranch).not.toHaveBeenCalled() + }) + + it('never throws when a merge fails (best-effort)', async () => { + engine.mergeBranch.mockRejectedValue(new Error('merge conflict')) + + await expect(reconcileMcpCloudAutoMerge(baseParams)).resolves.toBeUndefined() + // No successful merge → brain cache untouched. + expect(invalidateBrainCache).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/mcp-cloud-keys.test.ts b/tests/unit/mcp-cloud-keys.test.ts new file mode 100644 index 00000000..594cc464 --- /dev/null +++ b/tests/unit/mcp-cloud-keys.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +// mcp-cloud-keys imports the provider factory at module load; stub it so the +// unit test does not pull the Supabase provider graph. +vi.mock('../../server/utils/providers', () => ({ + useDatabaseProvider: vi.fn(), +})) + +const { generateMcpCloudKey, hashMcpCloudKey } = await import('../../server/utils/mcp-cloud-keys') + +describe('mcp-cloud-keys', () => { + it('generates a key with the crn_mcp_ prefix, display prefix, and a matching sha-256 hash', () => { + const { key, keyHash, keyPrefix } = generateMcpCloudKey() + + expect(key.startsWith('crn_mcp_')).toBe(true) + expect(keyPrefix).toBe(key.slice(0, 16)) + expect(keyHash).toBe(hashMcpCloudKey(key)) + expect(keyHash).toMatch(/^[a-f0-9]{64}$/) + }) + + it('produces unique keys and hashes across calls', () => { + const a = generateMcpCloudKey() + const b = generateMcpCloudKey() + + expect(a.key).not.toBe(b.key) + expect(a.keyHash).not.toBe(b.keyHash) + }) + + it('hashes deterministically', () => { + expect(hashMcpCloudKey('crn_mcp_sample')).toBe(hashMcpCloudKey('crn_mcp_sample')) + expect(hashMcpCloudKey('crn_mcp_a')).not.toBe(hashMcpCloudKey('crn_mcp_b')) + }) +})