How to Build an MCP Server: A Practical TypeScript Tutorial

How to Build an MCP Server: A Practical TypeScript Tutorial

If you've been building AI-powered tools, you've probably hit the same wall I did: every integration feels like reinventing the wheel. Connect Claude to your database? Custom code. Hook up ChatGPT to your file system? More custom code. The Model Context Protocol (MCP) fixes this by giving us a universal standard for connecting AI models to external tools and data sources. In this MCP server tutorial, I'll walk you through building one from scratch in TypeScript.

What is the Model Context Protocol (MCP)?

MCP is an open protocol introduced by Anthropic that standardizes how AI models communicate with external systems. Think of it like USB for AI โ€” instead of building a unique connector for every device, you build one standard interface that works everywhere.

An MCP server exposes three types of capabilities to AI models:

  • Tools โ€” Functions the AI can call (like querying a database or sending an email)
  • Resources โ€” Read-only data the AI can access (like file contents or API responses)
  • Prompts โ€” Pre-built templates for common interactions

The protocol has been adopted by OpenAI, Google, Microsoft, and hundreds of developer tools. Any MCP-compatible client โ€” Claude, ChatGPT Desktop, Cursor, VS Code, GitHub Copilot โ€” can connect to any MCP server. With over 97 million monthly SDK downloads and 10,000+ active servers, MCP has become the de facto standard for AI tool integration.

Why MCP Matters for Developers

Before MCP, every AI integration was a one-off. You'd write custom API glue code for each model and each tool. MCP changes this:

  • Write once, use everywhere โ€” Build a tool once as an MCP server and it works with Claude, ChatGPT, Cursor, and any other MCP client
  • Modular architecture โ€” Each server has a single responsibility, making your code easier to test and maintain
  • Type-safe contracts โ€” The SDK uses Zod schemas to validate inputs and outputs at runtime
  • Growing ecosystem โ€” Thousands of pre-built servers exist for common tools (databases, APIs, file systems)
  • Future-proof โ€” OpenAI is deprecating the Assistants API in favor of MCP by mid-2026, so the ecosystem is converging on this standard

If you're building any kind of AI-powered tooling, MCP is no longer optional โ€” it's the infrastructure layer you need to know.

Getting Started with Your First MCP Server

Let's build an MCP server that provides a simple utility: fetching and summarizing the content of any URL. This is practical, demonstrates the core concepts, and you can extend it later.

Step 1: Set Up the Project

Create a new directory and initialize it:

mkdir mcp-url-summarizer
cd mcp-url-summarizer
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D @types/node typescript

Create a tsconfig.json:

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

Update package.json to add "type": "module" and a build script:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Step 2: Create the Server

Create src/index.ts:

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: "url-summarizer",
  version: "1.0.0",
  capabilities: {
    tools: {},
  },
});

