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
107 changes: 107 additions & 0 deletions .changeset/mcp-1-4-multi-tenant-app-auth-fixtures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
"@contentrain/mcp": minor
---

feat(mcp): 1.4.0 — multi-tenant HTTP MCP, GitHub App auth, published conformance fixtures

### Multi-tenant HTTP MCP — per-request provider resolver

`startHttpMcpServerWith` now accepts a `resolveProvider(req)` callback
instead of (or in addition to) a single pre-built provider. Every new
MCP session resolves its own `RepoProvider` from the incoming HTTP
request — Studio's MCP Cloud and any similar hosted agent can serve
many projects from one endpoint without spinning up N server
instances.

```ts
await startHttpMcpServerWith({
resolveProvider: async (req) => {
const projectId = req.headers['x-project-id']
const { repo, auth } = await lookupProject(projectId)
return createGitHubProvider({ auth, repo })
},
authToken: workspaceBearerToken,
port: 3333,
sessionTtlMs: 15 * 60 * 1000, // default 15m
})
```

Resolver invoked exactly once per MCP session; subsequent requests
carrying `Mcp-Session-Id` reuse the resolved server + transport pair.
Idle sessions are disposed after `sessionTtlMs`. Existing single-
provider shape is fully backward compatible.

### GitHub App installation auth in the factory

`createGitHubProvider({ auth: { type: 'app', appId, privateKey,
installationId } })` now mints a short-lived JWT, exchanges it for an
installation access token, and instantiates Octokit with the
resulting bearer. Removes the old "`app` auth coming in Phase 5.2"
throw.

New public exports under `@contentrain/mcp/providers/github`:
- `exchangeInstallationToken(config, opts?)` — standalone helper,
useful when callers want to cache / refresh tokens externally
(redis, KV, cross-worker pool). Supports custom `baseUrl` for
GitHub Enterprise Server.
- `signAppJwt(config)` — pure JWT signer (RS256, 10-min TTL).
- Types: `AppAuthConfig`, `InstallationTokenResult`.

The factory ships a ~1-hour bearer and does not auto-refresh — for
long-lived hosted providers, inject your own Octokit with
`@octokit/auth-app`'s auth strategy instead (Studio's pattern — see
the embedding guide).

### Conformance fixtures published

New subpath export `@contentrain/mcp/testing/conformance` exposes the
byte-parity scenarios the package tests itself against, so external
tools (Studio, alt-provider harnesses, third-party reimplementations)
can assert matching output without symlinking `packages/mcp/tests/`.

Fixtures were moved from `packages/mcp/tests/fixtures/conformance/`
to `packages/mcp/testing/conformance/` and are included in the
published tarball via `files[]`. Helpers:

```ts
import {
fixturesDir,
listConformanceScenarios,
loadConformanceScenario,
} from '@contentrain/mcp/testing/conformance'
```

### `validateProject(reader, options)` overload pinned

Phase 5.5b's reader overload got a dedicated test file
(`tests/core/validator/reader-overload.test.ts`) that exercises:
- validation through a pure `RepoReader`
- error surfacing from reader-backed content
- `OverlayReader` composition — the exact shape Studio uses for
pre-commit validation

The test pins the contract so the overload cannot regress silently.

### Docs

`docs/guides/embedding-mcp.md` Recipe 3 now shows **three** GitHub App
auth patterns with a trade-off table:
1. Factory `auth.type: 'app'` — simple, 1-hour TTL
2. `exchangeInstallationToken` + external cache — manual refresh
3. Octokit injection with `@octokit/auth-app` — auto-refresh
(recommended for Studio-style hosted providers)

Plus a new 3a section showing the multi-tenant resolver pattern.

Package description updated from "13 deterministic tools" to
accurately describe the current 17-tool surface.

### Verification

