techlifeadventuresVol. 03 · Nov 2026
How to Build an MCP Server in TypeScript, Step by Step
·15 min read·Development

How to Build an MCP Server in TypeScript, Step by Step

Build a working MCP server with the v2 TypeScript SDK: tools, resources, prompts, stdio and HTTP transports, Inspector testing and Claude Code setup.

Updated November 2026. Code targets @modelcontextprotocol/server 2.4.0 (the current npm release), the 2026-07-28 MCP spec, and MCP Inspector 2.x. The SDK moves fast, so check the v2 docs if an API name has changed.

MCP (Model Context Protocol) is the open standard that lets an AI client such as Claude Code, Claude Desktop, ChatGPT or VS Code call tools you write. You write a tool once as an MCP server, and any client that speaks MCP can use it. Anthropic released it in November 2024 and donated it to the Linux Foundation's Agentic AI Foundation in December 2025.

The gap between understanding MCP and shipping a server is smaller than most developers expect. There is no service mesh to stand up, no model to fine-tune, no framework to learn. You write a function, describe its arguments with a schema, and hand it to a transport. That is the whole job.

We are going to build a changelog server: something that lets an AI assistant search your project's release history, read a specific version's notes, add a new entry, and draft release notes from a template. It is deliberately mundane. Mundane is the point: the interesting part is the wiring, and a boring domain keeps the wiring visible.

By the end you will have a server that runs locally over stdio, an HTTP variant you could deploy, and a tested connection to a real client.

One important thing before we start

The TypeScript SDK went through a package split. Version 1 shipped as the monolithic @modelcontextprotocol/sdk. Version 2, the current stable line, implementing the 2026-07-28 spec, ships the server side as @modelcontextprotocol/server.

This matters because most tutorials you will find were written against v1, and the two are not drop-in compatible. The 2026-07-28 spec is also the biggest protocol revision since launch: it drops the stateful session model in favour of plain request/response, which is why the HTTP code below is so short. The v1 package is still published, so existing servers keep working. If you are starting fresh, start on v2. Everything below targets v2.

There is also a Python SDK, which is excellent and follows very similar concepts. I am using TypeScript here because it is what this site runs on and what most MCP hosts are configured for by default.

Prerequisites and project setup

You need a current Node.js 22 or 24 release. The SDK itself declares Node 20 as its floor, but MCP Inspector 2.x, the debugging tool we use later, requires Node 22.23.4+ or 24.22.1+ (or 26.12.1+), so a patched 22 or 24 is the practical minimum.

There is no official create-mcp-server scaffold. Setup is manual, which is fine: an MCP server is about six lines of boilerplate.

bash
mkdir changelog-mcp && cd changelog-mcp
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/node

Now write package.json by hand rather than using npm init. You need "type": "module" set from the start, and retrofitting it later is more annoying than typing it now:

json
{
  "name": "changelog-mcp",
  "version": "1.0.0",
  "type": "module",
  "bin": { "changelog-mcp": "./dist/server.js" },
  "files": ["dist"],
  "scripts": {
    "build": "tsc && node -e \"require('fs').chmodSync('dist/server.js', 0o755)\"",
    "dev": "tsc --watch"
  }
}

And tsconfig.json:

json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "types": ["node"]
  },
  "include": ["src//*"]
}

