Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions packages/mcp/src/core/config.ts
Original file line number Diff line number Diff line change
@@ -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<ContentrainConfig | null> {
const raw = await readJson<Partial<ContentrainConfig>>(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<T>(reader: RepoReader, path: string): Promise<T | null> {
try {
return JSON.parse(await reader.readFile(path)) as T
} catch {
return null
}
}

function normaliseConfig(raw: Partial<ContentrainConfig> | null): ContentrainConfig | null {
if (!raw) return null
return {
version: raw.version ?? 1,
stack: raw.stack ?? 'other',
Expand All @@ -22,6 +31,32 @@ export async function readConfig(projectRoot: string): Promise<ContentrainConfig
}
}

export async function readVocabulary(projectRoot: string): Promise<Vocabulary | null> {
return readJson<Vocabulary>(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<ContentrainConfig | null>
export function readConfig(reader: RepoReader): Promise<ContentrainConfig | null>
export async function readConfig(input: string | RepoReader): Promise<ContentrainConfig | null> {
if (typeof input === 'string') {
const raw = await readJson<Partial<ContentrainConfig>>(join(contentrainDir(input), 'config.json'))
return normaliseConfig(raw)
}
const raw = await readJsonViaReader<Partial<ContentrainConfig>>(input, CONFIG_PATH)
return normaliseConfig(raw)
}

/**
* Read `.contentrain/vocabulary.json`. Same dual signature as {@link readConfig}.
*/
export function readVocabulary(projectRoot: string): Promise<Vocabulary | null>
export function readVocabulary(reader: RepoReader): Promise<Vocabulary | null>
export async function readVocabulary(input: string | RepoReader): Promise<Vocabulary | null> {
if (typeof input === 'string') {
return readJson<Vocabulary>(join(contentrainDir(input), 'vocabulary.json'))
}
return readJsonViaReader<Vocabulary>(input, VOCABULARY_PATH)
}
55 changes: 45 additions & 10 deletions packages/mcp/src/core/model-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelSummary[]> {
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<ModelDefinition>(join(modelsDir, file))),
)
async function tryReadJsonViaReader<T>(reader: RepoReader, path: string): Promise<T | null> {
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<ModelSummary[]>
export function listModels(reader: RepoReader): Promise<ModelSummary[]>
export async function listModels(input: string | RepoReader): Promise<ModelSummary[]> {
let files: string[]
let load: (file: string) => Promise<ModelDefinition | null>

if (typeof input === 'string') {
const modelsDir = join(contentrainDir(input), 'models')
files = await readDir(modelsDir)
load = file => readJson<ModelDefinition>(join(modelsDir, file))
} else {
files = await input.listDirectory(MODELS_DIR_PATH)
load = file => tryReadJsonViaReader<ModelDefinition>(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)
Expand All @@ -28,9 +55,17 @@ export async function listModels(projectRoot: string): Promise<ModelSummary[]> {
.toSorted((a, b) => a.id.localeCompare(b.id, 'en'))
}

export async function readModel(projectRoot: string, modelId: string): Promise<ModelDefinition | null> {
const filePath = join(contentrainDir(projectRoot), 'models', `${modelId}.json`)
return readJson<ModelDefinition>(filePath)
/**
* Read a single model definition. Same dual signature as {@link listModels}.
*/
export function readModel(projectRoot: string, modelId: string): Promise<ModelDefinition | null>
export function readModel(reader: RepoReader, modelId: string): Promise<ModelDefinition | null>
export async function readModel(input: string | RepoReader, modelId: string): Promise<ModelDefinition | null> {
if (typeof input === 'string') {
const filePath = join(contentrainDir(input), 'models', `${modelId}.json`)
return readJson<ModelDefinition>(filePath)
}
return tryReadJsonViaReader<ModelDefinition>(input, `${MODELS_DIR_PATH}/${modelId}.json`)
}

async function countDocumentFileStrategy(
Expand Down
86 changes: 78 additions & 8 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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')
}
4 changes: 2 additions & 2 deletions packages/mcp/src/server/http/index.ts
Original file line number Diff line number Diff line change
@@ -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'
59 changes: 53 additions & 6 deletions packages/mcp/src/server/http/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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 <token>`. */
authToken?: string
/** Mount path for the MCP JSON-RPC endpoint. Defaults to `/mcp`. */
path?: string
}

export interface HttpMcpServerHandle {
server: http.Server
mcp: McpServer
Expand Down Expand Up @@ -41,22 +52,58 @@ export interface HttpMcpServerHandle {
export async function startHttpMcpServer(
opts: HttpMcpServerOptions & { port: number, host?: string },
): Promise<HttpMcpServerHandle> {
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<HttpMcpServerHandle> {
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<HttpMcpServerHandle> {
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<void>((resolve, reject) => {
server.once('error', reject)
server.listen(opts.port, host, () => {
server.listen(input.port, host, () => {
server.off('error', reject)
resolve()
})
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp/src/tools/bulk.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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.',
Expand All @@ -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 {
Expand Down
Loading
Loading