← Back to Blog

Build Your First MCP Server in TypeScript: A 30-Minute Hands-On Guide

If you have used Claude Desktop or Cursor, you have probably seen MCP servers in action. They are how an AI client discovers and calls external tools, reads files, queries databases, or hits REST APIs, all through one standardized protocol. Building one is way easier than it looks. In about 30 minutes, you can have a working MCP server written in TypeScript, exposing a couple of custom tools, and wired up to Claude Desktop for testing.

This is a hands-on walkthrough. By the end, you will understand the three MCP primitives (tools, resources, prompts), how JSON-RPC 2.0 carries messages between client and server, and how to ship a server that another developer can install with npx.

Prerequisites

  • Node.js 20+ (check with node --version)
  • npm 10+
  • Claude Desktop installed (for the testing part), or any MCP-compatible client
  • About 5 minutes of disk space

The official spec lives at modelcontextprotocol.io. The TypeScript SDK is at github.com/modelcontextprotocol/typescript-sdk.

What MCP actually is

MCP is a JSON-RPC 2.0 based protocol where a host application (Claude Desktop, an IDE, a chat app) embeds a client that talks to one or more servers. Each server exposes three kinds of capability:

  • Tools: functions the LLM can call (like OpenAI function calling, but standardized)
  • Resources: read-only data the client can fetch and inject into context (files, DB rows, API responses)
  • Prompts: reusable prompt templates the user can trigger with parameters

Transport is either stdio (the server runs as a child process, communicates over stdin/stdout — the most common pattern for local dev) or Streamable HTTP (the server runs as a regular HTTP service for remote or shared use). MCP launched with HTTP+SSE in late 2024 and added the Streamable HTTP transport in the 2025-03-26 spec revision, so any code you find from before that is outdated.

Step 1: Scaffold the project

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

Initialize TypeScript:

npx tsc --init

Edit tsconfig.json to set "outDir": "./build" and "rootDir": "./src", then add a "bin" field in package.json so the server can be launched with npx:

{
  "name": "my-mcp-server",
  "version": "0.1.0",
  "type": "module",
  "bin": {
    "my-mcp-server": "./build/index.js"
  },
  "scripts": {
    "build": "tsc && chmod +x build/index.js"
  }
}

Step 2: A minimal server

Create src/index.ts:

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

const server = new McpServer({
  name: "my-mcp-server",
  version: "0.1.0",
});

// A simple tool: echo a string back
server.tool(
  "echo",
  "Returns whatever the user passes in",
  {
    message: { type: "string", description: "Text to echo back" },
  },
  async ({ message }) => ({
    content: [{ type: "text", text: `Echo: ${message}` }],
  })
);

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

Build and run it:

npm run build
node build/index.js

If you run this, nothing visible happens — the server is just waiting for JSON-RPC messages on stdin. That is correct. The next step is to point a real client at it.

Step 3: Connect it to Claude Desktop

Open ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows). Add your server:

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/build/index.js"]
    }
  }
}

Restart Claude Desktop. You should see a small hammer icon in the chat input. Click it and your echo tool will show up. Ask Claude "use the echo tool to say hello" and it will call your server, get Echo: hello, and return that.

Step 4: Add a real tool with input validation

A tool that does nothing useful is fine for the first 5 minutes. The interesting part is wiring it to something real. Here is a tool that lists files in a directory the user permits:

import { z } from "zod";
import { readdir } from "node:fs/promises";
import { resolve } from "node:path";

server.tool(
  "list_files",
  "List files in a directory (read-only, capped at 50 entries)",
  {
    path: z.string().describe("Absolute path to a directory"),
  },
  async ({ path }) => {
    const safe = resolve(path);
    const entries = await readdir(safe, { withFileTypes: true });
    const names = entries
      .slice(0, 50)
      .map((e) => `${e.isDirectory() ? "📁" : "📄"} ${e.name}`)
      .join("\n");
    return {
      content: [{ type: "text", text: names || "(empty directory)" }],
    };
  }
);

The Zod schema does double duty: it validates the input AND produces the JSON Schema that gets sent to the LLM as part of the tool definition. The LLM reads the schema to figure out which arguments to pass.

Step 5: Expose a resource

Resources are addressable by URI. A common pattern is file:// for local files or a custom scheme for your own data. Here is a resource that returns a static config:

server.resource(
  "config",
  "config://app",
  async (uri) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({ env: "dev", version: "0.1.0" }, null, 2),
      },
    ],
  })
);

The client can list resources with resources/list and read one with resources/read. In Claude Desktop, these get attached to the conversation when the user mentions them in chat.

Step 6: Add a prompt template

Prompts are parameterized message templates the user can trigger from the client UI. Example:

server.prompt(
  "review-code",
  "Ask the model to review a code snippet for bugs",
  { code: z.string() },
  ({ code }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Please review this code for bugs, security issues, and clarity:\n\n\`\`\`\n${code}\n\`\`\``,
        },
      },
    ],
  })
);

Step 7: Make it shippable

Add a README.md that shows how to install and configure. Then anyone can run it with:

npx my-mcp-server

For the Streamable HTTP transport (useful for shared servers), swap the transport:

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";

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

const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: undefined, // or a function for stateful sessions
});

app.post("/mcp", async (req, res) => {
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000, () => console.log("MCP server on http://localhost:3000/mcp"));

Note: the StreamableHTTPServerTransport requires express to be installed separately (npm install express), and in production you will want to add authentication, since anyone who can reach the endpoint can drive your tools.

Debugging tips

  • Use the MCP Inspector to test your server without a real client: npx @modelcontextprotocol/inspector node build/index.js
  • Log to stderr, not stdout — stdout is reserved for JSON-RPC messages and any other text will break the stdio transport
  • If Claude Desktop says your server failed to start, check the logs at ~/Library/Logs/Claude/mcp.log (macOS)
  • For HTTP transports, the inspector at /mcp will show you the full request/response JSON

When MCP makes sense (and when it does not)

MCP is a fit when:

  • You want any MCP-compatible client (Claude Desktop, Cursor, Continue, custom) to use the same integration
  • You are building a reusable integration you want to publish (one server, many hosts)
  • You want the LLM to discover capabilities at runtime instead of hardcoding tool lists

It is overkill when:

  • You only target one specific LLM SDK and one specific app
  • The integration is a one-off prototype
  • You need streaming responses back to the LLM mid-call (MCP tools return a single result per call, though they can be large)

For most production agent work today, MCP is the right default. The ecosystem already has servers for Postgres, GitHub, Notion, Slack, Sentry, and dozens more. Building your own is a 30-minute job once you have seen the pattern.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation