codeTutorials6 min read

How to Build Your Own MCP Server (Step-by-Step)

A practical tutorial for building a custom MCP server from scratch using TypeScript. Covers tool registration, input validation, transport modes, and connecting to AI coding tools.

personAgent Shelf Teamcalendar_todayApril 10, 2026schedule6 min read

Why build a custom MCP server?

There are thousands of MCP servers available for common tools — databases, browsers, APIs. But sometimes you need something specific: access to an internal tool, a custom workflow, or a proprietary API that no public MCP server covers.

Building your own MCP server is simpler than it sounds. At its core, an MCP server is a process that registers tools (functions your AI can call) and communicates over a standard protocol. If you can write a function, you can build an MCP server.

This tutorial walks through building one from scratch in TypeScript using the official MCP SDK.

What you'll build

A simple MCP server that exposes two tools:

  1. check_status — checks whether a URL returns a healthy response
  2. get_headers — returns the HTTP headers from a URL

These are basic examples, but the pattern applies to any tool: database queries, API calls, file operations, or internal service integrations.

Prerequisites

  • Node.js 18+
  • Basic TypeScript familiarity
  • An MCP-compatible AI tool (Claude Code, Cursor, Windsurf, VS Code with Copilot, etc.)

Step 1: Set up the project

Create a new directory and initialize it:

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

Create a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "build",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

Update package.json to add the build script and set the type:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node build/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: "my-mcp-server",
  version: "1.0.0",
});

This creates a server instance with a name and version. The MCP SDK handles the protocol details — you just register tools.

Step 3: Register tools

Add tools to the server. Each tool has a name, description, input schema (using Zod), and a handler function:

server.tool(
  "check_status",
  "Check if a URL returns a healthy HTTP response",
  {
    url: z.string().url().describe("The URL to check"),
  },
  async ({ url }) => {
    try {
      const response = await fetch(url, {
        method: "HEAD",
        signal: AbortSignal.timeout(10000),
      });

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                url,
                status: response.status,
                ok: response.ok,
                statusText: response.statusText,
              },
              null,
              2
            ),
          },
        ],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Failed to reach ${url}: ${error instanceof Error ? error.message : "Unknown error"}`,
          },
        ],
        isError: true,
      };
    }
  }
);

server.tool(
  "get_headers",
  "Get the HTTP response headers from a URL",
  {
    url: z.string().url().describe("The URL to fetch headers from"),
  },
  async ({ url }) => {
    try {
      const response = await fetch(url, {
        method: "HEAD",
        signal: AbortSignal.timeout(10000),
      });

      const headers: Record<string, string> = {};
      response.headers.forEach((value, key) => {
        headers[key] = value;
      });

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(headers, null, 2),
          },
        ],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Failed to fetch headers from ${url}: ${error instanceof Error ? error.message : "Unknown error"}`,
          },
        ],
        isError: true,
      };
    }
  }
);

Key things to notice:

  • Zod schemas define the input. The MCP SDK uses these to validate inputs before your handler runs and to tell the AI what parameters are available.
  • Return format is always { content: [{ type: "text", text: "..." }] }. You can return multiple content blocks.
  • Error handling uses isError: true to signal failures to the AI.
  • Timeouts prevent your server from hanging on slow requests.

Step 4: Start the transport

Add the startup code at the bottom of src/index.ts:

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio");
}

main().catch(console.error);

The stdio transport communicates over standard input/output — the AI tool spawns your server as a child process and sends/receives JSON messages through stdin/stdout. Use console.error for debug output so it doesn't interfere with the protocol on stdout.

Step 5: Build and test

npm run build

You can test the server manually by running it and sending JSON on stdin, but the easier approach is to connect it to your AI tool.

Step 6: Connect to your AI tool

Claude Code

Add to your project's .mcp.json:

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

Cursor

Add to your Cursor MCP settings:

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

Windsurf

Add to your Windsurf MCP configuration with the same format.

After configuring, restart your AI tool. You should see the tools available. Try asking: "Check if https://example.com is healthy" — the AI will call your check_status tool.

Adding more tools

The pattern is the same for any tool. Here's a more complex example — a tool that queries a SQLite database:

import Database from "better-sqlite3";

server.tool(
  "query_db",
  "Run a read-only SQL query against the project database",
  {
    sql: z.string().describe("The SQL query to run (SELECT only)"),
  },
  async ({ sql }) => {
    // Safety: only allow SELECT queries
    if (!sql.trim().toUpperCase().startsWith("SELECT")) {
      return {
        content: [{ type: "text", text: "Only SELECT queries are allowed" }],
        isError: true,
      };
    }

    const db = new Database("./data.db", { readonly: true });
    try {
      const rows = db.prepare(sql).all();
      return {
        content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Query failed: ${error instanceof Error ? error.message : "Unknown error"}`,
          },
        ],
        isError: true,
      };
    } finally {
      db.close();
    }
  }
);

Notice the input validation: only SELECT queries are allowed. This is important — your MCP server is the security boundary. The AI can call any tool you expose, so validate inputs carefully.

Best practices

Validate all inputs

The AI constructs tool inputs based on conversation context. It usually gets them right, but treat all inputs as untrusted. Validate URLs, sanitize SQL, check file paths for traversal, and reject malformed requests.

Use timeouts

Every external call should have a timeout. If your MCP server hangs, the AI tool hangs too. Use AbortSignal.timeout() for fetch calls and equivalent mechanisms for other operations.

Return structured data

Return JSON when possible. The AI can parse and reason about structured data more effectively than free-form text. Use JSON.stringify(data, null, 2) for readable output.

Handle errors gracefully

Set isError: true on error responses. This tells the AI that something went wrong so it can report the failure or try an alternative approach rather than treating error text as a successful result.

Keep tools focused

Each tool should do one thing well. Instead of a single database tool that handles queries, inserts, and schema inspection, create separate query_db, list_tables, and describe_table tools. This makes it easier for the AI to choose the right tool for each task.

Write clear descriptions

The tool description and parameter descriptions are what the AI reads to decide when and how to use your tool. Be specific: "Check if a URL returns a healthy HTTP response" is better than "Check a URL." Include constraints: "SELECT queries only" or "Maximum 100 results."

Transport modes

This tutorial uses stdio transport (the most common for local servers). The MCP protocol also supports:

  • Streamable HTTP — the server runs as an HTTP endpoint. Useful for remote/hosted servers. No local installation required for users.
  • SSE (Server-Sent Events) — an older HTTP-based transport, largely superseded by Streamable HTTP.

For hosted servers, Streamable HTTP lets users connect by just adding a URL to their config — no download or build step. The AgentShelf MCP server uses this approach.

Next steps

sellmcptutorialtypescriptdeveloper-toolsbuild
group

Written by Agent Shelf Team

The Agent Shelf team builds open infrastructure for AI agent discovery and distribution. We maintain the Agent Shelf registry, MCP server, and publish skill.

arrow_backAll posts