diff --git a/.changeset/mcp-1-4-multi-tenant-app-auth-fixtures.md b/.changeset/mcp-1-4-multi-tenant-app-auth-fixtures.md new file mode 100644 index 0000000..f330590 --- /dev/null +++ b/.changeset/mcp-1-4-multi-tenant-app-auth-fixtures.md @@ -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. diff --git a/docs/guides/embedding-mcp.md b/docs/guides/embedding-mcp.md index d785e28..30ba192 100644 --- a/docs/guides/embedding-mcp.md +++ b/docs/guides/embedding-mcp.md @@ -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, @@ -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: diff --git a/packages/mcp/package.json b/packages/mcp/package.json index b096876..b74e6dd 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -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", @@ -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" @@ -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" diff --git a/packages/mcp/src/providers/github/app-auth.ts b/packages/mcp/src/providers/github/app-auth.ts new file mode 100644 index 0000000..438f7bf --- /dev/null +++ b/packages/mcp/src/providers/github/app-auth.ts @@ -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): 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 { + 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 } +} diff --git a/packages/mcp/src/providers/github/factory.ts b/packages/mcp/src/providers/github/factory.ts index d39b76e..d924ef0 100644 --- a/packages/mcp/src/providers/github/factory.ts +++ b/packages/mcp/src/providers/github/factory.ts @@ -1,4 +1,5 @@ import type { GitHubClient } from './client.js' +import { exchangeInstallationToken } from './app-auth.js' import { GitHubProvider } from './provider.js' import type { GitHubAuth, RepoRef } from './types.js' @@ -10,17 +11,19 @@ import type { GitHubAuth, RepoRef } from './types.js' * it. If the module is not installed, the import throws with a helpful * hint pointing the operator at the peer dependency. * - * Phase 5.1 ships with PAT auth only. App-based auth (JWT + installation - * token) lands in phase 5.2 together with the HTTP transport. + * Two auth modes: + * + * - `pat` — personal access token or fine-grained PAT. Simplest for + * self-hosted MCP or CI runners. + * - `app` — GitHub App installation auth. The factory mints a short- + * lived JWT, exchanges it for an installation token via + * `exchangeInstallationToken`, and instantiates Octokit with the + * resulting bearer. The returned token expires in ~1 hour; callers + * that need auto-refresh should instead inject their own Octokit + * built with `@octokit/auth-app`'s auth strategy and construct + * `GitHubProvider` directly. See the embedding guide for trade-offs. */ export async function createGitHubClient(auth: GitHubAuth): Promise { - if (auth.type !== 'pat') { - throw new Error( - `GitHub auth type "${auth.type}" is not yet supported. Phase 5.1 ships with "pat" only; ` - + `"app" installation auth arrives in phase 5.2.`, - ) - } - let OctokitCtor: typeof import('@octokit/rest').Octokit try { ({ Octokit: OctokitCtor } = await import('@octokit/rest')) @@ -32,7 +35,21 @@ export async function createGitHubClient(auth: GitHubAuth): Promise ToolProvider | Promise + /** Optional fallback project root injected into each resolved session. */ + projectRoot?: string + /** Optional Bearer token — when set, requests must send `Authorization: Bearer `. */ + authToken?: string + /** Mount path for the MCP JSON-RPC endpoint. Defaults to `/mcp`. */ + path?: string + /** + * Idle-session TTL in ms. Sessions that haven't received a request + * within this window are closed and their providers discarded. + * Defaults to 15 minutes. + */ + sessionTtlMs?: number +} + export interface HttpMcpServerHandle { server: http.Server - mcp: McpServer + /** + * The single shared `McpServer` when the server was started with + * `{ projectRoot }` or `{ provider }`. **Not set** in multi-tenant + * resolver mode — in that mode each session owns its own server and + * you should address them through the HTTP endpoint, not directly. + */ + mcp: McpServer | null url: string close: () => Promise } +const MCP_SESSION_HEADER = 'mcp-session-id' + /** * Start an HTTP transport for the MCP server on top of the local project. * - * Phase 5.2 scope: stateless, single-endpoint `POST /mcp`. The underlying - * McpServer is the same one the stdio entry point uses, so every tool - * registered for stdio is available over HTTP with no duplication. Only - * the transport swaps out. - * - * Auth is a simple opaque Bearer token. When `authToken` is supplied, any - * request without the exact `Authorization: Bearer ` header gets - * a 401 before the MCP transport sees it. Multi-tenant auth (per-project - * API keys, GitHub App scopes) is Studio's concern — MCP stays minimal. + * Stateless, single-endpoint `POST /mcp`. The underlying `McpServer` is + * the same one the stdio entry point uses, so every tool registered for + * stdio is available over HTTP with no duplication. Only the transport + * swaps out. * - * GitHubProvider HTTP routing will land in phase 5.3 together with the - * tool-handler provider abstraction; today the HTTP server still drives - * a LocalProvider under the hood. + * Auth is a simple opaque Bearer token. When `authToken` is supplied, + * any request without the exact `Authorization: Bearer ` header + * gets a 401 before the MCP transport sees it. Non-local bind addresses + * should always set this — the `contentrain serve --mcpHttp` CLI hard- + * errors when it detects a non-localhost bind without a token. */ export async function startHttpMcpServer( opts: HttpMcpServerOptions & { port: number, host?: string }, ): Promise { + const mcp = createMcpServer(opts.projectRoot) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }) + await mcp.connect(transport) + return startHttpMcpServerInternal({ - mcp: createMcpServer(opts.projectRoot), + mode: 'single', + mcp, + transport, port: opts.port, host: opts.host, authToken: opts.authToken, @@ -62,18 +109,34 @@ export async function startHttpMcpServer( } /** - * Variant of {@link startHttpMcpServer} that accepts a pre-built provider - * (and an optional `projectRoot`). This is the phase-5.3 entry point that - * lets HTTP-hosted MCP route tool calls to a non-Local provider — e.g. a - * `GitHubProvider` shared across many projects. Read-only tools work out - * of the box; write-path tools return `capability_required: localWorktree` - * when no `projectRoot` is supplied. + * Variant accepting a pre-built provider or a per-request resolver. + * + * **Single-provider mode** (`{ provider }`): + * All sessions share one `McpServer` backed by the given provider. + * Good for CI runners, single-tenant deployments, or any case where + * the backing repository is fixed at boot time. + * + * **Multi-tenant mode** (`{ resolveProvider }`): + * Each session resolves its own provider from the incoming request. + * The resolver is invoked once per session; subsequent requests with + * the `Mcp-Session-Id` header reuse the same server + transport pair. + * Idle sessions are closed after `sessionTtlMs` (default 15m). */ export async function startHttpMcpServerWith( - opts: HttpMcpServerProviderOptions & { port: number, host?: string }, + opts: (HttpMcpServerProviderOptions | HttpMcpServerResolverOptions) & { port: number, host?: string }, ): Promise { + if ('resolveProvider' in opts) { + return startMultiTenantHttpMcpServer(opts) + } + const mcp = createMcpServer({ provider: opts.provider, projectRoot: opts.projectRoot }) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }) + await mcp.connect(transport) return startHttpMcpServerInternal({ - mcp: createMcpServer({ provider: opts.provider, projectRoot: opts.projectRoot }), + mode: 'single', + mcp, + transport, port: opts.port, host: opts.host, authToken: opts.authToken, @@ -81,19 +144,150 @@ export async function startHttpMcpServerWith( }) } +interface MultiTenantSession { + mcp: McpServer + transport: StreamableHTTPServerTransport + lastUsed: number +} + +async function startMultiTenantHttpMcpServer( + opts: HttpMcpServerResolverOptions & { port: number, host?: string }, +): Promise { + const sessions = new Map() + const ttl = opts.sessionTtlMs ?? 15 * 60 * 1000 + const mountPath = opts.path ?? '/mcp' + const host = opts.host ?? '127.0.0.1' + + async function disposeSession(sessionId: string): Promise { + const session = sessions.get(sessionId) + if (!session) return + sessions.delete(sessionId) + await session.transport.close().catch(() => { /* best-effort */ }) + await session.mcp.close().catch(() => { /* best-effort */ }) + } + + const reaper = setInterval(() => { + const now = Date.now() + for (const [id, session] of sessions) { + if (now - session.lastUsed > ttl) void disposeSession(id) + } + }, Math.min(ttl, 60_000)).unref() + + const server = http.createServer((req, res) => { + void handleMultiTenantRequest(req, res, { + mountPath, + authToken: opts.authToken, + resolveProvider: opts.resolveProvider, + projectRoot: opts.projectRoot, + sessions, + }) + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(opts.port, host, () => { + server.off('error', reject) + resolve() + }) + }) + + const address = server.address() as AddressInfo + const url = `http://${host}:${address.port}${mountPath}` + + return { + server, + mcp: null, + url, + close: async () => { + clearInterval(reaper) + await new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())) + }) + const ids = [...sessions.keys()] + await Promise.all(ids.map(id => disposeSession(id))) + }, + } +} + +async function handleMultiTenantRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + ctx: { + mountPath: string + authToken?: string + resolveProvider: (req: http.IncomingMessage) => ToolProvider | Promise + projectRoot?: string + sessions: Map + }, +): Promise { + if (!applyRouteChecks(req, res, { mountPath: ctx.mountPath, authToken: ctx.authToken })) return + + const existingSessionId = pickSessionId(req.headers[MCP_SESSION_HEADER]) + if (existingSessionId && ctx.sessions.has(existingSessionId)) { + const session = ctx.sessions.get(existingSessionId)! + session.lastUsed = Date.now() + try { + await session.transport.handleRequest(req, res) + } catch (error) { + writeInternalError(res, error) + } + return + } + + try { + const provider = await ctx.resolveProvider(req) + const mcp = createMcpServer({ provider, projectRoot: ctx.projectRoot }) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId: string) => { + ctx.sessions.set(sessionId, { mcp, transport, lastUsed: Date.now() }) + }, + }) + await mcp.connect(transport) + await transport.handleRequest(req, res) + } catch (error) { + writeInternalError(res, error) + } +} + +function pickSessionId(header: string | string[] | undefined): string | undefined { + if (!header) return undefined + return Array.isArray(header) ? header[0] : header +} + +function applyRouteChecks( + req: http.IncomingMessage, + res: http.ServerResponse, + ctx: { mountPath: string, authToken?: string }, +): boolean { + if (req.method !== 'POST' && req.method !== 'GET' && req.method !== 'DELETE') { + writeJson(res, 405, { error: 'Method Not Allowed' }) + return false + } + if (!req.url || !req.url.startsWith(ctx.mountPath)) { + writeJson(res, 404, { error: 'Not Found' }) + return false + } + if (ctx.authToken) { + const header = req.headers.authorization + if (header !== `Bearer ${ctx.authToken}`) { + writeJson(res, 401, { error: 'Unauthorized' }) + return false + } + } + return true +} + async function startHttpMcpServerInternal(input: { + mode: 'single' mcp: McpServer + transport: StreamableHTTPServerTransport port: number host?: string authToken?: string path?: string }): Promise { - const { mcp } = input - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }) - await mcp.connect(transport) - + const { mcp, transport } = input const mountPath = input.path ?? '/mcp' const host = input.host ?? '127.0.0.1' @@ -131,36 +325,23 @@ async function handleRequest( res: http.ServerResponse, ctx: { transport: StreamableHTTPServerTransport, mountPath: string, authToken?: string }, ): Promise { - if (req.method !== 'POST' && req.method !== 'GET' && req.method !== 'DELETE') { - writeJson(res, 405, { error: 'Method Not Allowed' }) - return - } - - if (!req.url || !req.url.startsWith(ctx.mountPath)) { - writeJson(res, 404, { error: 'Not Found' }) - return - } - - if (ctx.authToken) { - const header = req.headers.authorization - if (header !== `Bearer ${ctx.authToken}`) { - writeJson(res, 401, { error: 'Unauthorized' }) - return - } - } + if (!applyRouteChecks(req, res, { mountPath: ctx.mountPath, authToken: ctx.authToken })) return try { await ctx.transport.handleRequest(req, res) } catch (error) { - if (!res.headersSent) { - writeJson(res, 500, { - error: 'Internal Server Error', - message: error instanceof Error ? error.message : String(error), - }) - } + writeInternalError(res, error) } } +function writeInternalError(res: http.ServerResponse, error: unknown): void { + if (res.headersSent) return + writeJson(res, 500, { + error: 'Internal Server Error', + message: error instanceof Error ? error.message : String(error), + }) +} + function writeJson(res: http.ServerResponse, status: number, body: unknown): void { res.writeHead(status, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(body)) diff --git a/packages/mcp/src/testing/conformance.ts b/packages/mcp/src/testing/conformance.ts new file mode 100644 index 0000000..4d08b7a --- /dev/null +++ b/packages/mcp/src/testing/conformance.ts @@ -0,0 +1,107 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Conformance fixture loader — published as `@contentrain/mcp/testing/conformance` + * so external tools (Studio, third-party reimplementations, alt-provider + * test harnesses) can assert byte-identical output against the same + * scenarios `@contentrain/mcp` tests itself against. + * + * Each scenario directory under `testing/conformance/` is: + * + * scenario.json — { id, description, operation, input, context_tool? } + * setup/ — files seeded into a temp projectRoot before the op + * expected/ — byte-identical file tree after the op runs + * + * Consumers use the `fixturesDir` export to locate the root and the + * helpers below to iterate scenarios without hardcoding directory names. + * + * The fixtures are published as-is — no transformation, no compilation. + * They ship next to the package so consumers can read them with + * `node:fs` without additional dependencies. + */ + +export interface ConformanceScenario { + /** Stable scenario id — e.g. `01-new-collection-entry`. */ + id: string + /** Short human-readable description pulled from `scenario.json`. */ + description: string + /** The write operation under test — `save_content`, `save_model`, `delete_content`, …. */ + operation: string + /** Tool input object — passed into the operation at runtime. */ + input: Record + /** Optional MCP tool name recorded in the resulting `context.json`. */ + context_tool?: string + /** Absolute path to this scenario's directory on disk. */ + dir: string + /** Absolute path to this scenario's `setup/` tree. */ + setupDir: string + /** Absolute path to this scenario's `expected/` tree. */ + expectedDir: string +} + +const MODULE_DIR = dirname(fileURLToPath(import.meta.url)) + +/** + * Resolve the conformance fixtures root. When called from the built + * package (`dist/testing/conformance.mjs`), the fixtures live at + * `../../testing/conformance`. When called from source tests + * (`src/testing/conformance.ts`), they live at + * `../../testing/conformance` as well — the layout is identical. + */ +export const fixturesDir: string = resolve(MODULE_DIR, '..', '..', 'testing', 'conformance') + +/** + * List every scenario directory under `fixturesDir`. Returns an array + * sorted by `id` for deterministic iteration. + */ +export function listConformanceScenarios(): ConformanceScenario[] { + if (!existsSync(fixturesDir)) { + throw new Error( + `Conformance fixtures not found at ${fixturesDir}. ` + + 'Is @contentrain/mcp/testing/conformance installed as a dev dependency?', + ) + } + + const entries = readdirSync(fixturesDir, { withFileTypes: true }) + const scenarios: ConformanceScenario[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue + const dir = join(fixturesDir, entry.name) + const scenarioPath = join(dir, 'scenario.json') + if (!existsSync(scenarioPath)) continue + scenarios.push({ id: entry.name, ...loadScenarioFile(scenarioPath), dir, setupDir: join(dir, 'setup'), expectedDir: join(dir, 'expected') }) + } + return scenarios.toSorted((a, b) => a.id.localeCompare(b.id)) +} + +/** + * Load a single scenario by id. Throws with a helpful message when the + * id does not resolve to a published fixture. + */ +export function loadConformanceScenario(id: string): ConformanceScenario { + const all = listConformanceScenarios() + const match = all.find(s => s.id === id) + if (!match) { + throw new Error(`Unknown conformance scenario "${id}". Known ids: ${all.map(s => s.id).join(', ')}`) + } + return match +} + +function loadScenarioFile(path: string): Omit { + const raw = readFileSync(path, 'utf-8') + const parsed = JSON.parse(raw) as { + description: string + operation: string + input: Record + context_tool?: string + } + return { + description: parsed.description, + operation: parsed.operation, + input: parsed.input, + ...(parsed.context_tool ? { context_tool: parsed.context_tool } : {}), + } +} diff --git a/packages/mcp/tests/fixtures/conformance/01-new-collection-entry/expected/.contentrain/config.json b/packages/mcp/testing/conformance/01-new-collection-entry/expected/.contentrain/config.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/01-new-collection-entry/expected/.contentrain/config.json rename to packages/mcp/testing/conformance/01-new-collection-entry/expected/.contentrain/config.json diff --git a/packages/mcp/tests/fixtures/conformance/01-new-collection-entry/expected/.contentrain/content/blog/blog/en.json b/packages/mcp/testing/conformance/01-new-collection-entry/expected/.contentrain/content/blog/blog/en.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/01-new-collection-entry/expected/.contentrain/content/blog/blog/en.json rename to packages/mcp/testing/conformance/01-new-collection-entry/expected/.contentrain/content/blog/blog/en.json diff --git a/packages/mcp/tests/fixtures/conformance/01-new-collection-entry/expected/.contentrain/context.json b/packages/mcp/testing/conformance/01-new-collection-entry/expected/.contentrain/context.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/01-new-collection-entry/expected/.contentrain/context.json rename to packages/mcp/testing/conformance/01-new-collection-entry/expected/.contentrain/context.json diff --git a/packages/mcp/tests/fixtures/conformance/01-new-collection-entry/expected/.contentrain/meta/blog/en.json b/packages/mcp/testing/conformance/01-new-collection-entry/expected/.contentrain/meta/blog/en.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/01-new-collection-entry/expected/.contentrain/meta/blog/en.json rename to packages/mcp/testing/conformance/01-new-collection-entry/expected/.contentrain/meta/blog/en.json diff --git a/packages/mcp/tests/fixtures/conformance/01-new-collection-entry/expected/.contentrain/models/blog.json b/packages/mcp/testing/conformance/01-new-collection-entry/expected/.contentrain/models/blog.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/01-new-collection-entry/expected/.contentrain/models/blog.json rename to packages/mcp/testing/conformance/01-new-collection-entry/expected/.contentrain/models/blog.json diff --git a/packages/mcp/tests/fixtures/conformance/01-new-collection-entry/scenario.json b/packages/mcp/testing/conformance/01-new-collection-entry/scenario.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/01-new-collection-entry/scenario.json rename to packages/mcp/testing/conformance/01-new-collection-entry/scenario.json diff --git a/packages/mcp/tests/fixtures/conformance/01-new-collection-entry/setup/.contentrain/config.json b/packages/mcp/testing/conformance/01-new-collection-entry/setup/.contentrain/config.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/01-new-collection-entry/setup/.contentrain/config.json rename to packages/mcp/testing/conformance/01-new-collection-entry/setup/.contentrain/config.json diff --git a/packages/mcp/tests/fixtures/conformance/01-new-collection-entry/setup/.contentrain/models/blog.json b/packages/mcp/testing/conformance/01-new-collection-entry/setup/.contentrain/models/blog.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/01-new-collection-entry/setup/.contentrain/models/blog.json rename to packages/mcp/testing/conformance/01-new-collection-entry/setup/.contentrain/models/blog.json diff --git a/packages/mcp/tests/fixtures/conformance/02-new-singleton/expected/.contentrain/config.json b/packages/mcp/testing/conformance/02-new-singleton/expected/.contentrain/config.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/02-new-singleton/expected/.contentrain/config.json rename to packages/mcp/testing/conformance/02-new-singleton/expected/.contentrain/config.json diff --git a/packages/mcp/tests/fixtures/conformance/02-new-singleton/expected/.contentrain/content/marketing/hero/en.json b/packages/mcp/testing/conformance/02-new-singleton/expected/.contentrain/content/marketing/hero/en.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/02-new-singleton/expected/.contentrain/content/marketing/hero/en.json rename to packages/mcp/testing/conformance/02-new-singleton/expected/.contentrain/content/marketing/hero/en.json diff --git a/packages/mcp/tests/fixtures/conformance/02-new-singleton/expected/.contentrain/context.json b/packages/mcp/testing/conformance/02-new-singleton/expected/.contentrain/context.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/02-new-singleton/expected/.contentrain/context.json rename to packages/mcp/testing/conformance/02-new-singleton/expected/.contentrain/context.json diff --git a/packages/mcp/tests/fixtures/conformance/02-new-singleton/expected/.contentrain/meta/hero/en.json b/packages/mcp/testing/conformance/02-new-singleton/expected/.contentrain/meta/hero/en.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/02-new-singleton/expected/.contentrain/meta/hero/en.json rename to packages/mcp/testing/conformance/02-new-singleton/expected/.contentrain/meta/hero/en.json diff --git a/packages/mcp/tests/fixtures/conformance/02-new-singleton/expected/.contentrain/models/hero.json b/packages/mcp/testing/conformance/02-new-singleton/expected/.contentrain/models/hero.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/02-new-singleton/expected/.contentrain/models/hero.json rename to packages/mcp/testing/conformance/02-new-singleton/expected/.contentrain/models/hero.json diff --git a/packages/mcp/tests/fixtures/conformance/02-new-singleton/scenario.json b/packages/mcp/testing/conformance/02-new-singleton/scenario.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/02-new-singleton/scenario.json rename to packages/mcp/testing/conformance/02-new-singleton/scenario.json diff --git a/packages/mcp/tests/fixtures/conformance/02-new-singleton/setup/.contentrain/config.json b/packages/mcp/testing/conformance/02-new-singleton/setup/.contentrain/config.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/02-new-singleton/setup/.contentrain/config.json rename to packages/mcp/testing/conformance/02-new-singleton/setup/.contentrain/config.json diff --git a/packages/mcp/tests/fixtures/conformance/02-new-singleton/setup/.contentrain/models/hero.json b/packages/mcp/testing/conformance/02-new-singleton/setup/.contentrain/models/hero.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/02-new-singleton/setup/.contentrain/models/hero.json rename to packages/mcp/testing/conformance/02-new-singleton/setup/.contentrain/models/hero.json diff --git a/packages/mcp/tests/fixtures/conformance/03-new-dictionary/expected/.contentrain/config.json b/packages/mcp/testing/conformance/03-new-dictionary/expected/.contentrain/config.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/03-new-dictionary/expected/.contentrain/config.json rename to packages/mcp/testing/conformance/03-new-dictionary/expected/.contentrain/config.json diff --git a/packages/mcp/tests/fixtures/conformance/03-new-dictionary/expected/.contentrain/content/marketing/ui-strings/en.json b/packages/mcp/testing/conformance/03-new-dictionary/expected/.contentrain/content/marketing/ui-strings/en.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/03-new-dictionary/expected/.contentrain/content/marketing/ui-strings/en.json rename to packages/mcp/testing/conformance/03-new-dictionary/expected/.contentrain/content/marketing/ui-strings/en.json diff --git a/packages/mcp/tests/fixtures/conformance/03-new-dictionary/expected/.contentrain/context.json b/packages/mcp/testing/conformance/03-new-dictionary/expected/.contentrain/context.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/03-new-dictionary/expected/.contentrain/context.json rename to packages/mcp/testing/conformance/03-new-dictionary/expected/.contentrain/context.json diff --git a/packages/mcp/tests/fixtures/conformance/03-new-dictionary/expected/.contentrain/meta/ui-strings/en.json b/packages/mcp/testing/conformance/03-new-dictionary/expected/.contentrain/meta/ui-strings/en.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/03-new-dictionary/expected/.contentrain/meta/ui-strings/en.json rename to packages/mcp/testing/conformance/03-new-dictionary/expected/.contentrain/meta/ui-strings/en.json diff --git a/packages/mcp/tests/fixtures/conformance/03-new-dictionary/expected/.contentrain/models/ui-strings.json b/packages/mcp/testing/conformance/03-new-dictionary/expected/.contentrain/models/ui-strings.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/03-new-dictionary/expected/.contentrain/models/ui-strings.json rename to packages/mcp/testing/conformance/03-new-dictionary/expected/.contentrain/models/ui-strings.json diff --git a/packages/mcp/tests/fixtures/conformance/03-new-dictionary/scenario.json b/packages/mcp/testing/conformance/03-new-dictionary/scenario.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/03-new-dictionary/scenario.json rename to packages/mcp/testing/conformance/03-new-dictionary/scenario.json diff --git a/packages/mcp/tests/fixtures/conformance/03-new-dictionary/setup/.contentrain/config.json b/packages/mcp/testing/conformance/03-new-dictionary/setup/.contentrain/config.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/03-new-dictionary/setup/.contentrain/config.json rename to packages/mcp/testing/conformance/03-new-dictionary/setup/.contentrain/config.json diff --git a/packages/mcp/tests/fixtures/conformance/03-new-dictionary/setup/.contentrain/models/ui-strings.json b/packages/mcp/testing/conformance/03-new-dictionary/setup/.contentrain/models/ui-strings.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/03-new-dictionary/setup/.contentrain/models/ui-strings.json rename to packages/mcp/testing/conformance/03-new-dictionary/setup/.contentrain/models/ui-strings.json diff --git a/packages/mcp/tests/fixtures/conformance/README.md b/packages/mcp/testing/conformance/README.md similarity index 100% rename from packages/mcp/tests/fixtures/conformance/README.md rename to packages/mcp/testing/conformance/README.md diff --git a/packages/mcp/tests/fixtures/conformance/scenarios.json b/packages/mcp/testing/conformance/scenarios.json similarity index 100% rename from packages/mcp/tests/fixtures/conformance/scenarios.json rename to packages/mcp/testing/conformance/scenarios.json diff --git a/packages/mcp/tests/conformance.test.ts b/packages/mcp/tests/conformance.test.ts index ce91d88..b5bec8c 100644 --- a/packages/mcp/tests/conformance.test.ts +++ b/packages/mcp/tests/conformance.test.ts @@ -26,7 +26,10 @@ import { writeContext } from '../src/core/context.js' * differently. */ -const FIXTURES_DIR = new URL('./fixtures/conformance/', import.meta.url).pathname +// Fixtures live under `packages/mcp/testing/conformance/` so they can be +// published as `@contentrain/mcp/testing/conformance` for external +// byte-parity testing (Studio, alt-provider harnesses, etc.). +const FIXTURES_DIR = new URL('../testing/conformance/', import.meta.url).pathname const FIXED_DATE = new Date('2026-01-01T00:00:00.000Z') const GENERATE_MODE = process.env['GENERATE_FIXTURES'] === '1' diff --git a/packages/mcp/tests/core/validator/reader-overload.test.ts b/packages/mcp/tests/core/validator/reader-overload.test.ts new file mode 100644 index 0000000..2af9e77 --- /dev/null +++ b/packages/mcp/tests/core/validator/reader-overload.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { validateProject } from '../../../src/core/validator/index.js' +import { OverlayReader } from '../../../src/core/overlay-reader.js' +import type { FileChange, RepoReader } from '../../../src/core/contracts/index.js' + +/** + * `validateProject(reader, options)` is the reader-backed overload + * introduced in Phase 5.5b. Studio and any other consumer that wants + * post-commit validation — reading from a `RepoProvider` or an + * `OverlayReader` layered over pending `FileChange`s — depends on it. + * + * These tests pin the overload's contract so future refactors don't + * accidentally break it. + */ + +function makeInMemoryReader(files: Record): RepoReader { + return { + async readFile(path) { + const content = files[path] + if (content === undefined) throw new Error(`File not found: ${path}`) + return content + }, + async listDirectory(path) { + const prefix = path.endsWith('/') ? path : `${path}/` + const children = new Set() + for (const filePath of Object.keys(files)) { + if (!filePath.startsWith(prefix)) continue + const rest = filePath.slice(prefix.length) + const firstSegment = rest.split('/')[0] + if (firstSegment) children.add(firstSegment) + } + return [...children].toSorted() + }, + async fileExists(path) { + return Object.hasOwn(files, path) + }, + } +} + +describe('validateProject reader overload', () => { + it('validates a project read entirely through a RepoReader (no filesystem access)', async () => { + const reader = makeInMemoryReader({ + '.contentrain/config.json': JSON.stringify({ + version: 1, + stack: 'next', + workflow: 'auto-merge', + locales: { default: 'en', supported: ['en'] }, + domains: ['marketing'], + }), + '.contentrain/models/hero.json': JSON.stringify({ + id: 'hero', + name: 'Hero', + kind: 'singleton', + domain: 'marketing', + i18n: false, + fields: { title: { type: 'string', required: true } }, + }), + '.contentrain/content/marketing/hero/data.json': JSON.stringify({ title: 'Welcome' }), + }) + + const result = await validateProject(reader) + expect(result.valid).toBe(true) + expect(result.summary.errors).toBe(0) + expect(result.summary.models_checked).toBe(1) + }) + + it('surfaces errors from content read through the reader', async () => { + // Missing required `title` on hero — validator must catch this + // via the reader overload exactly the same way it catches it + // when reading from disk. + const reader = makeInMemoryReader({ + '.contentrain/config.json': JSON.stringify({ + version: 1, + stack: 'next', + workflow: 'auto-merge', + locales: { default: 'en', supported: ['en'] }, + domains: ['marketing'], + }), + '.contentrain/models/hero.json': JSON.stringify({ + id: 'hero', + name: 'Hero', + kind: 'singleton', + domain: 'marketing', + i18n: false, + fields: { title: { type: 'string', required: true } }, + }), + '.contentrain/content/marketing/hero/data.json': JSON.stringify({}), + }) + + const result = await validateProject(reader) + expect(result.valid).toBe(false) + expect(result.summary.errors).toBeGreaterThan(0) + expect(result.issues.some(i => i.field === 'title' && i.severity === 'error')).toBe(true) + }) + + it('sees pending writes when layered behind an OverlayReader', async () => { + // Base state: no content file. The overlay adds one. Validator + // must treat the pending FileChange as if it were committed — + // this is the exact shape Studio uses to validate before a write + // lands. + const base = makeInMemoryReader({ + '.contentrain/config.json': JSON.stringify({ + version: 1, + stack: 'next', + workflow: 'auto-merge', + locales: { default: 'en', supported: ['en'] }, + domains: ['marketing'], + }), + '.contentrain/models/hero.json': JSON.stringify({ + id: 'hero', + name: 'Hero', + kind: 'singleton', + domain: 'marketing', + i18n: false, + fields: { title: { type: 'string', required: true } }, + }), + }) + + const pendingChanges: FileChange[] = [ + { + path: '.contentrain/content/marketing/hero/data.json', + content: JSON.stringify({ title: 'Pending' }), + }, + ] + const overlay = new OverlayReader(base, pendingChanges) + + const result = await validateProject(overlay, { model: 'hero' }) + expect(result.valid).toBe(true) + expect(result.summary.errors).toBe(0) + }) +}) diff --git a/packages/mcp/tests/providers/github/app-auth.test.ts b/packages/mcp/tests/providers/github/app-auth.test.ts new file mode 100644 index 0000000..3bc51b3 --- /dev/null +++ b/packages/mcp/tests/providers/github/app-auth.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import { generateKeyPairSync, createPublicKey, createVerify } from 'node:crypto' +import { exchangeInstallationToken, signAppJwt } from '../../../src/providers/github/app-auth.js' + +function makeTestKey() { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }) + return { publicKey, privateKey } +} + +describe('signAppJwt', () => { + it('produces a three-segment RS256 JWT that verifies against the public key', () => { + const { publicKey, privateKey } = makeTestKey() + const token = signAppJwt({ appId: 12345, privateKey }) + + const parts = token.split('.') + expect(parts).toHaveLength(3) + + const header = JSON.parse(Buffer.from(parts[0]!, 'base64url').toString('utf8')) + expect(header).toEqual({ alg: 'RS256', typ: 'JWT' }) + + const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8')) + expect(payload.iss).toBe('12345') + expect(payload.exp - payload.iat).toBeLessThanOrEqual(60 * 10) + expect(payload.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)) + + const verifier = createVerify('RSA-SHA256') + verifier.update(`${parts[0]}.${parts[1]}`) + verifier.end() + const signatureBytes = Buffer.from(parts[2]!, 'base64url') + expect(verifier.verify(createPublicKey(publicKey), signatureBytes)).toBe(true) + }) +}) + +describe('exchangeInstallationToken', () => { + it('POSTs to /app/installations/:id/access_tokens with the signed JWT and returns the token', async () => { + const { privateKey } = makeTestKey() + const mockResponse = { + token: 'ghs_mockInstallationToken', + expires_at: '2026-04-18T13:00:00Z', + } + const fetchImpl = vi.fn(async () => new Response(JSON.stringify(mockResponse), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof globalThis.fetch + + const result = await exchangeInstallationToken( + { appId: 98765, installationId: 111222, privateKey }, + { fetchImpl }, + ) + + expect(result.token).toBe('ghs_mockInstallationToken') + expect(result.expiresAt).toBe('2026-04-18T13:00:00Z') + + expect(fetchImpl).toHaveBeenCalledTimes(1) + const [url, init] = vi.mocked(fetchImpl).mock.calls[0]! + expect(url).toBe('https://api.github.com/app/installations/111222/access_tokens') + const headers = (init as RequestInit).headers as Record + expect(headers.Authorization).toMatch(/^Bearer ey.+/u) + expect(headers.Accept).toBe('application/vnd.github+json') + expect(headers['X-GitHub-Api-Version']).toBe('2022-11-28') + }) + + it('throws a descriptive error on non-2xx responses', async () => { + const { privateKey } = makeTestKey() + const fetchImpl = vi.fn(async () => new Response('Bad credentials', { + status: 401, + statusText: 'Unauthorized', + })) as unknown as typeof globalThis.fetch + + await expect(exchangeInstallationToken( + { appId: 1, installationId: 2, privateKey }, + { fetchImpl }, + )).rejects.toThrow(/installation-token exchange failed: 401 Unauthorized — Bad credentials/u) + }) + + it('accepts a custom baseUrl for GitHub Enterprise Server', async () => { + const { privateKey } = makeTestKey() + const fetchImpl = vi.fn(async () => new Response( + JSON.stringify({ token: 'x', expires_at: '2026-01-01T00:00:00Z' }), + { status: 201, headers: { 'Content-Type': 'application/json' } }, + )) as unknown as typeof globalThis.fetch + + await exchangeInstallationToken( + { appId: 1, installationId: 2, privateKey }, + { fetchImpl, baseUrl: 'https://github.acme.internal/api/v3' }, + ) + const [url] = vi.mocked(fetchImpl).mock.calls[0]! + expect(url).toBe('https://github.acme.internal/api/v3/app/installations/2/access_tokens') + }) +}) diff --git a/packages/mcp/tests/server/http.test.ts b/packages/mcp/tests/server/http.test.ts index 9e92434..e459b25 100644 --- a/packages/mcp/tests/server/http.test.ts +++ b/packages/mcp/tests/server/http.test.ts @@ -5,10 +5,28 @@ import { join } from 'node:path' import { simpleGit } from 'simple-git' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { startHttpMcpServer } from '../../src/server/http/index.js' +import { startHttpMcpServer, startHttpMcpServerWith } from '../../src/server/http/index.js' vi.setConfig({ testTimeout: 30_000, hookTimeout: 30_000 }) +function makeReadOnlyTenantProvider(label: string) { + return { + label, + capabilities: { + localWorktree: false, + sourceRead: false, + sourceWrite: false, + pushRemote: false, + branchProtection: false, + pullRequestFallback: false, + astScan: false, + }, + async readFile() { throw new Error(`${label}: no reads expected`) }, + async listDirectory() { return [] }, + async fileExists() { return false }, + } +} + let testDir: string beforeEach(async () => { @@ -125,7 +143,6 @@ describe('startHttpMcpServer', () => { async fileExists() { return false }, } - const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') const handle = await startHttpMcpServerWith({ provider: readOnlyProvider, port: 0 }) try { const client = new Client({ name: 'test-http-client', version: '1.0.0' }) @@ -225,7 +242,6 @@ describe('startHttpMcpServer', () => { const { GitHubProvider } = await import('../../src/providers/github/index.js') const provider = new GitHubProvider(client as unknown as import('../../src/providers/github/client.js').GitHubClient, { owner: 'acme', name: 'site' }) - const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') const handle = await startHttpMcpServerWith({ provider, port: 0 }) try { const mcpClient = new Client({ name: 'test-http-client', version: '1.0.0' }) @@ -341,7 +357,6 @@ describe('startHttpMcpServer', () => { { projectId: 'acme/site' }, ) - const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') const handle = await startHttpMcpServerWith({ provider, port: 0 }) try { const mcpClient = new Client({ name: 'test-http-client', version: '1.0.0' }) @@ -383,7 +398,6 @@ describe('startHttpMcpServer', () => { it('commits content_delete through a GitHubProvider-like remote provider', async () => { const { makeGitHubMock, makeConfig } = await import('./fixtures/github-mock.js') const { GitHubProvider } = await import('../../src/providers/github/index.js') - const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') const model = { id: 'blog', @@ -453,7 +467,6 @@ describe('startHttpMcpServer', () => { it('commits model_save through a GitHubProvider-like remote provider', async () => { const { makeGitHubMock, makeConfig } = await import('./fixtures/github-mock.js') const { GitHubProvider } = await import('../../src/providers/github/index.js') - const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') const filesOnHead: Record = { '.contentrain/config.json': JSON.stringify(makeConfig()), @@ -504,7 +517,6 @@ describe('startHttpMcpServer', () => { it('commits model_delete through a GitHubProvider-like remote provider', async () => { const { makeGitHubMock, makeConfig } = await import('./fixtures/github-mock.js') const { GitHubProvider } = await import('../../src/providers/github/index.js') - const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') const model = { id: 'blog', @@ -559,7 +571,6 @@ describe('startHttpMcpServer', () => { it('runs contentrain_validate read-only over a GitHubProvider-like remote provider', async () => { const { makeGitHubMock, makeConfig } = await import('./fixtures/github-mock.js') const { GitHubProvider } = await import('../../src/providers/github/index.js') - const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') const model = { id: 'blog', @@ -631,7 +642,6 @@ describe('startHttpMcpServer', () => { async fileExists() { return false }, } - const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') const handle = await startHttpMcpServerWith({ provider: readOnlyProvider, port: 0 }) try { const client = new Client({ name: 'test-http-client', version: '1.0.0' }) @@ -660,7 +670,6 @@ describe('startHttpMcpServer', () => { it('status works read-only over a remote provider (no localWorktree rejection)', async () => { const { makeGitHubMock, makeConfig } = await import('./fixtures/github-mock.js') const { GitHubProvider } = await import('../../src/providers/github/index.js') - const { startHttpMcpServerWith } = await import('../../src/server/http/index.js') const committedContext = { version: '1', @@ -720,4 +729,109 @@ describe('startHttpMcpServer', () => { await handle.close() } }) + + // ─── 1.4.0: per-request provider resolver ─── + // Studio MCP Cloud hosts one HTTP endpoint serving many projects. + // Each session's provider must be resolved from the inbound request + // (usually by a workspace or project identifier) rather than baked in + // at boot. The resolver runs exactly once per session; subsequent + // requests with the same Mcp-Session-Id reuse the resolved server. + + it('invokes resolveProvider once per session and isolates provider state between sessions', async () => { + const alpha = makeReadOnlyTenantProvider('alpha') + const bravo = makeReadOnlyTenantProvider('bravo') + const resolveSpy = vi.fn((req: { headers: Record }) => { + const header = req.headers['x-project-id'] + const projectId = Array.isArray(header) ? header[0] : header + if (projectId === 'alpha') return alpha + if (projectId === 'bravo') return bravo + throw new Error(`unknown project: ${String(projectId)}`) + }) + + const handle = await startHttpMcpServerWith({ + resolveProvider: resolveSpy, + port: 0, + }) + try { + const clientAlpha = new Client({ name: 'alpha-client', version: '1.0.0' }) + const transportAlpha = new StreamableHTTPClientTransport(new URL(handle.url), { + requestInit: { headers: { 'x-project-id': 'alpha' } }, + }) + await clientAlpha.connect(transportAlpha) + + const clientBravo = new Client({ name: 'bravo-client', version: '1.0.0' }) + const transportBravo = new StreamableHTTPClientTransport(new URL(handle.url), { + requestInit: { headers: { 'x-project-id': 'bravo' } }, + }) + await clientBravo.connect(transportBravo) + + try { + // describe_format is a static read-only tool — it does not hit + // the provider, but proving it works through both sessions + // confirms the two sessions are wired correctly. + const alphaResult = await clientAlpha.callTool({ name: 'contentrain_describe_format', arguments: {} }) + const bravoResult = await clientBravo.callTool({ name: 'contentrain_describe_format', arguments: {} }) + expect(parseResult(alphaResult)['overview']).toBeDefined() + expect(parseResult(bravoResult)['overview']).toBeDefined() + + // Resolver was called exactly once per session, with each + // request's headers distinguishing the target project. + expect(resolveSpy).toHaveBeenCalledTimes(2) + const calledWithHeaders = resolveSpy.mock.calls.map(c => + (c[0] as { headers: Record }).headers['x-project-id'], + ) + expect(calledWithHeaders).toEqual(expect.arrayContaining(['alpha', 'bravo'])) + } finally { + await clientAlpha.close() + await clientBravo.close() + } + } finally { + await handle.close() + } + }) + + it('rejects resolver failures with a 500 instead of hanging the request', async () => { + const handle = await startHttpMcpServerWith({ + resolveProvider: () => { throw new Error('tenant not found') }, + port: 0, + }) + try { + const response = await fetch(handle.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), + }) + expect(response.status).toBe(500) + const body = await response.json() as { error: string, message: string } + expect(body.message).toContain('tenant not found') + } finally { + await handle.close() + } + }) + + it('single-provider mode still works and exposes the shared McpServer on handle.mcp', async () => { + const provider = { + capabilities: { + localWorktree: false, + sourceRead: false, + sourceWrite: false, + pushRemote: false, + branchProtection: false, + pullRequestFallback: false, + astScan: false, + }, + async readFile() { throw new Error('no reads') }, + async listDirectory() { return [] }, + async fileExists() { return false }, + } + const handle = await startHttpMcpServerWith({ provider, port: 0 }) + try { + expect(handle.mcp).not.toBeNull() + } finally { + await handle.close() + } + }) }) diff --git a/packages/mcp/tests/testing/conformance.test.ts b/packages/mcp/tests/testing/conformance.test.ts new file mode 100644 index 0000000..62cf0c5 --- /dev/null +++ b/packages/mcp/tests/testing/conformance.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { + fixturesDir, + listConformanceScenarios, + loadConformanceScenario, +} from '../../src/testing/conformance.js' + +/** + * Smoke test for the `@contentrain/mcp/testing/conformance` subpath + * export. External packages (Studio, third-party reimplementations) + * rely on these helpers + the shipped fixtures for byte-parity testing; + * if anything here breaks, that assertion ecosystem goes with it. + */ + +describe('conformance subpath export', () => { + it('fixturesDir resolves to an existing directory that contains scenarios.json', () => { + expect(existsSync(fixturesDir)).toBe(true) + expect(existsSync(join(fixturesDir, 'scenarios.json'))).toBe(true) + }) + + it('listConformanceScenarios returns the shipped scenarios sorted by id', () => { + const scenarios = listConformanceScenarios() + expect(scenarios.length).toBeGreaterThan(0) + const ids = scenarios.map(s => s.id) + expect(ids).toEqual([...ids].toSorted((a, b) => a.localeCompare(b))) + }) + + it('each scenario has description, operation, input, setupDir, expectedDir that resolve on disk', () => { + for (const scenario of listConformanceScenarios()) { + expect(scenario.description).toBeTypeOf('string') + expect(scenario.description.length).toBeGreaterThan(0) + expect(scenario.operation).toBeTypeOf('string') + expect(scenario.input).toBeTypeOf('object') + expect(existsSync(scenario.setupDir)).toBe(true) + expect(existsSync(scenario.expectedDir)).toBe(true) + } + }) + + it('loadConformanceScenario resolves a known id', () => { + const first = listConformanceScenarios()[0]! + const byId = loadConformanceScenario(first.id) + expect(byId.id).toBe(first.id) + expect(byId.dir).toBe(first.dir) + }) + + it('loadConformanceScenario throws a helpful error for an unknown id', () => { + expect(() => loadConformanceScenario('does-not-exist')).toThrow(/Unknown conformance scenario/u) + }) +})