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
12 changes: 12 additions & 0 deletions .changeset/mcp-honest-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@contentrain/mcp": minor
---

feat(mcp): capability-aware tool listing, server instructions, openWorldHint, session tenant binding

**@contentrain/mcp**: the MCP surface now tells the truth about what it can do, per session.

- **Capability-aware registration.** `createServer` consults a new declarative requirement map (`TOOL_REQUIREMENTS`, exported from `@contentrain/mcp/tools/availability` together with `isToolAvailable`) and only registers tools the resolved provider + `projectRoot` pair can satisfy. Local stdio/CLI flows keep the full 19-tool surface; remote-provider sessions (Studio MCP Cloud, GitHub/GitLab providers) now list only the remote-safe subset instead of advertising tools that always failed with a capability error. Input-dependent checks (`validate --fix`, `apply` reuse) remain call-time guards.
- **`instructions` support.** `CreateServerOptions.instructions` threads the MCP `instructions` string to clients at `initialize`. Defaults to a new `DEFAULT_INSTRUCTIONS` (< 512 chars, describes the describe-format-first and dry-run-first operating rules); pass `''` to omit.
- **`openWorldHint: false`** added to all 19 tool annotations — every tool operates on the configured repository only.
- **Session tenant binding.** Multi-tenant HTTP mode accepts `sessionFingerprint(req)`: the fingerprint captured at session creation must match on every follow-up request carrying that `Mcp-Session-Id`; a mismatch answers `404 Session not found` so the client re-initializes against its own provider. Closes cross-tenant session-id replay.
19 changes: 13 additions & 6 deletions docs/guides/embedding-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ const handle = await startHttpMcpServerWith({
const { repo, auth } = await lookupProjectFromDatabase(projectId)
return createGitHubProvider({ auth, repo })
},
// Bind each session to its tenant: follow-up requests must produce the
// same fingerprint or they get 404 and the client re-initializes.
sessionFingerprint: (req) => req.headers['x-project-id'] as string,
authToken: workspaceBearerToken,
port: 3333,
sessionTtlMs: 15 * 60 * 1000,
Expand All @@ -177,6 +180,8 @@ const handle = await startHttpMcpServerWith({

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

**Session tenant binding.** A session's provider is resolved once, at `initialize`. Without `sessionFingerprint`, any caller that presents a known `Mcp-Session-Id` reaches that session's provider — fine on trusted loopback, not across tenants. Set `sessionFingerprint` to derive a stable tenant identity from each request (e.g. the same headers `resolveProvider` uses); a mismatch answers `404 Session not found`, which per the Streamable HTTP spec makes the client transparently re-initialize its own session.

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

If you want to run a Contentrain tool inside your own Node.js process without MCP's JSON-RPC layer:
Expand Down Expand Up @@ -234,17 +239,17 @@ This is only needed for remote / reader-based flows. `LocalProvider`'s transacti

### `capability_required` is a structured error

Tools that need capabilities the active provider doesn't expose return:
Tools whose requirements can never be met by the session's provider are not registered at all (see [Capability gating](#capability-gating)), so most capability mismatches never reach a handler. The structured error remains for the two input-dependent cases — `validate` with `fix: true` and `apply` in `reuse` mode:

```json
{
"error": "contentrain_scan requires local filesystem access.",
"capability_required": "astScan",
"error": "contentrain_validate requires local filesystem access.",
"capability_required": "localWorktree",
"hint": "This tool is unavailable when MCP is driven by a remote provider. Use a LocalProvider or the stdio transport."
}
```

Treat `capability_required` as a retry signal at the client. Typical fallback: prompt the user to switch to a local checkout, or downgrade the request (e.g. `content_list` with `resolve: true` → `resolve: false`).
Treat `capability_required` as a retry signal at the client. Typical fallback: prompt the user to switch to a local checkout, or downgrade the request (e.g. `validate` without `fix`).

See [Providers & Transports](/guides/providers) for the full capability matrix.

Expand All @@ -258,18 +263,20 @@ Rotate Bearer tokens regularly. MCP does not support per-tool ACLs; a valid toke

## Capability gating

Each provider advertises a `ProviderCapabilities` manifest. Tools gate on capabilities and reject uniformly when the active provider can't satisfy them.
Each provider advertises a `ProviderCapabilities` manifest. `createServer` consults it (together with `projectRoot`) at registration time: tools whose requirements can never be met by the session's provider are **not registered**, so `tools/list` only shows what can actually run. The declarative requirement map is exported as `TOOL_REQUIREMENTS` from `@contentrain/mcp/tools/availability`, alongside an `isToolAvailable(name, provider, projectRoot)` helper for embedders that want to reason about the effective surface without spinning up a server.

| Capability | Local | GitHub | GitLab | Gated tools |
|---|---|---|---|---|
| `localWorktree` | ✓ | — | — | `init`, `scaffold`, `validate --fix`, `submit`, `merge`, `bulk` |
| `localWorktree` | ✓ | — | — | `validate --fix`, `submit`, `merge`, `branch_list`, `branch_delete` |
| `sourceRead` | ✓ | — | — | `apply` (extract) |
| `sourceWrite` | ✓ | — | — | `apply` (reuse) |
| `astScan` | ✓ | — | — | `scan` |
| `pushRemote` | ✓ | ✓ | ✓ | `submit` |
| `branchProtection` | — | ✓ | ✓ | merge fallback |
| `pullRequestFallback` | — | ✓ | ✓ | merge fallback |

`init`, `scaffold`, `doctor`, and `bulk` additionally require a local `projectRoot` on disk. Input-dependent checks (`validate --fix`, `apply` reuse) stay as call-time guards and return the structured `capability_required` error above.

Read-only tools (`status`, `describe`, `describe_format`, `content_list`, `validate` without `--fix`) work on every provider — they use only the reader surface.

## Extension: custom providers
Expand Down
4 changes: 4 additions & 0 deletions docs/guides/http-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ const handle = await startHttpMcpServerWith({

The same pattern works for `createGitLabProvider` with a `GitLabProvider`. Both require their respective optional peers (`@octokit/rest`, `@gitbeaker/rest`).

Multi-tenant deployments (`{ resolveProvider }`) should also set `sessionFingerprint` to bind each MCP session to the tenant it was created for — follow-up requests whose fingerprint doesn't match the session's get `404` and the client re-initializes. See the [embedding guide](/guides/embedding-mcp#3a-http--per-request-provider-resolver-multi-tenant) for the full pattern.

Note that the tool list is capability-aware: a session backed by a remote provider only advertises the tools it can actually run (no `init`/`scaffold`/`doctor`/`bulk`/`submit`/`merge`/branch lifecycle/normalize tools without a local worktree).

## Deployment patterns

### Studio (hosted agent)
Expand Down
6 changes: 5 additions & 1 deletion docs/packages/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ Optional parser support for higher-quality source scanning:

## Tool Catalog

The MCP server exposes **19 tools** organized by function. Each tool includes [MCP annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`) so clients can distinguish safe reads from writes and destructive operations.
The MCP server exposes **19 tools** organized by function. Each tool includes [MCP annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint: false` — every tool operates on the configured repository only) so clients can distinguish safe reads from writes and destructive operations.

::: info Capability-aware listing
`tools/list` is filtered per session: tools whose requirements (local project root, provider capabilities) cannot be met are not registered at all. A local stdio server lists all 19 tools; a remote-provider session (e.g. Studio MCP Cloud) lists only the remote-safe subset — `status`, `describe`, `describe_format`, `model_save`, `model_delete`, `content_save`, `content_delete`, `content_list`, `validate`. See `TOOL_REQUIREMENTS` in `@contentrain/mcp/tools/availability`.
:::

| Tool | Title | Read-only | Destructive |
|------|-------|-----------|-------------|
Expand Down
6 changes: 5 additions & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ All write operations are designed around git-backed safety:

## Tool Surface

19 MCP tools with [annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`) for client safety hints:
19 MCP tools with [annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint: false` — every tool operates on the configured repository only) for client safety hints.

**Tool listing is capability-aware.** `tools/list` only advertises tools the resolved provider + `projectRoot` pair can actually satisfy. A local stdio server lists all 19; a session driven by a remote provider (GitHub/GitLab, no local checkout) lists only the remote-safe subset — `status`, `describe`, `describe_format`, `model_save`, `model_delete`, `content_save`, `content_delete`, `content_list`, `validate`. The requirement map lives in `TOOL_REQUIREMENTS` (`@contentrain/mcp/tools/availability`).

| Tool | Purpose | Read-only | Destructive |
| --- | --- | --- | --- |
Expand Down Expand Up @@ -125,6 +127,8 @@ const transport = new StdioServerTransport()
await server.connect(transport)
```

`createServer` also accepts an options object: `{ provider, projectRoot?, instructions? }`. `instructions` sets the MCP `instructions` string clients receive at `initialize` (defaults to a built-in `DEFAULT_INSTRUCTIONS`, kept under 512 characters; pass `''` to omit).

## Example MCP Flow

Typical agent workflow:
Expand Down
8 changes: 6 additions & 2 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@
"types": "./dist/tools/annotations.d.mts",
"import": "./dist/tools/annotations.mjs"
},
"./tools/availability": {
"types": "./dist/tools/availability.d.mts",
"import": "./dist/tools/availability.mjs"
},
"./testing/conformance": {
"types": "./dist/testing/conformance.d.mts",
"import": "./dist/testing/conformance.mjs"
Expand Down Expand Up @@ -148,8 +152,8 @@
"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/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",
"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/tools/availability.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/tools/availability.ts src/testing/conformance.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
Expand Down
94 changes: 79 additions & 15 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { RepoProvider } from './core/contracts/index.js'
import { LocalProvider } from './providers/local/index.js'
import { isToolAvailable, TOOL_REQUIREMENTS } from './tools/availability.js'

/**
* The provider shape tool handlers consume. Now that every provider
Expand All @@ -20,6 +21,21 @@ import { registerBulkTools } from './tools/bulk.js'
import { registerDoctorTools } from './tools/doctor.js'
import packageJson from '../package.json' with { type: 'json' }

/**
* Default MCP `instructions` surfaced to clients at initialize time.
* Deliberately kept under 512 characters — directory listings and client
* UIs truncate longer strings. Override via `CreateServerOptions.instructions`.
*/
export const DEFAULT_INSTRUCTIONS
= 'Contentrain is git-native content governance: models define structure; '
+ 'content is canonical JSON/Markdown on a dedicated branch. Call '
+ 'contentrain_describe_format before creating models or content. Preview '
+ 'writes with dry_run:true, review the plan, then re-run with '
+ 'dry_run:false. Start with contentrain_status for models, locales, and '
+ 'workflow; inspect a model with contentrain_describe before editing its '
+ 'entries. Tools are deterministic infrastructure — content decisions '
+ 'stay with the agent.'

export interface CreateServerOptions {
/**
* Content provider — drives reads (and, in later phases, writes) through
Expand All @@ -31,14 +47,50 @@ export interface CreateServerOptions {
/**
* 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.
* (normalize, setup, git submit/merge) are not registered when no
* projectRoot is available.
*/
projectRoot?: string
/**
* MCP `instructions` string sent to clients in the `initialize` response.
* Defaults to `DEFAULT_INSTRUCTIONS`; pass an empty string to omit
* instructions entirely.
*/
instructions?: string
}

/**
* Create an MCP server instance with every Contentrain tool registered.
* `McpServer` variant that silently skips registration for tools named in
* `skipTools`. Register functions call `server.tool(...)` unconditionally;
* this subclass is what keeps capability-gated tools out of `tools/list`
* when the provider (or missing projectRoot) could never satisfy them.
*/
class CapabilityFilteredMcpServer extends McpServer {
private readonly _skipTools: ReadonlySet<string>

constructor(
serverInfo: ConstructorParameters<typeof McpServer>[0],
options: ConstructorParameters<typeof McpServer>[1],
skipTools: ReadonlySet<string>,
) {
super(serverInfo, options)
this._skipTools = skipTools
}

// McpServer.tool has six overloads; a rest-args override is the only
// signature that satisfies all of them. Skipped tools return undefined —
// register functions discard the handle, so nothing downstream observes it.
override tool(...args: unknown[]): ReturnType<McpServer['tool']> {
if (this._skipTools.has(args[0] as string)) {
return undefined as unknown as ReturnType<McpServer['tool']>
}
return (McpServer.prototype.tool as (...toolArgs: unknown[]) => ReturnType<McpServer['tool']>).apply(this, args)
}
}

/**
* Create an MCP server instance with every *available* Contentrain tool
* registered.
*
* Two signatures:
*
Expand All @@ -48,21 +100,31 @@ export interface CreateServerOptions {
* - `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.
* provider's own `projectRoot` is used.
*
* Public MCP tool surface (names, parameters, response JSON shape) is
* unchanged across both signatures.
* Tool listing is capability-aware: tools whose requirements
* (`TOOL_REQUIREMENTS`) cannot be met by the resolved provider +
* projectRoot pair are not registered, so `tools/list` only advertises
* tools that can actually succeed. With a `LocalProvider` (stdio and CLI
* flows) all 19 tools remain registered — behavior there is unchanged.
*/
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 { provider, projectRoot, instructions } = resolveServerContext(input)

const skipTools = new Set(
Object.keys(TOOL_REQUIREMENTS).filter(name => !isToolAvailable(name, provider, projectRoot)),
)

const server = new McpServer({
name: 'contentrain-mcp',
version: packageJson.version,
})
const server = new CapabilityFilteredMcpServer(
{
name: 'contentrain-mcp',
version: packageJson.version,
},
{ instructions: instructions || undefined },
skipTools,
)

registerContextTools(server, provider, projectRoot)
registerSetupTools(server, provider, projectRoot)
Expand All @@ -79,19 +141,21 @@ export function createServer(input: string | CreateServerOptions): McpServer {
function resolveServerContext(input: string | CreateServerOptions): {
provider: ToolProvider
projectRoot: string | undefined
instructions: string
} {
if (typeof input === 'string') {
return { provider: new LocalProvider(input), projectRoot: input }
return { provider: new LocalProvider(input), projectRoot: input, instructions: DEFAULT_INSTRUCTIONS }
}
const instructions = input.instructions ?? DEFAULT_INSTRUCTIONS
if (input.provider) {
let projectRoot = input.projectRoot
if (!projectRoot && input.provider instanceof LocalProvider) {
projectRoot = input.provider.projectRoot
}
return { provider: input.provider, projectRoot }
return { provider: input.provider, projectRoot, instructions }
}
if (input.projectRoot) {
return { provider: new LocalProvider(input.projectRoot), projectRoot: input.projectRoot }
return { provider: new LocalProvider(input.projectRoot), projectRoot: input.projectRoot, instructions }
}
throw new Error('createServer: either `provider` or `projectRoot` must be provided')
}
Loading
Loading