Building a Local MCP Server for Malayalam Recipes

| #ai,#mcp,#typescript,#recipes

For a while, I wanted to try MCP with something more personal than a documentation search example or a generic API wrapper.

Malayalam recipes turned out to be a good fit.

The idea was simple: keep a small collection of Kerala recipes in local JSON, then expose that data through a Model Context Protocol server. The server lets an MCP-compatible client search recipes, fetch a full recipe, scale servings, build a shopping list, translate food terms, and suggest festival menus.

The important part is that the model is not inventing recipes from memory. It is calling tools backed by deterministic local data.

The source code is available at github.com/supillai/malayalam-recipes-mcp.

Why MCP Fits This Use Case

Recipe data has a few properties that make it useful for testing MCP:

  • the data should be reliable and repeatable
  • users may ask questions in flexible natural language
  • serving calculations and shopping-list merging should be deterministic
  • the same data should work from different MCP clients

Instead of putting all the recipe knowledge into a long prompt, the project keeps the recipes in structured JSON and exposes a small set of tools around that data.

That changes the role of the model. The model can still explain, summarize, and adapt the answer, but it does not need to guess the source data.

Project Shape

The project is a Node.js and TypeScript package using the official MCP SDK.

{
  "type": "module",
  "engines": {
    "node": ">=18.0.0"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.29.0",
    "zod": "^3.25.0"
  }
}

The source layout keeps MCP wiring separate from recipe logic:

