From 09e26a2a9f95d74c8f687a28b23dc3c792d41c9b Mon Sep 17 00:00:00 2001 From: Kailas Mahavarkar <66670953+KailasMahavarkar@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:42:45 +0530 Subject: [PATCH] feat: add corpus-backed echo recipe loader + migrate hello-world Adds corpus support to `src/plugins/echo/tools/get-recipe.ts`. Tool now reads per-recipe slices from `corpus/backend/echo/.yaml`, validates required fields (category, description, when, code), and falls back to the in-file RECIPES registry when the corpus entry is missing or invalid. Creates the echo corpus namespace: - `corpus/backend/echo/index.yaml` registers the `backend.echo` namespace and recipe files - `corpus/backend/echo/hello-world.yaml` captures the minimal routing recipe (echo.New() + GET + e.Start gotchas) - `corpus/index.yaml` registers the new namespace New `tests/echo-recipe-corpus-backed-tools-behaviour.test.ts` asserts corpus source for hello-world and fallback for crud-api. Co-Authored-By: Claude Sonnet 4.6 --- corpus/backend/echo/hello-world.yaml | 29 +++++ corpus/backend/echo/index.yaml | 4 + corpus/index.yaml | 2 + src/plugins/echo/tools/get-recipe.ts | 111 +++++++++++++++++- ...cipe-corpus-backed-tools-behaviour.test.ts | 22 ++++ 5 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 corpus/backend/echo/hello-world.yaml create mode 100644 corpus/backend/echo/index.yaml create mode 100644 tests/echo-recipe-corpus-backed-tools-behaviour.test.ts diff --git a/corpus/backend/echo/hello-world.yaml b/corpus/backend/echo/hello-world.yaml new file mode 100644 index 0000000..cf9e6ca --- /dev/null +++ b/corpus/backend/echo/hello-world.yaml @@ -0,0 +1,29 @@ +name: hello-world +category: routing +description: Minimal Echo server with a single GET route +when: Starting a new Echo project or verifying your setup +code: | + package main + + import ( + "net/http" + + "github.com/labstack/echo/v4" + ) + + func main() { + e := echo.New() + + e.GET("/", func(c echo.Context) error { + return c.String(http.StatusOK, "Hello, World!") + }) + + e.Logger.Fatal(e.Start(":8080")) + } +gotchas: + - e.Logger.Fatal calls os.Exit on error - use it only in main + - "echo.New() returns a configured instance; always use this, not raw http.Server" +relatedRecipes: + - crud-api + - middleware-chain + - graceful-shutdown diff --git a/corpus/backend/echo/index.yaml b/corpus/backend/echo/index.yaml new file mode 100644 index 0000000..0601bb1 --- /dev/null +++ b/corpus/backend/echo/index.yaml @@ -0,0 +1,4 @@ +namespace: backend.echo +recipes: + hello-world: + file: hello-world.yaml diff --git a/corpus/index.yaml b/corpus/index.yaml index 58756a7..af30c6d 100644 --- a/corpus/index.yaml +++ b/corpus/index.yaml @@ -13,6 +13,8 @@ namespaces: index: frontend/shadcn/index.yaml backend.golang: index: backend/golang/index.yaml + backend.echo: + index: backend/echo/index.yaml frontend.ui-ux: index: frontend/ui-ux/index.yaml frontend.react: diff --git a/src/plugins/echo/tools/get-recipe.ts b/src/plugins/echo/tools/get-recipe.ts index fe0fadd..0ca4a83 100644 --- a/src/plugins/echo/tools/get-recipe.ts +++ b/src/plugins/echo/tools/get-recipe.ts @@ -1,6 +1,102 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; -import { RECIPES, getRecipeByName, searchRecipes, formatRecipe } from "../data.js"; +import { RECIPES, getRecipeByName, searchRecipes, formatRecipe, type Recipe } from "../data.js"; + +type CorpusIndex = { + namespaces?: { + "backend.echo"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + recipes?: Record; +}; + +type CorpusRecipeEntry = { + name?: string; + category?: string; + description?: string; + when?: string; + code?: string; + gotchas?: string[]; + relatedRecipes?: string[]; +}; + +type LoadedCorpusRecipe = { recipe: Recipe; source: string }; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = join(moduleDir, "../../../../corpus"); +const corpusNamespace = "backend.echo"; + +const cachedCorpusRecipes = new Map(); + +function normalize(name: string): string { + return name.toLowerCase().trim(); +} + +function loadCorpusRecipe(name: string): LoadedCorpusRecipe | null { + const key = normalize(name); + if (cachedCorpusRecipes.has(key)) { + return cachedCorpusRecipes.get(key) ?? null; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + cachedCorpusRecipes.set(key, null); + return null; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const recipePath = namespaceIndex?.recipes?.[key]?.file; + if (!recipePath) { + cachedCorpusRecipes.set(key, null); + return null; + } + + const raw = readFileSync(join(corpusRoot, "backend/echo", recipePath), "utf8"); + const entry = YAML.parse(raw) as CorpusRecipeEntry | null; + if ( + !entry || + normalize(entry.name ?? "") !== key || + !entry.category || + !entry.description || + !entry.when || + !entry.code + ) { + cachedCorpusRecipes.set(key, null); + return null; + } + + const loaded: LoadedCorpusRecipe = { + recipe: { + name: entry.name ?? name, + category: entry.category as Recipe["category"], + description: entry.description, + when: entry.when, + code: entry.code, + gotchas: entry.gotchas, + relatedRecipes: entry.relatedRecipes, + }, + source: corpusNamespace, + }; + cachedCorpusRecipes.set(key, loaded); + return loaded; + } catch { + cachedCorpusRecipes.set(key, null); + return null; + } +} export function register(server: McpServer): void { server.tool( @@ -10,7 +106,8 @@ export function register(server: McpServer): void { name: z.string().describe("Recipe name (e.g., 'crud-api', 'websocket', 'sse', 'jwt-auth', 'graceful-shutdown')"), }, async ({ name }) => { - const recipe = getRecipeByName(name); + const corpusEntry = loadCorpusRecipe(name); + const recipe = corpusEntry?.recipe ?? getRecipeByName(name); if (!recipe) { const suggestions = searchRecipes(name).map((r) => r.name); const allNames = RECIPES.map((r) => r.name).join(", "); @@ -24,7 +121,15 @@ export function register(server: McpServer): void { isError: true, }; } - return { content: [{ type: "text", text: formatRecipe(recipe) }] }; + const formatted = formatRecipe(recipe); + return { + content: [ + { + type: "text", + text: corpusEntry ? `${formatted}\n**Corpus Source:** ${corpusEntry.source}` : formatted, + }, + ], + }; } ); } diff --git a/tests/echo-recipe-corpus-backed-tools-behaviour.test.ts b/tests/echo-recipe-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..78bd3ae --- /dev/null +++ b/tests/echo-recipe-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerEchoGetRecipe } from "../src/plugins/echo/tools/get-recipe.ts"; + +const echoGetRecipe = captureTool(registerEchoGetRecipe); + +test("echo_get_recipe prefers corpus metadata for hello-world", async () => { + const result = await echoGetRecipe.invoke({ name: "hello-world" }); + const text = extractTextContent(result); + + expect(text).toContain("# hello-world"); + expect(text).toContain("**Corpus Source:** backend.echo"); + expect(text).toContain('e.Start(":8080")'); +}); + +test("echo_get_recipe falls back to in-file data for non-corpus recipes", async () => { + const result = await echoGetRecipe.invoke({ name: "crud-api" }); + const text = extractTextContent(result); + + expect(text).toContain("# crud-api"); + expect(text).not.toContain("**Corpus Source:** backend.echo"); +});