Skip to content

Commit ed4ca6b

Browse files
feat(mcp): add server instructions and CODEMAP_MCP_TOOLS allowlist (#126)
Inject a tool-selection playbook via MCP initialize instructions and expose it as codemap://mcp-instructions. Add CODEMAP_MCP_TOOLS for subset tool registration (eval ablation / minimal installs).
1 parent 8807614 commit ed4ca6b

12 files changed

Lines changed: 366 additions & 25 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
Add MCP initialize server instructions (tool-selection playbook) and `CODEMAP_MCP_TOOLS` env for subset tool registration.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ codemap agents init --force
234234
codemap agents init --interactive # -i; IDE wiring + symlink vs copy
235235
```
236236

237-
**Environment / flags:** `--root` overrides **`CODEMAP_ROOT`** / **`CODEMAP_TEST_BENCH`**, then **`process.cwd()`**; **`--state-dir`** overrides **`CODEMAP_STATE_DIR`** (default `.codemap/`); **`CODEMAP_WATCH=0`** opts out of the default-ON watcher on `mcp` / `serve` (mirrors `--no-watch`). Indexing a project outside this clone: [docs/benchmark.md § Indexing another project](docs/benchmark.md#indexing-another-project).
237+
**Environment / flags:** `--root` overrides **`CODEMAP_ROOT`** / **`CODEMAP_TEST_BENCH`**, then **`process.cwd()`**; **`--state-dir`** overrides **`CODEMAP_STATE_DIR`** (default `.codemap/`); **`CODEMAP_WATCH=0`** opts out of the default-ON watcher on `mcp` / `serve` (mirrors `--no-watch`); **`CODEMAP_MCP_TOOLS`** registers a subset of MCP tools (comma-separated snake_case names; see [agents.md § MCP tool allowlist](docs/agents.md#mcp-tool-allowlist)). Indexing a project outside this clone: [docs/benchmark.md § Indexing another project](docs/benchmark.md#indexing-another-project).
238238

239239
**Configuration:** optional **`<state-dir>/config.{ts,js,json}`** (default `.codemap/config.*`; default export object or async factory). Shape: [codemap.config.example.json](codemap.config.example.json). Runtime validation (**Zod**, strict keys) and API surface: [docs/architecture.md § User config](docs/architecture.md#user-config). When developing inside this repo you can use `defineConfig` from `@stainless-code/codemap` or `./src/config`. If you set **`include`**, it **replaces** the default glob list entirely. **Self-healing files (D11):** `<state-dir>/.gitignore` is rewritten to canonical on every codemap boot; JSON config gets unknown-key pruning + key-sort drift; TS/JS configs are validate-only.
240240

docs/agents.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,25 @@ Once `agents init` has written the pointer templates, the consumer's disk holds
7878
| MCP | resource `codemap://skill` | resource `codemap://rule` |
7979
| HTTP (`codemap serve`) | `GET /resources/{encoded uri}` against `codemap://skill` | `GET /resources/{encoded uri}` against `codemap://rule` |
8080

81-
All three transports resolve to the same `assembleAgentContent(kind)` function in `src/application/agent-content.ts` — there is no MCP-only or HTTP-only path for skill/rule content. The MCP and HTTP paths share a lazy per-process cache via `readResource()` in `src/application/resource-handlers.ts` for schema/skill/rule; recipes, files, and symbols read live every call. The CLI re-assembles every call (cheap — markdown read + concat).
81+
All three transports resolve to the same `assembleAgentContent(kind)` function in `src/application/agent-content.ts` — there is no MCP-only or HTTP-only path for skill/rule content. The MCP and HTTP paths share a lazy per-process cache via `readResource()` in `src/application/resource-handlers.ts` for schema/skill/rule/mcp-instructions; recipes, files, and symbols read live every call. The CLI re-assembles every call (cheap — markdown read + concat).
82+
83+
## MCP server instructions
84+
85+
`codemap mcp` passes a tool-selection playbook in the MCP **`initialize`** response **`instructions`** field. MCP clients (Cursor, Claude Code, etc.) inject this into the agent system prompt — operational guidance only (which tool when, common chains, anti-patterns). Full schema and recipe catalog stay on **`codemap://skill`** / **`codemap://rule`**.
86+
87+
| Surface | URI / field |
88+
| ------------------- | -------------------------------------------------------------------------------------------------------------- |
89+
| MCP initialize | `instructions` on handshake |
90+
| MCP / HTTP resource | `codemap://mcp-instructions` |
91+
| Source file | `templates/agent-content/mcp-instructions.md` (assembled by `assembleMcpInstructions()` in `agent-content.ts`) |
92+
93+
Recipe ids cited in the playbook are machine-validated in tests against the live catalog (`extractMcpInstructionRecipeIds`).
94+
95+
## MCP tool allowlist
96+
97+
**`CODEMAP_MCP_TOOLS`** — comma-separated snake_case MCP tool names. When set, only listed tools register (stderr lists the active set). Unknown names are ignored with a warning. Unset = all tools (default). **`query_batch`** registers only when listed or when unset (eval ablation).
98+
99+
Example: `CODEMAP_MCP_TOOLS=query,context,show codemap mcp --no-watch`
82100

83101
## Section assembler and `*.gen.md`
84102

docs/packaging.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ How **@stainless-code/codemap** is built and published. **Doc index:** [README.m
1010
The `templates/` directory ships two parallel subtrees:
1111

1212
- **`templates/agents/`** — consumer-disk targets copied by `codemap agents init` (thin pointer files: ~16-line SKILL.md + ~22-line rule).
13-
- **`templates/agent-content/`** — server-side source assembled live by `codemap skill` / `codemap rule` / `codemap://skill` / `codemap://rule`. Section files in `agent-content/skill/` concatenate in lexical order; `*.gen.md` files are replaced at fetch time by renderers in `src/application/agent-content.ts`. See [agents.md](./agents.md#section-assembler-and-genmd) for the split rationale.
13+
- **`templates/agent-content/`** — server-side source assembled live by `codemap skill` / `codemap rule` / `codemap://skill` / `codemap://rule` / `codemap://mcp-instructions`. Section files in `agent-content/skill/` concatenate in lexical order; `*.gen.md` files are replaced at fetch time by renderers in `src/application/agent-content.ts`. Root-level `mcp-instructions.md` feeds MCP initialize `instructions`. See [agents.md](./agents.md#section-assembler-and-genmd) for the split rationale.
1414
- **`templates/recipes/`** — bundled SQL recipe `.sql` + `.md` pairs (every recipe in `--recipes-json`).
1515

1616
## Consuming locally

src/application/agent-content.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,25 @@ export function resolveAgentContentDir(): string {
3939
return join(resolveAgentsTemplateDir(), "..", "agent-content");
4040
}
4141

42+
const MCP_INSTRUCTIONS_FILE = "mcp-instructions.md";
43+
const MCP_RECIPE_REFS_RE = /<!--\s*codemap-mcp-recipe-refs:\s*([^>]+?)-->/;
44+
45+
/** MCP initialize playbook — `templates/agent-content/mcp-instructions.md`. */
46+
export function assembleMcpInstructions(): string {
47+
const path = join(resolveAgentContentDir(), MCP_INSTRUCTIONS_FILE);
48+
return readFileSync(path, "utf8").trimEnd() + "\n";
49+
}
50+
51+
/** Recipe ids declared in the MCP instructions machine-ref comment. */
52+
export function extractMcpInstructionRecipeIds(content: string): string[] {
53+
const match = content.match(MCP_RECIPE_REFS_RE);
54+
if (match === null) return [];
55+
return match[1]!
56+
.split(",")
57+
.map((id) => id.trim())
58+
.filter(Boolean);
59+
}
60+
4261
/**
4362
* Renderer registry — keyed by `<kind>/<filename>`. Files ending in
4463
* `.gen.md` are treated as generated content: if a renderer is

src/application/mcp-server.test.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ afterEach(() => {
3333
rmSync(benchDir, { recursive: true, force: true });
3434
});
3535

36-
async function makeClient() {
37-
const server = createMcpServer({ version: "0.0.0-test", root: benchDir });
36+
async function makeClient(env?: NodeJS.ProcessEnv) {
37+
const server = createMcpServer({
38+
version: "0.0.0-test",
39+
root: benchDir,
40+
env,
41+
});
3842
const client = new Client({ name: "test-client", version: "0.0.0" });
3943
const [clientTransport, serverTransport] =
4044
InMemoryTransport.createLinkedPair();
@@ -54,6 +58,62 @@ function readJson(result: unknown): any {
5458
return JSON.parse(first.text) as unknown;
5559
}
5660

61+
describe("MCP server — initialize instructions", () => {
62+
it("includes tool-selection playbook in initialize handshake", async () => {
63+
const { client, server } = await makeClient();
64+
try {
65+
const instructions = client.getInstructions();
66+
expect(instructions).toBeDefined();
67+
expect(instructions!.length).toBeGreaterThan(500);
68+
expect(instructions).toContain("Session start");
69+
expect(instructions).toContain("codemap://rule");
70+
} finally {
71+
await server.close();
72+
}
73+
});
74+
75+
it("cites only shipped recipe ids", async () => {
76+
const { assembleMcpInstructions, extractMcpInstructionRecipeIds } =
77+
await import("./agent-content");
78+
const { listQueryRecipeCatalog } = await import("./query-recipes");
79+
const cited = extractMcpInstructionRecipeIds(assembleMcpInstructions());
80+
expect(cited.length).toBeGreaterThan(0);
81+
const catalog = new Set(listQueryRecipeCatalog().map((e) => e.id));
82+
for (const id of cited) {
83+
expect(catalog.has(id)).toBe(true);
84+
}
85+
});
86+
});
87+
88+
describe("MCP server — tool allowlist", () => {
89+
it("registers only listed tools when CODEMAP_MCP_TOOLS is set", async () => {
90+
const { client, server } = await makeClient({
91+
CODEMAP_MCP_TOOLS: "query,show",
92+
});
93+
try {
94+
const tools = await client.listTools();
95+
const names = tools.tools.map((t) => t.name).sort();
96+
expect(names).toEqual(["query", "show"]);
97+
} finally {
98+
await server.close();
99+
}
100+
});
101+
102+
it("excludes query_batch unless explicitly listed", async () => {
103+
const { client, server } = await makeClient({
104+
CODEMAP_MCP_TOOLS: "query",
105+
});
106+
try {
107+
const tools = await client.listTools();
108+
const names = tools.tools.map((t) => t.name);
109+
expect(names).toContain("query");
110+
expect(names).not.toContain("query_batch");
111+
} finally {
112+
await server.close();
113+
}
114+
});
115+
});
116+
57117
describe("MCP server — query tool", () => {
58118
it("lists query and query_batch in tools/list", async () => {
59119
const { client, server } = await makeClient();
@@ -717,6 +777,7 @@ describe("MCP server — resources", () => {
717777
expect(uris).toContain("codemap://schema");
718778
expect(uris).toContain("codemap://skill");
719779
expect(uris).toContain("codemap://rule");
780+
expect(uris).toContain("codemap://mcp-instructions");
720781
// The recipe-by-id resource is a template — surfaced via list-template
721782
// callback as one entry per recipe id.
722783
const recipeUris = uris.filter((u) => u.startsWith("codemap://recipes/"));
@@ -815,6 +876,22 @@ describe("MCP server — resources", () => {
815876
}
816877
});
817878

879+
it("codemap://mcp-instructions returns the MCP playbook", async () => {
880+
const { client, server } = await makeClient();
881+
try {
882+
const r = await client.readResource({
883+
uri: "codemap://mcp-instructions",
884+
});
885+
const first = r.contents[0] as { mimeType?: string };
886+
expect(first.mimeType).toBe("text/markdown");
887+
const text = readResourceText(r);
888+
expect(text).toContain("tool selection");
889+
expect(text).toContain("Session start");
890+
} finally {
891+
await server.close();
892+
}
893+
});
894+
818895
it("codemap://files/{path} returns a per-file roll-up", async () => {
819896
const { client, server } = await makeClient();
820897
try {

src/application/mcp-server.ts

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ import {
1212
getTsconfigPath,
1313
initCodemap,
1414
} from "../runtime";
15+
import { assembleMcpInstructions } from "./agent-content";
16+
import {
17+
isMcpToolEnabled,
18+
logMcpToolAllowlist,
19+
resolveMcpToolAllowlist,
20+
} from "./mcp-tool-allowlist";
21+
import type { McpToolName } from "./mcp-tool-allowlist";
1522
import { listQueryRecipeCatalog } from "./query-recipes";
1623
import { readResource } from "./resource-handlers";
1724
import type { ResourcePayload } from "./resource-handlers";
@@ -67,6 +74,8 @@ interface ServerOpts {
6774
root: string;
6875
configFile?: string | undefined;
6976
stateDir?: string | undefined;
77+
/** Test hook — defaults to `process.env`. */
78+
env?: NodeJS.ProcessEnv | undefined;
7079
/**
7180
* If true, boot a co-process file watcher (chokidar via
7281
* `runWatchLoop`) so the server's tools always read live data without
@@ -107,25 +116,39 @@ function wrapToolResult(r: ToolResult) {
107116
* `InMemoryTransport.createLinkedPair()` for in-process driving).
108117
*/
109118
export function createMcpServer(opts: ServerOpts): McpServer {
110-
const server = new McpServer({
111-
name: "codemap",
112-
version: opts.version,
113-
});
119+
const allowlistResolved = resolveMcpToolAllowlist(opts.env ?? process.env);
120+
const server = new McpServer(
121+
{
122+
name: "codemap",
123+
version: opts.version,
124+
},
125+
{
126+
instructions: assembleMcpInstructions(),
127+
},
128+
);
129+
130+
const registered: McpToolName[] = [];
131+
const maybeRegister = (name: McpToolName, register: () => void): void => {
132+
if (!isMcpToolEnabled(name, allowlistResolved.allowlist)) return;
133+
register();
134+
registered.push(name);
135+
};
114136

115-
registerQueryTool(server, opts);
116-
registerQueryBatchTool(server, opts);
117-
registerQueryRecipeTool(server, opts);
118-
registerAuditTool(server);
119-
registerContextTool(server);
120-
registerValidateTool(server);
121-
registerSaveBaselineTool(server, opts);
122-
registerListBaselinesTool(server);
123-
registerDropBaselineTool(server);
124-
registerShowTool(server, opts);
125-
registerSnippetTool(server, opts);
126-
registerImpactTool(server);
127-
registerApplyTool(server, opts);
137+
maybeRegister("query", () => registerQueryTool(server, opts));
138+
maybeRegister("query_batch", () => registerQueryBatchTool(server, opts));
139+
maybeRegister("query_recipe", () => registerQueryRecipeTool(server, opts));
140+
maybeRegister("audit", () => registerAuditTool(server));
141+
maybeRegister("context", () => registerContextTool(server));
142+
maybeRegister("validate", () => registerValidateTool(server));
143+
maybeRegister("save_baseline", () => registerSaveBaselineTool(server, opts));
144+
maybeRegister("list_baselines", () => registerListBaselinesTool(server));
145+
maybeRegister("drop_baseline", () => registerDropBaselineTool(server));
146+
maybeRegister("show", () => registerShowTool(server, opts));
147+
maybeRegister("snippet", () => registerSnippetTool(server, opts));
148+
maybeRegister("impact", () => registerImpactTool(server));
149+
maybeRegister("apply", () => registerApplyTool(server, opts));
128150
registerResources(server);
151+
logMcpToolAllowlist(allowlistResolved, registered);
129152

130153
return server;
131154
}
@@ -320,6 +343,12 @@ function registerResources(server: McpServer): void {
320343
"codemap://rule",
321344
"Full text of the bundled `templates/agents/rules/codemap.md` (always-on priming for agents working in this repo).",
322345
);
346+
registerStaticResource(
347+
server,
348+
"mcp-instructions",
349+
"codemap://mcp-instructions",
350+
"MCP initialize tool-selection playbook (operational guidance only; full catalog in codemap://skill).",
351+
);
323352

324353
// codemap://recipes/{id} — one recipe (template form). Payload includes
325354
// `body` / `source` / `shadows` from the catalog entry — session-start
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import {
4+
isMcpToolEnabled,
5+
resolveMcpToolAllowlist,
6+
} from "./mcp-tool-allowlist";
7+
8+
describe("mcp-tool-allowlist", () => {
9+
it("returns null allowlist when env unset", () => {
10+
expect(resolveMcpToolAllowlist({})).toEqual({
11+
allowlist: null,
12+
unknown: [],
13+
});
14+
});
15+
16+
it("returns null allowlist when env is whitespace", () => {
17+
expect(resolveMcpToolAllowlist({ CODEMAP_MCP_TOOLS: " " })).toEqual({
18+
allowlist: null,
19+
unknown: [],
20+
});
21+
});
22+
23+
it("parses comma-separated tool names", () => {
24+
const { allowlist, unknown } = resolveMcpToolAllowlist({
25+
CODEMAP_MCP_TOOLS: "query, show",
26+
});
27+
expect(unknown).toEqual([]);
28+
expect(allowlist).toEqual(new Set(["query", "show"]));
29+
});
30+
31+
it("ignores unknown names without failing", () => {
32+
const { allowlist, unknown } = resolveMcpToolAllowlist({
33+
CODEMAP_MCP_TOOLS: "query,not_a_tool,show",
34+
});
35+
expect(unknown).toEqual(["not_a_tool"]);
36+
expect(allowlist).toEqual(new Set(["query", "show"]));
37+
});
38+
39+
it("query_batch is excluded unless explicitly listed", () => {
40+
const { allowlist } = resolveMcpToolAllowlist({
41+
CODEMAP_MCP_TOOLS: "query,show",
42+
});
43+
expect(isMcpToolEnabled("query_batch", allowlist)).toBe(false);
44+
expect(
45+
isMcpToolEnabled(
46+
"query_batch",
47+
resolveMcpToolAllowlist({ CODEMAP_MCP_TOOLS: "query_batch" }).allowlist,
48+
),
49+
).toBe(true);
50+
});
51+
52+
it("isMcpToolEnabled allows all when allowlist is null", () => {
53+
expect(isMcpToolEnabled("query_batch", null)).toBe(true);
54+
});
55+
});

0 commit comments

Comments
 (0)