KetanShukla.dev
MCP5 min read

An MCP server is one Next.js route handler

No framework, no scaffolding, no SDK ceremony. Four tools, a resource, and a prompt in a single file — and the one line in it that decides whether your tool ever gets used.

A language model is a brain in a jar. Very capable, completely stuck. It can think about your files; it cannot open them. It can talk about rolling dice, but ask it for a random number and it picks 7 far more often than chance allows, because it is pattern-matching rather than rolling.

Your code is the opposite: it has hands and no idea what anyone wants.

The Model Context Protocol is the agreed shape of the message between the two. That is the entire concept, and the implementation is smaller than the explanation.

The whole server, structurally

app/api/mcp/route.ts
import { createMcpHandler } from "mcp-handler";
import { z } from "zod";
 
const handler = createMcpHandler((server) => {
  server.registerTool(
    "roll_dice",
    {
      title: "Roll Dice",
      description:
        "Roll one or more dice and get the numbers plus the total. Use this " +
        "any time real randomness is needed — games, picking a winner, or " +
        "deciding something by chance.",
      inputSchema: z.object({
        sides: z.number().int().min(2).max(1000).default(6)
          .describe("How many sides each die has. Normal dice have 6."),
        times: z.number().int().min(1).max(20).default(1)
          .describe("How many dice to roll."),
      }),
    },
    async ({ sides, times }) => say(rollThem(sides, times)),
  );
 
  // ...three more tools, one resource, one prompt
});
 
export { handler as GET, handler as POST };

That is a Next.js App Router route handler. Deploy it and a host can connect to it over Streamable HTTP at /api/mcp. There is no server process to run, no container, no gateway.

Every tool needs exactly four things

  1. A name — what the model says out loud to pick it.
  2. A description — how the model knows when to pick it.
  3. An input schema — what you need from the model, as Zod.
  4. A do-thing — the code that runs.

Three of those are mechanical. One of them is the whole job.

The description is the most important line in the file

This is the part that surprised me, and the part most MCP tutorials underweight.

A vague description means the model never calls your tool. Not "calls it badly" — never calls it. The description is not documentation for a human reading your repo. It is the only evidence the model has when it decides, mid-sentence, whether this tool is relevant to what the user just asked.

Compare:

// The model has to guess. It usually guesses "no".
description: "Rolls dice."
 
// The model has a decision procedure.
description:
  "Roll one or more dice and get the numbers plus the total. Use this any " +
  "time real randomness is needed — games, picking a winner, or deciding " +
  "something by chance."

The second one does something the first does not: it names the situations. "Any time real randomness is needed", then three concrete cases. You are not describing the function; you are describing the trigger.

The same applies inside the schema. .describe() on each field is not a comment — it is what the model reads when deciding what to put there.

Validation you get for free

Look at the constraints on roll_dice:

sides: z.number().int().min(2).max(1000).default(6)
times: z.number().int().min(1).max(20).default(1)

If the model tries to roll a one-sided die, or roll 10,000 dice, MCP rejects the call before your code runs and tells the model what it did wrong — in a form it can correct on the next turn.

This is a genuinely good deal: input validation, self-documenting parameters, and a repair loop, all from describing the shape honestly once. The mistake is to write a permissive schema because it feels friendlier. A permissive schema moves every guard rail into your handler, where a failure becomes an exception rather than a correction.

Tools, resources, prompts — and why the distinction matters

MCP has three primitives, and the difference between them is about who initiates:

primitivewho decides to use itgood for
toolthe model, mid-conversationactions and lookups
resourcethe application, on the user's behalfcontext the user attaches deliberately
promptthe user, explicitlya canned workflow the user invokes

Most servers ship only tools, which is fine, but it means everything has to be worth interrupting the model's reasoning for. A resource is how you hand over a document without pretending it was an action. A prompt is how you offer a workflow without hoping the model infers it.

The bug that teaches you the most

The cookie jar in this server stores its count in a module-level variable:

let cookiesInJar = 12;

A real application would use a database. This one deliberately does not, and the result is instructive: serverless machines fall asleep, so the number resets by itself. Eat three cookies, wait, come back, and there are twelve again.

That is not a bug to fix in a teaching server — it is the clearest possible demonstration that an MCP server is a stateless HTTP handler, not a long-lived process. Anything you want to survive between calls has to live somewhere you chose on purpose. Anyone who has shipped a stateful assumption to a serverless runtime has learned this once; this is a cheap place to learn it.

What to build first

If you are writing your first MCP server, resist the urge to wrap something big. Wrap something you already do by hand five times a week, give it a description that names the situations, and connect it to a host you actually use. The protocol is small enough that the interesting work is entirely in the tool design.

The complete file — four tools, one resource, one prompt, with the reasoning in comments — is app/api/mcp/route.ts. The live endpoint is at /api/mcp if you want to point a host at it before writing anything yourself.

mcpmcp-servernextjszodtutorialtypescript

Read next