Skip to content

Commit 2aa6a53

Browse files
authored
Merge pull request #73 from Contentrain/feat/mcp-honest-surface
feat(mcp): capability-aware tool listing, instructions, openWorldHint, session tenant binding
2 parents 3f28677 + 1387ce1 commit 2aa6a53

13 files changed

Lines changed: 461 additions & 34 deletions

File tree

.changeset/mcp-honest-surface.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@contentrain/mcp": minor
3+
---
4+
5+
feat(mcp): capability-aware tool listing, server instructions, openWorldHint, session tenant binding
6+
7+
**@contentrain/mcp**: the MCP surface now tells the truth about what it can do, per session.
8+
9+
- **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.
10+
- **`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.
11+
- **`openWorldHint: false`** added to all 19 tool annotations — every tool operates on the configured repository only.
12+
- **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.

docs/guides/embedding-mcp.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ const handle = await startHttpMcpServerWith({
169169
const { repo, auth } = await lookupProjectFromDatabase(projectId)
170170
return createGitHubProvider({ auth, repo })
171171
},
172+
// Bind each session to its tenant: follow-up requests must produce the
173+
// same fingerprint or they get 404 and the client re-initializes.
174+
sessionFingerprint: (req) => req.headers['x-project-id'] as string,
172175
authToken: workspaceBearerToken,
173176
port: 3333,
174177
sessionTtlMs: 15 * 60 * 1000,
@@ -177,6 +180,8 @@ const handle = await startHttpMcpServerWith({
177180

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

183+
**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.
184+
180185
### 4. Programmatic tool calls (no transport at all)
181186

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

235240
### `capability_required` is a structured error
236241

237-
Tools that need capabilities the active provider doesn't expose return:
242+
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:
238243

239244
```json
240245
{
241-
"error": "contentrain_scan requires local filesystem access.",
242-
"capability_required": "astScan",
246+
"error": "contentrain_validate requires local filesystem access.",
247+
"capability_required": "localWorktree",
243248
"hint": "This tool is unavailable when MCP is driven by a remote provider. Use a LocalProvider or the stdio transport."
244249
}
245250
```
246251

247-
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`).
252+
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`).
248253

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

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

259264
## Capability gating
260265

261-
Each provider advertises a `ProviderCapabilities` manifest. Tools gate on capabilities and reject uniformly when the active provider can't satisfy them.
266+
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.
262267

263268
| Capability | Local | GitHub | GitLab | Gated tools |
264269
|---|---|---|---|---|
265-
| `localWorktree` |||| `init`, `scaffold`, `validate --fix`, `submit`, `merge`, `bulk` |
270+
| `localWorktree` |||| `validate --fix`, `submit`, `merge`, `branch_list`, `branch_delete` |
266271
| `sourceRead` |||| `apply` (extract) |
267272
| `sourceWrite` |||| `apply` (reuse) |
268273
| `astScan` |||| `scan` |
269274
| `pushRemote` |||| `submit` |
270275
| `branchProtection` |||| merge fallback |
271276
| `pullRequestFallback` |||| merge fallback |
272277

278+
`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.
279+
273280
Read-only tools (`status`, `describe`, `describe_format`, `content_list`, `validate` without `--fix`) work on every provider — they use only the reader surface.
274281

275282
## Extension: custom providers

docs/guides/http-transport.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ const handle = await startHttpMcpServerWith({
7373

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

76+
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.
77+
78+
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).
79+
7680
## Deployment patterns
7781

7882
### Studio MCP Cloud

docs/packages/mcp.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ Use the local stdio server when the agent should work against a checkout on your
5555

5656
## Tool Catalog
5757

58-
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.
58+
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.
59+
60+
::: info Capability-aware listing
61+
`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`.
62+
:::
5963

6064
| Tool | Title | Read-only | Destructive |
6165
|------|-------|-----------|-------------|

packages/mcp/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ All write operations are designed around git-backed safety:
7171

7272
## Tool Surface
7373

74-
19 MCP tools with [annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`) for client safety hints:
74+
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.
75+
76+
**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`).
7577

7678
| Tool | Purpose | Read-only | Destructive |
7779
| --- | --- | --- | --- |
@@ -125,6 +127,8 @@ const transport = new StdioServerTransport()
125127
await server.connect(transport)
126128
```
127129

130+
`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).
131+
128132
## Example MCP Flow
129133

