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
29 changes: 29 additions & 0 deletions corpus/backend/echo/hello-world.yaml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions corpus/backend/echo/index.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace: backend.echo
recipes:
hello-world:
file: hello-world.yaml
2 changes: 2 additions & 0 deletions corpus/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
111 changes: 108 additions & 3 deletions src/plugins/echo/tools/get-recipe.ts
Original file line number Diff line number Diff line change
@@ -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<string, { file?: string }>;
};

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<string, LoadedCorpusRecipe | null>();

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(
Expand All @@ -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(", ");
Expand All @@ -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,
},
],
};
}
);
}
22 changes: 22 additions & 0 deletions tests/echo-recipe-corpus-backed-tools-behaviour.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading