diff --git a/packages/mcp/src/core/config.ts b/packages/mcp/src/core/config.ts index 3576668..b4a80ad 100644 --- a/packages/mcp/src/core/config.ts +++ b/packages/mcp/src/core/config.ts @@ -1,12 +1,21 @@ import type { ContentrainConfig, Vocabulary } from '@contentrain/types' import { join } from 'node:path' import { contentrainDir, readJson } from '../util/fs.js' +import type { RepoReader } from './contracts/index.js' -export async function readConfig(projectRoot: string): Promise { - const raw = await readJson>(join(contentrainDir(projectRoot), 'config.json')) - if (!raw) return null +const CONFIG_PATH = '.contentrain/config.json' +const VOCABULARY_PATH = '.contentrain/vocabulary.json' - // Normalize with safe defaults +async function readJsonViaReader(reader: RepoReader, path: string): Promise { + try { + return JSON.parse(await reader.readFile(path)) as T + } catch { + return null + } +} + +function normaliseConfig(raw: Partial | null): ContentrainConfig | null { + if (!raw) return null return { version: raw.version ?? 1, stack: raw.stack ?? 'other', @@ -22,6 +31,32 @@ export async function readConfig(projectRoot: string): Promise { - return readJson(join(contentrainDir(projectRoot), 'vocabulary.json')) +/** + * Read `.contentrain/config.json` and normalise it with safe defaults. + * + * Accepts either a legacy `projectRoot` string (LocalReader is constructed + * internally) or any `RepoReader` — tool handlers pass their provider so + * the same logic resolves against local fs or the GitHub Git Data API. + */ +export function readConfig(projectRoot: string): Promise +export function readConfig(reader: RepoReader): Promise +export async function readConfig(input: string | RepoReader): Promise { + if (typeof input === 'string') { + const raw = await readJson>(join(contentrainDir(input), 'config.json')) + return normaliseConfig(raw) + } + const raw = await readJsonViaReader>(input, CONFIG_PATH) + return normaliseConfig(raw) +} + +/** + * Read `.contentrain/vocabulary.json`. Same dual signature as {@link readConfig}. + */ +export function readVocabulary(projectRoot: string): Promise +export function readVocabulary(reader: RepoReader): Promise +export async function readVocabulary(input: string | RepoReader): Promise { + if (typeof input === 'string') { + return readJson(join(contentrainDir(input), 'vocabulary.json')) + } + return readJsonViaReader(input, VOCABULARY_PATH) } diff --git a/packages/mcp/src/core/model-manager.ts b/packages/mcp/src/core/model-manager.ts index 69366b2..f4338ee 100644 --- a/packages/mcp/src/core/model-manager.ts +++ b/packages/mcp/src/core/model-manager.ts @@ -3,18 +3,45 @@ import { join } from 'node:path' import { rm } from 'node:fs/promises' import { z } from 'zod' import { contentrainDir, ensureDir, readDir, readJson, writeJson } from '../util/fs.js' +import type { RepoReader } from './contracts/index.js' import { resolveContentDir, resolveLocaleStrategy } from './content-manager.js' export type { ModelSummary } from '@contentrain/types' -export async function listModels(projectRoot: string): Promise { - const modelsDir = join(contentrainDir(projectRoot), 'models') - const files = await readDir(modelsDir) - const jsonFiles = files.filter(f => f.endsWith('.json')) +const MODELS_DIR_PATH = '.contentrain/models' - const models = await Promise.all( - jsonFiles.map(file => readJson(join(modelsDir, file))), - ) +async function tryReadJsonViaReader(reader: RepoReader, path: string): Promise { + try { + return JSON.parse(await reader.readFile(path)) as T + } catch { + return null + } +} + +/** + * List every model defined under `.contentrain/models/*.json`. + * + * Accepts either a legacy `projectRoot` string (filesystem walk) or any + * `RepoReader` — HTTP-hosted callers pass their `GitHubProvider` so the + * same helper resolves model metadata over the Git Data API. + */ +export function listModels(projectRoot: string): Promise +export function listModels(reader: RepoReader): Promise +export async function listModels(input: string | RepoReader): Promise { + let files: string[] + let load: (file: string) => Promise + + if (typeof input === 'string') { + const modelsDir = join(contentrainDir(input), 'models') + files = await readDir(modelsDir) + load = file => readJson(join(modelsDir, file)) + } else { + files = await input.listDirectory(MODELS_DIR_PATH) + load = file => tryReadJsonViaReader(input, `${MODELS_DIR_PATH}/${file}`) + } + + const jsonFiles = files.filter(f => f.endsWith('.json')) + const models = await Promise.all(jsonFiles.map(load)) return models .filter((m): m is ModelDefinition => m !== null && !!m.id) @@ -28,9 +55,17 @@ export async function listModels(projectRoot: string): Promise { .toSorted((a, b) => a.id.localeCompare(b.id, 'en')) } -export async function readModel(projectRoot: string, modelId: string): Promise { - const filePath = join(contentrainDir(projectRoot), 'models', `${modelId}.json`) - return readJson(filePath) +/** + * Read a single model definition. Same dual signature as {@link listModels}. + */ +export function readModel(projectRoot: string, modelId: string): Promise +export function readModel(reader: RepoReader, modelId: string): Promise +export async function readModel(input: string | RepoReader, modelId: string): Promise { + if (typeof input === 'string') { + const filePath = join(contentrainDir(input), 'models', `${modelId}.json`) + return readJson(filePath) + } + return tryReadJsonViaReader(input, `${MODELS_DIR_PATH}/${modelId}.json`) } async function countDocumentFileStrategy( diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 5f848e9..5845efa 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,4 +1,16 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { ProviderCapabilities, RepoReader } from './core/contracts/index.js' +import { LocalProvider } from './providers/local/index.js' + +/** + * Subset of a `RepoProvider` that tool handlers currently consume — a + * `RepoReader` plus the `capabilities` manifest. Narrower than the full + * `RepoProvider` so both `LocalProvider` (which implements reader + + * applyPlan today) and `GitHubProvider` (full provider) satisfy it without + * requiring LocalProvider to stub out the branch-ops methods it does not + * yet own. + */ +export type ToolProvider = RepoReader & { capabilities: ProviderCapabilities } import { registerContextTools } from './tools/context.js' import { registerSetupTools } from './tools/setup.js' import { registerModelTools } from './tools/model.js' @@ -8,19 +20,77 @@ import { registerNormalizeTools } from './tools/normalize.js' import { registerBulkTools } from './tools/bulk.js' import packageJson from '../package.json' with { type: 'json' } -export function createServer(projectRoot: string): McpServer { +export interface CreateServerOptions { + /** + * Content provider — drives reads (and, in later phases, writes) through + * a reader surface. Required when `projectRoot` is omitted. Accepts the + * narrow `ToolProvider` shape so either `LocalProvider` or + * `GitHubProvider` satisfies the contract. + */ + provider?: ToolProvider + /** + * Local project root. When the provider is a `LocalProvider`, its own + * `projectRoot` is used as the fallback. Tools that require local disk + * (normalize, setup, git submit/merge) short-circuit with a capability + * error when no projectRoot is available. + */ + projectRoot?: string +} + +/** + * Create an MCP server instance with every Contentrain tool registered. + * + * Two signatures: + * + * - `createServer('/path/to/project')` — legacy stdio flow. A `LocalProvider` + * is constructed under the hood; every tool keeps behaving exactly as it + * did before phase 5.3. + * - `createServer({ provider, projectRoot? })` — phase 5.3 flow. Any + * `RepoProvider` (including `GitHubProvider`) drives reads and writes. If + * the provider is a `LocalProvider` and `projectRoot` is omitted, the + * provider's own `projectRoot` is used. Otherwise `projectRoot` stays + * undefined and tools that need local disk report a capability error. + * + * Public MCP tool surface (names, parameters, response JSON shape) is + * unchanged across both signatures. + */ +export function createServer(projectRoot: string): McpServer +export function createServer(opts: CreateServerOptions): McpServer +export function createServer(input: string | CreateServerOptions): McpServer { + const { provider, projectRoot } = resolveServerContext(input) + const server = new McpServer({ name: 'contentrain-mcp', version: packageJson.version, }) - registerContextTools(server, projectRoot) - registerSetupTools(server, projectRoot) - registerModelTools(server, projectRoot) - registerContentTools(server, projectRoot) - registerWorkflowTools(server, projectRoot) - registerNormalizeTools(server, projectRoot) - registerBulkTools(server, projectRoot) + registerContextTools(server, provider, projectRoot) + registerSetupTools(server, provider, projectRoot) + registerModelTools(server, provider, projectRoot) + registerContentTools(server, provider, projectRoot) + registerWorkflowTools(server, provider, projectRoot) + registerNormalizeTools(server, provider, projectRoot) + registerBulkTools(server, provider, projectRoot) return server } + +function resolveServerContext(input: string | CreateServerOptions): { + provider: ToolProvider + projectRoot: string | undefined +} { + if (typeof input === 'string') { + return { provider: new LocalProvider(input), projectRoot: input } + } + if (input.provider) { + let projectRoot = input.projectRoot + if (!projectRoot && input.provider instanceof LocalProvider) { + projectRoot = input.provider.projectRoot + } + return { provider: input.provider, projectRoot } + } + if (input.projectRoot) { + return { provider: new LocalProvider(input.projectRoot), projectRoot: input.projectRoot } + } + throw new Error('createServer: either `provider` or `projectRoot` must be provided') +} diff --git a/packages/mcp/src/server/http/index.ts b/packages/mcp/src/server/http/index.ts index d27a199..5958d3a 100644 --- a/packages/mcp/src/server/http/index.ts +++ b/packages/mcp/src/server/http/index.ts @@ -1,2 +1,2 @@ -export type { HttpMcpServerHandle, HttpMcpServerOptions } from './server.js' -export { startHttpMcpServer } from './server.js' +export type { HttpMcpServerHandle, HttpMcpServerOptions, HttpMcpServerProviderOptions } from './server.js' +export { startHttpMcpServer, startHttpMcpServerWith } from './server.js' diff --git a/packages/mcp/src/server/http/server.ts b/packages/mcp/src/server/http/server.ts index b51bfc2..87353c5 100644 --- a/packages/mcp/src/server/http/server.ts +++ b/packages/mcp/src/server/http/server.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto' import type { AddressInfo } from 'node:net' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' -import { createServer as createMcpServer } from '../../server.js' +import { createServer as createMcpServer, type ToolProvider } from '../../server.js' export interface HttpMcpServerOptions { /** Project root the MCP server operates against (LocalProvider path). */ @@ -14,6 +14,17 @@ export interface HttpMcpServerOptions { path?: string } +export interface HttpMcpServerProviderOptions { + /** Pre-built content provider (GitHubProvider, a mock, etc.). */ + provider: ToolProvider + /** Local project root when the provider is a LocalProvider; optional otherwise. */ + 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 +} + export interface HttpMcpServerHandle { server: http.Server mcp: McpServer @@ -41,22 +52,58 @@ export interface HttpMcpServerHandle { export async function startHttpMcpServer( opts: HttpMcpServerOptions & { port: number, host?: string }, ): Promise { - const mcp = createMcpServer(opts.projectRoot) + return startHttpMcpServerInternal({ + mcp: createMcpServer(opts.projectRoot), + port: opts.port, + host: opts.host, + authToken: opts.authToken, + path: opts.path, + }) +} + +/** + * 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. + */ +export async function startHttpMcpServerWith( + opts: HttpMcpServerProviderOptions & { port: number, host?: string }, +): Promise { + return startHttpMcpServerInternal({ + mcp: createMcpServer({ provider: opts.provider, projectRoot: opts.projectRoot }), + port: opts.port, + host: opts.host, + authToken: opts.authToken, + path: opts.path, + }) +} + +async function startHttpMcpServerInternal(input: { + mcp: McpServer + port: number + host?: string + authToken?: string + path?: string +}): Promise { + const { mcp } = input const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), }) await mcp.connect(transport) - const mountPath = opts.path ?? '/mcp' - const host = opts.host ?? '127.0.0.1' + const mountPath = input.path ?? '/mcp' + const host = input.host ?? '127.0.0.1' const server = http.createServer((req, res) => { - void handleRequest(req, res, { transport, mountPath, authToken: opts.authToken }) + void handleRequest(req, res, { transport, mountPath, authToken: input.authToken }) }) await new Promise((resolve, reject) => { server.once('error', reject) - server.listen(opts.port, host, () => { + server.listen(input.port, host, () => { server.off('error', reject) resolve() }) diff --git a/packages/mcp/src/tools/bulk.ts b/packages/mcp/src/tools/bulk.ts index 2736a0e..4842838 100644 --- a/packages/mcp/src/tools/bulk.ts +++ b/packages/mcp/src/tools/bulk.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { EntryMeta } from '@contentrain/types' import { z } from 'zod' +import type { ToolProvider } from '../server.js' import { readConfig } from '../core/config.js' import { readModel } from '../core/model-manager.js' import { resolveContentDir, resolveJsonFilePath, deleteContent } from '../core/content-manager.js' @@ -9,8 +10,13 @@ import { createTransaction, buildBranchName } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' import { readJson, writeJson } from '../util/fs.js' import { TOOL_ANNOTATIONS } from './annotations.js' +import { capabilityError } from './guards.js' -export function registerBulkTools(server: McpServer, projectRoot: string): void { +export function registerBulkTools( + server: McpServer, + _provider: ToolProvider, + projectRoot: string | undefined, +): void { server.tool( 'contentrain_bulk', 'Batch operations on content entries. All operations are auto-committed to git.', @@ -25,6 +31,7 @@ export function registerBulkTools(server: McpServer, projectRoot: string): void }, TOOL_ANNOTATIONS['contentrain_bulk']!, async (input) => { + if (!projectRoot) return capabilityError('contentrain_bulk', 'localWorktree') const config = await readConfig(projectRoot) if (!config) { return { diff --git a/packages/mcp/src/tools/content.ts b/packages/mcp/src/tools/content.ts index c246b10..da646b9 100644 --- a/packages/mcp/src/tools/content.ts +++ b/packages/mcp/src/tools/content.ts @@ -1,5 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' +import type { ToolProvider } from '../server.js' import { readConfig, readVocabulary } from '../core/config.js' import { readModel } from '../core/model-manager.js' import { listContent } from '../core/content-manager.js' @@ -9,8 +10,13 @@ import { buildBranchName } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' import { validateProject } from '../core/validator/index.js' import { TOOL_ANNOTATIONS } from './annotations.js' +import { capabilityError } from './guards.js' -export function registerContentTools(server: McpServer, projectRoot: string): void { +export function registerContentTools( + server: McpServer, + _provider: ToolProvider, + projectRoot: string | undefined, +): void { // ─── contentrain_content_save ─── server.tool( 'contentrain_content_save', @@ -28,6 +34,7 @@ export function registerContentTools(server: McpServer, projectRoot: string): vo }, TOOL_ANNOTATIONS['contentrain_content_save']!, async (input) => { + if (!projectRoot) return capabilityError('contentrain_content_save', 'localWorktree') const config = await readConfig(projectRoot) if (!config) { return { @@ -201,6 +208,7 @@ export function registerContentTools(server: McpServer, projectRoot: string): vo }, TOOL_ANNOTATIONS['contentrain_content_delete']!, async (input) => { + if (!projectRoot) return capabilityError('contentrain_content_delete', 'localWorktree') const config = await readConfig(projectRoot) if (!config) { return { @@ -299,6 +307,7 @@ export function registerContentTools(server: McpServer, projectRoot: string): vo }, TOOL_ANNOTATIONS['contentrain_content_list']!, async (input) => { + if (!projectRoot) return capabilityError('contentrain_content_list', 'localWorktree') const config = await readConfig(projectRoot) if (!config) { return { diff --git a/packages/mcp/src/tools/context.ts b/packages/mcp/src/tools/context.ts index 7794a87..1449b47 100644 --- a/packages/mcp/src/tools/context.ts +++ b/packages/mcp/src/tools/context.ts @@ -1,5 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' +import type { ToolProvider } from '../server.js' import { readConfig, readVocabulary } from '../core/config.js' import { readContext } from '../core/context.js' import { countEntries, listModels, readModel } from '../core/model-manager.js' @@ -9,8 +10,13 @@ import { join } from 'node:path' import { contentrainDir, pathExists } from '../util/fs.js' import { checkBranchHealth, cleanupMergedBranches } from '../git/branch-lifecycle.js' import { TOOL_ANNOTATIONS } from './annotations.js' +import { capabilityError } from './guards.js' -export function registerContextTools(server: McpServer, projectRoot: string): void { +export function registerContextTools( + server: McpServer, + _provider: ToolProvider, + projectRoot: string | undefined, +): void { // ─── contentrain_status ─── server.tool( 'contentrain_status', @@ -18,6 +24,7 @@ export function registerContextTools(server: McpServer, projectRoot: string): vo {}, TOOL_ANNOTATIONS['contentrain_status']!, async () => { + if (!projectRoot) return capabilityError('contentrain_status', 'localWorktree') const crDir = contentrainDir(projectRoot) const initialized = await pathExists(join(crDir, 'config.json')) @@ -113,6 +120,7 @@ export function registerContextTools(server: McpServer, projectRoot: string): vo }, TOOL_ANNOTATIONS['contentrain_describe']!, async ({ model: modelId, include_sample, locale }) => { + if (!projectRoot) return capabilityError('contentrain_describe', 'localWorktree') const modelDef = await readModel(projectRoot, modelId) if (!modelDef) { return { diff --git a/packages/mcp/src/tools/guards.ts b/packages/mcp/src/tools/guards.ts new file mode 100644 index 0000000..d79a6d6 --- /dev/null +++ b/packages/mcp/src/tools/guards.ts @@ -0,0 +1,17 @@ +/** + * Emit a uniform "capability not available" response for tools that + * require local filesystem access but are being driven by a remote + * provider (HTTP + GitHubProvider at the moment). The agent can use + * `capability_required` to decide whether to retry against a different + * transport or surface the limitation to the user. + */ +export function capabilityError(tool: string, capability: string) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + error: `${tool} requires local filesystem access.`, + capability_required: capability, + hint: 'This tool is unavailable when MCP is driven by a remote provider (e.g. GitHubProvider). Use a LocalProvider or the stdio transport.', + }) }], + isError: true as const, + } +} diff --git a/packages/mcp/src/tools/model.ts b/packages/mcp/src/tools/model.ts index f266286..8c9b849 100644 --- a/packages/mcp/src/tools/model.ts +++ b/packages/mcp/src/tools/model.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { ModelDefinition } from '@contentrain/types' import { z } from 'zod' +import type { ToolProvider } from '../server.js' import { readConfig } from '../core/config.js' import { resolveContentDir, resolveJsonFilePath, resolveMdFilePath } from '../core/content-manager.js' @@ -10,11 +11,16 @@ import { LocalProvider } from '../providers/local/index.js' import { buildBranchName } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' import { TOOL_ANNOTATIONS } from './annotations.js' +import { capabilityError } from './guards.js' // Shared field definition schema — single source of truth with normalize extract const fieldDefSchema = fieldDefZodSchema -export function registerModelTools(server: McpServer, projectRoot: string): void { +export function registerModelTools( + server: McpServer, + _provider: ToolProvider, + projectRoot: string | undefined, +): void { // ─── contentrain_model_save ─── server.tool( 'contentrain_model_save', @@ -32,6 +38,7 @@ export function registerModelTools(server: McpServer, projectRoot: string): void }, TOOL_ANNOTATIONS['contentrain_model_save']!, async (input) => { + if (!projectRoot) return capabilityError('contentrain_model_save', 'localWorktree') const config = await readConfig(projectRoot) if (!config) { return { @@ -155,6 +162,7 @@ export function registerModelTools(server: McpServer, projectRoot: string): void }, TOOL_ANNOTATIONS['contentrain_model_delete']!, async ({ model: modelId }) => { + if (!projectRoot) return capabilityError('contentrain_model_delete', 'localWorktree') const config = await readConfig(projectRoot) if (!config) { return { diff --git a/packages/mcp/src/tools/normalize.ts b/packages/mcp/src/tools/normalize.ts index 7651536..1647d66 100644 --- a/packages/mcp/src/tools/normalize.ts +++ b/packages/mcp/src/tools/normalize.ts @@ -1,14 +1,20 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { FieldDef } from '@contentrain/types' import { z } from 'zod' +import type { ToolProvider } from '../server.js' import { readConfig } from '../core/config.js' import { buildGraph } from '../core/graph-builder.js' import { scanCandidates, scanSummary } from '../core/scanner.js' import { applyExtract, applyReuse } from '../core/apply-manager.js' import { fieldDefZodSchema } from '../core/model-manager.js' import { TOOL_ANNOTATIONS } from './annotations.js' +import { capabilityError } from './guards.js' -export function registerNormalizeTools(server: McpServer, projectRoot: string): void { +export function registerNormalizeTools( + server: McpServer, + _provider: ToolProvider, + projectRoot: string | undefined, +): void { // ─── contentrain_scan ─── server.tool( 'contentrain_scan', @@ -25,6 +31,7 @@ export function registerNormalizeTools(server: McpServer, projectRoot: string): }, TOOL_ANNOTATIONS['contentrain_scan']!, async (input) => { + if (!projectRoot) return capabilityError('contentrain_scan', 'astScan') const config = await readConfig(projectRoot) if (!config) { return { @@ -171,6 +178,10 @@ export function registerNormalizeTools(server: McpServer, projectRoot: string): }, TOOL_ANNOTATIONS['contentrain_apply']!, async (input) => { + if (!projectRoot) { + const capability = input.mode === 'reuse' ? 'sourceWrite' : 'sourceRead' + return capabilityError('contentrain_apply', capability) + } const config = await readConfig(projectRoot) if (!config) { return { diff --git a/packages/mcp/src/tools/setup.ts b/packages/mcp/src/tools/setup.ts index 3a166cf..5326ae5 100644 --- a/packages/mcp/src/tools/setup.ts +++ b/packages/mcp/src/tools/setup.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { join } from 'node:path' import { readFile, writeFile, appendFile } from 'node:fs/promises' import { simpleGit } from 'simple-git' +import type { ToolProvider } from '../server.js' import { detectStack } from '../util/detect.js' import { contentrainDir, ensureDir, pathExists, writeJson } from '../util/fs.js' import { readConfig, readVocabulary } from '../core/config.js' @@ -13,8 +14,13 @@ import { getTemplate, listTemplates } from '../templates/index.js' import { createTransaction, buildBranchName, ensureContentBranch } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' import { TOOL_ANNOTATIONS } from './annotations.js' +import { capabilityError } from './guards.js' -export function registerSetupTools(server: McpServer, projectRoot: string): void { +export function registerSetupTools( + server: McpServer, + _provider: ToolProvider, + projectRoot: string | undefined, +): void { // ─── contentrain_init ─── server.tool( 'contentrain_init', @@ -26,6 +32,7 @@ export function registerSetupTools(server: McpServer, projectRoot: string): void }, TOOL_ANNOTATIONS['contentrain_init']!, async ({ stack, locales, domains }) => { + if (!projectRoot) return capabilityError('contentrain_init', 'localWorktree') const crDir = contentrainDir(projectRoot) // Already initialized? @@ -172,6 +179,7 @@ export function registerSetupTools(server: McpServer, projectRoot: string): void }, TOOL_ANNOTATIONS['contentrain_scaffold']!, async ({ template: templateId, locales, with_sample_content }) => { + if (!projectRoot) return capabilityError('contentrain_scaffold', 'localWorktree') const config = await readConfig(projectRoot) if (!config) { return { diff --git a/packages/mcp/src/tools/workflow.ts b/packages/mcp/src/tools/workflow.ts index b9031de..008fccc 100644 --- a/packages/mcp/src/tools/workflow.ts +++ b/packages/mcp/src/tools/workflow.ts @@ -2,13 +2,19 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { CONTENTRAIN_BRANCH } from '@contentrain/types' import { z } from 'zod' import { simpleGit } from 'simple-git' +import type { ToolProvider } from '../server.js' import { validateProject } from '../core/validator/index.js' import { readConfig } from '../core/config.js' import { createTransaction, buildBranchName, mergeBranch } from '../git/transaction.js' import { checkBranchHealth, cleanupMergedBranches } from '../git/branch-lifecycle.js' import { TOOL_ANNOTATIONS } from './annotations.js' +import { capabilityError } from './guards.js' -export function registerWorkflowTools(server: McpServer, projectRoot: string): void { +export function registerWorkflowTools( + server: McpServer, + _provider: ToolProvider, + projectRoot: string | undefined, +): void { // ─── contentrain_validate ─── server.tool( 'contentrain_validate', @@ -19,6 +25,7 @@ export function registerWorkflowTools(server: McpServer, projectRoot: string): v }, TOOL_ANNOTATIONS['contentrain_validate']!, async (input) => { + if (!projectRoot) return capabilityError('contentrain_validate', 'localWorktree') const config = await readConfig(projectRoot) if (!config) { return { @@ -128,6 +135,7 @@ export function registerWorkflowTools(server: McpServer, projectRoot: string): v }, TOOL_ANNOTATIONS['contentrain_submit']!, async (input) => { + if (!projectRoot) return capabilityError('contentrain_submit', 'localWorktree') const config = await readConfig(projectRoot) if (!config) { return { @@ -272,6 +280,7 @@ export function registerWorkflowTools(server: McpServer, projectRoot: string): v }, TOOL_ANNOTATIONS['contentrain_merge']!, async (input) => { + if (!projectRoot) return capabilityError('contentrain_merge', 'localWorktree') const config = await readConfig(projectRoot) if (!config) { return { diff --git a/packages/mcp/tests/server/http.test.ts b/packages/mcp/tests/server/http.test.ts index f0bf9a7..94a65b5 100644 --- a/packages/mcp/tests/server/http.test.ts +++ b/packages/mcp/tests/server/http.test.ts @@ -99,4 +99,90 @@ describe('startHttpMcpServer', () => { await handle.close() } }) + + // ─── Phase 5.3: provider threading ─── + // createServer now accepts `{ provider, projectRoot }` so HTTP handlers can + // route to a non-local provider. Today the write path still needs + // projectRoot; read-only static tools (describe_format) work regardless of + // which provider is used. Phase 5.4 opens the write path to remote providers. + + it('serves describe_format when only a provider is configured (no projectRoot)', async () => { + // Synthetic read-only provider — no projectRoot, implements the + // minimum ToolProvider surface (RepoReader + capabilities). describe_format + // must still succeed because it does not touch disk. + const readOnlyProvider = { + capabilities: { + localWorktree: false, + sourceRead: false, + sourceWrite: false, + pushRemote: false, + branchProtection: false, + pullRequestFallback: false, + astScan: false, + }, + async readFile() { throw new Error('no reads expected') }, + async listDirectory() { return [] }, + 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' }) + const transport = new StreamableHTTPClientTransport(new URL(handle.url)) + await client.connect(transport) + + try { + const result = await client.callTool({ + name: 'contentrain_describe_format', + arguments: {}, + }) + const parsed = parseResult(result) + expect(parsed['overview']).toBeDefined() + } finally { + await client.close() + } + } finally { + await handle.close() + } + }) + + it('returns capability error for write tools when projectRoot is absent', async () => { + const readOnlyProvider = { + capabilities: { + localWorktree: false, + sourceRead: false, + sourceWrite: false, + pushRemote: false, + branchProtection: false, + pullRequestFallback: false, + astScan: false, + }, + async readFile() { throw new Error('no reads expected') }, + async listDirectory() { return [] }, + 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' }) + const transport = new StreamableHTTPClientTransport(new URL(handle.url)) + await client.connect(transport) + + try { + const result = await client.callTool({ + name: 'contentrain_status', + arguments: {}, + }) + const parsed = parseResult(result) + expect(parsed['capability_required']).toBe('localWorktree') + expect(result.isError).toBe(true) + } finally { + await client.close() + } + } finally { + await handle.close() + } + }) })