Build MCP Server TypeScript: Complete Tutorial with Claude

March 30, 2026 · 14 min read · mcp, typescript, claude, llm-tools, anthropic
Build MCP Server TypeScript: Complete Tutorial with Claude

Most teams do not need a custom MCP server. If you have one LLM app, one integration, and one codebase, calling the vendor API directly is faster to ship and easier to debug. The moment you have two Claude surfaces (Claude Desktop plus Claude Code, or Claude Code plus Cursor) hitting the same internal system, you stop duplicating tool code. That is when you build MCP server TypeScript projects worth maintaining.

This is an end-to-end build. You will scaffold a Node.js server with the official SDK, wire up stdio transport, validate input with zod, handle errors the way the protocol expects, connect it to Claude Desktop and Claude Code, debug with the MCP Inspector, and run it under systemd in production. The example server caches fetched URLs and exposes four tools. I run one of these in production that wraps TickTick’s API, so the patterns below are the patterns I use when something has to survive a cron loop at 6:30 AM every day.

No fluff, no toy code. Copy-paste, build, run.

What an MCP server actually is

MCP is the Model Context Protocol, Anthropic’s open spec for how an LLM client talks to external capabilities. An MCP server exposes three things to a client: tools (functions the model can call), resources (documents or data the model can read), and prompts (templated message flows the user can trigger). The transport layer is either stdio (a local child process speaking JSON-RPC over stdin/stdout) or SSE/HTTP (a remote server speaking JSON-RPC over server-sent events).

The model does not see your code. It sees a list of tool names with descriptions and JSON Schema input definitions. When it decides to call one, the client forwards the call to your server, your server runs the handler, and you return a result. That is it. The protocol is small, the SDK is thin, and the whole thing fits in a single process.

For a deeper conceptual overview, see MCP servers explained. For the decision tree on whether you even need one, MCP vs custom API integration covers the tradeoffs.

When building one beats calling an API directly

Here is my rule. Build an MCP server when at least two of these are true:

  1. You use Claude Code or Claude Desktop heavily, and you want the assistant to reach the same internal system (your issue tracker, your deploy pipeline, your customer DB) from any session without copy-pasting API keys.
  2. Multiple LLM clients need the same capability. Claude Desktop for ad-hoc queries, Claude Code for coding, maybe Cursor for a teammate. Without MCP you write the tool handler three times.
  3. The integration is long-lived. If it will still exist in six months, the MCP wrapper pays for itself in reduced duplication.
  4. You want local-only execution. stdio transport runs your server as a child process of the client, with access to local files and local networks. No webhook, no public URL, no auth layer to build.

Skip it when you have one Python script calling the Claude API with inline tool definitions. Wrapping that in MCP adds a process boundary and a protocol layer for zero benefit. Claude API tool use covers the direct-call pattern, which is still the right default for single-app use cases.

Counterexample from my own stack: I run a TickTick MCP server as a systemd service because the same tool surface gets used by Claude Code (for daily task triage), by a CLI called tt (for shell automation), and by a Telegram bot (for mobile capture). Three clients, one server, one tool definition per action. Build once, wire everywhere.

Scaffolding a TypeScript server

Create a new project. I am using Node 20 LTS and TypeScript 5.4.

mkdir url-cache-mcp && cd url-cache-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

Edit package.json. Add the module type, the bin entry, and scripts.

{
  "name": "url-cache-mcp",
  "version": "0.1.0",
  "type": "module",
  "bin": {
    "url-cache-mcp": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0",
    "zod": "^3.23.0"
  },
  "devDependencies": {
    "@types/node": "^20.11.0",
    "tsx": "^4.7.0",
    "typescript": "^5.4.0"
  }
}

Now tsconfig.json. Node16 module resolution is the critical part, the SDK uses explicit .js extensions in its exports map.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": false,
    "sourceMap": true
  },
  "include": ["src/**/*"]
}

Create src/index.ts with a minimal server that starts, announces itself, and exits cleanly on SIGINT. Add the shebang so the compiled output can run as a binary.

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
  {
    name: "url-cache-mcp",
    version: "0.1.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("url-cache-mcp ready on stdio");
}

main().catch((err) => {
  console.error("fatal:", err);
  process.exit(1);
});

Two things to notice. First, logs go to stderr, never stdout. Stdout is the protocol channel. Anything you write there corrupts the JSON-RPC stream and Claude Desktop will silently drop your server. Second, the capabilities object declares what this server offers. Tools-only is the common case.

