intermediateHow-toPrimary10 min read

Building an MCP Server in TypeScript: Complete Walkthrough

Overview

Building an MCP Server in TypeScript: Complete Walkthrough When I first set out to build a custom Model Context Protocol (MCP) server for Claude Desktop last year, I cut corners and used plain JavaScript. I’d heard about MCP, Anthropic’s open standard for conn

Key Concepts

  • Node.js 18 or higher (MCP requires modern Node features that aren’t supported in older versions)
  • npm or yarn for package management
  • A code editor with TypeScript support (I use VS Code, which works great out of the box)
  • Claude Desktop (if you want to test your server locally, which I recommend for this walkthrough)

When I first set out to build a custom Model Context Protocol (MCP) server for Claude Desktop last year, I cut corners and used plain JavaScript. I’d heard about MCP, Anthropic’s open standard for connecting external tools and data sources to AI clients, and I just wanted something that worked quickly to let me pull my saved development bookmarks into conversations with Claude. I was in a hurry to test a new workflow where I’d ask Claude to recommend relevant resources based on what I was working on, so I ignored the extra step of setting up TypeScript. I told myself it was just a small personal tool, type safety wouldn’t matter that much.

That decision came back to bite me three days later when I decided to add tagging to my bookmarks to make filtering easier. I added the `tags` array to my JSON schema, updated the search function, and restarted Claude. But every time I tried to search for bookmarks by tag, I got empty results. No error messages in the Claude UI, no obvious crashes in the MCP logs, just nothing. I spent an hour first assuming the problem was on Claude’s end: I reloaded the app three times, double-checked my schema against Anthropic’s documentation, even regenerated the JSON schema for the tools a half-dozen times. I thought I’d messed up how I passed schema descriptions to the client, so I rewrote half the server, still nothing. It wasn’t until I started adding console logs line by line to my handler that I realized I’d mistyped `tags` as `tga` in one of my filter functions. The code ran fine, because JavaScript doesn’t care about typos in object properties, it just returned an empty array every time. Two hours of my life gone for a one-character typo. That experience sold me on building all future MCP servers with TypeScript, and in this walkthrough, I’ll show you exactly how to build, test, and publish a production-ready MCP server for your own AI tools that avoids all the mistakes I made.

MCP isn’t just for Claude Desktop, either. As more AI clients adopt the open standard, your MCP server will work out of the box on any compatible client, from open-source local clients to hosted AI services. In this guide, we’ll build a fully functional bookmark manager MCP server that lets you add, list, and search your bookmarks from any MCP-compatible AI client.

Why TypeScript for MCP?

Before we jump into code, let’s talk about why TypeScript is the best choice for MCP development, and what tradeoffs you’re signing up for when you choose it over plain JavaScript.

First, the official `@modelcontextprotocol/sdk` is written in TypeScript, so you get first-class support for types out of the box, no extra `@types` packages needed. The biggest benefit, though, is type safety between your tool schemas and your implementation. MCP requires every tool to expose a schema that describes what inputs it accepts, and that schema is what the AI client uses to generate correctly formatted calls to your tool. If your implementation doesn’t match your schema, you get silent failures like the typo I ran into earlier. TypeScript eliminates that entire category of bugs by inferring the types of your handler inputs directly from your schema, so any mismatch is caught before you ever run the code.

Beyond that, you get full autocomplete and inline documentation right in your code editor. I don’t have to keep the MCP docs open in a second tab to remember what properties a tool response needs, VS Code just tells me as I type, and flags if I’m missing a required field. That speeds up development even more than plain JavaScript, once you get past the initial setup.

Another big benefit is that TypeScript forces you to be explicit about error states. MCP servers for local use communicate over stdio, which means there’s no outer framework to catch unhandled errors. A single uncaught exception will crash the entire server, and the client will just hang without any clear explanation of what went wrong. TypeScript’s strict mode pushes you to handle edge cases like missing files, invalid inputs, and permission errors before you ever run the code, which makes your server far more reliable.

