Build your own MCP server in TypeScript in under an hour
By Chris Artmann ·
Most MCP tutorials build a tool that adds two numbers. That's fine for learning the API. It's not much help when you have a real service and want Claude or Cursor to use it.
So let's build a real one. We'll wrap a REST API, a recipe database, in an MCP server with two tools. We'll test it, run it over HTTP, and connect it to Claude Desktop and Cursor. It takes about an hour, and most of that is reading.
If you're not sure what MCP is yet, start with What is MCP?. It's a five-minute read and it makes this post easier to follow.
Here's what we'll use. These are the exact versions this post was tested with:
- Node 24
@modelcontextprotocol/server2.4.0, the official TypeScript SDK@modelcontextprotocol/node2.1.2, for the HTTP transportzod4.6.7, for the tool schemastsx4.23.15, to run TypeScript without a build step
A quick heads-up if you've seen older tutorials. Version 1 of the SDK was a single package called @modelcontextprotocol/sdk. Version 2 splits it into server, client and small adapter packages. The ideas are the same, but the import paths changed. If you copy code from a 2025 blog post, that's usually why it breaks.
You'll also need a Tiny Plates API key. It's free, and you can get one on the dashboard. Any REST API works the same way, though, so feel free to swap in your own.
Set up the project
Make a folder and install the packages:
mkdir recipe-mcp && cd recipe-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server@2.4.0 @modelcontextprotocol/node@2.1.2 zod@4.6.7
npm install -D tsx@4.23.15 typescript @types/node
We'll write four small files:
src/api.ts calls the REST API
src/server.ts defines the tools
src/stdio.ts runs the server locally
src/http.ts runs the server over HTTP
First, a tiny helper for the API. It adds the key, builds the URL and turns a failed response into an error with a useful message. That last part matters more than it looks, and we'll come back to it.
// src/api.ts
const baseUrl = 'https://api.tinyplates.dev'
export async function tinyPlates(path: string, params: Record<string, string> = {}) {
const url = new URL(path, baseUrl)
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value)
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.TINY_PLATES_API_KEY}` }
})
if (!response.ok) {
const body = await response.text()
throw new Error(`Tiny Plates answered ${response.status}: ${body.slice(0, 300)}`)
}
return response.json()
}
Two tools, shaped for a model
Now the interesting part. An MCP tool has a name, a description, an input schema and a function that runs when the model calls it. The description and the schema are what the model actually reads. So write them for a model, not for a human skimming API docs.
We'll make two tools. search_recipes finds recipes by meaning. get_recipe fetches one in full. That split is on purpose. Search results stay small, so the model can scan many of them cheaply. Then it asks for the full recipe only when it has picked one.
// src/server.ts
import { McpServer } from '@modelcontextprotocol/server'
import { z } from 'zod'
import { tinyPlates } from './api.js'
type Recipe = {
id: string
title: string
description?: string
servings?: { quantity?: number }
times?: { total?: { seconds: number } }
allergens?: Record<string, boolean | string>
text: { ingredients: string[]; instructions: string[] }
url?: string
}
const minutes = (recipe: Recipe) =>
recipe.times?.total ? Math.round(recipe.times.total.seconds / 60) : null
// What a model needs to pick a recipe. Nothing more.
const summary = (recipe: Recipe) => ({
id: recipe.id,
title: recipe.title,
description: recipe.description,
totalMinutes: minutes(recipe)
})
const contains = (recipe: Recipe) =>
Object.entries(recipe.allergens ?? {})
.filter(([, value]) => value === true)
.map(([name]) => name)
const asText = (value: unknown) => ({
content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
})
export function createServer() {
const server = new McpServer({ name: 'recipes', version: '1.0.0' })
server.registerTool(
'search_recipes',
{
title: 'Search recipes',
description:
'Search recipes by meaning, such as "creamy pasta" or "quick vegan dinner". Returns ids, titles and total time. Call get_recipe with an id to see ingredients and steps.',
inputSchema: z.object({
query: z.string().min(2).describe('What to search for, in plain words.'),
limit: z.number().int().min(1).max(10).default(5).describe('How many recipes to return, 1–10.')
}),
annotations: { readOnlyHint: true }
},
async ({ query, limit }) => {
const { results } = await tinyPlates('/search', {
query,
limit: String(limit),
language: 'en',
fields: 'title,description,times'
})
return asText(results.map(summary))
}
)
server.registerTool(
'get_recipe',
{
title: 'Get a recipe',
description:
'Get one recipe with its ingredients, steps and allergens. Use an id from search_recipes. Never guess an id.',
inputSchema: z.object({
id: z.string().describe('The recipe id, from search_recipes.')
}),
annotations: { readOnlyHint: true }
},
async ({ id }) => {
const recipe: Recipe = await tinyPlates(`/recipes/${encodeURIComponent(id)}`)
return asText({
...summary(recipe),
servings: recipe.servings?.quantity,
ingredients: recipe.text.ingredients,
steps: recipe.text.instructions,
contains: contains(recipe),
source: recipe.url
})
}
)
return server
}
A few things are worth pointing out.
The descriptions tell the model how the tools fit together. "Call get_recipe with an id" and "Never guess an id" look like small details. Without them, models will happily make up an id that looks plausible.
The schema does your validation. The SDK turns the zod object into JSON Schema for the model. It also checks every call before your function runs. Ask for limit: 50 and the model gets an error back, and your code never sees the bad value.
The results are trimmed. A full recipe from our API is big. It has media, nutrition, per-step equipment, techniques and more. That's great for an app, but it's wasted context for a model that just needs to choose dinner. So search asks the API for only three fields with fields. And get_recipe turns the allergen map into a short contains list. Every token you send back is one the model has to read, and one you pay for.
readOnlyHint tells the host the tool doesn't change anything. Some clients use it to skip the "allow this tool?" prompt.
Run it and poke at it
The simplest way to run an MCP server is over stdio. The host starts your server as a child process and talks to it through stdin and stdout. Nothing to deploy and no ports to open.
// src/stdio.ts
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
import { createServer } from './server.js'
const server = createServer()
await server.connect(new StdioServerTransport())
console.error('Recipe MCP server running on stdio')
Notice the console.error. On stdio, stdout is the protocol. If you console.log anything, the client sees a broken message and drops the connection. Log to stderr. This is the most common reason a local server "connects and then dies".
Before you plug it into a real AI app, test it with the MCP Inspector. It's a small web UI that connects to your server, lists the tools and lets you call them by hand:
TINY_PLATES_API_KEY=your-key npx @modelcontextprotocol/inspector@2.14.0 npx tsx src/stdio.ts
Open the link it prints, click Connect, then List Tools. Try search_recipes with lemon chicken, copy an id, and call get_recipe with it. Try some bad input too: a one-letter query, limit: 50, a made-up id. Here's what the made-up id gives you:
{
"content": [
{
"type": "text",
"text": "Tiny Plates answered 404: {\"error\":{\"message\":\"Recipe not found.\"}}"
}
],
"isError": true
}
That's the payoff from the error handling in api.ts. When a tool throws, the SDK doesn't crash. It sends the message back to the model with isError: true. A clear message means the model can recover on its own: "that id doesn't exist, let me search again". A vague one leaves it guessing.
Now connect it to Claude Desktop. Open Settings → Developer → Edit Config and add your server. Use the full path to your project:
{
"mcpServers": {
"recipes": {
"command": "npx",
"args": ["tsx", "/full/path/to/recipe-mcp/src/stdio.ts"],
"env": { "TINY_PLATES_API_KEY": "your-key" }
}
}
}
Restart Claude Desktop and ask "find me a quick lemon chicken recipe and list what I need". You'll see it call search_recipes, pick one, and call get_recipe.
Put it on the web
Stdio is great on your own machine. But nobody else can use it, and every user has to install your code. To share a server, run it over Streamable HTTP. That's the standard transport for remote MCP servers.
// src/http.ts
import { createServer as createHttpServer } from 'node:http'
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'
import { createServer } from './server.js'
const port = Number(process.env.PORT ?? 3000)
createHttpServer(async (req, res) => {
if (req.url !== '/mcp') {
res.writeHead(404).end()
return
}
// Stateless: a fresh server and transport for every request.
const server = createServer()
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined })
res.on('close', () => {
transport.close()
server.close()
})
await server.connect(transport)
await transport.handleRequest(req, res)
}).listen(port, () => {
console.log(`Recipe MCP server on http://localhost:${port}/mcp`)
})
We run it stateless. Every request gets a fresh server, and nothing is kept between calls. Our tools don't need memory, and stateless servers are much easier to host. They survive restarts, scale sideways and run fine on serverless platforms. Only reach for sessions if your tools really need to remember something between calls.
Run it with TINY_PLATES_API_KEY=your-key npx tsx src/http.ts. Cursor can talk to it straight away. Add this to .cursor/mcp.json in your project, or to ~/.cursor/mcp.json to use it everywhere:
{
"mcpServers": {
"recipes": { "url": "http://localhost:3000/mcp" }
}
}
To deploy it, any host that runs Node will do: Railway, Fly, Render, a VPS. Set TINY_PLATES_API_KEY as a secret, expose the port, and your server lives at https://your-app.example.com/mcp. On claude.ai or ChatGPT, add that URL as a custom connector.
There's one big thing to think about before you share that URL. Right now, anyone who finds it can use your API key. That's fine for a private tool. For a public server, you want each user to sign in with their own account. MCP has a standard way to do that with OAuth, and the SDK has helpers for it. It's a post of its own, but don't skip it.
A few errors you'll probably run into:
- "Not Acceptable: Client must accept both application/json and text/event-stream." You poked the endpoint with plain
curl. Real MCP clients send the rightAcceptheader. Use the Inspector instead. It speaks HTTP too. - "Input validation error: … expected number to be <=10". Your schema did its job. If a model keeps hitting it, say the limits in the description too.
- The server connects, then disconnects at once. On stdio, something wrote to stdout. Look for a stray
console.log, including inside your dependencies. Cannot find module '@modelcontextprotocol/sdk/...'You mixed v1 and v2 code. In v2, it's@modelcontextprotocol/server,@modelcontextprotocol/server/stdioand@modelcontextprotocol/node.- "Tiny Plates answered 401". The key didn't reach the server process. Claude Desktop only passes the variables you list in
env.
That's a real, working MCP server in about 150 lines. The same pattern works for any API you have. Write one small helper for the API, write tools that match what users want to do, trim the results, and return clear errors. If you want to go deeper on that middle part, read How to design tools an LLM actually uses well. It covers what we learned running ours in public.
Or you can skip all this and use our hosted server. It has nine tools, including pantry search, substitutions and shopping lists, and sign-in is handled for you. Setup takes a minute: see the MCP docs or Give your agent a recipe database.