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
57 changes: 57 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,61 @@ over a remote provider returns a uniform capability error:
Agents driving a remote transport should fall back to a local transport
(or a local checkout) before invoking normalize.

## 🌐 Remote Providers

MCP supports three backends behind the same `RepoProvider` contract:

- **LocalProvider** — simple-git + worktree. Every tool (normalize
included) works on it. Stdio transport defaults to this.
- **GitHubProvider** — Octokit over the Git Data + Repos APIs. No
clone, no worktree. `@octokit/rest` ships as an optional peer
dependency.
- **GitLabProvider** — gitbeaker over the GitLab REST API. No clone,
no worktree. `@gitbeaker/rest` ships as an optional peer
dependency. Supports gitlab.com and self-hosted CE / EE.

Each remote provider implements the same surface: reader (readFile /
listDirectory / fileExists), writer (applyPlan — one atomic commit),
branch ops (list / create / delete / diff / merge / isMerged /
getDefaultBranch). `mergeBranch` goes straight through on GitHub; on
GitLab it opens an MR and immediately accepts it so the final
`MergeResult` shape matches either way.

### GitLab — installation & usage

```bash
pnpm add @gitbeaker/rest
```

```ts
import { createGitLabProvider } from '@contentrain/mcp/providers/gitlab'
import { createServer } from '@contentrain/mcp/server'

const provider = await createGitLabProvider({
auth: { type: 'pat', token: process.env.GITLAB_TOKEN! },
project: {
projectId: 'acme/site', // or numeric project ID
host: 'https://gitlab.company.com', // omit for gitlab.com
},
})

const server = createServer({ provider })
// serve over stdio or the HTTP transport from @contentrain/mcp/server/http
```

Capabilities: `sourceRead`, `sourceWrite`, `astScan`, `localWorktree`
are all `false`; `pushRemote`, `branchProtection`,
`pullRequestFallback` are `true`. Normalize / scan / apply reject
with a capability error on GitLabProvider — fall back to a local
transport for those flows.

### Bitbucket — coming soon

Bitbucket Cloud + Data Center support is on the roadmap. Until the
provider ships, use the `contentrain_describe_format` tool to drive
Contentrain content operations manually from a Bitbucket checkout via
the LocalProvider path.

## 📦 Core Exports

The package also exposes low-level modules for embedding and advanced use:
Expand All @@ -198,6 +253,8 @@ The package also exposes low-level modules for embedding and advanced use:
- `@contentrain/mcp/git/transaction`
- `@contentrain/mcp/git/branch-lifecycle`
- `@contentrain/mcp/templates`
- `@contentrain/mcp/providers/github`
- `@contentrain/mcp/providers/gitlab`

These are intended for Contentrain tooling and advanced integrations, not for direct manual editing of `.contentrain/` files.