Of course, TypeScript isn’t free. The biggest tradeoff is the extra build step: you can’t run your source code directly, you have to compile it to JavaScript first. You also need a little extra boilerplate up front to configure TypeScript correctly, which can feel overwhelming for small one-off tools. Another common tradeoff is the learning curve if you’re not already comfortable with TypeScript’s more advanced features, like type inference from Zod schemas. When I first started, I spent time confused about why my inferred types didn’t match my schema, because I messed up the `z.infer` syntax. That’s a small hurdle, but it’s something to be aware of if you’re new to TypeScript-first development with Zod.

If you’re building a tiny 2-function tool that you’ll use once to pull data from a personal API and never touch again, the 10 minutes you spend setting up TypeScript might not be worth the saved debugging time. But for any tool you plan to share, maintain for more than a week, or use regularly, the time you spend setting up TypeScript pays off many times over in reduced debugging time.

Prerequisites

Before we start, make sure you have these installed on your machine:

  • Node.js 18 or higher (MCP requires modern Node features that aren’t supported in older versions)
  • npm or yarn for package management
  • A code editor with TypeScript support (I use VS Code, which works great out of the box)
  • Claude Desktop (if you want to test your server locally, which I recommend for this walkthrough)

Step 1: Project Setup

Let’s start by creating a new project and installing all the dependencies we need. Open your terminal and run these commands:

```bash

mkdir mcp-bookmark-server

cd mcp-bookmark-server

npm init -y

npm install @modelcontextprotocol/sdk zod

npm install --save-dev typescript @types/node tsup jest ts-jest @types/jest

```

We’re using `zod` for schema validation because it lets us write one schema that gives us both TypeScript types and runtime input validation, which is perfect for MCP. Runtime validation is non-negotiable for MCP: inputs come over the wire from the client, so you can’t trust that they match your TypeScript types even if you have compile-time checks. Zod solves that problem in one line of code, no duplicate code required.

Next, let’s configure TypeScript. Create a `tsconfig.json` file in your project root with this content:

```json

{

"compilerOptions": {

"target": "ES2022",

"module": "NodeNext",

"moduleResolution": "NodeNext",

"outDir": "./dist",

"rootDir": "./src",

"strict": true,

"esModuleInterop": true,

"skipLibCheck": true,

"forceConsistentCasingInFileNames": true,

"declaration": true

},

"include": ["src/**/*"],

"exclude": ["node_modules", "dist"]

}

```

Add `"type": "module"` to your `package.json` to tell Node.js we’re using ES Modules, which is required for the official MCP SDK. Remember that gotcha I’ll talk more about later? If you forget this step and use CommonJS, you’ll run into cryptic module resolution errors later. We already configured TypeScript to output ES Modules with `module: NodeNext`, so this just aligns Node.js with that configuration.

Create a `src` folder for our source code, that’s where all our TypeScript will live.

Step 2: Define Types and Tool Schemas

Now that our project is set up, let’s define our core data types and tool schemas. Remember, with Zod and TypeScript, we only write the schema once, and TypeScript infers the types for us automatically. That means no duplicate code between our runtime schema and our static types.

Create `src/schemas.ts` with this code:

```typescript

import { z } from "zod";

// Define the shape of a bookmark

export const BookmarkSchema = z.object({

url: z.string().url(),

title: z.string(),

description: z.string().optional(),

tags: z.array(z.string()).default([]),

createdAt: z.coerce.date(),

});

// Infer TypeScript type from the schema - no duplicate code!

export type Bookmark = z.infer<typeof BookmarkSchema>;

// Schema for the addBookmark tool input

export const AddBookmarkInputSchema = z.object({

url: z.string().url("Must be a valid URL"),

title: z.string().min(1, "Title is required"),

description: z.string().optional(),

tags: z.array(z.string()).optional().default([]),

});

// Schema for the listBookmarks tool input

export const ListBookmarksInputSchema = z.object({

tag: z.string().optional().describe("Filter bookmarks by a single tag"),

});

// Schema for the searchBookmarks tool input

export const SearchBookmarksInputSchema = z.object({

query: z.string().min(1, "Search query is required").describe(

"Search term to match against bookmark title, URL, or description"

),

});

export type AddBookmarkInput = z.infer<typeof AddBookmarkInputSchema>;

export type ListBookmarksInput = z.infer<typeof ListBookmarksInputSchema>;

export type SearchBookmarksInput = z.infer<typeof SearchBookmarksInputSchema>;

```

