cookie-jar-mcp
by ketankshukla
README.md
# ๐ช MCP, Explained Like You Are Five
A complete, working **Model Context Protocol server** built with Next.js and deployed to Vercel โ plus the lesson that explains every line of it.
๐ด **Live lesson + playground:** https://learn-mcp-5-year-old.vercel.app
๐ **Live MCP endpoint:** `https://learn-mcp-5-year-old.vercel.app/api/mcp`
๐ **Write-up:** [An MCP server is one Next.js route handler](https://ketanshukla.dev/blog/an-mcp-server-is-one-route-handler)
**The whole server is one file:** [`app/api/mcp/route.ts`](app/api/mcp/route.ts)
> ๐จ **Want to build it yourself, from an empty folder?**
> **[BUILD_FROM_SCRATCH.md](BUILD_FROM_SCRATCH.md)** is the full developer walkthrough โ 13 stages, every command, a checkpoint after each one, and an appendix of the four things that actually broke during this build. This README teaches you *what MCP is*; that one teaches you *how to build one*.
---
## Part 1 โ What is MCP, really?
### The problem
An AI is a **brain in a jar**. It's very smart, and it's completely stuck.
It can *think* about your files. It can't *open* them. It can *talk* about rolling dice. It can't actually roll one โ ask it for a random number and it'll pick 7 far more often than chance allows, because it's pattern-matching, not rolling.
Your code, meanwhile, is the opposite: it has hands but no idea what the human wants.
```mermaid
flowchart LR
AI["๐ง <b>The AI</b><br/>โ
knows a lot<br/>โ has no hands"]
GAP["๐ง <b>the gap</b><br/>no agreed way<br/>to reach across"]
CODE["๐งฐ <b>Your code</b><br/>โ
can touch anything<br/>โ knows nothing about you"]
AI -.->|"wants to"| GAP
GAP -.->|"can't"| CODE
style AI fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
style CODE fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
style GAP fill:#450a0a,stroke:#ef4444,stroke-width:2px,stroke-dasharray: 6 4,color:#fecaca
```
### The fix
MCP is the **agreed-upon shape of the message** between the two.
Picture a toy box. The AI can't see inside it. So you tape a list to the outside:
```
Inside this box:
๐ฒ 1 dice โ for when you need real randomness
๐ช 1 cookie jar โ for counting cookies
๐ 1 decoder ring โ for secret messages
```
The AI reads the list, points at one, and says *"use that one, please, with these settings."* You reach in, use it, and hand back the result.
MCP is the plug that closes the gap:
```mermaid
flowchart LR
AI["๐ง The AI<br/>brain, no hands"]
MCP{{"๐ MCP<br/>one agreed shape<br/>of message"}}
SRV["๐งฐ Your server<br/>hands, no brain"]
TOOLS["๐ฒ dice<br/>๐ช cookie jar<br/>๐ decoder ring"]
AI -->|"1 . what have you got?"| MCP
MCP -->|"2 . here's the list"| AI
AI -->|"3 . use roll_dice"| MCP
MCP --> SRV
SRV --> TOOLS
TOOLS -->|"4 . the answer"| AI
style AI fill:#1e293b,stroke:#38bdf8,color:#f8fafc
style MCP fill:#78350f,stroke:#fbbf24,color:#fef3c7
style SRV fill:#1e293b,stroke:#38bdf8,color:#f8fafc
style TOOLS fill:#052e16,stroke:#4ade80,color:#dcfce7
```
That's the entire protocol. The rest is details.
### Why a *standard* matters
Before MCP, connecting 10 AI apps to 10 services meant writing **100** custom integrations. With MCP it's **10 + 10 = 20**: each app speaks MCP once, each service speaks MCP once, and everything plugs into everything.
```mermaid
flowchart TB
subgraph AFTER["โ
With MCP โ 3 + 3 = 6 connections"]
direction TB
B1["Claude"] --- HUB{{"MCP"}}
B2["Cursor"] --- HUB
B3["ChatGPT"] --- HUB
HUB --- B4["GitHub"]
HUB --- B5["Postgres"]
HUB --- B6["Your app"]
end
subgraph BEFORE["โ Without MCP โ 3 ร 3 = 9 custom integrations"]
direction TB
A1["Claude"] --- A4["GitHub"]
A1 --- A5["Postgres"]
A1 --- A6["Your app"]
A2["Cursor"] --- A4
A2 --- A5
A2 --- A6
A3["ChatGPT"] --- A4
A3 --- A5
A3 --- A6
end
style BEFORE fill:#450a0a,stroke:#ef4444,color:#fecaca
style AFTER fill:#052e16,stroke:#4ade80,color:#dcfce7
style HUB fill:#78350f,stroke:#fbbf24,color:#fef3c7
```
Scale that to 10 ร 10 and it's 100 versus 20. It's USB-C for AI. One shape of plug.
---
## Part 2 โ The six steps of every MCP conversation
There is no magic in here. It's six ordinary HTTP POSTs carrying JSON.
| # | What happens | The actual message |
|---|---|---|
| 1 | **The AI knocks.** A handshake: which version do you speak? | `initialize` |
| 2 | **The AI reads the list.** Your server sends back every tool's name, description, and inputs. | `tools/list` |
| 3 | **You ask for something.** "Roll me three d20s." | *(plain English, no MCP yet)* |
| 4 | **The AI points at a toy.** It matched your sentence to a description. | `tools/call` |
| 5 | **Your code runs.** On your server, with your data. The AI never sees inside. | *(just JavaScript)* |
| 6 | **The answer comes home.** Your text goes back; the AI turns it into a sentence. | *(the response)* |
Drawn out, the whole conversation looks like this โ and this diagram *is* the protocol. There is nothing hidden behind it:
```mermaid
sequenceDiagram
autonumber
actor You as ๐ง You
participant AI as ๐ง Claude
participant SRV as ๐งฐ Your MCP server
rect rgba(56, 189, 248, 0.15)
Note over AI,SRV: Handshake โ happens once, when Claude starts
AI->>SRV: initialize
SRV-->>AI: "I speak MCP 2025-06-18, I'm cookie-jar-mcp"
AI->>SRV: tools/list
SRV-->>AI: say_hello, roll_dice, cookie_jar, secret_code<br/>+ each one's description and inputs
end
rect rgba(251, 191, 36, 0.15)
Note over You,SRV: Now you actually ask for something
You->>AI: "roll me three twenty-sided dice"
Note right of AI: Reads your sentence.<br/>Matches it against the<br/>DESCRIPTIONS from step 4.
AI->>SRV: tools/call<br/>roll_dice { sides: 20, times: 3 }
Note right of SRV: YOUR JavaScript runs here.<br/>Claude never sees inside.
SRV-->>AI: "Rolled 3d20 -> [17, 9, 18] Total: 44"
AI-->>You: "You rolled 17, 9 and 18 โ 44 total!"
end
```
Every message is wrapped in a boring envelope called JSON-RPC 2.0:
```json
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "roll_dice", "arguments": { "sides": 20, "times": 3 } } }
```
That's it. That's the protocol you were nervous about.
---
## Part 3 โ The three things a server can offer
Almost everyone only ever builds **tools**. The other two matter because they answer a question tools can't: *who gets to decide?*
| | What it is | Who decides | Example here |
|---|---|---|---|
| ๐ง **Tool** | A verb. Something to **do**. | The **AI** picks it | `roll_dice` |
| ๐ **Resource** | A noun. Something to **read**. Has an address, not arguments. | The **app** loads it | `cookiejar://status` |
| ๐ฌ **Prompt** | A saved fill-in-the-blank instruction. | The **human** picks it | `bedtime_story` |
The easiest way to keep them straight is to follow the arrow back to whoever pulled the trigger:
```mermaid
flowchart TB
SERVER["๐งฐ Your MCP server"]
SERVER --> T["๐ง TOOLS<br/><i>verbs โ things to DO</i>"]
SERVER --> R["๐ RESOURCES<br/><i>nouns โ things to READ</i>"]
SERVER --> P["๐ฌ PROMPTS<br/><i>saved instructions</i>"]
T --> TW["๐ง the AI decides<br/>picks it when your<br/>description convinces it"]
R --> RW["๐ป the app decides<br/>loads it as context,<br/>addressed by URI"]
P --> PW["๐ง the human decides<br/>you pick it from a menu;<br/>the AI never can"]
TW --> TE["roll_dice"]
RW --> RE["cookiejar://status"]
PW --> PE["bedtime_story"]
style SERVER fill:#78350f,stroke:#fbbf24,color:#fef3c7
style T fill:#1e293b,stroke:#38bdf8,color:#f8fafc
style R fill:#1e293b,stroke:#a78bfa,color:#f8fafc
style P fill:#1e293b,stroke:#4ade80,color:#f8fafc
style TE fill:#0c4a6e,stroke:#38bdf8,color:#e0f2fe
style RE fill:#2e1065,stroke:#a78bfa,color:#ede9fe
style PE fill:#052e16,stroke:#4ade80,color:#dcfce7
```
---
## Part 4 โ Anatomy of a tool
Every tool needs exactly four things:
```ts
server.registerTool(
"roll_dice", // 1. NAME
{
description: "Roll one or more dice ...", // 2. DESCRIPTION โ the important one
inputSchema: z.object({ // 3. SHAPE
sides: z.number().int().min(2).max(1000).default(6),
times: z.number().int().min(1).max(20).default(1),
}),
},
async ({ sides, times }) => { // 4. DO-THING
return { content: [{ type: "text", text: "..." }] };
}
);
```
**The description is the whole ballgame.** It is the *only* thing the AI reads when deciding whether to use your tool. Vague description โ your tool never gets called. Write it like a job posting, not a variable name.
**The schema is a free bouncer.** `.min(2)` means a 1-sided die gets rejected *before your code runs*, with a clear message the AI can read and correct:
```
Input validation error: Invalid arguments for tool roll_dice:
sides: Too small: expected number to be >=2
```
You didn't write a line of validation code. You just described the shape honestly. Here's where that check sits โ note that bad input never reaches your function at all:
```mermaid
flowchart TB
START(["๐ง AI sends tools/call"]) --> EXISTS{"Does a tool<br/>with that name exist?"}
EXISTS -->|no| ERR1["โ Unknown tool<br/><i>AI is told, and can retry</i>"]
EXISTS -->|yes| VALID{"Do the arguments match<br/>your inputSchema?"}
VALID -->|"no โ sides: 1"| ERR2["โ Input validation error<br/>'Too small: expected >= 2'<br/><i>your code never ran</i>"]
VALID -->|"yes โ sides: 20"| DEFAULTS["โ๏ธ Missing optional values<br/>filled in from .default"]
DEFAULTS --> RUN["โถ๏ธ YOUR function finally runs"]
RUN --> LOGIC{"Your own rules<br/>e.g. enough cookies?"}
LOGIC -->|no| SOFT["๐
A polite refusal in plain English<br/>'only 7 cookies in the jar'<br/><i>not a crash โ the AI can explain it</i>"]
LOGIC -->|yes| OK["โ
content: [ { type: 'text' } ]"]
SOFT --> BACK(["๐ง back to the AI"])
OK --> BACK
ERR1 --> BACK
ERR2 --> BACK
style START fill:#1e293b,stroke:#38bdf8,color:#f8fafc
style BACK fill:#1e293b,stroke:#38bdf8,color:#f8fafc
style ERR1 fill:#450a0a,stroke:#ef4444,color:#fecaca
style ERR2 fill:#450a0a,stroke:#ef4444,color:#fecaca
style SOFT fill:#78350f,stroke:#fbbf24,color:#fef3c7
style OK fill:#052e16,stroke:#4ade80,color:#dcfce7
style RUN fill:#0c4a6e,stroke:#38bdf8,color:#e0f2fe
```
The two red boxes are free โ you get them from `z.object({...})`. The amber box is the one you write yourself, and writing it well is what separates a tool that helps from a tool that confuses.
---
## Part 5 โ What's in this particular toy box
| Tool | What it teaches |
|---|---|
| `say_hello` | The simplest tool that can exist. One input, one sentence out. |
| `roll_dice` | Optional inputs, defaults, and guard rails via `.min()` / `.max()`. |
| `cookie_jar` | State that survives between calls โ and a tool that politely says **no**. |
| `secret_code` | Why tools beat guessing. Letter-shift math an AI would fumble in its head. |
Plus one resource (`cookiejar://status`) and one prompt (`bedtime_story`).
> ๐ช **Note on the cookie jar:** it's a plain variable in memory. Serverless machines fall asleep, so the count resets on its own sometimes. That's not a bug โ it's the lesson. Real state belongs in a database.
You will hit this for real, and it's confusing until you've seen it drawn. Vercel runs your code on however many machines it feels like, and **each one gets its own copy of that variable**:
```mermaid
flowchart TB
subgraph NOW["โ What this repo does โ memory in a variable"]
direction TB
U1["you: add 20 cookies"] --> M1["๐ฅ๏ธ Machine A<br/>cookiesInJar = 12 โ 32"]
U2["you: look in the jar"] --> M2["๐ฅ๏ธ Machine B<br/>cookiesInJar = 12<br/><i>never heard of Machine A</i>"]
M2 --> WAT["๐ 'The jar has 12 cookies'<br/>where did the 20 go?"]
end
subgraph FIX["โ
What real servers do โ memory in a database"]
direction TB
V1["you: add 20 cookies"] --> N1["๐ฅ๏ธ Machine A"]
V2["you: look in the jar"] --> N2["๐ฅ๏ธ Machine B"]
N1 --> DB[("๐๏ธ Neon Postgres<br/>cookies = 32")]
N2 --> DB
DB --> YAY["๐ 'The jar has 32 cookies'<br/>every machine agrees"]
end
style NOW fill:#450a0a,stroke:#ef4444,color:#fecaca
style FIX fill:#052e16,stroke:#4ade80,color:#dcfce7
style DB fill:#78350f,stroke:#fbbf24,color:#fef3c7
style WAT fill:#7f1d1d,stroke:#ef4444,color:#fecaca
style YAY fill:#14532d,stroke:#4ade80,color:#dcfce7
```
**Memory in a serverless function is a sandcastle** โ real, working, and taken by the tide. Fixing it means changing one line in the tool. The AI can't tell the difference; it just calls `cookie_jar` and gets a number. How you store the cookies is entirely your business.
---
## Part 6 โ Run it yourself
```bash
npm install
npm run dev
```
Open http://localhost:3000 โ the page includes a live playground that shows you the exact JSON going back and forth.
### Poke the server directly
```bash
curl -X POST http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
### Inspect it with the official GUI
```bash
npx @modelcontextprotocol/inspector
```
Point it at `http://localhost:3000/api/mcp`, transport **Streamable HTTP**.
### Where everything lives
Only two files matter. The rest is scaffolding:
```mermaid
flowchart LR
subgraph REPO["๐ this repo"]
direction TB
ROUTE["โญ app/api/mcp/route.ts<br/><b>the entire MCP server</b><br/><i>4 tools, 1 resource, 1 prompt</i>"]
PAGE["app/page.tsx<br/>the lesson you're reading"]
PLAY["app/Playground.tsx<br/><i>a tiny MCP client โ<br/>the other half of the protocol</i>"]
MCPJSON[".mcp.json<br/>points Claude Code at<br/>local + live"]
end
ROUTE -->|"serves"| EP(["๐ /api/mcp"])
PLAY -->|"calls"| EP
PAGE -.->|"embeds"| PLAY
MCPJSON -.->|"points at"| EP
EP --> CLIENTS["๐ง Claude Code<br/>๐ง Claude Desktop<br/>๐ง any MCP client"]
style ROUTE fill:#78350f,stroke:#fbbf24,color:#fef3c7
style EP fill:#0c4a6e,stroke:#38bdf8,color:#e0f2fe
style CLIENTS fill:#052e16,stroke:#4ade80,color:#dcfce7
style REPO fill:#1e293b,stroke:#475569,color:#f8fafc
```
### How a change reaches the internet
Vercel is wired to this GitHub repo, so shipping a new tool is just a push:
```mermaid
flowchart LR
EDIT["โ๏ธ add a tool in<br/>route.ts"] --> COMMIT["git commit"]
COMMIT --> PUSH["git push origin main"]
PUSH --> GH["๐ GitHub"]
GH -->|"webhook"| VC["โฒ Vercel builds"]
VC --> LIVE(["๐ learn-mcp-5-year-old<br/>.vercel.app/api/mcp"])
LIVE --> CLAUDE["๐ง Claude sees the<br/>new tool on next<br/>tools/list"]
style EDIT fill:#1e293b,stroke:#38bdf8,color:#f8fafc
style GH fill:#1e293b,stroke:#a78bfa,color:#f8fafc
style VC fill:#78350f,stroke:#fbbf24,color:#fef3c7
style LIVE fill:#052e16,stroke:#4ade80,color:#dcfce7
style CLAUDE fill:#0c4a6e,stroke:#38bdf8,color:#e0f2fe
```
---
## Part 7 โ Plug it into Claude
### Claude Code
```bash
claude mcp add --transport http cookie-jar https://learn-mcp-5-year-old.vercel.app/api/mcp
```
### Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"cookie-jar": {
"type": "http",
"url": "https://learn-mcp-5-year-old.vercel.app/api/mcp"
}
}
}
```
Restart, then say **"roll me three twenty-sided dice"** and watch it reach into the jar.
---
## Part 8 โ Where to go next
1. **Build one yourself from an empty folder.** โ **[BUILD_FROM_SCRATCH.md](BUILD_FROM_SCRATCH.md)**, the 13-stage developer walkthrough.
2. **Add your own tool.** Copy the `say_hello` block, rename it, change the description. That's genuinely all it takes.
3. **Give the jar a real memory.** Swap the variable for **Neon Postgres** so it survives a nap โ `npx vercel install neon`, then one `update ... returning` statement. [Project #3](https://github.com/ketankshukla/learn-mcp-agent-guard) does exactly this, if you want the finished version.
4. **Lock the door.** `mcp-handler` ships `withMcpAuth` for token-checking, so not everyone on the internet can eat your cookies.
5. **Return structured data.** Add an `outputSchema` and `structuredContent` so the AI gets real JSON instead of a sentence.
---
## This is part of a series
This project is the **server** โ the vending machine that waits to be told what to do. The interesting question it raises is *who does the telling*, and that turns out to be a much bigger subject.
```mermaid
flowchart LR
P1["๐ช <b>#1 โ you are here</b><br/>learn-mcp-5-year-old<br/><i>an MCP SERVER</i><br/>offers tools, waits"]
P2["๐ #2<br/>learn-mcp-agent-loop<br/><i>an MCP HOST</i><br/>picks the tools, runs the loop"]
P3["โ #3<br/>learn-mcp-agent-guard<br/><i>the agent that ASKS FIRST</i><br/>approval gates, memory, evals"]
P4["๐ฅ #4<br/>learn-mcp-agent-crew<br/><i>one agent that HIRES HELP</i><br/>sub-agents, one queue"]
P5["๐ธ #5<br/>learn-mcp-agent-ledger<br/><i>the host that OWNS THE WALLET</i><br/>sampling, a spend gate, a ledger"]
P1 --> P2 --> P3 --> P4 --> P5
style P1 fill:#78350f,stroke:#fbbf24,stroke-width:3px,color:#fef3c7
style P2 fill:#1e293b,stroke:#38bdf8,color:#f8fafc
style P3 fill:#1e293b,stroke:#38bdf8,color:#f8fafc
style P4 fill:#1e293b,stroke:#38bdf8,color:#f8fafc
style P5 fill:#052e16,stroke:#4ade80,color:#dcfce7
```
| | What it builds | Start here ifโฆ |
|---|---|---|
| **#1** *(you are here)* | An MCP **server** | MCP itself is new to you |
| **[#2 โ the agent loop](https://github.com/ketankshukla/learn-mcp-agent-loop)** | An MCP **host** that owns the loop | You want to know what Claude Desktop was actually doing |
| **[#3 โ the agent that asks first](https://github.com/ketankshukla/learn-mcp-agent-guard)** | Approval gates, Postgres persistence, evals | You want to give an agent a dangerous tool and sleep at night |
| **[#4 โ the crew](https://github.com/ketankshukla/learn-mcp-agent-crew)** | Sub-agents, one approval queue, cost measurement | Your agent keeps running out of room on big jobs |
| **[#5 โ the ledger](https://github.com/ketankshukla/learn-mcp-agent-ledger)** | Sampling, a spend gate, a ledger | Something you connected can spend your money |
> ๐ธ **This server is a vending machine, and [project #5](https://github.com/ketankshukla/learn-mcp-agent-ledger) explains why that matters.** Everything here is request-in, result-out โ it costs you nothing beyond the request. MCP's `sampling` capability turns that around: a server can ask *your* host to run a model call, on your key. That needs a gate, and project #5 builds one.
> ๐ช **About that cookie jar confession in [Part 5](#part-5--whats-in-this-particular-toy-box):** project #3 finally fixes it. The jar there is backed by real Postgres, so the count survives restarts and agrees across machines โ same tool, same protocol, different storage. The AI genuinely can't tell the difference, which was the point.
---
## The three documents
| | For | Answers |
|---|---|---|
| **[README.md](README.md)** *(you are here)* | Understanding | *What is MCP? Why does it exist? What can a server offer?* |
| **[BUILD_FROM_SCRATCH.md](BUILD_FROM_SCRATCH.md)** | Doing | *Which commands, in what order, and what breaks along the way?* |
| **[NEXT_STEP.md](NEXT_STEP.md)** | Deciding | *What's missing from this, and what should the next project be?* |
---
## Stack
- **Next.js 16** (App Router, Turbopack) + **React 19** + **Tailwind v4**
- **[`mcp-handler`](https://www.npmjs.com/package/mcp-handler)** โ Vercel's adapter that turns an MCP server into a route handler
- **`@modelcontextprotocol/server`** โ the official TypeScript SDK
- **Zod v4** โ describes tool inputs and validates them for free
---
<div align="center">
**[How this was built โ](BUILD_FROM_SCRATCH.md)** ยท **[What's next โ](NEXT_STEP.md)** ยท **[project #2: the agent loop โ](https://github.com/ketankshukla/learn-mcp-agent-loop)**
</div>
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues