How to Build an MCP Server in TypeScript: A Practical 2026 Guide

20 min readaidevtoolsmcpagentsprogramming

The first MCP server I wrote did one thing. It read my Postgres database and returned the schema as structured JSON. That was it. No fancy joins, no query builder, just a list of tables, columns, and types.

It took me an afternoon. Two weeks later it had saved me hours. Every time I asked Claude Code to add a new feature that touched the database, it pulled the schema through the MCP server instead of hallucinating column names. The bug rate on AI-generated migrations dropped to roughly zero. The pattern was so obviously useful that I now write a small MCP server for almost every project I work on.

How Do You Build an MCP Server?

To build an MCP server, install the official TypeScript SDK, create an McpServer, register each tool with a name, description, and Zod input schema, then connect a transport. Use stdio for local servers that a host like Claude Code launches, and Streamable HTTP for remote servers. Test with the MCP Inspector before you wire it into a model.

If you are new to the protocol, start with what an MCP server is. If you only want to install existing servers, the Claude Code MCP setup guide covers that. This post is the part those do not cover: actually building one. Not theoretical, not waving at the spec. The exact steps I take when I sit down and decide a project needs its own MCP server, and the mistakes I keep watching other developers make on the way.


Build an MCP Server in TypeScript: Complete Example

Here is a full working server. It exposes two tools that let the model read the state of a git repo: git_status and git_log. You need Node.js 20 or later.

bash
mkdir git-tools-mcp && cd git-tools-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod
npm install -D tsx typescript @types/node
mkdir src

Create src/index.ts:

ts
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';

const run = promisify(execFile);
const DEFAULT_LOG_LIMIT = 10;
const MAX_LOG_LIMIT = 50;

const server = new McpServer({ name: 'git-tools', version: '0.1.0' });

server.registerTool(
  'git_status',
  {
    description:
      'Return the porcelain git status of the current working directory. Use this before suggesting commits.',
    inputSchema: z.object({}),
  },
  async () => {
    const { stdout } = await run('git', ['status', '--porcelain=v1']);
    return { content: [{ type: 'text', text: stdout || '(clean)' }] };
  }
);