The tradeoff here is that we’re adding Zod as a dependency, but the alternative is writing TypeScript interfaces manually and then writing a separate runtime validation function, which is more code and more room for error. For 99% of MCP servers, Zod is worth the extra 8kb of bundle size.

One thing to notice: we add descriptions to our schema fields. MCP passes these descriptions to the AI client, which helps the LLM understand when and how to use each tool. That’s a small detail that makes a huge difference in how well your tools work. LLMs work best with clear, explicit descriptions of what each field and tool does, so don’t skip this step even if it feels redundant.

Step 3: Build the MCP Server Implementation

Now let’s write the core server code. We’ll store bookmarks in a JSON file in your home directory, which keeps things simple for a local tool. This is great for personal use because it doesn’t require any external services, API keys, or internet access beyond what your browser already has. The tradeoff is that your bookmarks won’t sync across multiple devices, but we’ll talk about how to change that later in the actionable next steps.

We’ll add error handling for common edge cases like corrupted JSON files, permission errors, and invalid inputs. Create `src/index.ts` with this code:

```typescript

#!/usr/bin/env node

import { Server } from "@modelcontextprotocol/sdk/server/index.js";

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

import { z } from "zod";

import fs from "fs/promises";

import os from "os";

import path from "path";

import {

AddBookmarkInputSchema,

Bookmark,

BookmarkSchema,

ListBookmarksInputSchema,

SearchBookmarksInputSchema,

} from "./schemas.js";

// Path to store bookmarks in user's home directory

const BOOKMARKS_FILE_PATH = path.join(os.homedir(), ".mcp-bookmarks.json");

// Helper to read and parse bookmarks from disk

export async function readBookmarks(): Promise<Bookmark[]> {

try {

const rawData = await fs.readFile(BOOKMARKS_FILE_PATH, "utf8");

const parsed = JSON.parse(rawData);

// Validate the data matches our schema at runtime

return z.array(BookmarkSchema).parse(parsed);

} catch (error) {

// If file doesn't exist or is corrupted, return empty array

if ((error as NodeJS.ErrnoException).code === "ENOENT") {

return [];

}

// Re-throw other errors (like permission issues) to be handled by the caller

throw error;

}

}

// Helper to write bookmarks back to disk

export async function writeBookmarks(bookmarks: Bookmark[]): Promise<void> {

await fs.writeFile(BOOKMARKS_FILE_PATH, JSON.stringify(bookmarks, null, 2), "utf8");

}

// Initialize MCP server

const server = new Server(

{

name: "mcp-bookmark-server",

version: "1.0.0",

},

{

capabilities: {

tools: {},

},

}

);

// Register addBookmark tool

server.tool(

"add_bookmark",

"Add a new bookmark to your collection",

AddBookmarkInputSchema,

async (input) => {

try {

const bookmarks = await readBookmarks();

// Check for duplicate URL

const existing = bookmarks.find((b) => b.url === input.url);

if (existing) {

return {

content: [{ type: "text", text: `Bookmark for ${input.url} already exists with title: ${existing.title}` }],

};

}

const newBookmark: Bookmark = {

...input,

createdAt: new Date(),

};

bookmarks.push(newBookmark);

await writeBookmarks(bookmarks);

return {

content: [{ type: "text", text: `Successfully added bookmark: ${newBookmark.title}` }],

};

} catch (error) {

if (error instanceof z.ZodError) {

return {

isError: true,

content: [{ type: "text", text: `Invalid input: ${error.errors.map(e => e.message).join(", ")}` }],

};

}

return {

isError: true,

content: [{ type: "text", text: `Failed to add bookmark: ${(error as Error).message}` }],

};

}

}

);

// Register listBookmarks tool

server.tool(

"list_bookmarks",

"List all bookmarks, optionally filtered by tag",

ListBookmarksInputSchema,

async (input) => {

try {

const bookmarks = await readBookmarks();

let filtered = bookmarks;

if (input.tag) {

filtered = bookmarks.filter((b) => b.tags.includes(input.tag));

}

if (filtered.length === 0) {

return {

content: [{ type: "text", text: "No bookmarks found matching your criteria" }],

};

}

const formatted = filtered.map((b) => (

`- ${b.title}: ${b.url} (tags: ${b.tags.join(", ")})`

)).join("\n");

return {

content: [{ type: "text", text: `Found ${filtered.length} bookmarks:\n${formatted}` }],

};

} catch (error) {

return {

isError: true,

content: [{ type: "text", text: `Failed to list bookmarks: ${(error as Error).message}` }],

};

}

}

);

// Register searchBookmarks tool

server.tool(

"search_bookmarks",

"Search bookmarks by keyword in title, URL, or description",

SearchBookmarksInputSchema,

async (input) => {

try {

const bookmarks = await readBookmarks();

const query = input.query.toLowerCase();

const results = bookmarks.filter((b) =>

b.title.toLowerCase().includes(query) ||

b.url.toLowerCase().includes(query) ||

(b.description && b.description.toLowerCase().includes(query))

);

if (results.length === 0) {

return {

content: [{ type: "text", text: `No bookmarks found matching search: ${input.query}` }],

};

}

const formatted = results.map((b) => (

`- ${b.title}: ${b.url} (tags: ${b.tags.join(", ")})`

)).join("\n");

return {

content: [{ type: "text", text: `Found ${results.length} matching bookmarks:\n${formatted}` }],

};

} catch (error) {

return {

isError: true,

content: [{ type: "text", text: `Failed to search bookmarks: ${(error as Error).message}` }],

};

}

}

);

// Start the server

async function main() {

const transport = new StdioServerTransport();

await server.connect(transport);

console.error("MCP Bookmark Server running on stdio");

}

main().catch((error) => {

console.error("Fatal error starting server:", error);

process.exit(1);

});

```