Run npm run build && node dist/index.js. It will hang waiting for a client on stdin. That is correct. Kill it with Ctrl-C.

Writing the first tool

The example server caches URL fetches. Four tools: fetch_url, list_cache, clear_cache, search_cache. I will walk the first one end to end, then show the rest compactly.

Tool definitions happen in two places. You advertise the tool schema in ListTools, and you run the handler in CallTool. I define schemas with zod and convert to JSON Schema at advertise time. This gives you runtime validation on the handler side for free.

Add a cache module at src/cache.ts.

type CacheEntry = {
  url: string;
  content: string;
  fetchedAt: number;
  bytes: number;
};

const cache = new Map<string, CacheEntry>();

export function put(url: string, content: string): CacheEntry {
  const entry: CacheEntry = {
    url,
    content,
    fetchedAt: Date.now(),
    bytes: Buffer.byteLength(content, "utf8"),
  };
  cache.set(url, entry);
  return entry;
}

export function get(url: string): CacheEntry | undefined {
  return cache.get(url);
}

export function list(): CacheEntry[] {
  return Array.from(cache.values()).sort(
    (a, b) => b.fetchedAt - a.fetchedAt
  );
}

export function remove(url: string): boolean {
  return cache.delete(url);
}

export function clearAll(): number {
  const n = cache.size;
  cache.clear();
  return n;
}

export function search(query: string): CacheEntry[] {
  const q = query.toLowerCase();
  return list().filter(
    (e) =>
      e.url.toLowerCase().includes(q) ||
      e.content.toLowerCase().includes(q)
  );
}

Now the tool schemas. Add src/tools.ts.

import { z } from "zod";

export const FetchUrlInput = z.object({
  url: z.string().url().describe("Absolute HTTP or HTTPS URL to fetch"),
  force: z
    .boolean()
    .optional()
    .describe("Bypass cache and refetch (default false)"),
});

export const ListCacheInput = z.object({});

export const ClearCacheInput = z.object({
  url: z
    .string()
    .url()
    .optional()
    .describe("Clear only this URL. Omit to clear everything."),
});

export const SearchCacheInput = z.object({
  query: z.string().min(1).describe("Substring match on URL or page text"),
});

Zod schemas are great for runtime, but the MCP protocol wants JSON Schema in the tool advertisement. Add a tiny converter helper. You can use zod-to-json-schema if you want to skip this, but the hand-rolled shapes below are clearer for a tutorial.

export const toolDefinitions = [
  {
    name: "fetch_url",
    description:
      "Fetch the text content of a URL. Results are cached in-process. Returns the page text, content type, and whether the result came from cache.",
    inputSchema: {
      type: "object",
      properties: {
        url: {
          type: "string",
          format: "uri",
          description: "Absolute HTTP or HTTPS URL to fetch",
        },
        force: {
          type: "boolean",
          description: "Bypass cache and refetch (default false)",
        },
      },
      required: ["url"],
    },
  },
  {
    name: "list_cache",
    description: "List all cached URLs with fetch timestamp and byte size.",
    inputSchema: { type: "object", properties: {} },
  },
  {
    name: "clear_cache",
    description:
      "Clear one cached URL, or all cached URLs if no URL is given.",
    inputSchema: {
      type: "object",
      properties: {
        url: {
          type: "string",
          format: "uri",
          description: "URL to clear. Omit to clear everything.",
        },
      },
    },
  },
  {
    name: "search_cache",
    description:
      "Substring search across cached URLs and page text. Case insensitive.",
    inputSchema: {
      type: "object",
      properties: {
        query: {
          type: "string",
          description: "Substring to match on URL or content",
        },
      },
      required: ["query"],
    },
  },
];

Now wire the handlers in src/index.ts. Replace the file contents with this.

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ErrorCode,
  ListToolsRequestSchema,
  McpError,
} from "@modelcontextprotocol/sdk/types.js";
import * as cache from "./cache.js";
import {
  ClearCacheInput,
  FetchUrlInput,
  ListCacheInput,
  SearchCacheInput,
  toolDefinitions,
} from "./tools.js";

