Skip to content

Commit 0caf6eb

Browse files
committed
feat(recipes): catalog payload carries source / body / shadows (Tracer 4 of 6)
Extends QueryRecipeCatalogEntry with three new additive fields: - body — full Markdown body of sibling <id>.md (description = first non-empty line; body = long-form 'when to use' / 'follow-up SQL' content) - source — 'bundled' | 'project' (provenance discriminator) - shadows — true ONLY on project entries that override a bundled recipe of the same id (per Q-E settled — agents check this at session start to know when a recipe behaves differently from the documented bundled version) All additive: existing callers that destructure {id, description, sql, actions?} keep working unchanged. New helper: getQueryRecipeCatalogEntry(id) — same shape as listQueryRecipeCatalog entries, for one id (undefined for unknown). Used by codemap://recipes/{id} MCP resource so the per-id payload includes the same provenance fields the full catalog has. MCP server changes: - codemap://recipes/{id} payload now includes body / source / shadows (replaced the inline {id, description, sql, actions?} construction with JSON.stringify(getQueryRecipeCatalogEntry(id))) - codemap://recipes list-callback uses listQueryRecipeCatalog() (drops dependency on the legacy QUERY_RECIPES Proxy access) - Resource description updated to 'Single recipe by id: {id, description, body?, sql, actions?, source, shadows?}' - Removed unused listQueryRecipeIds + QUERY_RECIPES imports 5 new shim tests: bundled.source, bundled.body presence, project.source, project.shadows=true on override, getQueryRecipeCatalogEntry parity + unknown-id-undefined. Tracer 5 next: YAML frontmatter parser for project-recipe actions + load-time DML/DDL lexical check.
1 parent 114e01c commit 0caf6eb

3 files changed

Lines changed: 109 additions & 35 deletions

File tree

src/application/mcp-server.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,9 @@ import { buildContextEnvelope } from "../cli/cmd-context";
2020
import { computeValidateRows } from "../cli/cmd-validate";
2121
import {
2222
getQueryRecipeActions,
23+
getQueryRecipeCatalogEntry,
2324
getQueryRecipeSql,
2425
listQueryRecipeCatalog,
25-
listQueryRecipeIds,
26-
QUERY_RECIPES,
2726
} from "../cli/query-recipes";
2827
import { loadUserConfig, resolveCodemapConfig } from "../config";
2928
import {
@@ -605,23 +604,26 @@ function registerResources(server: McpServer): void {
605604
},
606605
);
607606