Let’s talk about error handling patterns here, which are critical for MCP servers. We wrap every tool handler in a try/catch block, and we return structured errors with the `isError: true` flag, which tells the MCP client that the tool failed, and lets the LLM handle the error gracefully instead of crashing the entire conversation. We specifically handle Zod validation errors, which gives the LLM clear feedback about what’s wrong with the input. The tradeoff here is that adding try/catch to every handler adds a little boilerplate, but it makes your server much more robust than letting errors bubble up to a global handler, which would just return a generic error message that the LLM can’t work with.

Reducing Boilerplate With a Custom Tool Wrapper

If you look at the code we wrote above, you’ll notice that every tool handler has the same basic structure: a try/catch block that handles Zod errors, formats error responses, and returns success content. This repetition adds up quickly if you add more than 3 or 4 tools, and it’s easy to forget to add the `isError` flag to an error response, which leads to the client not handling the error correctly.

I like to extract this common logic into a reusable wrapper function that handles error formatting for me, which cuts down on boilerplate and keeps all my error handling consistent. The tradeoff here is that we add a small layer of abstraction, but it’s well worth it for any server with more than a couple of tools. Create a new file `src/utils.ts` with this runnable code:

```typescript

import { z } from "zod";

import type { ToolHandler } from "@modelcontextprotocol/sdk/server/index.js";

// Wraps a tool handler to add consistent error handling

export function withErrorHandling<InputSchema extends z.ZodObject<any>>(

handler: (input: z.infer<InputSchema>) => ReturnType<ToolHandler<InputSchema>>

): ToolHandler<InputSchema> {

return async (input) => {

try {

return await handler(input);

} catch (error) {

if (error instanceof z.ZodError) {

return {

isError: true,

content: [

{

type: "text",

text: `Invalid input: ${error.errors.map(e => e.message).join(", ")}`,

},

],

};

}

const message = error instanceof Error ? error.message : String(error);

return {

isError: true,

content: [{ type: "text", text: `Operation failed: ${message}` }],

};

}

};

}

```

Now we can rewrite our tool handlers to remove all the repetitive try/catch boilerplate. For example, the `add_bookmark` handler becomes much cleaner:

