DEV Community

Jangwook Kim
Jangwook Kim

Posted on • Originally published at jangwook.net

Building Your Own MCP Server with TypeScript — A Practical @modelcontextprotocol/sdk Tutorial

// This one line is all it takes
const server = new McpServer({ name: "my-server", version: "1.0.0" });
server.tool("get_book_info", "Fetch book info", { isbn: z.string() }, async ({ isbn }) => { ... });
Enter fullscreen mode Exit fullscreen mode

When I first decided to build an MCP server, I was honestly surprised by how much easier it was than I expected. I'd heard plenty about MCP (Model Context Protocol) becoming the standard for AI platform interop, but "build your own server" sounded like it would be complex and have a steep learning curve. Turns out, with just the @modelcontextprotocol/sdk package and Zod, you can have a working MCP server in under 30 minutes.

In this post, I walk through building a TypeScript MCP server from scratch, step by step, based on code I actually ran and output I actually got. The goal is to build a real tool that fetches book information by ISBN using the Open Library public API — and to internalize the core concepts of MCP server development along the way.

Why Build Your Own MCP Server Right Now

Claude, Cursor, Windsurf, Zed, and other major AI coding tools have all adopted MCP as their standard integration protocol. That means one MCP server can expose the same functionality across multiple AI tools without locking you into any single platform.

Before MCP, you had to build your own API and then develop separate plugins or integrations for each AI platform. MCP makes "build once, use everywhere" actually viable. My reasoning is simple: if I wrap an internal database query, a document search, or a workflow automation script as an MCP tool once, I can use it from Claude, from Cursor, from whatever comes next.

The ecosystem isn't fully mature yet. But as I covered in MCP Open Standard and the Linux Foundation, this direction is already solidifying into an industry standard.

Environment Setup and Package Installation

Start by initializing a Node.js project and installing the required packages.

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
Enter fullscreen mode Exit fullscreen mode

Here's the actual output I got:

$ mkdir test-project && cd test-project && npm init -y
$ npm install @modelcontextprotocol/sdk zod
added 92 packages, and audited 93 packages in 2s
found 0 vulnerabilities
Enter fullscreen mode Exit fullscreen mode

You get @modelcontextprotocol/sdk version 1.29.0 and zod version 4.4.3. The dependency tree balloons to 92 packages, but there are zero vulnerabilities. If you're using TypeScript, add the dev dependencies too:

npm install -D typescript @types/node tsx
npx tsc --init
Enter fullscreen mode Exit fullscreen mode

In tsconfig.json, setting "module": "ESNext" and "moduleResolution": "bundler" (or "node16") is important. The SDK ships as an ESM module.

package.json Configuration