130134
Typical agent workflow:

packages/mcp/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@
122122
"types": "./dist/tools/annotations.d.mts",
123123
"import": "./dist/tools/annotations.mjs"
124124
},
125+
"./tools/availability": {
126+
"types": "./dist/tools/availability.d.mts",
127+
"import": "./dist/tools/availability.mjs"
128+
},
125129
"./testing/conformance": {
126130
"types": "./dist/testing/conformance.d.mts",
127131
"import": "./dist/testing/conformance.mjs"
@@ -150,8 +154,8 @@
150154
"testing"
151155
],
152156
"scripts": {
153-
"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",
154-
"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",
157+
"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",
158+
"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",
155159
"test": "vitest run",
156160
"typecheck": "tsc --noEmit",
157161
"clean": "rm -rf dist"

packages/mcp/src/server.ts

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
22
import type { RepoProvider } from './core/contracts/index.js'
33
import { LocalProvider } from './providers/local/index.js'
4+
import { isToolAvailable, TOOL_REQUIREMENTS } from './tools/availability.js'
45

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

24+
/**
25+
* Default MCP `instructions` surfaced to clients at initialize time.
26+
* Deliberately kept under 512 characters — directory listings and client
27+
* UIs truncate longer strings. Override via `CreateServerOptions.instructions`.
28+
*/
29+
export const DEFAULT_INSTRUCTIONS
30+
= 'Contentrain is git-native content governance: models define structure; '
31+
+ 'content is canonical JSON/Markdown on a dedicated branch. Call '
32+
+ 'contentrain_describe_format before creating models or content. Preview '
33+
+ 'writes with dry_run:true, review the plan, then re-run with '
34+
+ 'dry_run:false. Start with contentrain_status for models, locales, and '
35+
+ 'workflow; inspect a model with contentrain_describe before editing its '
36+
+ 'entries. Tools are deterministic infrastructure — content decisions '
37+
+ 'stay with the agent.'
38+
2339
export interface CreateServerOptions {
2440
/**
2541
* Content provider — drives reads (and, in later phases, writes) through
@@ -31,14 +47,50 @@ export interface CreateServerOptions {
3147
/**
3248
* Local project root. When the provider is a `LocalProvider`, its own
3349
* `projectRoot` is used as the fallback. Tools that require local disk
34-
* (normalize, setup, git submit/merge) short-circuit with a capability
35-
* error when no projectRoot is available.
50+
* (normalize, setup, git submit/merge) are not registered when no
51+
* projectRoot is available.
3652
*/
3753
projectRoot?: string
54+
/**
55+
* MCP `instructions` string sent to clients in the `initialize` response.
56+
* Defaults to `DEFAULT_INSTRUCTIONS`; pass an empty string to omit
57+
* instructions entirely.
58+
*/
59+
instructions?: string
3860
}
3961

4062
/**
41-
* Create an MCP server instance with every Contentrain tool registered.
63+
* `McpServer` variant that silently skips registration for tools named in
64+
* `skipTools`. Register functions call `server.tool(...)` unconditionally;
65+
* this subclass is what keeps capability-gated tools out of `tools/list`
66+
* when the provider (or missing projectRoot) could never satisfy them.
67+
*/
68+
class CapabilityFilteredMcpServer extends McpServer {
69+
private readonly _skipTools: ReadonlySet<string>
70+
71+
constructor(
72+
serverInfo: ConstructorParameters<typeof McpServer>[0],
73+
options: ConstructorParameters<typeof McpServer>[1],
74+
skipTools: ReadonlySet<string>,
75+
) {
76+
super(serverInfo, options)
77+
this._skipTools = skipTools
78+
}
79+
80+
// McpServer.tool has six overloads; a rest-args override is the only
81+
// signature that satisfies all of them. Skipped tools return undefined —
82+
// register functions discard the handle, so nothing downstream observes it.
83+
override tool(...args: unknown[]): ReturnType<McpServer['tool']> {
84+
if (this._skipTools.has(args[0] as string)) {
85+
return undefined as unknown as ReturnType<McpServer['tool']>
86+
}
87+
return (McpServer.prototype.tool as (...toolArgs: unknown[]) => ReturnType<McpServer['tool']>).apply(this, args)
88+
}
89+
}
90+
91+
/**
92+
* Create an MCP server instance with every *available* Contentrain tool
93+
* registered.
4294
*
4395
* Two signatures:
4496
*
@@ -48,21 +100,31 @@ export interface CreateServerOptions {
48100
* - `createServer({ provider, projectRoot? })` — phase 5.3 flow. Any
49101
* `RepoProvider` (including `GitHubProvider`) drives reads and writes. If
50102
* the provider is a `LocalProvider` and `projectRoot` is omitted, the
51-
* provider's own `projectRoot` is used. Otherwise `projectRoot` stays
52-
* undefined and tools that need local disk report a capability error.
103+
* provider's own `projectRoot` is used.
53104
*
54-
* Public MCP tool surface (names, parameters, response JSON shape) is
55-
* unchanged across both signatures.
105+
* Tool listing is capability-aware: tools whose requirements
106+
* (`TOOL_REQUIREMENTS`) cannot be met by the resolved provider +
107+
* projectRoot pair are not registered, so `tools/list` only advertises
108+
* tools that can actually succeed. With a `LocalProvider` (stdio and CLI
109+
* flows) all 19 tools remain registered — behavior there is unchanged.
56110
*/
57111
export function createServer(projectRoot: string): McpServer
58112
export function createServer(opts: CreateServerOptions): McpServer
59113
export function createServer(input: string | CreateServerOptions): McpServer {
60-
const { provider, projectRoot } = resolveServerContext(input)
114+
const { provider, projectRoot, instructions } = resolveServerContext(input)
115+
116+
const skipTools = new Set(
117+
Object.keys(TOOL_REQUIREMENTS).filter(name => !isToolAvailable(name, provider, projectRoot)),
118+
)
61119

62-
const server = new McpServer({
63-
name: 'contentrain-mcp',
64-
version: packageJson.version,
65-
})
120+
const server = new CapabilityFilteredMcpServer(
121+
{
122+
name: 'contentrain-mcp',
123+
version: packageJson.version,
124+
},
125+
{ instructions: instructions || undefined },
126+
skipTools,
127+
)
66128

67129
registerContextTools(server, provider, projectRoot)
68130
registerSetupTools(server, provider, projectRoot)
@@ -79,19 +141,21 @@ export function createServer(input: string | CreateServerOptions): McpServer {
79141
function resolveServerContext(input: string | CreateServerOptions): {
80142
provider: ToolProvider
81143
projectRoot: string | undefined
144+
instructions: string
82145
} {
83146
if (typeof input === 'string') {
84-
return { provider: new LocalProvider(input), projectRoot: input }
147+
return { provider: new LocalProvider(input), projectRoot: input, instructions: DEFAULT_INSTRUCTIONS }
85148
}
149+
const instructions = input.instructions ?? DEFAULT_INSTRUCTIONS
86150
if (input.provider) {
87151
let projectRoot = input.projectRoot
88152
if (!projectRoot && input.provider instanceof LocalProvider) {
89153
projectRoot = input.provider.projectRoot
90154
}
91-
return { provider: input.provider, projectRoot }
155+
return { provider: input.provider, projectRoot, instructions }
92156
}
93157
if (input.projectRoot) {
94-
return { provider: new LocalProvider(input.projectRoot), projectRoot: input.projectRoot }
158+
return { provider: new LocalProvider(input.projectRoot), projectRoot: input.projectRoot, instructions }
95159
}
96160
throw new Error('createServer: either `provider` or `projectRoot` must be provided')
97161
}

0 commit comments

Comments
 (0)