How to build an MCP server (with a real, running example)

9 min read

On this page

An MCP server is three things wired together: a transport that moves JSON-RPC messages between client and server, a set of tools the model can call, and an auth layer deciding who's calling and what they're allowed to touch. Building one means picking a transport (stdio for a local subprocess, Streamable HTTP for a remote server shared by many clients), registering tools with the official SDK against a typed input schema, and putting the auth check in front of the handler rather than inside it. The fastest way to see all three done for real - not in a toy weather-API demo - is to read a server that's actually running in production.

TL;DR - Use Streamable HTTP if more than one client needs to reach your server remotely; stdio only works for a local subprocess the client itself launches. Register tools with @modelcontextprotocol/sdk's server.registerTool, each with a zod input schema and a description specific enough that a model can pick the right one. Put auth in front of the handler, not scattered inside it, and scope every tool call to whoever's token it was. Test the auth gate first - if it doesn't reject bad requests, nothing else about the server is trustworthy.

What actually needs to exist in an MCP server

Strip away the framing and every MCP server, in any language, needs the same three pieces:

Transport

How JSON-RPC messages physically move between client and server.

mcp-handler ^1.1.0 on Streamable HTTP - no SSE, no Redis, one route.

Tools

Functions the model can call, each with a name, a description, and a typed input schema.

40 tools registered via @modelcontextprotocol/sdk 1.26.0's server.registerTool.

Auth

Who's calling, and what data they're allowed to touch.

One bearer token per tenant, resolved to a project row before any tool runs.

The official quickstart walks through tools in isolation - a weather API with two functions, run over stdio, with no auth at all because it never leaves your machine. That's the right way to learn the shape of a tool registration. It's the wrong reference once your server has to run on a shared host and answer to more than one caller, which is where transport and auth stop being optional.

Pick a transport: stdio or Streamable HTTP - never plain SSE

If your server is a subprocess the client launches and only that client ever talks to it, stdio is simpler and the spec says clients should support it whenever possible. If your server runs as its own long-lived process serving multiple remote clients, you need HTTP - and as of the 2025-06-18 protocol revision, that means Streamable HTTP, not the older HTTP+SSE transport:

TransportRuns asClientsSpec status
stdioSubprocess the client launchesOne, localCurrent - local servers only
HTTP+SSELong-lived server processMany, remoteDeprecated (spec 2024-11-05)
Streamable HTTPLong-lived server processMany, remoteCurrent (spec 2025-06-18)

The MCP spec is explicit that Streamable HTTP "replaces the HTTP+SSE transport from protocol version 2024-11-05." A fair number of MCP tutorials and blog posts still teach that older transport as if it were current, because they were written before the replacement shipped - if a guide you're reading has your server opening a separate /sse endpoint and a separate POST endpoint, it's describing the deprecated shape. dispatchseo.com's own server runs on mcp-handler (currently ^1.1.0 in its package.json), which speaks Streamable HTTP only: one route, no SSE endpoint, no Redis required for session state.

Register tools against a schema, not a guess

The official SDK pattern is the same whether you're building a two-tool weather demo or a forty-tool production server - registerTool takes a name, a title and description, a zod inputSchema, and an async handler:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "example", version: "1.0.0" });

server.registerTool(
  "get_forecast",
  {
    description: "Get weather forecast for a location",
    inputSchema: {
      latitude: z.number().min(-90).max(90),
      longitude: z.number().min(-180).max(180),
    },
  },
  async ({ latitude, longitude }) => {
    // ... fetch and format the forecast
    return { content: [{ type: "text", text: "..." }] };
  },
);

The description is not documentation for a human - it's the only signal the calling model has for choosing this tool over another one. dispatchseo.com's real registrations run long (several sentences, sometimes a full paragraph) because a vague one-liner leaves an agent guessing which of forty tools actually does what it needs. Every tool in that server also returns through the same two small helpers instead of hand-rolling the response shape each time:

function ok(data: unknown) {
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}
function fail(message: string) {
  return {
    isError: true,
    content: [{ type: "text" as const, text: JSON.stringify({ error: message }, null, 2) }],
  };
}

Pretty-printed JSON text, not a bespoke string format per tool, so the calling agent gets structured data to reason over every time.

Auth that actually gates something