{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.29.0",
    "zod": "^4.4.3"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

"type": "module" is not optional. Without it, Node can't resolve the SDK's import paths correctly (e.g. @modelcontextprotocol/sdk/server/mcp.js).

Creating a McpServer Instance — The Core 3-Step Pattern

After experimenting with this myself, the core pattern comes down to three steps.

  1. Create a McpServer instance
  2. Register tools with server.tool() using Zod schemas
  3. Connect a transport

Understand this pattern and you've got 90% of MCP server development figured out.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { z } from "zod";

// Step 1: Create the server instance
const server = new McpServer({ name: "demo-server", version: "1.0.0" });
Enter fullscreen mode Exit fullscreen mode

The McpServer constructor takes two fields: name and version. These are what MCP clients use to identify the server.

Tool Registration: server.tool()

// Step 2: Register a tool
server.tool(
  "get_book_info",                               // Tool name
  "Fetch book metadata from Open Library by ISBN", // Tool description (how the AI decides when to use it)
  { isbn: z.string().describe("ISBN-13 or ISBN-10") }, // Zod schema
  async ({ isbn }) => {                           // Handler
    const url = `https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`;
    const res = await fetch(url);
    const data = await res.json();
    const book = data[`ISBN:${isbn}`];

    if (!book) {
      return {
        content: [{ type: "text", text: `No book found for ISBN: ${isbn}` }]
      };
    }

    const summary = [
      `Title: ${book.title}`,
      `Author(s): ${(book.authors || []).map((a: { name: string }) => a.name).join(", ")}`,
      `Published: ${book.publish_date || "unknown"}`,
      `Pages: ${book.number_of_pages || "unknown"}`
    ].join("\n");

    return { content: [{ type: "text", text: summary }] };
  }
);
Enter fullscreen mode Exit fullscreen mode

It's worth being clear on what each argument to server.tool() does:

  • First argument: Tool name (the identifier the AI uses when calling it)
  • Second argument: Tool description (the natural language explanation that tells the AI model when to use this tool)
  • Third argument: Zod schema object (input types and descriptions)
  • Fourth argument: Async handler function

The second argument, the tool description, is a prompt the AI model reads directly. The more specific it is, the better the AI gets at knowing when to call it. "Fetch book metadata from Open Library by ISBN" is far more useful than just "book info."

Response Format: The MCP Standard Structure

The format handlers return is also standardized:

return {
  content: [
    { type: "text", text: "response text here" }
  ]
};
Enter fullscreen mode Exit fullscreen mode

Each item in the content array has a type and the corresponding data. You can return images (type: "image") or resources (type: "resource") in addition to text, but text covers most practical cases.

When errors happen, MCP convention is to return the error message inside content rather than throwing an exception. Throwing can cause inconsistent behavior depending on which client is consuming the server.

Testing with InMemoryTransport in the Same Process

Before connecting your MCP server to actual Claude or Cursor, there's a cleaner way to test server-client round trips within the same process. That's what InMemoryTransport is for.

// Step 3: Connect the transport
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

// Connect the server
await server.connect(serverTransport);

// Connect the client
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
Enter fullscreen mode Exit fullscreen mode

InMemoryTransport.createLinkedPair() returns a paired client transport and server transport that talk to each other directly in memory. No stdio setup, no HTTP server needed.

Now you can list the server's tools and call them from the client:

// List registered tools
const { tools } = await client.listTools();
console.log("=== Tools registered ===");
tools.forEach(t => console.log(`  - ${t.name}: ${t.description}`));

// Call a tool
const result = await client.callTool({
  name: "get_book_info",
  arguments: { isbn: "9780132350884" }
});

console.log("\n=== Result ===");
console.log(result.content[0].text);

await client.close();
Enter fullscreen mode Exit fullscreen mode

Here's the actual output:

=== Tools registered ===
  - get_book_info: Fetch book metadata from Open Library by ISBN

=== Calling get_book_info (ISBN: 9780132350884 — Clean Code) ===
Title: Clean Code
Author(s): Robert C. Martin
Published: July 2008
Pages: 431

✓ MCP server + client round-trip succeeded
Enter fullscreen mode Exit fullscreen mode

Real data pulled from Open Library's public API, output correctly. No auth setup required since it's an open REST API with no API key.

Full Working Code (mcp-demo.mjs)

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { z } from "zod";

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

server.tool(
  "get_book_info",
  "Fetch book metadata from Open Library by ISBN",
  { isbn: z.string().describe("ISBN-13 or ISBN-10") },
  async ({ isbn }) => {
    const url = `https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`;
    const res = await fetch(url);
    const data = await res.json();
    const book = data[`ISBN:${isbn}`];
    if (!book) {
      return { content: [{ type: "text", text: `No book found for ISBN: ${isbn}` }] };
    }
    const summary = `Title: ${book.title}\nAuthor(s): ${(book.authors || []).map(a => a.name).join(", ")}\nPages: ${book.number_of_pages || "unknown"}`;
    return { content: [{ type: "text", text: summary }] };
  }
);

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);

const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);

const { tools } = await client.listTools();
console.log("Tools:", tools.map(t => t.name));

const result = await client.callTool({ name: "get_book_info", arguments: { isbn: "9780132350884" } });
console.log(result.content[0].text);
await client.close();
Enter fullscreen mode Exit fullscreen mode

Connecting to Claude with StdioServerTransport

InMemoryTransport is great for testing and development, but to connect your server to actual Claude Desktop or Cursor, you switch to StdioServerTransport. This is the standard deployment mode for MCP servers.

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

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

server.tool(
  "get_book_info",
  "Fetch book metadata from Open Library by ISBN",
  { isbn: z.string().describe("ISBN-13 or ISBN-10") },
  async ({ isbn }) => {
    const url = `https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`;
    const res = await fetch(url);
    const data = await res.json();
    const book = data[`ISBN:${isbn}`];
    if (!book) {
      return { content: [{ type: "text", text: `No book found for ISBN: ${isbn}` }] };
    }
    const summary = `Title: ${book.title}\nAuthor(s): ${(book.authors || []).map((a: { name: string }) => a.name).join(", ")}\nPublished: ${book.publish_date || "unknown"}\nPages: ${book.number_of_pages || "unknown"}`;
    return { content: [{ type: "text", text: summary }] };
  }
);

// Start with stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
Enter fullscreen mode Exit fullscreen mode

Now register the server in Claude Desktop's MCP config file (~/Library/Application Support/Claude/claude_desktop_config.json):

