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
3 changes: 2 additions & 1 deletion .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"contentrain": {
"command": "npx",
"args": [
"@contentrain/mcp"
"-y",
"@contentrain/mcp@1.5.0"
]
},
"nuxt": {
Expand Down
64 changes: 54 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,34 +237,78 @@ 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:

- Content SSOT is the `contentrain` branch. Feature branches always
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

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 18 additions & 30 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions server/api/mcp/v1/[projectId]/[...slug].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
5 changes: 3 additions & 2 deletions server/plugins/branch-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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`)
Expand Down
Loading
Loading