A server with real tenants can't just check a token once and move on - every tool call has to know whose data it's touching, without every single tool function taking a project parameter and threading it through by hand. dispatchseo.com resolves the bearer token to a project once, in front of the handler, and rides it through the request on Node's AsyncLocalStorage:

  1. 1

    POST /api/mcp

    Authorization: Bearer <token>

  2. 2

    authed()

    reads the header, extracts the token

  3. 3

    getProjectByToken(token)

    no match -> 401, stop here

  4. 4

    projectStore.run(project, ...)

    AsyncLocalStorage scopes this request to one tenant

  5. 5

    mcpHandler(req)

    routes to the requested tool

  6. 6

    currentProject()

    the tool callback reads the scoped project - no project param passed in

  7. 7

    ok() / fail()

    pretty-printed JSON text back to the client

async function authed(req: Request): Promise<Response> {
  const header = req.headers.get("authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
  const project = token ? await getProjectByToken(token) : null;
  if (!project) {
    return Response.json({ error: "..." }, { status: 401 });
  }
  return projectStore.run(project, () => mcpHandler(req));
}

Every tool callback then reads currentProject() off that store instead of accepting a project argument nobody would remember to pass correctly. It's a small pattern, but it's the difference between "auth happens somewhere" and a token that can never, structurally, leak one tenant's rows into another's response.

Common mistakes

Test your server before you ship it

Test the rejection path first - a server that accepts bad auth is worse than one that's merely undocumented. Here's the real response from a local build of dispatchseo.com's server, hit with no Authorization header at all:

$ curl -i http://localhost:3000/api/mcp
HTTP/1.1 401 Unauthorized
{"error":"Missing Authorization header. Connect with the exact `claude mcp add` command from the dashboard (Settings -> Project key) - the Bearer token is what routes calls to your project."}

Once the gate rejects correctly, connect a real client and list its tools. The official TypeScript SDK ships a StreamableHTTPClientTransport for exactly this - dispatchseo.com's own install-verification script uses it to confirm a fresh deploy actually serves what it's supposed to:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(new URL("http://localhost:3000/api/mcp"), {
  requestInit: { headers: { Authorization: `Bearer ${token}` } },
});
const client = new Client({ name: "smoke-test", version: "1.0.0" });
await client.connect(transport);

const { tools } = await client.listTools();
console.log(`${tools.length} tools registered`);

Run it in CI after every deploy, not just once by hand - a tool that silently stops registering (see the mistake above) is invisible from the outside until something calls it.

When not to build a full MCP server

If you have exactly one client you control, always calling the same fixed function, a plain HTTP endpoint or webhook is less machinery for the same result - MCP's payoff is standardizing tool discovery across callers you don't control one-by-one (Claude Code, Cursor, and whatever else someone connects next), and that payoff is small with a single caller. Skip the per-tenant AsyncLocalStorage plumbing too if your server only ever serves one account - it exists to stop one tenant's request from ever touching another tenant's data, which is a real problem only once there's more than one tenant.

FAQ

Do I need resources and prompts, or is registering tools enough? Tools cover the large majority of real servers - functions the model calls, with arguments it fills in. Resources (file-like data a client can read directly) and prompts (reusable templates) exist in the spec but plenty of production servers, dispatchseo.com's included, ship tools only.

Is stdio obsolete now that Streamable HTTP exists? No - they solve different problems. stdio is for a local subprocess with exactly one client; Streamable HTTP is for a remote server serving many. Neither replaced the other; HTTP+SSE is the one that got replaced.

Can an MCP server run on a serverless platform like Vercel? Yes, as long as it's stateless per-request or uses an external store for session state - dispatchseo.com's does, on Vercel, via mcp-handler's Streamable HTTP support with no Redis dependency.

Do I need a database to build one? Not to start. The official quickstart's weather server has none. A database (or any state store) only becomes necessary once tools need to remember something between calls or scope data per caller.

What's the minimum to test that a server works? Confirm the auth gate rejects a request with no token, then connect a real MCP client and call listTools() - if the count matches what you registered, the wiring is correct end to end.

Between the transport choice, the auth pattern, and the two mistakes above, that's most of what separates a demo server from one you'd trust with real tenants - the rest is the tools themselves, which is where DispatchSEO's own agent spends its time.