{
  "mcpServers": {
    "book-server": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/dist/server.js"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you're using TypeScript and want to skip the build step, use tsx:

{
  "mcpServers": {
    "book-server": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/my-mcp-server/src/server.ts"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Restart Claude Desktop and it will automatically use the get_book_info tool whenever you mention an ISBN in conversation.

Registering Multiple Tools

Call server.tool() multiple times to add more tools:

// Add a book search tool
server.tool(
  "search_books",
  "Search books by title or author on Open Library",
  {
    query: z.string().describe("Book title or author name"),
    limit: z.number().int().min(1).max(10).default(5).describe("Max results"),
  },
  async ({ query, limit }) => {
    const url = `https://openlibrary.org/search.json?q=${encodeURIComponent(query)}&limit=${limit}`;
    const res = await fetch(url);
    const data = await res.json();

    if (!data.docs || data.docs.length === 0) {
      return { content: [{ type: "text", text: `No results for: ${query}` }] };
    }

    const results = data.docs
      .slice(0, limit)
      .map((doc: { title: string; author_name?: string[]; first_publish_year?: number }, i: number) =>
        `${i + 1}. ${doc.title}${(doc.author_name || ["unknown"]).join(", ")} (${doc.first_publish_year || "?"})`
      )
      .join("\n");

    return { content: [{ type: "text", text: results }] };
  }
);
Enter fullscreen mode Exit fullscreen mode

Using .default(5) in a Zod schema makes the argument optional — if the AI doesn't explicitly supply a value, the default is used.

Things to Watch Out for When Designing Tools

I hit a few gotchas while building this out.

First, tool names and descriptions matter more than you'd think. The AI model picks tools based on their descriptions. If your description is vague — like "get info" — the AI will either skip the tool when it should use it or apply it in the wrong situations. Describe the specific action, the expected input, and what the output looks like.

Second, Zod v4's API is different from v3. Some APIs changed in Zod 4.4.3. Copying Zod v3 code directly can cause type errors. In particular, the behavior of .optional() combined with .nullable() and z.union() has changed. If you're using Zod elsewhere in your project, check for version conflicts.

Third, error handling needs to be deliberate. My early mistake was throwing exceptions on network errors. MCP convention is to return error messages inside content rather than throwing. Different clients handle thrown exceptions differently:

async ({ isbn }) => {
  try {
    const res = await fetch(url);
    if (!res.ok) {
      return {
        content: [{ type: "text", text: `API error: ${res.status} ${res.statusText}` }],
        isError: true  // explicitly mark as error
      };
    }
    // ... normal handling
  } catch (error) {
    return {
      content: [{ type: "text", text: `Network error: ${error instanceof Error ? error.message : String(error)}` }],
      isError: true
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Adding isError: true lets the client know to treat the response as an error.

Fourth, long responses need to be trimmed. AI context windows have limits. If a tool response is too long, the AI loses earlier conversation or hits a context overflow. For search results or list-type data, cap the number of items.

Debugging with MCP Inspector

During development, MCP Inspector became one of my go-to tools. It's a browser-based GUI where you can inspect registered tools and call them directly.

npx @modelcontextprotocol/inspector node dist/server.js
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:5173 in your browser and you'll see the registered tools and their Zod schemas laid out visually. Fill in arguments and run them, and the actual response shows up as JSON.

One thing to be aware of: MCP Inspector requires Node.js 18 or later, and the first run takes a while since it installs its own npm packages. Expect to wait 30〜60 seconds on the first launch.

Resources and Prompts — MCP Features Beyond Tools

The MCP spec includes two more features beyond Tools: Resources and Prompts.

Resources are data sources the AI can read. You can expose files, database tables, or API responses as resources for the AI to use as context:

server.resource(
  "book-catalog",
  "books://catalog",
  async (uri) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: "text/plain",
        text: "Book catalog contents..."
      }
    ]
  })
);
Enter fullscreen mode Exit fullscreen mode

Prompts are reusable prompt templates. You define multi-step prompts for complex tasks and clients can pull them on demand.

Honestly, when I started out I wasn't sure how much real-world use Resources and Prompts would get. For most practical use cases, Tools alone are enough. Resources shine when you're providing large context data, and Prompts are useful for standardizing workflows across your team.

Handling Complex Schemas with Zod v4

You're not limited to simple z.string() — Zod gives you plenty of options for tool arguments:

server.tool(
  "advanced_book_search",
  "Advanced book search with filters",
  {
    query: z.string().min(1).max(200).describe("Search query"),
    filters: z.object({
      publishedAfter: z.number().int().min(1000).max(2030).optional()
        .describe("Filter by publication year (start)"),
      language: z.enum(["en", "ko", "ja", "zh"]).optional()
        .describe("Language filter"),
    }).optional().describe("Optional search filters"),
    sortBy: z.enum(["relevance", "date", "title"]).default("relevance")
      .describe("Sort order"),
    limit: z.number().int().min(1).max(20).default(5),
  },
  async ({ query, filters, sortBy, limit }) => {
    // implementation...
    return { content: [{ type: "text", text: "results..." }] };
  }
);
Enter fullscreen mode Exit fullscreen mode

Combining z.object(), z.enum(), .optional(), and .default() lets you define complex tool interfaces in a type-safe way. The AI converts this schema to JSON Schema format internally to understand what arguments to fill in and how.

One Zod v4 specifics worth noting: .describe() should come before .optional(). z.string().optional().describe() can produce unexpected type inference. The correct order is z.string().describe("description").optional().

HTTP/SSE Transport — Deploying a Remote MCP Server

Everything so far has used local stdio. If you want to run an MCP server shared across a team or deploy it to the cloud, you need the HTTP/SSE transport.

npm install express
npm install -D @types/express
Enter fullscreen mode Exit fullscreen mode
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { z } from "zod";

const app = express();
app.use(express.json());

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

server.tool(
  "get_book_info",
  "Fetch book metadata from Open Library by ISBN",
  { isbn: z.string() },
  async ({ isbn }) => {
    // ... same implementation
  }
);

const transports: Record<string, SSEServerTransport> = {};

app.get("/sse", (req, res) => {
  const sessionId = crypto.randomUUID();
  const transport = new SSEServerTransport(`/messages/${sessionId}`, res);
  transports[sessionId] = transport;
  server.connect(transport);

  req.on("close", () => {
    delete transports[sessionId];
  });
});

app.post("/messages/:sessionId", async (req, res) => {
  const transport = transports[req.params.sessionId];
  if (!transport) {
    res.status(404).json({ error: "Session not found" });
    return;
  }
  await transport.handlePostMessage(req, res);
});

app.listen(3000, () => {
  console.log("MCP server running on http://localhost:3000");
});
Enter fullscreen mode Exit fullscreen mode

With this setup, you register http://localhost:3000/sse as the MCP server URL in Cursor or Claude Desktop. That said, HTTP mode adds more configuration complexity and requires you to handle security separately (auth tokens, HTTPS). For internal team tools, stdio is far simpler.

As I covered in MCP vs A2A vs Open Responses Protocol Comparison, the remote MCP server architecture still has rough edges around maturity and security. If you're planning a remote deployment right now, pay close attention to auth tokens, CORS, and session management.

Practical Tool Ideas

If you've read this far, you understand the MCP server structure well enough to build something real. Here are some practical tool ideas I've been thinking through:

Internal system integrations:

  • Jira/Linear issue queries (get_issue, create_issue, list_my_issues)
  • Confluence/Notion document search (search_docs, get_page)
  • Slack message search (search_messages, get_channel_history)

Development workflow:

  • GitHub PR lists and reviews (list_prs, get_pr_diff, add_review_comment)
  • Deployment status checks (get_deployment_status, rollback_deployment)
  • Log queries (search_logs, get_error_trace)

Data analysis:

  • SQL query execution (with read-only permissions) (run_query, list_tables)
  • Metrics dashboard data (get_metrics, get_alert_status)

Any of these can be wrapped with the server.tool() pattern and turned into a working MCP server in under 30 minutes.

Bottom Line: I Think This Is the Realistic Standard for AI Tool Deployment

Working through this experiment, I confirmed that MCP server development has a much lower barrier to entry than I expected. The McpServer API in @modelcontextprotocol/sdk is clean, and defining type-safe schemas with Zod is intuitive.

That said, the limitations are real. Connecting to actual Claude or Cursor clients requires switching to StdioServerTransport, which introduces additional configuration: deployment environment, absolute paths, Node.js version compatibility. And Zod v4 has API changes from v3, so existing code won't always port directly.

Still, getting an end-to-end pipeline working — a public REST API wrapped as an MCP tool, returning real data, with no API key — in under 30 minutes is genuinely compelling. With Claude, Cursor, Windsurf, and others adopting MCP as their standard, building an MCP server is the most practical way to expose your own tools across multiple AI platforms at once.

My recommendation for the next step: pick one internal system and wrap it as an MCP tool. The code structure is everything covered in this post. The rest is just understanding that system's API.


Reference links:

Top comments (0)