const server = new Server(
  { name: "url-cache-mcp", version: "0.1.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: toolDefinitions,
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  try {
    switch (name) {
      case "fetch_url": {
        const input = FetchUrlInput.parse(args);
        const cached = cache.get(input.url);
        if (cached && !input.force) {
          return {
            content: [
              {
                type: "text",
                text: JSON.stringify(
                  {
                    url: cached.url,
                    bytes: cached.bytes,
                    fromCache: true,
                    content: cached.content,
                  },
                  null,
                  2
                ),
              },
            ],
          };
        }
        const res = await fetch(input.url, {
          headers: { "User-Agent": "url-cache-mcp/0.1" },
        });
        if (!res.ok) {
          throw new McpError(
            ErrorCode.InternalError,
            `HTTP ${res.status} ${res.statusText} for ${input.url}`
          );
        }
        const text = await res.text();
        const entry = cache.put(input.url, text);
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(
                {
                  url: entry.url,
                  bytes: entry.bytes,
                  fromCache: false,
                  content: entry.content,
                },
                null,
                2
              ),
            },
          ],
        };
      }

      case "list_cache": {
        ListCacheInput.parse(args ?? {});
        const items = cache.list().map((e) => ({
          url: e.url,
          bytes: e.bytes,
          fetchedAt: new Date(e.fetchedAt).toISOString(),
        }));
        return {
          content: [
            { type: "text", text: JSON.stringify(items, null, 2) },
          ],
        };
      }

      case "clear_cache": {
        const input = ClearCacheInput.parse(args ?? {});
        if (input.url) {
          const ok = cache.remove(input.url);
          return {
            content: [
              {
                type: "text",
                text: ok
                  ? `Cleared ${input.url}`
                  : `Not in cache: ${input.url}`,
              },
            ],
          };
        }
        const n = cache.clearAll();
        return {
          content: [{ type: "text", text: `Cleared ${n} entries` }],
        };
      }

      case "search_cache": {
        const input = SearchCacheInput.parse(args);
        const hits = cache.search(input.query).map((e) => ({
          url: e.url,
          bytes: e.bytes,
          snippet: snippet(e.content, input.query),
        }));
        return {
          content: [
            { type: "text", text: JSON.stringify(hits, null, 2) },
          ],
        };
      }

      default:
        throw new McpError(
          ErrorCode.MethodNotFound,
          `Unknown tool: ${name}`
        );
    }
  } catch (err) {
    if (err instanceof McpError) throw err;
    if (err instanceof Error) {
      throw new McpError(
        ErrorCode.InvalidParams,
        `${name} failed: ${err.message}`
      );
    }
    throw new McpError(ErrorCode.InternalError, `${name} failed`);
  }
});

function snippet(text: string, query: string, radius = 80): string {
  const idx = text.toLowerCase().indexOf(query.toLowerCase());
  if (idx === -1) return "";
  const start = Math.max(0, idx - radius);
  const end = Math.min(text.length, idx + query.length + radius);
  return (start > 0 ? "..." : "") + text.slice(start, end) + (end < text.length ? "..." : "");
}

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("url-cache-mcp ready on stdio");
}

main().catch((err) => {
  console.error("fatal:", err);
  process.exit(1);
});

Build it with npm run build. You now have a working MCP server at dist/index.js.

Error handling and validation

The pattern above is the one to copy. Three rules.

Throw McpError, not plain Error. The SDK serializes McpError into a proper JSON-RPC error response. A plain throw becomes a generic server crash from the client’s perspective, and Claude sees nothing useful.

Parse with zod at the handler boundary. Zod catches schema drift, coerces types, and gives you typed inputs after .parse(). If zod throws, the outer catch converts it to InvalidParams, which is what the model expects when it sent bad arguments.

Never swallow errors silently. Return them. The model is perfectly capable of reading “HTTP 404 for https://example.com/missing" and retrying or giving up. Eating the error and returning an empty result teaches the model your tool is unreliable.

One extra pattern I use in production: when a tool can partially succeed, return a structured response with both the success payload and any warnings, rather than throwing. The model reasons better with structured state than with binary success/failure.

Running it locally with Claude Desktop and Claude Code

For Claude Desktop, edit ~/Library/Application Support/Claude/claude_desktop_config.json on macOS or the equivalent on Windows. Add an mcpServers entry.

{
  "mcpServers": {
    "url-cache": {
      "command": "node",
      "args": ["/absolute/path/to/url-cache-mcp/dist/index.js"]
    }
  }
}

Restart Claude Desktop. The tools appear in the tools menu. Ask it to fetch a URL and you will see the call.

For Claude Code, edit ~/.claude.json. Same shape.

{
  "mcpServers": {
    "url-cache": {
      "command": "node",
      "args": ["/absolute/path/to/url-cache-mcp/dist/index.js"]
    }
  }
}

Or use the CLI: claude mcp add url-cache node /absolute/path/to/dist/index.js.