server.registerTool(
  'git_log',
  {
    description:
      'Return the most recent commits as one line each. Use this to understand recent changes before editing.',
    inputSchema: z.object({
      limit: z
        .number()
        .int()
        .min(1)
        .max(MAX_LOG_LIMIT)
        .optional()
        .describe(`How many commits to return. Defaults to ${DEFAULT_LOG_LIMIT}.`),
    }),
  },
  async ({ limit }) => {
    try {
      const count = String(limit ?? DEFAULT_LOG_LIMIT);
      const { stdout } = await run('git', ['log', '--oneline', '-n', count]);
      return { content: [{ type: 'text', text: stdout }] };
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      return {
        content: [{ type: 'text', text: `git log failed: ${message}. Check that this is a git repository.` }],
        isError: true,
      };
    }
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);
console.error('git-tools MCP server running on stdio');

Run it through the MCP Inspector to call the tools without a model:

bash
npx @modelcontextprotocol/inspector npx tsx src/index.ts

Click Connect, open the Tools tab, pick git_log, and run it. When that works, add it to Claude Code. Use an absolute path, because Claude Code launches the command from your project directory and the server reads the git repo in that directory:

bash
claude mcp add git-tools -- npx tsx /absolute/path/to/git-tools-mcp/src/index.ts

Run /mcp in a Claude Code session and git-tools should show as connected with both tools listed.

Three things to notice in that code.

First, stdout is the protocol channel. Log with console.error. A single console.log corrupts the JSON-RPC stream and the host drops the connection.

Second, the tool description. It is not "returns git status." It is "Return the porcelain git status of the current working directory. Use this before suggesting commits." That last sentence is what the model reads when it decides whether to call the tool. Tool descriptions are not documentation for humans. They are activation prompts for the agent.

Third, the input schema is a Zod schema, and it is the only schema you write. The SDK derives the JSON Schema the model sees from it and validates arguments before your handler runs. Fuzzy schemas produce fuzzy calls, so describe every field.

Which SDK package should you install?

The TypeScript SDK now has two lines. The main branch is v2, which ships as @modelcontextprotocol/server (and @modelcontextprotocol/client) and implements the 2026-07-28 spec. The v1 line, @modelcontextprotocol/sdk, is still published and receives bug fixes and security updates for at least six months after v2's release on 2026-07-27. I use v2 for new servers.

If you are on v1, the same server works with different imports, and inputSchema takes a plain object of Zod fields instead of z.object(...):

ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({ name: 'git-tools', version: '0.1.0' });

server.registerTool(
  'git_log',
  {
    description: 'Return the most recent commits as one line each.',
    inputSchema: { limit: z.number().int().min(1).max(50).optional() },
  },
  async ({ limit }) => ({
    content: [{ type: 'text', text: `limit was ${limit ?? 10}` }],
  })
);

await server.connect(new StdioServerTransport());

Older tutorials (including the first version of this post) use the low-level Server class with setRequestHandler and ListToolsRequestSchema. It still exists, but McpServer with registerTool is the high-level API the SDK docs lead with. Use it unless you need to control the raw protocol messages.


Why You Should Build One Even If You Are Not At Anthropic

There is a weird assumption I keep running into. People think MCP servers are something big AI companies write for their integrations, and that the rest of us just install them. That is half right. The official servers (GitHub, Linear, Notion, Postgres) are excellent. They cover the obvious cases. But they cannot cover your case.

Your case is your repo's weird internal CLI. Your case is the JSON schema you keep pasting into prompts by hand. Your case is the three internal APIs nobody outside your team will ever wrap. Your case is the build script that ships your product, the migration tool you wrote in 2023, the analytics dashboard you query through a homemade SQL view.

Every one of those things is a candidate for an MCP server. Not because the protocol is glamorous, but because once it is wrapped, every AI agent you use can hit it. Claude Code, Cursor, Windsurf, Zed, the official Anthropic chat app, any future agent runtime. One server, many consumers. The payoff is hard to overstate.

The other reason to build is simpler. You will understand MCP at a level that is not available from reading the spec. The first time you have to decide what your searchTickets tool returns and what it does not, you learn more about agent design than a year of theory.


What You Are Actually Building

An MCP server is a process that speaks JSON-RPC and exposes a small set of primitives. The protocol calls them tools, resources, and prompts. Most servers ship only tools. Some ship resources. Almost nobody ships prompts. Start with tools.

A tool is a function with a name, a description, an input schema (JSON Schema), and an implementation. When the model decides to call your tool, the runtime serialises the arguments, sends them to your server, your server runs the function, and the result comes back as text or structured content. That is the whole loop.

A resource is something the model can read but not write. Think of it as a file the agent can fetch by URI. A common pattern is to expose your project's docs, your database schema, or a snapshot of system state as resources. The model pulls them when relevant.

A prompt is a templated instruction the user can invoke by name. They are useful for codifying common workflows. In practice almost every server I have seen skips them and lets the user invoke slash commands or skills instead.

The protocol does not care what language you write the server in. There are mature SDKs for TypeScript, Python, Go, Rust, Kotlin, C#, and Java. I write almost all of mine in TypeScript because the toolchain matches the rest of my stack and because the official @modelcontextprotocol/sdk is the most actively maintained. Pick whatever language gets you to a working server fastest. The model does not know or care.


Pick Your Transport: stdio Versus HTTP

There are two transports that matter in 2026, and the choice between them shapes everything else.

The stdio transport is the one you want for local-only servers. The agent runtime spawns your server as a child process and pipes JSON-RPC over stdin and stdout. There is no port, no auth, no network. The server lives and dies with the agent session. Most local development tools (Postgres helpers, git wrappers, file system tools, build runners) ship as stdio servers because the security model is dead simple: if the agent can run a process on your machine, it already has the same trust level as that process.

The streamable HTTP transport is the one you want for hosted servers. It runs over HTTP and upgrades to a Server-Sent Events stream only when a response needs to stream. You stand it up on a real server (Vercel, Cloudflare Workers, Lambda, a VM, whatever), give it a URL, and any agent that supports remote MCP can connect. Use this when the server needs to be shared across machines, when it needs centralised auth, or when it wraps an API that should not have its credentials on every developer's laptop.

There is a third option, the deprecated HTTP+SSE transport, which you should ignore. The current spec (2026-07-28) lists it as deprecated and points to Streamable HTTP. It also made the protocol stateless: no initialize handshake and no protocol-level sessions, so a remote server does not need to remember anything between requests.

For your first MCP server I strongly recommend stdio. The feedback loop is fast, the auth story is non-existent, and the deployment story is "drop the binary on your laptop." You can graduate to HTTP later when you have something worth hosting.


Designing Tools The Model Will Actually Use

This is the part nobody tells you, and the part that makes the difference between a server you wrote and a server you actually use.

Tools are not API endpoints. The temptation is to expose every method of your underlying system one to one. Resist it. A server with forty tools is worse than a server with eight, because the model has to read every tool description on every call and the noise drowns out the signal. I have hit this directly. A server I wrote with twenty-five tools was used less than a server I refactored to expose eight tools that grouped related operations.

The rule I follow: tools should match user intentions, not implementation details. A search_tickets tool that takes a natural-language query and returns ranked results is better than five tools called filter_by_status, filter_by_assignee, filter_by_label, filter_by_date, and combine_filters. The model can compose the natural-language query. It cannot reliably compose five micro-tools without getting confused.

The second rule: return what the model needs to decide what to do next, not the raw payload. If your tool returns a 10,000-line JSON blob, the model will spend half its context reading it. Trim aggressively. Summarise. Paginate. If the user really needs the full payload, expose a second tool that fetches by ID. Default to small, focused responses.

The third rule: make errors educational, not stack-tracey. When a tool fails, return a message that tells the model how to recover. "User not found. Try search_users first to get a valid user id." is fifty times more useful than Error: 404 Not Found. The model is not your operator. It cannot read your terminal. The error message is its entire view of the failure.

The fourth rule: idempotency where you can swing it. Models retry. Sometimes for good reasons, sometimes because they got confused. A tool that creates duplicate records on retry will burn you. Either return existing records when called with the same arguments, or expose a dry_run mode that the agent can call first.

If you internalise those four rules before writing any code, you will skip the year of pain I went through.


Resources, Prompts, And The Other Primitives

I said start with tools. I meant it. But there is a small, specific case where resources earn their keep, and it is worth covering.

Resources are for things the model needs to read but never modify. The canonical example is project context: a README.md, a CLAUDE.md, an OpenAPI spec, a database schema dump, a CHANGELOG. The model fetches them by URI, reads them, uses them, and moves on. Resources are cheaper than tools because they do not require a function call round trip. The agent loads them, often automatically, when starting a session.

If your server wraps a system with structured context that does not change per-request (the schema, the docs, the config), expose it as resources. Otherwise stick to tools.

Prompts are the third primitive and I genuinely have not seen a useful one in production. The idea is that you ship reusable prompt templates that the user can invoke. In practice, slash commands in Claude Code and rules in Cursor cover the same ground with less friction. Skip prompts for your first server. Revisit them if you ever feel the lack.

There is also a notifications/sampling/elicitation surface in the protocol that I am skipping entirely here. It is not necessary for any normal server. If you find yourself needing it, you have already outgrown this guide.


Authentication And Secrets

The auth model is where local and hosted servers diverge sharply.

For stdio servers, you mostly do not have an auth problem. The server inherits the user's environment. If your tool needs an API key, the standard pattern is to read it from an env var the user sets in their shell or in the agent runtime's config. There is no token exchange, no OAuth, no session. The trust boundary is the user's local machine.

For hosted HTTP servers, you have a real auth problem and you should treat it as such. The MCP spec builds authorization on OAuth 2.1 (the 2026-07-28 revision deprecates Dynamic Client Registration in favor of Client ID Metadata Documents, though DCR still works for compatibility). The agent presents a token. Your server validates it. There is also a simpler bearer-token pattern for first-party servers, where the user pastes a token into their agent config and your server checks it on every request. Both are fine for different use cases.

The mistake I keep seeing: developers ship an HTTP MCP server with no auth at all because they assume only they will hit it. Then six weeks later they leave the URL in a tweet, somebody scrapes their database, and there is a bad afternoon. If your server is on the public internet and it does anything beyond returning constants, it needs auth. No exceptions.

The other mistake: storing the wrong secrets. Your MCP server is going to handle the model's queries, which include user data. Treat the server like any other production service. Use env vars, not hardcoded values. Use a secrets manager for production. Rotate credentials. Log auth failures. The fact that the consumer is an AI agent does not change the security model. If anything, it raises the stakes, because the agent will retry queries automatically and amplify any leak.


Testing Without A Model In The Loop

The single biggest mistake I made on my first three MCP servers was testing them only through Claude Code. The feedback loop is too slow and too coarse. The model decides whether to call your tool, what to call it with, and how to interpret the result, which means a single end-to-end test exercises ten degrees of freedom you cannot isolate.

Test the server like a normal HTTP API. The official SDKs ship inspector tools that let you send raw JSON-RPC messages and see the responses. Use them.

The workflow that saved me hours.

Step one, run npx @modelcontextprotocol/inspector against your server. It opens a UI where you can list tools, call them with arbitrary arguments, and inspect the responses. Every tool gets exercised here first.

Step two, write integration tests against your tool handlers directly. They are normal async functions. Call them from a test runner. Assert on the output. This catches schema mismatches, edge cases on inputs, and regressions when you refactor.

Step three, only after the above two pass, wire the server into Claude Code and exercise the actual model loop. The model will do things you did not expect with your tools. That is fine. You are watching for "did the model find this tool" and "did it use the tool sensibly," not for "did this tool work." Those questions were already answered.

If you follow that order, you will catch ninety percent of bugs without ever burning a token.


How to Deploy an MCP Server (Remote HTTP on Vercel or Cloudflare)

A stdio server runs on one machine. To share a server with a team, or to give a hosted product an MCP endpoint, deploy it as a remote Streamable HTTP server. Two platforms have documented paths.

OptionWhat you useEndpointGood for
Vercelmcp-handler in a Next.js App Router route/api/mcpYou already run a Next.js app on Vercel
Cloudflare WorkersCloudflare's MCP Worker template, deployed with Wrangler/mcpA standalone server with no existing app
Any web-standard runtimecreateMcpHandler from @modelcontextprotocol/serverYou chooseWorkers, Deno, Bun, or Node behind a framework

Deploy an MCP server on Vercel

Vercel documents mcp-handler for adding an MCP server to a Next.js App Router app. It needs Node.js 20 or later. Install the packages, pinned as the docs show:

bash
npm i mcp-handler@2.1.1 @modelcontextprotocol/server@2 zod@4

Then create app/api/mcp/route.ts:

ts
import { createMcpHandler } from 'mcp-handler';
import { z } from 'zod';

const handler = createMcpHandler((server) => {
  server.registerTool(
    'roll_dice',
    {
      description: 'Roll an N-sided die',
      inputSchema: z.object({ sides: z.number().int().min(2) }),
    },
    async ({ sides }) => {
      const value = 1 + Math.floor(Math.random() * sides);
      return { content: [{ type: 'text', text: `You rolled a ${value}` }] };
    }
  );
});

export { handler as GET, handler as POST };

After you deploy, clients connect to https://<your-app>.vercel.app/api/mcp. Add it to Claude Code with claude mcp add --transport http my-server https://<your-app>.vercel.app/api/mcp. The mcp-handler package also ships withMcpAuth for bearer-token checks and helpers for OAuth protected resource metadata, which you need before this endpoint goes public.

Deploy an MCP server on Cloudflare

Cloudflare's guide scaffolds a Worker from a template, runs it locally, and deploys it with Wrangler:

bash
npm create cloudflare@latest -- remote-mcp-server-authless --template=cloudflare/ai/demos/remote-mcp-authless
cd remote-mcp-server-authless
npm start
npx wrangler@latest deploy

Locally the endpoint is http://localhost:8788/mcp, and after deploy it is https://<name>.<account>.workers.dev/mcp. One caveat from Cloudflare's own docs: that quick-start template uses their McpAgent class, which they describe as deprecated for new servers. For a new stateless server they recommend createMcpHandler() and point to the mcp-worker example in their agents repository. Check that guide before you copy the template.

Deploy with the SDK directly

If you want neither platform's wrapper, the v2 SDK has createMcpHandler(factory), which returns a handler with a web-standard fetch(Request) => Promise<Response>. On Workers, Deno, or Bun, export default handler is the whole mount. Under Node frameworks you wrap it once with toNodeHandler from @modelcontextprotocol/node. The factory runs once per request and returns a fresh McpServer, so register your tools inside it.

One thing the SDK docs are explicit about: the handler validates no Host header, no Origin header, and no token. You mount those checks in front of it. Treat that as your job, not the SDK's.

Whichever platform you pick, do not ship the endpoint without auth. A public MCP URL that can touch your database is a public API with a very persuasive client.


Wiring It Into Claude Code

Claude Code adds servers with the claude mcp add command. For a stdio server, everything after -- is the launch command.

bash
claude mcp add git-tools -- npx tsx /absolute/path/to/git-tools-mcp/src/index.ts

For a hosted HTTP server.

bash
claude mcp add --transport http my-internal-api https://mcp.example.com/mcp \
  --header "Authorization: Bearer your-token"

To share a server with your team, add --scope project. That writes it to a .mcp.json file in the repo, and you can reference secrets as ${MY_INTERNAL_TOKEN} instead of committing them. The full command reference, scopes, and .mcp.json format are in the Claude Code MCP setup guide.

Run /mcp inside a session to see the server's status. If it is connected, your tools are loaded. If it failed, the panel tells you why. The most common failure is a wrong path or a missing env var. The second most common is the server crashing on startup. Run the server manually from the same shell Claude Code launches from to confirm it boots.

Cursor and Windsurf both ship MCP support with similar config files. The surface is identical. Whatever language and transport you picked, the server runs the same against every consumer.


What I Wish I Knew Before Shipping The First Version

Six things, in order of how much pain they would have saved me.

Pick a clear scope before writing the first tool. "An MCP server for my project" is not a scope. "An MCP server that exposes our Postgres schema and lets the agent run safe read-only queries" is a scope. The narrow servers I have shipped have all been useful. The "general-purpose" ones have all been deleted within a month.

Write the tool descriptions before the tool implementations. This is the single most useful practice I have found. If you cannot describe what a tool does in two sentences that include when the model should call it, the tool is wrong. Rewrite. The description is the API contract with the agent. The code is just implementation.

Log every tool call with arguments and results. Even in development. You need to see what the model is actually doing with your server. The patterns are not what you expect. I have watched models call my tools with arguments I would never have predicted, and the logs are how I find out. Without logging you are guessing.

Version your server from day one. Use semver. Tag releases. When you make a breaking change to a tool's schema, bump the major version, and add a deprecation period for the old schema if the server is shared. Agents do not handle silent breaking changes well. They will keep calling the old shape until the descriptions tell them otherwise.

Cap response sizes. Set a hard ceiling on how much text any tool can return (I default to 16 KB, less for noisy tools). When you hit the cap, return a truncation notice with a hint about how to fetch more. Letting a tool dump 200 KB into the model context once will teach you why this matters.

Treat the server like a product. Your future self is its first user. Your team is its second. The agent is its third. Write a README that explains what the server does, what tools it exposes, and what to do when something breaks. Six months from now you will be grateful.


Where To Take It Next

Once you have shipped one MCP server you will see candidates everywhere. The instinct is to write more servers. Resist a little. The better move is usually to grow the first server with carefully chosen tools until it covers most of your daily workflow, then split out a second server only when the first server starts to feel unfocused.

One of mine was a Marketing Assistant MCP on makers.page: a local MCP that drafted channel-native posts from Cursor or Claude Code, with approval on by default. The project is now paused and archived.

A few directions worth exploring once you are comfortable.

Combine your MCP server with Claude Code skills by packaging both into a single plugin. Skills tell the model when to reach for the tools your server exposes. The combination is dramatically more reliable than either piece in isolation.

If your server wraps a third-party API, look at the agent tool design patterns for what production-grade tool signatures look like, especially around pagination, partial failure, and rate limiting.

If you are running the server in production for multiple users, look at agent observability for what you actually need to monitor. The interesting metrics are not the ones you would track for a normal HTTP API. They are things like "what percentage of tool calls resulted in the agent making a follow-up call to the same tool with corrected arguments," which is a strong signal that your descriptions are unclear.

If you are wondering how MCP fits next to direct agent-to-agent communication, the A2A vs MCP comparison is the right next read. Short version: MCP exposes capabilities to a single agent, A2A coordinates multiple agents. They solve adjacent problems.

If you are building your own agent instead of using Claude Code, the Claude Agent SDK guide is the next read.


The Honest Read On MCP Servers In 2026

Most developers will never write one. That is fine. The official integrations are good enough for most use cases. But the developers who do write their own end up with an advantage that is hard to describe until you have it. Every model gets your tool. Every agent runtime gets your tool. Every future tool gets your tool, for free, the moment they ship MCP support, which they all will because the protocol won.

The cost of entry is one afternoon. The payoff is permanent.

If you are sitting on a project that has any repetitive interaction you wish the agent could automate, write the server. Start with one tool. Make sure the tool description is clear enough that the model uses it without prompting. Wire it in. See what happens.

The first time the agent calls your tool unprompted, in the middle of a task, and uses the result correctly, the rest of the post will make sense. That is the moment MCP stops being a protocol you read about and starts being something you build with.

That moment is one afternoon away. Worth the afternoon.


Frequently Asked

How do I build an MCP server? Install the official SDK (@modelcontextprotocol/server for v2, or @modelcontextprotocol/sdk for v1), create an McpServer, register tools with registerTool, and connect a StdioServerTransport for a local server or serve it over Streamable HTTP for a remote one. The complete example is near the top of this post.

What language should I use to build an MCP server? Any language with an SDK works. Official SDKs exist for TypeScript, Python, Go, Rust, Kotlin, C#, Java, Ruby, PHP, and Swift. I use TypeScript because it matches the rest of my stack.

Do I need Zod to build an MCP server in TypeScript? The SDK uses Standard Schema, so Zod v4, Valibot, and ArkType all work. The examples here use Zod.

How do I test an MCP server? Run npx @modelcontextprotocol/inspector <your launch command>, connect, and call each tool from the Tools tab. Then add handler-level tests, and only then try it with a model.

Should I use stdio or HTTP? Use stdio for a local server that one host launches. Use Streamable HTTP when the server must be shared across machines or runs as a hosted service. Start with stdio.

Where can I deploy a remote MCP server? Vercel (with mcp-handler in a Next.js route) and Cloudflare Workers both have documented paths. Any host that runs a web-standard fetch handler or a Node server works.

How do I add my MCP server to Claude Code? Run claude mcp add <name> -- <launch command> for a local server, or claude mcp add --transport http <name> <url> for a remote one. See the Claude Code MCP setup guide.