Expand Down
13 changes: 11 additions & 2 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@
"./providers/github": {
"types": "./dist/providers/github/index.d.mts",
"import": "./dist/providers/github/index.mjs"
},
"./providers/gitlab": {
"types": "./dist/providers/gitlab/index.d.mts",
"import": "./dist/providers/gitlab/index.mjs"
}
},
"main": "./dist/index.mjs",
Expand All @@ -115,8 +119,8 @@
"dist"
],
"scripts": {
"build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/util/serializer.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/providers/github/index.ts src/server/http/index.ts --format esm --dts --external typescript",
"dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/util/serializer.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/providers/github/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch",
"build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/util/serializer.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript",
"dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/util/serializer.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
Expand All @@ -134,15 +138,20 @@
"svelte": "^4.2.0"
},
"peerDependencies": {
"@gitbeaker/rest": ">=43.0.0",
"@octokit/rest": ">=20.0.0"
},
"peerDependenciesMeta": {
"@gitbeaker/rest": {
"optional": true
},
"@octokit/rest": {
"optional": true
}
},
"devDependencies": {
"@astrojs/compiler": "^2.10.0",
"@gitbeaker/rest": "^43.8.0",
"@octokit/rest": "^22.0.1",
"@types/node": "^25.4.0",
"@vue/compiler-sfc": "^3.5.30",
Expand Down
173 changes: 173 additions & 0 deletions packages/mcp/src/providers/gitlab/apply-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { ApplyPlanInput, Commit, FileChange } from '../../core/contracts/index.js'
import type { GitLabClient } from './client.js'
import { resolveRepoPath } from './paths.js'
import type { ProjectRef } from './types.js'

type CommitActionType = 'create' | 'update' | 'delete'

interface CommitAction {
action: CommitActionType
filePath: string
content?: string
encoding?: 'text' | 'base64'
}

/**
* Apply a plan to a GitLab repository as a single atomic commit via the
* Commits API. High-level flow:
*
* 1. Check whether the target branch exists. If it does not, GitLab's
* `startBranch` option forks a new branch from `input.base` (or the
* project's default branch) as part of the same commit.
* 2. For each `FileChange`, figure out the right GitLab action verb
* (`create` vs `update` vs `delete`) by probing the path against the
* resolved ref. GitLab validates this server-side and returns a 400
* for mismatches — we avoid the round trip by getting it right up
* front.
* 3. POST `/repository/commits` once with the full action set. No
* working tree, no multi-call tree-assembly dance — GitLab exposes
* a richer primitive than the GitHub Git Data API for this case.
*
* The commit is durable as soon as the call returns; GitLab's ref
* update happens inside the same request.
*/
export async function applyPlanToGitLab(
client: GitLabClient,
project: ProjectRef,
input: ApplyPlanInput,
): Promise<Commit> {
const branchExists = await branchHasRef(client, project, input.branch)
const baseBranch = input.base ?? await resolveDefaultBranch(client, project)

// Actions are computed against the HEAD of the feature branch when it
// exists, otherwise against the fork point (baseBranch). GitLab
// treats `create` / `update` / `delete` as a strict check against the
// path's existence at that ref.
const refForActionResolution = branchExists ? input.branch : baseBranch
const rawActions = await Promise.all(
input.changes.map(change => resolveAction(client, project, refForActionResolution, change)),
)
const actions = rawActions.filter((a): a is CommitAction => a !== null)

if (actions.length === 0) {
// Nothing to apply. Callers don't generally build empty plans, but
// if they do we short-circuit before touching the API.
throw new Error('applyPlanToGitLab: plan contained no applicable actions')
}

const options: Record<string, unknown> = {
authorName: input.author.name,
authorEmail: input.author.email,
}
if (!branchExists) {
options.startBranch = baseBranch
}

const response = await client.Commits.create(
project.projectId,
input.branch,
input.message,
actions,
options,
)

// Gitbeaker's generic-heavy response type widens string fields to
// `string | Camelize<…>` — at runtime they are always strings under
// the default (non-camelize) response mode we use. Cast narrows the
// shape we actually touch.
const commit = response as {
id: string
message?: string
author_name?: string
author_email?: string
created_at?: string
}
const commitTimestamp = toIsoTimestamp(commit.created_at) ?? new Date().toISOString()
return {
sha: commit.id,
message: commit.message ?? input.message,
author: {
name: commit.author_name ?? input.author.name,
email: commit.author_email ?? input.author.email,
},
timestamp: commitTimestamp,
}
}

async function resolveAction(
client: GitLabClient,
project: ProjectRef,
ref: string,
change: FileChange,
): Promise<CommitAction | null> {
const filePath = resolveRepoPath(project.contentRoot, change.path)
const exists = await fileExistsAtRef(client, project, filePath, ref)

if (change.content === null) {
// Filter out deletes against non-existent files — GitLab returns
// 400 otherwise. The plan author asked us to "make this file not
// exist", which is already satisfied.
if (!exists) return null
return { action: 'delete', filePath }
}

return {
action: exists ? 'update' : 'create',
filePath,
content: change.content,
encoding: 'text',
}
}

async function branchHasRef(
client: GitLabClient,
project: ProjectRef,
branch: string,
): Promise<boolean> {
try {
await client.Branches.show(project.projectId, branch)
return true
} catch (error) {
if (isNotFound(error)) return false
throw error
}
}

async function fileExistsAtRef(
client: GitLabClient,
project: ProjectRef,
filePath: string,
ref: string,
): Promise<boolean> {
try {
await client.RepositoryFiles.show(project.projectId, filePath, ref)
return true
} catch (error) {
if (isNotFound(error)) return false
throw error
}
}

async function resolveDefaultBranch(
client: GitLabClient,
project: ProjectRef,
): Promise<string> {
const p = await client.Projects.show(project.projectId) as { default_branch?: string }
return p.default_branch ?? 'main'
}

function toIsoTimestamp(raw: unknown): string | null {
if (typeof raw !== 'string') return null
const date = new Date(raw)
if (Number.isNaN(date.getTime())) return null
return date.toISOString()
}

function isNotFound(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const err = error as { cause?: { response?: { status?: number } }, description?: unknown }
const status = err.cause?.response?.status
if (status === 404) return true
const description = typeof err.description === 'string' ? err.description : ''
return /not found/i.test(description)
}
Loading
Loading