Claude Code reloads MCP config on session start. Open a new session and the tools are available. In a Claude Code session, the model will autonomously call fetch_url if you ask it to summarize a web page. That is the whole point of the Claude mcp integration path, you stop pasting URLs into context and let the model pull them.

For a fuller picture of agent patterns layered on top of MCP, see Claude Code SDK agents.

Running it in production

Local stdio is enough for 90% of single-user cases. When you need something that runs continuously, serves multiple clients, or survives reboots, move it under a process manager. I run my TickTick MCP server as a systemd service on a small Debian VPS. The unit file is 15 lines, the server has been up for months, and restarting after a code edit is one command.

A minimal unit file at /etc/systemd/system/url-cache-mcp.service:

[Unit]
Description=URL Cache MCP Server
After=network.target

[Service]
Type=simple
User=debian
WorkingDirectory=/home/user/url-cache-mcp
ExecStart=/usr/bin/node /home/user/url-cache-mcp/dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target

Then sudo systemctl daemon-reload && sudo systemctl enable --now url-cache-mcp. To tail logs: journalctl -u url-cache-mcp -f.

A caveat. Stdio transport runs as a child of the client, so a systemd-managed stdio server is not directly useful unless you front it with something that multiplexes stdio connections. In practice, production MCP servers either stay as locally-spawned stdio processes per session, or they run as SSE/HTTP servers behind auth. The SSE transport is documented in the SDK and follows the same handler pattern shown above, only the transport line changes.

Debugging with the MCP Inspector

The single best debugging tool is the Inspector. Run it against your built server.

npx @modelcontextprotocol/inspector node dist/index.js

This opens a local web UI. You can see every tool your server advertises, invoke them manually with arbitrary JSON arguments, and watch the request/response frames in real time. Any time a tool is misbehaving in Claude Desktop, I drop the Inspector in front of it first. The problem is almost always a schema mismatch or a log line going to stdout.

For integration tests from a script, you can also call the server over SSE JSON-RPC using supergateway as a stdio-to-SSE bridge. I use that pattern to hit my TickTick MCP server from shell scripts without spawning a full Node child process each time. The point is the same, the protocol is just JSON-RPC, so any client that speaks it works.

Security

One thing teams miss. An MCP server runs with the full privileges of the user that started it. If Claude Desktop runs as your user, your MCP server can read ~/.ssh, delete files in your home directory, and hit any internal URL your machine can reach. The model decides when to call a tool, and a prompt injection in a fetched web page can absolutely convince it to call one with adversarial arguments.

Three defenses worth building in from day one.

  1. Validate every input hard. Zod is not optional. URL schemes, path whitelists, argument length limits. A fetch_url that accepts file:///etc/passwd is a bug.
  2. Rate-limit and cap resource use. A model in a loop can call fetch_url 200 times in a minute. Add a per-tool counter and return an error past the threshold.
  3. Never expose a remote MCP server without auth. SSE transport with no bearer token is a backdoor. If you must ship remote, put a reverse proxy with HTTP auth in front and rotate tokens.

The safer default for most teams is stdio-only and local-only, with any external API keys loaded from environment variables the server reads on startup. That is the posture I run in.

Should you build or wrap?

Here is the verdict, straight.

Build an MCP server when: you use Claude Code or Claude Desktop daily, you have more than one LLM client hitting the same internal system, the integration is long-lived (six months plus), and the wrapped API is something you control or trust.

Skip it and call the API directly when: you have one script, one workflow, one LLM entry point. Wrapping a single-use call in MCP adds a process boundary and a protocol layer for zero benefit. Claude API tool use shows the direct pattern.

Use someone else’s MCP server when: one already exists for the system you want to integrate (GitHub, Postgres, filesystem, Slack). The ecosystem has grown fast, and rewriting a published server to shave 50 lines is rarely worth the maintenance.

Think carefully when: you are considering whether MCP is even the right abstraction. For some internal integrations, a small REST API or a Claude-API-native tool definition is enough. The build-vs-buy calculus matters here and I cover it in AI agents build vs buy.

Most of the value from MCP is not the protocol itself, it is the discipline of defining your tools once, cleanly, with real schemas and real error types. Even if you never connect a second client, that exercise tends to produce better integration code than the inline tools=[...] array you would have written otherwise.

The tutorial above gives you a runnable server in under 200 lines. If you build one thing with this, build a wrapper around the one internal system you paste into Claude the most. You will use it every day.

Download the AI Automation Checklist (PDF)