608-
// codemap://recipes/{id} — one recipe (template form)
607+
// codemap://recipes/{id} — one recipe (template form). Per Tracer 4 the
608+
// payload includes `body` / `source` / `shadows` from the catalog entry —
609+
// session-start agents check `shadows` to know when a project recipe
610+
// overrides the documented bundled version.
609611
const oneRecipeCache = new Map<string, string>();
610612
server.registerResource(
611613
"recipe",
612614
new ResourceTemplate("codemap://recipes/{id}", {
613615
list: () => ({
614-
resources: listQueryRecipeIds().map((id) => ({
615-
uri: `codemap://recipes/${id}`,
616-
name: id,
617-
description: QUERY_RECIPES[id]!.description,
616+
resources: listQueryRecipeCatalog().map((entry) => ({
617+
uri: `codemap://recipes/${entry.id}`,
618+
name: entry.id,
619+
description: entry.description,
618620
mimeType: "application/json",
619621
})),
620622
}),
621623
}),
622624
{
623625
description:
624-
"Single recipe by id: {id, description, sql, actions?}. Replaces `codemap query --print-sql <id>` for agents.",
626+
"Single recipe by id: {id, description, body?, sql, actions?, source, shadows?}. Replaces `codemap query --print-sql <id>` for agents; carries provenance fields so agents see when a project-local recipe overrides a bundled one.",
625627
mimeType: "application/json",
626628
},
627629
(uri, variables) => {
@@ -635,20 +637,15 @@ function registerResources(server: McpServer): void {
635637
],
636638
};
637639
}
638-
const meta = QUERY_RECIPES[id];
639-
if (meta === undefined) {
640+
const entry = getQueryRecipeCatalogEntry(id);
641+
if (entry === undefined) {
640642
// Resources can't return structured errors the way tools do; throw so
641643
// the SDK surfaces a JSON-RPC error to the host.
642644
throw new Error(
643645
`codemap: unknown recipe "${id}". Read codemap://recipes for the catalog.`,
644646
);
645647
}
646-
const payload = JSON.stringify({
647-
id,
648-
description: meta.description,
649-
sql: meta.sql,
650-
...(meta.actions !== undefined ? { actions: meta.actions } : {}),
651-
});
648+
const payload = JSON.stringify(entry);
652649
oneRecipeCache.set(id, payload);
653650
return {
654651
contents: [

src/cli/query-recipes.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { initCodemap } from "../runtime";
88
import {
99
_resetRecipesCacheForTests,
1010
getQueryRecipeActions,
11+
getQueryRecipeCatalogEntry,
1112
getQueryRecipeSql,
1213
listQueryRecipeCatalog,
1314
listQueryRecipeIds,
@@ -97,3 +98,54 @@ describe("query-recipes shim — project recipes via runtime root", () => {
9798
expect(ids).toContain("fan-out");
9899
});
99100
});
101+
102+
describe("query-recipes shim — catalog source / shadows / body fields (Tracer 4)", () => {
103+
it("bundled entries carry source: 'bundled' and no shadows flag", () => {
104+
const fanOut = listQueryRecipeCatalog().find((c) => c.id === "fan-out");
105+
expect(fanOut?.source).toBe("bundled");
106+
expect(fanOut?.shadows).toBeUndefined();
107+
});
108+
109+
it("bundled entries carry body when sibling .md exists", () => {
110+
const fanOut = listQueryRecipeCatalog().find((c) => c.id === "fan-out");
111+
expect(fanOut?.body).toBeDefined();
112+
expect(fanOut?.body).toContain("Top 10 files by dependency fan-out");
113+
});
114+
115+
it("project entries carry source: 'project' (no bundled clash → no shadows)", () => {
116+
const recipesDir = join(projectRoot, ".codemap", "recipes");
117+
mkdirSync(recipesDir, { recursive: true });
118+
writeFileSync(join(recipesDir, "internal-fizz.sql"), "SELECT 1\n");
119+
_resetRecipesCacheForTests();
120+
121+
const fizz = listQueryRecipeCatalog().find((c) => c.id === "internal-fizz");
122+
expect(fizz?.source).toBe("project");
123+
expect(fizz?.shadows).toBeUndefined();
124+
});
125+
126+
it("project recipe shadowing bundled carries shadows: true", () => {
127+
const recipesDir = join(projectRoot, ".codemap", "recipes");
128+
mkdirSync(recipesDir, { recursive: true });
129+
writeFileSync(
130+
join(recipesDir, "fan-out.sql"),
131+
"SELECT 'project override' AS marker\n",
132+
);
133+
_resetRecipesCacheForTests();
134+
135+
const fanOut = listQueryRecipeCatalog().find((c) => c.id === "fan-out");
136+
expect(fanOut?.source).toBe("project");
137+
expect(fanOut?.shadows).toBe(true);
138+
});
139+
});
140+
141+
describe("getQueryRecipeCatalogEntry (single-id lookup)", () => {
142+
it("returns the same entry shape as listQueryRecipeCatalog for known id", () => {
143+
const fromList = listQueryRecipeCatalog().find((c) => c.id === "fan-out");
144+
const fromGet = getQueryRecipeCatalogEntry("fan-out");
145+
expect(fromGet).toEqual(fromList);
146+
});
147+
148+
it("returns undefined for unknown id", () => {
149+
expect(getQueryRecipeCatalogEntry("no-such-recipe")).toBeUndefined();
150+
});
151+
});

src/cli/query-recipes.ts

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,27 @@ export type { RecipeAction } from "../application/recipes-loader";
1010
import type { RecipeAction } from "../application/recipes-loader";
1111

1212
/**
13-
* One bundled recipe: id, human description, SQL, and optional per-row actions
14-
* (canonical source for CLI, `--recipes-json`, and the JSON output enrichment).
13+
* Catalog entry surfaced to `--recipes-json`, the `codemap://recipes` MCP
14+
* resource, and the per-id `codemap://recipes/{id}` lookup. Backwards-compat
15+
* shape with three extensions added in Tracer 4:
1516
*
16-
* NOTE: Kept for backwards-compat with callers that destructure the legacy
17-
* shape. `LoadedRecipe` (from `application/recipes-loader`) is the new
18-
* canonical type — has `body`, `source`, `shadows` in addition.
17+
* - **`body`** — full Markdown body of the sibling `<id>.md` (when present);
18+
* description is the first non-empty line of that body.
19+
* - **`source`** — `"bundled"` (ships with the npm package) or `"project"`
20+
* (loaded from `<projectRoot>/.codemap/recipes/`).
21+
* - **`shadows`** — `true` when a project recipe overrides a bundled recipe
22+
* of the same id (per plan §9 Q-E — agents read this at session start to
23+
* know when a recipe behaves differently from the documented bundled
24+
* version). Absent / `false` for non-shadowing entries.
1925
*/
2026
export interface QueryRecipeCatalogEntry {
2127
id: string;
2228
description: string;
29+
body?: string;
2330
sql: string;
2431
actions?: RecipeAction[];
32+
source: "bundled" | "project";
33+
shadows?: boolean;
2534
}
2635

2736
/**
@@ -206,22 +215,38 @@ export function listQueryRecipeIds(): string[] {
206215
}
207216

208217
/**
209-
* Full catalog for **`codemap query --recipes-json`**.
210-
*
211-
* Tracer 2 returns the legacy shape (id / description / sql / actions?).
212-
* Tracer 4 will extend the catalog payload to include `body`, `source`,
213-
* and `shadows` from the {@link LoadedRecipe} shape.
218+
* Full catalog for **`codemap query --recipes-json`** and the
219+
* `codemap://recipes` MCP resource. Per Tracer 4, includes `body`,
220+
* `source`, and `shadows` fields on each entry.
214221
*/
215222
export function listQueryRecipeCatalog(): QueryRecipeCatalogEntry[] {
216-
return getRegistry().map((r) => {
217-
const entry: QueryRecipeCatalogEntry = {
218-
id: r.id,
219-
description: r.description ?? r.id,
220-
sql: r.sql,
221-
};
222-
if (r.actions !== undefined) entry.actions = r.actions;
223-
return entry;
224-
});
223+
return getRegistry().map((r) => buildCatalogEntry(r));
224+
}
225+
226+
/**
227+
* Single-entry lookup for the `codemap://recipes/{id}` MCP resource and any
228+
* future `--recipe-json <id>` CLI shape. Returns `undefined` for unknown
229+
* ids; otherwise the same {@link QueryRecipeCatalogEntry} shape as the
230+
* full-catalog listing.
231+
*/
232+
export function getQueryRecipeCatalogEntry(
233+
id: string,
234+
): QueryRecipeCatalogEntry | undefined {
235+
const recipe = getRegistry().find((r) => r.id === id);
236+
return recipe === undefined ? undefined : buildCatalogEntry(recipe);
237+
}
238+
239+
function buildCatalogEntry(r: LoadedRecipe): QueryRecipeCatalogEntry {
240+
const entry: QueryRecipeCatalogEntry = {
241+
id: r.id,
242+
description: r.description ?? r.id,
243+
sql: r.sql,
244+
source: r.source,
245+
};
246+
if (r.body !== undefined) entry.body = r.body;
247+
if (r.actions !== undefined) entry.actions = r.actions;
248+
if (r.shadows) entry.shadows = true;
249+
return entry;
225250
}
226251

227252
/**

0 commit comments

Comments
 (0)