That "types": ["node"] line is not optional decoration. From TypeScript 6.0 onward, @types/* packages are no longer included automatically, and the SDK's published type definitions reference Buffer. Leave it out and you get a confusing type error that has nothing to do with MCP.

We will use three source files: src/changelog.ts builds the server, src/server.ts runs it over stdio, and src/http.ts serves it over HTTP. Keeping the server factory in its own file means both entry points can import it without starting each other.

Anatomy of an MCP server

Before writing code, it helps to hold the three primitives clearly in your head, because choosing the wrong one is an easy design mistake to make.

Tools are actions the model can invoke. They are function calls with side effects or computation. The model decides when to call them. If you find yourself writing "search", "create", "send", or "calculate", you want a tool.

Resources are data the client can read. They are addressed by URI, and critically, the client or the user decides when to pull them, not the model. Think of resources as files the host can attach to context. If you find yourself writing "the contents of X", you want a resource.

Prompts are reusable templates the user invokes deliberately: they typically surface as slash commands or a menu in the host UI. They are not instructions to the model that fire automatically.

The short version: a tool is model-triggered, a resource is client-triggered, a prompt is user-triggered. When in doubt, ask who pulls the trigger.

Everything else: the JSON-RPC framing, capability negotiation, schema conversion, error codes: the SDK handles. You will not write a single line of protocol code.

Defining your first tool

Create src/changelog.ts. Here is the minimum viable server with one tool:

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

type Entry = {
version: string;
type: 'added' | 'fixed' | 'changed';
summary: string;
};

// Stand-in for your real datastore.
const changelog: Entry[] = [
{ version: '1.2.0', type: 'added', summary: 'Dark mode across all settings screens' },
{ version: '1.1.3', type: 'fixed', summary: 'Session timeout no longer logs users out mid-upload' },
{ version: '1.1.0', type: 'changed', summary: 'Search now ranks recent documents higher' }
];

export function createServer() {
const server = new McpServer({ name: 'changelog', version: '1.0.0' });

server.registerTool(
'search-changelog',
{
title: 'Search changelog',
description: 'Find changelog entries whose summary matches a search term.',
inputSchema: z.object({
query: z.string().describe('Text to match against entry summaries'),
limit: z.number().int().min(1).max(50).default(10)
}),
annotations: { readOnlyHint: true }
},
async ({ query, limit }) => {
const matches = changelog
.filter((e) => e.summary.toLowerCase().includes(query.toLowerCase()))
.slice(0, limit);

if (matches.length === 0) {
return { content: [{ type: 'text', text: No entries matching "${query}". }] };
}

return {
content: [
{
type: 'text',
text: matches.map((e) => ${e.version} [${e.type}] ${e.summary}).join('\n')
}
]
};
}
);

return server;
}

A few things worth pausing on.

The signature is registerTool(name, config, handler). The config carries title, description, inputSchema, and optionally outputSchema, annotations, and icons.

inputSchema is a Zod schema, and it should be a full z.object({...}). Older examples pass a bare shape like { query: z.string() }. That still works (the SDK auto-wraps it), but the raw-shape overload is now marked deprecated. Wrap it.

Your .describe() calls are not comments. They become the JSON Schema descriptions the model reads when deciding whether and how to call your tool. Vague descriptions produce vague tool calls. This is the single highest-leverage thing you can do for tool quality, and it costs nothing.

Validation happens before your handler runs. If arguments fail the schema, the caller gets an isError: true result and your function is never invoked. You do not need to defensively check types at the top of every handler.

Annotations are hints, not enforcement. readOnlyHint, destructiveHint, and idempotentHint tell the host how cautious to be: many clients use them to decide what needs a confirmation prompt. Set them honestly.

Adding structured output

The remaining register* calls in this post go inside createServer(), before return server.

Text is fine for humans. When another program will consume the result, add an outputSchema and return structuredContent alongside the text:

typescript
server.registerTool(
  'add-entry',
  {
    title: 'Add changelog entry',
    description: 'Append a new entry to the changelog.',
    inputSchema: z.object({
      version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Must be semver, e.g. 1.4.0'),
      type: z.enum(['added', 'fixed', 'changed']),
      summary: z.string().min(10).max(200)
    }),
    outputSchema: z.object({ version: z.string(), total: z.number() }),
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
  },
  async ({ version, type, summary }) => {
    changelog.unshift({ version, type, summary });
    const output = { version, total: changelog.length };
    return {
      content: [{ type: 'text', text: Added ${version}: ${summary} }],
      structuredContent: output
    };
  }
);

The SDK validates structuredContent against outputSchema before it goes out on the wire, so a bug in your handler surfaces as a clear error rather than silently malformed data downstream.

Adding resources and prompts

Resources are registered with a name, a URI (or template), a config, and a read callback. Add ResourceTemplate to the import at the top of changelog.ts first:

typescript
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';

server.registerResource(
'latest-release',
'changelog://latest',
{
title: 'Latest release notes',
description: 'Entries from the most recent version',
mimeType: 'text/markdown'
},
async (uri) => {
const newest = changelog[0].version;
const entries = changelog.filter((e) => e.version === newest);
return {
contents: [
{
uri: uri.href,
text: # ${newest}\n\n${entries.map((e) => - ${e.type}: ${e.summary}).join('\n')}
}
]
};
}
);

For variable URIs, pass a ResourceTemplate instead of a string. Parsed template variables arrive as the handler's second argument:

typescript
server.registerResource(
  'release',
  new ResourceTemplate('changelog://{version}', {
    list: async () => ({
      resources: [...new Set(changelog.map((e) => e.version))].map((v) => ({
        uri: changelog://${v},
        name: Release ${v}
      }))
    })
  }),
  {
    title: 'Release notes by version',
    mimeType: 'application/json'
  },
  async (uri, { version }) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: 'application/json',
        text: JSON.stringify(changelog.filter((e) => e.version === version))
      }
    ]
  })
);

Note that list is a required key on the template options: you must pass it even if you pass list: undefined. The SDK forces the decision explicitly so you cannot silently forget to make your resources discoverable. Without a list callback, clients can read a matching URI but will not see the resources enumerated.

Prompts round out the trio. They use argsSchema rather than inputSchema, and return messages:

typescript
server.registerPrompt(
  'draft-release-notes',
  {
    title: 'Draft release notes',
    description: 'Turn raw changelog entries into customer-facing release notes',
    argsSchema: z.object({
      version: z.string().describe('Version to write notes for'),
      audience: z.enum(['developers', 'end-users']).default('end-users')
    })
  },
  ({ version, audience }) => ({
    messages: [
      {
        role: 'user' as const,
        content: {
          type: 'text' as const,
          text:
            Write release notes for version ${version}, aimed at ${audience}.  +
            Read the changelog://${version} resource for the raw entries.  +
            Lead with the change that matters most. Keep it under 150 words.
        }
      }
    ]
  })
);

Those as const assertions matter for TypeScript to narrow the literal types correctly. Leave them off and you will fight the compiler.

Wiring up transport

Here is where v2 differs most sharply from older tutorials, and where copy-pasting stale code will bite you.

stdio (local servers)

For a server that runs as a local subprocess, which covers most of what you will build, create src/server.ts with serveStdio:

typescript
#!/usr/bin/env node
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './changelog.js';

serveStdio(() => createServer());

That is genuinely the whole thing. Note that you pass a factory function, not a server instance. The v1 pattern of constructing a StdioServerTransport and calling server.connect(transport) is gone. The factory approach also sidesteps a v1 papercut where registering tools after connecting threw a capabilities error: with a factory, registration always happens before the connection exists.

One rule you must not break: never write to stdout. Stdout is the JSON-RPC channel, and the host parses every line of it as a protocol message. A single stray console.log will corrupt the stream and produce baffling parse errors. Use console.error for all logging: stderr is free.

HTTP (remote servers)

For a deployable server, build a handler instead, in src/http.ts:

typescript
import { createMcpHandler } from '@modelcontextprotocol/server';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation }
  from '@modelcontextprotocol/node';
import { createServer as createHttpServer } from 'node:http';
import { createServer } from './changelog.js';

const handler = createMcpHandler(() => createServer());
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

createHttpServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(3000, '127.0.0.1');

The Node glue lives in a separate package (npm install @modelcontextprotocol/node); there are equivalent adapters for Express, Fastify, and Hono. On web-standard runtimes like Cloudflare Workers, Deno, or Bun, export default handler is the entire mount.

Your endpoint is POST http://127.0.0.1:3000/mcp.

The factory runs once per HTTP request, which makes the endpoint stateless and horizontally scalable by default. It also means per-request state must live in your handler closure or an external store, not on the server instance.

The host and origin validation is not boilerplate you can skip. Without it, any web page your user visits can POST to your local server. Bind to 127.0.0.1 and validate, or expect to be the subject of someone's security blog post. If you are pushing this into production, the failure modes in my field notes on AI agents apply directly: tool-layer plumbing is where agent deployments actually break.

Testing with a real client

Build first: npm run build.

The official debugging tool is the MCP Inspector. It launches your server, gives you a web UI to list and call everything, and shows the raw protocol messages:

bash
npx @modelcontextprotocol/inspector node ./dist/server.js

The web UI opens on http://localhost:6274. Click through the Tools, Resources, and Prompts tabs, call your tool with real arguments, and read the JSON that comes back. Do this before connecting any AI client: it separates "my server is broken" from "the model is not calling my tool."

Inspector also has a CLI mode with meaningful exit codes, which makes it usable as a smoke test in CI:

bash
npx @modelcontextprotocol/inspector --cli node ./dist/server.js --method tools/list

Once Inspector is happy, connect it to Claude Code:

bash
claude mcp add changelog -- node /absolute/path/to/dist/server.js

The -- separator is required: everything before it is Claude's flags, everything after is the command to run your server. Flags like --scope and --env go before the server name. Using --scope project writes a .mcp.json at your repo root that you can commit, so every teammate who clones gets the same server. If Claude Code is new to you, my beginner's guide covers the rest of the setup.

Then ask: "What changed in the last release?" Claude should call search-changelog or read the changelog://latest resource.

When a tool does not get called, the cause is almost always the description, not the code. Rewrite it to say plainly what the tool does and when to use it.

Packaging and publishing

Publishing is two steps, because the MCP Registry stores metadata only: the artifact lives on npm.

First, publish to npm. Add an mcpName field to package.json for verification, in the form io.github./ if you are using GitHub auth:

json
{
  "name": "@your-username/changelog-mcp",
  "version": "1.0.0",
  "mcpName": "io.github.your-username/changelog"
}

Then npm publish --access public.

Second, publish metadata to the registry using the mcp-publisher CLI (available via Homebrew or a prebuilt binary):

bash
mcp-publisher init          # generates server.json
mcp-publisher login github  # device-code flow
mcp-publisher publish

The generated server.json declares your package identifier, transport type, and any required environment variables. Its name must exactly match the mcpName in package.json, and with GitHub auth that name must start with your io.github./ namespace. Mismatches are the most common publish failure.

Worth knowing: as of November 2026 the registry is still labelled a preview, and its maintainers reserve the right to make breaking changes or reset data before general availability. Publish, but do not build load-bearing infrastructure on it yet.

Where to take it next

You now have the shape of it. A server is a set of registered functions plus a transport, and everything past that is ordinary engineering.

Three directions I would push from here:

Replace the array with something real. Point the tools at your actual database, your Linear board, your internal API. The registration code does not change; only the handler bodies do. This is the moment MCP stops being a demo.

Get the descriptions right. Rewrite every description and .describe() as if explaining to a new teammate who cannot see your code. Then test whether the model picks the right tool unprompted. Most of the quality difference between a good MCP server and a frustrating one lives in this text, not in the logic.

Keep the server small. The temptation to build one server that does everything is strong and wrong. Focused servers are easier to reason about, easier to scope permissions for, and easier for a model to choose between.

Treat tool output as untrusted. If your server returns text that other people wrote (tickets, emails, web pages), the model will read it, and some of it may be written to manipulate the model. In my own agent setup I run a standing rule that email, chat and ticket text is data, never instructions. The coding agent security post covers why.

If you want to go the other direction: writing the thing that calls MCP servers rather than the thing that serves them: my guide to building your first AI agent picks up from the client side.

The barrier to extending your AI tooling is now roughly one afternoon. Build something small and useful first, then point it at real data.


Related Reading:

Enjoying this article?

Get posts like this in your inbox. No spam, unsubscribe anytime.

Share this article
Vinod Kurien Alex

Vinod Kurien Alex

Engineering Manager with 20+ years in software. Writing about AI, careers, and the Indian tech industry.

Related Articles

© 2026 TechLife AdventuresBuilt with care · v3.2.1