// Register the fetch_url tool
server.tool(
  "fetch_url",
  "Fetches a URL and returns its text content",
  {
    url: z.string().url().describe("The URL to fetch"),
    max_length: z
      .number()
      .optional()
      .default(5000)
      .describe("Maximum characters to return"),
  },
  async ({ url, max_length }) => {
    try {
      const response = await fetch(url);

      if (!response.ok) {
        return {
          content: [
            {
              type: "text",
              text: `Error: HTTP ${response.status} ${response.statusText}`,
            },
          ],
          isError: true,
        };
      }

      const html = await response.text();
      // Strip HTML tags for a cleaner output
      const text = html
        .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
        .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
        .replace(/<[^>]+>/g, " ")
        .replace(/\s+/g, " ")
        .trim()
        .slice(0, max_length);

      return {
        content: [{ type: "text", text }],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Error fetching URL: ${error instanceof Error ? error.message : String(error)}`,
          },
        ],
        isError: true,
      };
    }
  }
);

// Connect via stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);

Step 3: Build and Test

Build the TypeScript:

npm run build

Test it using the MCP Inspector (the official debugging tool):

npx @modelcontextprotocol/inspector node dist/index.js

This opens a web UI where you can call your fetch_url tool interactively and see the JSON-RPC messages flowing back and forth.

Deep Dive: Adding Resources and Advanced Patterns

Tools are just one piece of MCP. Let's extend our server with a resource โ€” a read-only data source the AI can access without calling a function.

// Add a resource that lists recently fetched URLs
const fetchHistory: Array<{ url: string; timestamp: string }> = [];

server.resource(
  "fetch-history",
  "fetch://history",
  {
    description: "List of recently fetched URLs",
    mimeType: "application/json",
  },
  async () => ({
    contents: [
      {
        uri: "fetch://history",
        text: JSON.stringify(fetchHistory, null, 2),
        mimeType: "application/json",
      },
    ],
  })
);

Then update the fetch_url tool to record history:

// Inside the tool handler, after a successful fetch:
fetchHistory.push({
  url,
  timestamp: new Date().toISOString(),
});

Using Environment Variables Securely

For servers that need API keys or credentials, use environment variables โ€” never hardcode secrets:

const apiKey = process.env.API_KEY;
if (!apiKey) {
  console.error("API_KEY environment variable is required");
  process.exit(1);
}

When configuring the server in Claude Desktop, pass env vars in the config:

{
  "mcpServers": {
    "url-summarizer": {
      "command": "node",
      "args": ["/path/to/dist/index.js"],
      "env": {
        "API_KEY": "your-key-here"
      }
    }
  }
}

Connecting to Claude Desktop

Add your server to Claude Desktop's config file (located at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS or %APPDATA%\Claude\claude_desktop_config.json on Windows):

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

Restart Claude Desktop and you'll see your tool available in the interface.

Common Mistakes and How to Avoid Them

1. Using console.log() in STDIO Servers

This is the most common beginner mistake. For STDIO-based servers, console.log() writes to stdout, which corrupts the JSON-RPC messages. Use console.error() instead, which writes to stderr:

// Bad โ€” breaks the JSON-RPC protocol
console.log("Debug info");

// Good โ€” writes to stderr, safe for STDIO
console.error("Debug info");

2. Returning Bloated Tool Responses

Tool responses end up inside the model's context window. Returning a 50KB JSON blob means less room for the model to think. Keep responses concise, paginate large datasets, and summarize where possible.

3. Forgetting Input Validation

MCP uses Zod for schema validation, but you should still handle edge cases in your tool logic. A user might pass a malformed URL that passes the Zod z.string().url() check but fails at the network level.

4. Not Handling the Context Lifecycle

If your server maintains state (like our fetch history), remember that different users might be connecting. Don't use global variables for user-specific data in production. Use sessions or per-connection state instead.

5. Registering Tools After Transport Connection

Tools must be registered before you call server.connect(). If you register tools after connecting the transport, they won't be available to clients.

FAQ

How do I debug my MCP server when something goes wrong?

Use the MCP Inspector (npx @modelcontextprotocol/inspector) โ€” it's the official debugging tool that lets you test tools interactively and see the raw JSON-RPC messages. For runtime debugging, write logs to stderr with console.error() and check the MCP log files in Claude Desktop's logs directory.

Can I deploy an MCP server remotely instead of running it locally?

Yes. The latest MCP spec supports Streamable HTTP transport alongside STDIO. You can containerize your server with Docker and deploy it to any cloud provider. The key difference is you'll use StreamableHTTPServerTransport instead of StdioServerTransport and handle authentication with OAuth 2.1 as recommended by the spec.

What's the difference between MCP tools, resources, and prompts?

Tools are functions the AI can execute (like calling an API). Resources are read-only data the AI can access (like file contents). Prompts are pre-written templates that guide the AI's behavior for specific tasks. Most servers start with tools, then add resources as needed. Prompts are less common but useful for standardizing complex interactions.

Conclusion

MCP is quickly becoming the standard way to connect AI models to the outside world. In this tutorial, we built a working MCP server in TypeScript that exposes a URL-fetching tool and a resource for tracking history. The key takeaways:

  • MCP provides a universal interface between AI models and external tools
  • The TypeScript SDK makes it straightforward to define tools with type-safe schemas
  • Always use console.error() for logging in STDIO servers
  • Start small with one tool, test with the Inspector, then expand

The best way to learn MCP is to build something you'll actually use. Pick a service you interact with daily โ€” your database, your CI pipeline, your project management tool โ€” and wrap it in an MCP server. Once you've done it once, you'll want to build them for everything.



More from AI Agents & Automation