AI NewsletterSubscribe →
Resource HubAdvanced

Building Your First MCP Server

A working MCP server in TypeScript — package setup, tool registration, stdio transport, registering it with Claude Code, and the gotchas that bite first-time builders. Uses the official @modelcontextprotocol/sdk.

Larry Maguire

Larry Maguire

GenAI Skills Academy

An MCP server is a small program that exposes tools to Claude Code over a defined protocol. When you connect a server, Claude can call its tools the same way it calls Read, Bash, or Edit — except the tools are yours. A weather server. A bespoke database query tool. A wrapper around your billing system. Anything you can write in code, you can hand to Claude as a tool.

This article walks through building a working MCP server in TypeScript from nothing, registering it with Claude Code, and the gotchas that catch first-time builders. You will end with a server that exposes a tool Claude can call. Once you understand the shape, building richer servers is mostly a question of writing more tool handlers.

What you will build

A stdio MCP server with one tool — greet, which takes a name and returns a greeting. Trivial on purpose: the point is to learn the protocol and the registration flow without getting tangled in business logic. Once it runs, swap the handler for whatever you actually want.

Prerequisites

  • Node.js 18 or later (run node --version to check)
  • Claude Code installed and authenticated
  • A text editor — VS Code is fine, anything with TypeScript awareness is fine

Project setup

Create a folder for the server and initialise a Node project:

mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install --save-dev typescript @types/node
npx tsc --init

The two runtime dependencies are @modelcontextprotocol/sdk (the official TypeScript SDK) and zod (a schema library — the SDK uses it to validate tool inputs).

Open tsconfig.json and set "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", and "outDir": "./dist". Add "type": "module" to package.json so Node treats your output as ES modules.

The server code

Create src/index.ts:

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

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

server.registerTool(
  "greet",
  {
    description: "Greet someone by name",
    inputSchema: z.object({
      name: z.string().describe("The person's name"),
    }),
  },
  async ({ name }) => ({
    content: [{ type: "text", text: `Hello, ${name}!` }],
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);

Five things to notice. First, McpServer is the high-level class — there is also a lower-level Server, but for almost every case you want McpServer. Second, the input schema is a Zod object — the SDK converts it to JSON Schema internally so Claude sees the parameters correctly. Third, the handler is async and returns an object with a content array. Fourth, every content item has a type — usually text, but image and resource are also valid. Fifth, there is no main loop; server.connect(transport) wires up stdio and runs forever until the parent process closes the stream.

Build and test locally

Compile and verify it runs without crashing:

npx tsc
node dist/index.js

You should see — nothing. Stdio servers communicate over stdin/stdout in JSON-RPC, so a healthy server sits silently waiting for messages. If you see a stack trace, something is wrong. If the process exits immediately, your tsconfig probably output CommonJS and Node is rejecting the ES module imports — re-check the settings above.

Press Ctrl+C to stop the server. You don't talk to it directly; Claude Code does that for you.

Register with Claude Code

From any workspace where you want the server available, run:

claude mcp add greeting-server -- node /absolute/path/to/my-mcp-server/dist/index.js

The double dash separates the claude mcp add arguments from the command Claude will run to start the server. Use the absolute path — relative paths break when Claude Code is launched from a different directory.

By default this registers the server in your user-level config at ~/.claude.json, which means it's available in every Claude Code workspace on your machine. If you want it scoped to a single workspace, add it to that workspace's .mcp.json file instead (or use the project scope flag if your CLI version supports one).

Confirm Claude can see it

Open Claude Code in any workspace. Run /mcp — you should see greeting-server in the list, marked connected. Now ask Claude:

Use the greet tool to say hello to Larry.

Claude will call the tool and reply with the result. If you watch /mcp while it runs, you'll see the call logged. That's the whole loop: Claude decides to call the tool, your server runs the handler, the response goes back as a tool result, Claude continues the conversation.

Adding more tools

Each tool is another server.registerTool() call. Define the input schema, write the async handler, return content. A weather tool might call an external API; a database tool might run a SQL query; a workspace tool might read a specific file format. The protocol stays the same — what differs is what your handler does.

For tools with side effects (writing files, calling APIs that cost money, modifying external state), give the tool description a clear declaration of what it does. Claude reads tool descriptions to decide which to call; a vague description leads to over-eager use, and a precise description leads to correct use.

Top five gotchas

  1. Console.log breaks stdio transport. The stdio transport reads and writes JSON-RPC on stdout. If your code does console.log("debug"), you have just written invalid JSON into the protocol stream and Claude will disconnect with a parse error. Use console.error() for any debug output — stderr is not part of the protocol.
  2. Missing or malformed input schema. If you skip inputSchema, Claude has no idea what arguments to pass and will either fail to call the tool or pass garbage. If your Zod schema doesn't compile cleanly to JSON Schema (rare, but happens with some advanced types), the SDK will silently produce something Claude can't reason about. Test by asking Claude to call the tool — if it can't construct an argument list, your schema is the problem.
  3. Unhandled async errors. If your handler throws, the SDK catches it and surfaces an error to Claude — but only if the throw is inside the awaited path. An unhandled promise rejection in a .then() chain crashes the whole server silently. Wrap risky work in try/catch and return { content: [...], isError: true } on failure.
  4. Hardcoded secrets. Never embed API keys in the server source. Pass them via --env when registering — claude mcp add my-server --env API_KEY=sk-... -- node /path/to/server.js. The keys end up in ~/.claude.json on your machine, not in your source code or git history.
  5. Forgetting to rebuild. You change the TypeScript, run Claude — and your changes don't apply. The server runs the compiled JavaScript in dist/, so every change requires npx tsc. A dev script in package.json that runs tsc --watch in another terminal saves a lot of frustration.

Where to go next

Once you have a working server, the things you might want to explore next: HTTP transport (for remote servers reachable over the network); resources and prompts (other MCP primitives, currently exposed less broadly than tools); OAuth-protected remote servers; the official MCP specification for the full protocol surface. The TypeScript SDK README has worked examples for each. Before you connect anyone else's server to your workspace, read the next article — Auditing a Third-Party MCP Server.

GenAI Skills Academy

Achieve Productivity Gains With AI Today

Send me your details and let’s book a 15 min no-obligation call to discuss your needs and concerns around AI.