- `oxlint` across the monorepo → 0 warnings on 424 files.
- `@contentrain/mcp` typecheck → 0 errors.
- MCP fast suite → **471 passed / 2 skipped / 34 files** (21 new
tests beyond 1.3.0 baseline: 4 app-auth, 3 resolver, 5 conformance
subpath, 3 validateProject reader, plus the fixture-move
adjustments).
- `vitepress build docs/` → success.
90 changes: 88 additions & 2 deletions docs/guides/embedding-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,70 @@ const handle = await startHttpMcpServer({

CLI equivalent: `contentrain serve --mcpHttp --authToken $TOKEN`.

### 3. HTTP + Remote Provider (Studio's pattern)
### 3. HTTP + Remote Provider (three patterns)

**a. Factory with GitHub App credentials.** Simplest for one-off scripts and CI runners — the factory signs the JWT, exchanges it for an installation token, and hands Octokit a bearer. The returned token lasts ~1 hour; at that point the factory must be re-called.

```ts
import { createGitHubProvider } from '@contentrain/mcp/providers/github'
import { startHttpMcpServerWith } from '@contentrain/mcp/server/http'

const provider = await createGitHubProvider({
auth: { type: 'pat', token: await exchangeInstallationToken(installationId) },
auth: {
type: 'app',
appId: Number(process.env.GITHUB_APP_ID),
privateKey: process.env.GITHUB_APP_PRIVATE_KEY!,
installationId: Number(process.env.GITHUB_INSTALLATION_ID),
},
repo: { owner: 'acme', name: 'site' },
})

const handle = await startHttpMcpServerWith({
provider,
port: 3333,
authToken: workspaceBearerToken,
})
```

**b. `exchangeInstallationToken` helper for external token caching.** When you want to pin the token lifecycle yourself (cache across requests, refresh on a schedule, share across workers), call the helper directly and pass the opaque bearer to `createGitHubProvider({ auth: { type: 'pat', token } })`.

```ts
import {
createGitHubProvider,
exchangeInstallationToken,
} from '@contentrain/mcp/providers/github'

const { token, expiresAt } = await exchangeInstallationToken({
appId: Number(process.env.GITHUB_APP_ID),
privateKey: process.env.GITHUB_APP_PRIVATE_KEY!,
installationId: Number(process.env.GITHUB_INSTALLATION_ID),
})
// cache { token, expiresAt } in redis / your KV of choice

const provider = await createGitHubProvider({
auth: { type: 'pat', token },
repo: { owner: 'acme', name: 'site' },
})
```

**c. Inject your own Octokit with `@octokit/auth-app` (recommended for hosted / long-lived providers).** This is Studio's pattern. The Octokit SDK auto-refreshes installation tokens for the lifetime of the instance, so your provider never has to think about expiry.

```ts
import { Octokit } from '@octokit/rest'
import { createAppAuth } from '@octokit/auth-app'
import { GitHubProvider } from '@contentrain/mcp/providers/github'
import { startHttpMcpServerWith } from '@contentrain/mcp/server/http'

const octokit = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: Number(process.env.GITHUB_APP_ID),
privateKey: process.env.GITHUB_APP_PRIVATE_KEY!,
installationId: Number(process.env.GITHUB_INSTALLATION_ID),
},
})

const provider = new GitHubProvider(octokit, { owner: 'acme', name: 'site' })

const handle = await startHttpMcpServerWith({
provider,
Expand All @@ -89,8 +143,40 @@ const handle = await startHttpMcpServerWith({
})
```

**Trade-offs:**

| Pattern | Best for | Auto-refresh | Deps |
|---|---|---|---|
| a — factory `auth.type: 'app'` | Short-lived scripts, CI | No (1-hour TTL) | `@octokit/rest` |
| b — `exchangeInstallationToken` + PAT | External token cache (redis, KV) | You decide | `@octokit/rest` |
| c — Octokit injection + `@octokit/auth-app` | Long-lived hosted providers (Studio) | Yes | `@octokit/rest` + `@octokit/auth-app` |

For **multi-tenant** deployments where each request targets a different project, see the **per-request resolver** section below.

Swap in `createGitLabProvider({ auth, project })` for GitLab. Self-hosted GitLab instances pass `project.host`.

### 3a. HTTP + per-request provider resolver (multi-tenant)

When one HTTP endpoint serves many projects (Studio's MCP Cloud), pass a `resolveProvider` function instead of a single provider. The resolver is invoked once per MCP session; subsequent requests with the same `Mcp-Session-Id` header reuse the same server + transport pair. Idle sessions are cleaned up after `sessionTtlMs` (default 15 minutes).

```ts
import { createGitHubProvider } from '@contentrain/mcp/providers/github'
import { startHttpMcpServerWith } from '@contentrain/mcp/server/http'

const handle = await startHttpMcpServerWith({
resolveProvider: async (req) => {
const projectId = req.headers['x-project-id'] as string
const { repo, auth } = await lookupProjectFromDatabase(projectId)
return createGitHubProvider({ auth, repo })
},
authToken: workspaceBearerToken,
port: 3333,
sessionTtlMs: 15 * 60 * 1000,
})
```

The single-provider shape (`{ provider }`) and the resolver shape (`{ resolveProvider }`) are mutually exclusive — pass one or the other.

### 4. Programmatic tool calls (no transport at all)

If you want to run a Contentrain tool inside your own Node.js process without MCP's JSON-RPC layer:
Expand Down
13 changes: 9 additions & 4 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "1.3.0",
"mcpName": "io.github.Contentrain/contentrain",
"license": "MIT",
"description": "Local-first MCP server for AI-generated content governance — 13 deterministic tools for any platform",
"description": "Local-first MCP server for AI-generated content governance — 17 deterministic tools, stdio + HTTP transports, Local / GitHub / GitLab providers",
"type": "module",
"repository": {
"type": "git",
Expand Down Expand Up @@ -120,6 +120,10 @@
"types": "./dist/tools/annotations.d.mts",
"import": "./dist/tools/annotations.mjs"
},
"./testing/conformance": {
"types": "./dist/testing/conformance.d.mts",
"import": "./dist/testing/conformance.mjs"
},
"./templates": {
"types": "./dist/templates/index.d.mts",
"import": "./dist/templates/index.mjs"
Expand All @@ -140,11 +144,12 @@
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"files": [
"dist"
"dist",
"testing"
],
"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/doctor.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/tools/annotations.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/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/doctor.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/tools/annotations.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/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/doctor.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/tools/annotations.ts src/testing/conformance.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/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/doctor.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/tools/annotations.ts src/testing/conformance.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/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 Down
104 changes: 104 additions & 0 deletions packages/mcp/src/providers/github/app-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { createPrivateKey, createSign } from 'node:crypto'

/**
* GitHub App authentication helpers.
*
* Two entry points:
*
* - {@link signAppJwt} — mint a short-lived (10 min) GitHub App JWT from
* `appId` + `privateKey`. Used to authenticate app-level endpoints
* (listing installations, creating installation tokens).
*
* - {@link exchangeInstallationToken} — exchange an app JWT for a
* per-installation access token that can be passed straight to
* `Octokit({ auth: token })`. Installation tokens last ~1 hour and
* must be refreshed.
*
* Both helpers are pure — they never import `@octokit/rest` or
* `@octokit/auth-app`. Callers that want auto-refresh behaviour should
* either wire `@octokit/auth-app` as the `authStrategy` when constructing
* their own Octokit (see the embedding guide), or call
* `exchangeInstallationToken` on their own schedule.
*/

export interface AppAuthConfig {
/** GitHub App ID (numeric, from the app's settings page). */
appId: number
/** PEM-encoded private key — the contents of the `.pem` the app issued. */
privateKey: string
/** Installation the token should be scoped to. */
installationId: number
}

export interface InstallationTokenResult {
/** Opaque bearer token — pass to `new Octokit({ auth: token })`. */
token: string
/** ISO 8601 expiry. Installation tokens expire after ~1 hour. */
expiresAt: string
}

/**
* Sign a GitHub App JWT.
*
* GitHub's spec: RS256 (RSASSA-PKCS1-v1_5), 10-minute max lifetime,
* `iat` 60 seconds in the past to cover small clock skew, `iss`
* set to the numeric app ID.
*/
function base64UrlEncode(obj: unknown): string {
return Buffer.from(JSON.stringify(obj), 'utf8').toString('base64url')
}

export function signAppJwt(config: Pick<AppAuthConfig, 'appId' | 'privateKey'>): string {
const now = Math.floor(Date.now() / 1000)
const header = { alg: 'RS256', typ: 'JWT' }
const payload = {
iat: now - 60,
exp: now + 9 * 60,
iss: String(config.appId),
}

const toSign = `${base64UrlEncode(header)}.${base64UrlEncode(payload)}`
const keyObject = createPrivateKey({ key: config.privateKey, format: 'pem' })
const signer = createSign('RSA-SHA256')
signer.update(toSign)
signer.end()
const signature = signer.sign(keyObject).toString('base64url')

return `${toSign}.${signature}`
}

/**
* Exchange an App JWT for an installation-scoped access token by calling
* GitHub's `POST /app/installations/{id}/access_tokens` endpoint.
*
* Uses `fetch` (Node ≥22 has it native) so the helper stays dependency-
* free. Throws on non-2xx responses with the GitHub-returned message.
*/
export async function exchangeInstallationToken(
config: AppAuthConfig,
opts: { baseUrl?: string, fetchImpl?: typeof globalThis.fetch } = {},
): Promise<InstallationTokenResult> {
const jwt = signAppJwt(config)
const baseUrl = opts.baseUrl ?? 'https://api.github.com'
const fetchImpl = opts.fetchImpl ?? globalThis.fetch

const url = `${baseUrl}/app/installations/${config.installationId}/access_tokens`
const response = await fetchImpl(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
})

if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(
`GitHub installation-token exchange failed: ${response.status} ${response.statusText}${body ? ` — ${body}` : ''}`,
)
}

const data = await response.json() as { token: string, expires_at: string }
return { token: data.token, expiresAt: data.expires_at }
}
Loading
Loading