techlifeadventuresVol. 03 · Aug 2026
·14 min read·Development

Building an MCP Server: A Hands-On Walkthrough

You know what MCP is — now build one. A step-by-step TypeScript tutorial from empty folder to working server: tools, resources, transport, and testing.

Note: Details reflect the MCP ecosystem as of August 2026. The spec and its SDKs move fast — check the current SDK docs before relying on exact API names.

A while back I wrote about what MCP actually is and why the "USB-C for AI" framing stuck. That post explained the plug. This one wires it.

The gap between understanding MCP and shipping an MCP 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. My first working server took about twenty minutes, and most of that was me reading docs I could have skipped.

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 rename. Version 1 shipped as the monolithic @modelcontextprotocol/sdk. Version 2 — the current stable line, implementing the 2026-07-28 spec — is @modelcontextprotocol/server.

This matters because most tutorials you will find were written against v1, and the two are not drop-in compatible. The v1 line still exists and still works, so you are not obligated to migrate today. But if you are starting fresh in August 2026, 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 Node.js 22 or later. The SDK itself declares Node 20 as its floor, but the MCP Inspector — the debugging tool we will use later — wants 22.7.5+, so 22 is the practical minimum.

There is no official create-mcp-server scaffold. I looked, because I assumed there would be. Setup is manual, which is honestly 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. TypeScript 6.0 stopped auto-including @types/* packages, 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. This cost me ten minutes the first time.

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 the most common design mistake I see.

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 distinction that took me longest to internalize: 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/server.ts. Here is the minimum viable server with one tool:

typescript
#!/usr/bin/env node
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

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:

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 — use serveStdio:

typescript
import { serveStdio } from '@modelcontextprotocol/server/stdio';

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:

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

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 enterprise 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 UI opens on http://localhost:6274 with a proxy on 6277. 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 just ask: "What changed in the last release?" Watching a model reach into code you wrote ten minutes ago is a genuinely good feeling.

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: the registry is still in preview, and the 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.

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. That is a genuinely new situation, and the developers who notice it first will build the integrations everyone else ends up using.

Go build something small and useful. Then tell people about it.


Related Reading:

Enjoying this article?

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

Share this article
VK

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