```typescript

// After using our wrapper:

server.tool(

"add_bookmark",

"Add a new bookmark to your collection",

AddBookmarkInputSchema,

withErrorHandling(async (input) => {

const bookmarks = await readBookmarks();

const existing = bookmarks.find((b) => b.url === input.url);

if (existing) {

return {

content: [{ type: "text", text: `Bookmark for ${input.url} already exists with title: ${existing.title}` }],

};

}

const newBookmark: Bookmark = {

...input,

createdAt: new Date(),

};

bookmarks.push(newBookmark);

await writeBookmarks(bookmarks);

return {

content: [{ type: "text", text: `Successfully added bookmark: ${newBookmark.title}` }],

};

})

);

```

This works for all your tools, and it guarantees that every error is formatted correctly for the MCP client. The tradeoff here is that if you need custom error handling for a specific tool (for example, if you want to return different error formats for different error types), you can just skip the wrapper for that tool and write your own try/catch. This approach gives you the best of both: less boilerplate for most tools, flexibility when you need it.

My Personal Gotchas That Cost Me Hours of Debugging

Before we move on to testing and deployment, I want to share the two biggest mistakes I made when building my first MCP server that you can avoid entirely. I already mentioned the one-character typo that cost me two hours thanks to lack of type safety, but there are two other common gotchas that trip up almost every new MCP developer.

First, the module mismatch issue that I ran into when I reused an old tsconfig. When I built my first TypeScript MCP server, I copied a tsconfig.json from an old CommonJS project I’d worked on, so I had `module: CommonJS` set, and I forgot to add `"type": "module"` to my package.json. TypeScript compiled the code without any errors, because it only checks type correctness, not module compatibility. It doesn’t warn you that the output module format won’t work with the dependencies you’re importing. The official MCP SDK is distributed as an ES Module only, so when I tried to run the server, I got a cryptic `Cannot find module '@modelcontextprotocol/sdk'` error, even though the module was clearly installed in node_modules, I could see it right there.

I tried everything: reinstalling dependencies from scratch, changing Node versions, even rewriting all my import paths to add .js extensions manually, nothing worked. It took me two full hours of googling and debugging to realize that Node.js doesn’t allow CommonJS modules to import ES Modules by default, and TypeScript wasn’t going to warn me about the mismatch. The fix was 2 lines of changes: switch to `module: NodeNext` in tsconfig and add `"type": "module"` to package.json, which lets TypeScript output the correct ES Module syntax that works with the SDK.

The second gotcha that cost me 20 extra minutes after I fixed the module issue is relative paths in your Claude Desktop config. When I first set up my local config, I used a relative path like `./dist/index.js` for the server, because that’s what worked in my terminal. But Claude Desktop runs from a system working directory, not your project folder, so it couldn’t find the file. It just failed silently, with no clear error message telling me the path was wrong. I had to dig through Claude’s hidden logs to find the `ENOENT` error for the file path, then realize I needed to use an absolute path. Always use absolute paths in your Claude Desktop config, that’s a rule I never break now.

Step 4: Testing with Jest

Now that our server is built, let’s add tests to make sure it works as expected. We’ll use Jest to test our core business logic in isolation, which is fast and easy. We’ll mock the file system so we don’t touch the user’s actual bookmarks file during tests.

First, create a `jest.config.ts` file in your project root:

```typescript

import { JestConfigWithTsJest } from 'ts-jest';

const config: JestConfigWithTsJest = {

preset: 'ts-jest',

testEnvironment: 'node',

moduleNameMapper: {

'^(\\.{1,2}/.*)\\.js$': '$1',

},

extensionsToTreatAsEsm: ['.ts'],

};

export default config;

```

Now, let’s write a test file `src/index.test.ts` for our core functionality:

```typescript

import fs from 'fs/promises';

import { readBookmarks, writeBookmarks } from './index';

import { Bookmark } from './schemas';

jest.mock('fs/promises');

const mockedFs = fs as jest.Mocked<typeof fs>;

describe('Bookmark Manager', () => {

beforeEach(() => {

jest.clearAllMocks();

});

it('should return empty array when bookmarks file does not

Related Guides In This Intent

These pages cover nearby scope with different focus, helping reduce overlap and choose the right guide.

What To Do Next

Move from this guide to a concrete workflow and a matching tool page to apply the concepts.

References

Last updated: April 5, 2026

Sponsored