src/
  index.ts
  server.ts
  types.ts
  data/
    recipes/*.json
    terms.json
    loadRecipes.ts
    validateRecipes.ts
  tools/
  resources/
  prompts/
  utils/

That split matters. server.ts registers the MCP capabilities, but most of the behavior lives in normal TypeScript functions. That makes the recipe logic easier to test without starting an MCP process.

Starting With Stdio

This server uses stdio transport:

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.js";

const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);

For a local personal data server, stdio is the simplest option. There is no port to expose, no separate hosting setup, and no authentication layer to design. The MCP client starts the process and communicates over standard input and output.

After building the package, a client can run:

node /path/to/malayalam-recipes-mcp/dist/index.js

That is enough for clients that support local stdio MCP servers, such as Claude Desktop, Cursor, VS Code integrations, Codex-style agents, and similar tools.

Tools As Deterministic Actions

The server exposes six tools:

  • search_recipes
  • get_recipe
  • suggest_recipes_by_ingredients
  • generate_shopping_list
  • get_festival_menu
  • translate_food_term

Each tool has a Zod-backed input schema. For example, get_recipe can look up a recipe by id, slug, English name, Malayalam name, or transliteration:

server.registerTool(
  "get_recipe",
  {
    title: "Get Recipe",
    description: "Get a full Kerala/Malayalam recipe by ID, slug, name, Malayalam name, or transliteration.",
    inputSchema: {
      idOrName: z.string(),
      language: z.enum(["en", "ml", "both"]).default("both"),
      servings: z.number().positive().optional()
    }
  },
  async (input) => toMcpToolResult(getRecipe(input))
);

The handler is intentionally small. It validates input, calls the domain function, and returns the result in the MCP format.

That is the pattern I like for MCP servers. Keep the protocol boundary thin. Put the actual behavior in code that can be tested directly.

Local JSON Instead of External APIs

The server does not call a recipe API, database, authentication service, or language model. Recipes live in src/data/recipes/*.json, and glossary terms live in src/data/terms.json.

At runtime, the loader reads JSON and validates each recipe:

export function loadRecipes(): Recipe[] {
  const dir = recipeDir();

  if (!existsSync(dir)) {
    throw new Error(`Recipe data directory not found: ${dir}`);
  }

  return readdirSync(dir)
    .filter((file) => file.endsWith(".json"))
    .sort((a, b) => a.localeCompare(b))
    .map((file) => {
      const fullPath = path.join(dir, file);
      const parsed = JSON.parse(readFileSync(fullPath, "utf8"));
      return RecipeSchema.parse(parsed);
    });
}

The key line is RecipeSchema.parse(parsed). Invalid data fails early instead of leaking malformed responses into the MCP client.

One small build detail is also important. TypeScript compiles the source into dist, but JSON files are not emitted by tsc. The build script also copies the data files:

npm run build

which runs:

tsc && npm run copy-data

This is easy to miss in data-backed MCP servers. The code can compile successfully and still fail at runtime if the JSON data does not exist in the build output.

Bilingual Data

The recipe schema treats English and Malayalam as first-class data:

export const BilingualTextSchema = z.object({
  en: z.string(),
  ml: z.string()
});

export const BilingualNameSchema = BilingualTextSchema.extend({
  transliteration: z.array(z.string()).default([])
});

Recipes use this structure for names, descriptions, ingredients, steps, tips, serving suggestions, and disclaimers.

The tools also accept a language option:

language: z.enum(["en", "ml", "both"]).default("both")

That gives the client some flexibility. The user can ask for English only, Malayalam only, or both languages side by side without duplicating the recipe logic.

Search Is Ranking, Not Generation

The search tool does not ask a model to decide what matches. It ranks local recipes using fields such as:

  • id
  • slug
  • English name
  • Malayalam name
  • transliteration
  • keywords
  • ingredients
  • meal type
  • diet
  • region
  • occasion

The result includes a score and match reason. That gives the host model something explainable to work with.

For example, a user can ask:

Suggest a Kerala breakfast using rice flour and coconut. Reply in English and Malayalam.

The client can call search_recipes or suggest_recipes_by_ingredients, inspect the deterministic matches, and then compose a useful answer.

Serving Math Belongs In Code

get_recipe supports a servings input. When it is present, the server scales the ingredients from the original recipe yield:

const data =
  parsed.data.servings !== undefined
    ? {
        ...recipe,
        originalServings: recipe.servings,
        requestedServings: parsed.data.servings,
        ingredients: scaleRecipeIngredients(recipe, parsed.data.servings)
      }
    : recipe;

The shopping-list tool builds on the same scaling logic. It accepts multiple recipe ids and optional per-recipe servings, then merges ingredients only when the normalized English ingredient name and normalized unit match.

That rule is conservative on purpose. It is safe to merge two quantities of coconut measured in the same unit. It is not safe to casually combine cups, grams, pieces, and tablespoons unless explicit conversion logic exists.

This is a good boundary for MCP projects. Let the model explain the result, but keep arithmetic and merging rules in code.

Resources And Prompts

The server also exposes resources:

  • recipes://all
  • recipes://recipe/{id}
  • recipes://terms

Tools are for actions. Resources are for inspectable context.

recipes://all returns metadata for every recipe. recipes://recipe/{id} returns the full JSON for one recipe. recipes://terms returns glossary data.

The project also registers prompt templates:

  • plan_kerala_meal
  • explain_recipe_for_beginner
  • make_bilingual_recipe_card

Prompts are useful for repeatable workflows, but they are not a replacement for tools. In this project, prompts shape the task while tools provide the reliable data.

Predictable Errors

Every tool returns a predictable envelope:

{
  "success": true,
  "data": {}
}

or:

{
  "success": false,
  "error": {
    "code": "RECIPE_NOT_FOUND",
    "message": "English error message",
    "messageML": "Malayalam error message"
  }
}

This is friendlier for host models than arbitrary exceptions. The model can see whether the tool call worked, read the error code, and explain the failure in the user’s preferred language.

For MCP tools, predictable failure is part of the interface.

Testing The Domain

The test suite focuses on recipe behavior:

  • recipe validation
  • language filtering
  • recipe lookup
  • search
  • ingredient suggestion
  • shopping-list generation

That is the right level for most of this project. The MCP SDK owns the protocol mechanics. This code owns the recipe behavior.

The useful commands are:

npm run validate-recipes
npm test
npm run build

validate-recipes catches malformed data. npm test checks the domain behavior. npm run build proves the package can compile and copy its JSON assets.

Connecting From A Client

For Claude Desktop, the local server can be configured like this after building the project:

{
  "mcpServers": {
    "malayalam-recipes": {
      "command": "node",
      "args": ["/path/to/malayalam-recipes-mcp/dist/index.js"]
    }
  }
}

For Codex on Windows, the same idea can be represented in config.toml:

[mcp_servers.malayalam-recipes]
command = "node"
args = ["C:/path/to/malayalam-recipes-mcp/dist/index.js"]
startup_timeout_sec = 20
tool_timeout_sec = 60

After restarting the client, the user can ask:

Use the malayalam-recipes MCP server to search for puttu and show the recipe in English and Malayalam.

The client discovers the MCP tools, calls the right one, and uses the result to answer.

What I Learned

The main lesson is that an MCP server does not need to be large to be useful.

A good small MCP server has:

  • a clear data boundary
  • typed inputs
  • deterministic behavior
  • predictable errors
  • a small set of tools mapped to real user tasks
  • tests around the domain logic

This pattern works beyond recipes. The same structure could support internal documentation, product catalogs, training material, legal templates, incident runbooks, or any other domain where a model should reason over trusted data instead of improvising.

Final Thoughts

The Malayalam recipes MCP server is intentionally modest: TypeScript, Zod, local JSON, stdio, and a handful of tools.

That modesty is the point.

MCP is not only for complex integrations. It is also a clean way to give AI clients reliable access to small, carefully maintained datasets. In this case, the server lets a model help with Kerala cooking while keeping the recipes, terminology, serving math, and search behavior grounded in code and data.