Build a recipe chatbot with Claude and MCP in 30 minutes
Claude handles the chat. Real recipe data handles the facts.
Ask a language model for a chicken recipe and you'll get one. It'll sound great. But the amounts are made up, the cooking time is a guess, and if you ask "is this peanut-free?" it'll say yes because that's the friendly answer.
For a cooking chatbot that's the wrong way round. The model is great at the chatty part: working out what you mean, picking between options, explaining a step. It shouldn't be the source of the facts. Quantities, times and allergens should come from data, and the model should pass them along.
So let's build a small recipe chatbot that works that way. It uses Claude for the conversation and the Tiny Plates MCP server for the recipes. You type "I have chicken and rice, nothing spicy, 30 minutes", and it finds real recipes, suggests swaps and builds a shopping list. It's about 50 lines of TypeScript.
You'll need an Anthropic API key, a free Tiny Plates key and Node 20 or later.
Connecting Claude to the MCP server
The Claude API has an MCP connector. You give it the URL of a remote MCP server, and Anthropic connects to it for you. Claude sees the server's tools, calls them during the request, and the results come back in the same response. You don't run an MCP client yourself. It's in beta, so it needs the mcp-client-2025-11-20 beta flag.
There's one thing to sort out first: signing in. When you add Tiny Plates to Claude Code or Cursor, it opens a browser and you sign in with your account. That's OAuth, and those apps run the whole flow for you. The Claude API doesn't. Its connector takes an authorization_token, and you're expected to get that token (and refresh it) yourself.
For a bot running on your server, there's an easier way. The Tiny Plates MCP server also accepts a normal API key as the bearer token. So you pass your rd_ key as the authorization_token and skip OAuth entirely. Keep the key on the server, never in the browser.
The catch is that every chat now counts against your account. On the free plan that's 50 requests a day and 300 a week, and each tool call is one request. That's plenty for a prototype or a personal bot. If real users show up, ask us to raise it. If you want each user signed in with their own Tiny Plates account, you'll need to run the OAuth flow yourself and pass their token instead. On Vercel, Vercel Connect can do that part for you.
Here's the whole chatbot:
import Anthropic from '@anthropic-ai/sdk'
import * as readline from 'node:readline/promises'
const client = new Anthropic() // reads ANTHROPIC_API_KEY
const system = `You are a friendly cooking assistant.
Only suggest recipes you found with the Tiny Plates tools.
Take every quantity, time and allergen from a tool result. Never estimate them.
When the user mentions an allergy, pass it to "without" on every search.
If the tools can't answer something, say so.`
const messages: Anthropic.Beta.BetaMessageParam[] = []
const chat = readline.createInterface({ input: process.stdin, output: process.stdout })
while (true) {
messages.push({ role: 'user', content: await chat.question('> ') })
let response: Anthropic.Beta.BetaMessage
do {
response = await client.beta.messages.create({
model: 'claude-opus-5-5',
max_tokens: 16000,
output_config: { effort: 'medium' },
betas: ['mcp-client-2025-11-20'],
system,
messages,
mcp_servers: [
{
type: 'url',
url: 'https://api.tinyplates.dev/mcp',
name: 'tinyplates',
authorization_token: process.env.TINY_PLATES_API_KEY
}
],
tools: [
{
type: 'mcp_toolset',
mcp_server_name: 'tinyplates',
// Listing recipes by filter needs a business plan, so leave it out
configs: { find_recipes: { enabled: false } }
}
]
})
// Keep every block, not just the text, so the next turn has the full context
messages.push({ role: 'assistant', content: response.content })
for (const block of response.content) {
if (block.type === 'mcp_tool_use') {
console.log(` [${block.name}] ${JSON.stringify(block.input)}`)
} else if (block.type === 'text') {
console.log(block.text)
}
}
} while (response.stop_reason === 'pause_turn')
}
Save it as chat.ts, set ANTHROPIC_API_KEY and TINY_PLATES_API_KEY, and run it with npx tsx chat.ts.
A few notes on the code. We're on Claude Opus 5.5, which always thinks before it answers, and effort sets how hard. Medium is a good start for chat. The do … while loop is there because a turn with lots of tool calls can pause, with stop_reason: "pause_turn". You send it back and Claude carries on. And we turn off find_recipes. It lists recipes by cuisine, diet and time, but it needs a business plan that isn't on sale yet. The other eight tools work on a free key.
What the tool calls look like
Let's try it.
I have chicken and rice. Nothing spicy, 30 minutes max, and my son can't have peanuts.
The first thing Claude does is search:
[search_recipes] {"query":"quick chicken and rice dinner","without":["peanuts"],"fields":["title","times"],"limit":5}
That without is the important bit. When we ran this search without it, a 25-minute Ginger Peanut Chicken with Coconut Rice was near the top. With it, that recipe is gone. It didn't get filtered by the model reading the title. The API left it out because its ingredients contain peanuts.
What comes back are titles and times in seconds. Claude reads times.total to keep what fits in 30 minutes. A soup at an hour and a casserole at two hours drop out.
"Nothing spicy" is harder. We don't have a spice flag, so the model goes by the recipe itself. It dropped one called "Spicy Chicken Meal Prep" by name. That's fine for a chat, but it's a judgement call, not data. A good bot says that out loud.
Then the follow-ups:
The second one. We're out of sour cream though.
[get_recipe] {"id":"…","servings":2}
[find_substitutions] {"ingredients":["sour cream"]}
The recipe comes back scaled to two, with real amounts. The swap comes from our substitutions data: Greek yogurt, same amount. If there's no swap we're sure about, the ingredient comes back as unknown and the bot should say "I don't know a good swap" instead of inventing one.
Great. Add two more dinners and give me one shopping list.
[search_recipes] {"query":"easy weeknight dinner","without":["peanuts"],"limit":5}
[build_shopping_list] {"recipes":["…:2","…:2","…:2"]}
The shopping list is added up by the API, sorted by aisle. The model just presents it. That's the pattern all the way through. The model decides what to call. The data decides what's true.
If the model sends a bad value, say dairy instead of milk for an allergen, the API answers with an error that names the fix. Claude reads it and tries again. You'll see two calls in the log when that happens.
Guardrails: allergens come from data
The system prompt asks Claude to pass allergies to without and to quote amounts from tool results. Claude is good at following that. But a prompt is a request, not a guarantee. If you're building something for people with real allergies, don't rely on the model to remember.
The fix is to move the rule into code. Instead of handing Claude the MCP server, you write your own small tool that calls the REST API. Your code adds the user's allergens to every search, whatever the model sends. The allergens come from the user's saved profile, not from the chat.
import Anthropic from '@anthropic-ai/sdk'
import { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod'
import { z } from 'zod'
const client = new Anthropic()
const savedAllergens = ['peanuts', 'milk'] // from your database, not the model
const searchRecipes = betaZodTool({
name: 'search_recipes',
description: 'Search recipes by meaning. Returns titles, times and allergens.',
inputSchema: z.object({ query: z.string() }),
run: async ({ query }) => {
const url = new URL('https://api.tinyplates.dev/search')
url.searchParams.set('query', query)
url.searchParams.set('without', savedAllergens.join(',')) // always applied
url.searchParams.set('fields', 'title,times,allergens')
url.searchParams.set('limit', '5')
const response = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.TINY_PLATES_API_KEY}` }
})
return await response.text()
}
})
const reply = await client.beta.messages.toolRunner({
model: 'claude-opus-5-5',
max_tokens: 16000,
tools: [searchRecipes],
messages: [{ role: 'user', content: 'Something quick with chicken and rice?' }]
})
The tool runner handles the loop for you. Claude asks for a search, your function runs, the result goes back, and so on until Claude is done. It's more code than the MCP connector, since you write a tool per endpoint you want. But you decide exactly what reaches the API, and that's the right trade when safety matters.
Two more habits help. First, show allergen badges in your UI from the recipe's allergens block, not from the model's reply. The model can phrase things however it likes. The badge is the fact. Second, show the disclaimer that comes with every allergen block. We can tell you what's in a recipe. We can't tell you what's in a particular packet, so people with allergies should still check the label. There's more on how the flags work in our post on allergen data.
Same goes for numbers. If someone asks "how much rice for six?", the answer should come from get_recipe with servings: 6, not from the model doing maths in its head.
That's the whole bot. From here you could put it behind a web chat, remember each user's allergies, or hand the shopping list to a delivery service. The MCP docs list every tool and its parameters.
Tiny Plates is free, with no credit card. Grab a key and give your bot something real to cook from.