CodexSpot

How to Build Your Own MCP Server

March 15, 2026 · 4 min read

TL;DR

  • An MCP server is a process that exposes tools, resources, and prompts over a standard transport (stdio or HTTP)
  • The TypeScript SDK handles protocol framing — you define tools as typed functions and the SDK does the rest
  • Test locally with Claude Desktop before deploying; the everything-server is a useful reference implementation
  • Keep tools focused and idempotent where possible; avoid side effects that are hard to undo

Building your own MCP server lets you give AI assistants access to any internal system, API, or data source you control. If your company has an internal tool, a proprietary database, or a custom workflow that no existing server covers, a custom server is the right answer.

This guide walks through building a working MCP server from scratch using the official TypeScript SDK. By the end you will have a server that exposes a real tool, connects to Claude Desktop, and is ready to extend.

What an MCP Server Actually Is

Before writing code, it helps to be precise about what an MCP server is at the protocol level.

An MCP server is a process that communicates over a defined transport (stdio or HTTP/SSE) using JSON-RPC 2.0 messages. It implements a handful of methods:

  • initialize / initialized — handshake with the client
  • tools/list — return available tools
  • tools/call — execute a tool and return a result
  • resources/list / resources/read — optional: expose file-like data
  • prompts/list / prompts/get — optional: expose prompt templates

The TypeScript and Python SDKs handle all of this framing. You define what your tools do; the SDK handles the protocol.

Prerequisites

  • Node.js 18 or later
  • npm or pnpm
  • Claude Desktop (for local testing)
  • Basic TypeScript knowledge

Project Setup

Create a new directory and initialize it:

bash
mkdir my-mcp-server
cd my-mcp-server
npm init -y

Install the MCP SDK and TypeScript:

bash
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node tsx

Create a tsconfig.json:

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

Add a build script to package.json:

json
{
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts"
  }
}

Writing Your First Tool

Create src/index.ts. We will build a server that exposes a get_weather tool — a simple example that hits a public API and returns structured data.

typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

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

Declaring Tools

Register a handler for tools/list to tell clients what your server can do:

typescript
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "get_weather",
        description:
          "Get the current weather for a city. Returns temperature in Celsius and a short condition description.",
        inputSchema: {
          type: "object",
          properties: {
            city: {
              type: "string",
              description: "The city name, e.g. 'London' or 'New York'",
            },
          },
          required: ["city"],
        },
      },
    ],
  };
});

The inputSchema is a JSON Schema object. Claude uses it to understand what arguments to pass. Be specific in your descriptions — vague schemas lead to incorrect invocations.

Implementing Tools

Register a handler for tools/call:

typescript
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "get_weather") {
    const { city } = request.params.arguments as { city: string };

    // Replace with a real weather API in production
    const response = await fetch(
      `https://wttr.in/${encodeURIComponent(city)}?format=j1`
    );

    if (!response.ok) {
      return {
        content: [
          {
            type: "text",
            text: `Failed to fetch weather for ${city}: HTTP ${response.status}`,
          },
        ],
        isError: true,
      };
    }

    const data = (await response.json()) as {
      current_condition: Array<{
        temp_C: string;
        weatherDesc: Array<{ value: string }>;
      }>;
    };
    const condition = data.current_condition[0];

    return {
      content: [
        {
          type: "text",
          text: `Weather in ${city}: ${condition.temp_C}°C, ${condition.weatherDesc[0].value}`,
        },
      ],
    };
  }

  return {
    content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
    isError: true,
  };
});

Starting the Transport

Add the transport setup at the bottom of the file:

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

main().catch((err) => {
  console.error("Server error:", err);
  process.exit(1);
});

Note that output goes to stderr for logs — stdout is reserved for the MCP protocol messages.

Adding Resources

Resources expose data the AI can read, similar to files. They are optional but useful for structured reference data.

typescript
import {
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

// Add "resources: {}" to capabilities in the Server constructor

server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return {
    resources: [
      {
        uri: "weather://config",
        name: "Weather Server Configuration",
        mimeType: "application/json",
      },
    ],
  };
});

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  if (request.params.uri === "weather://config") {
    return {
      contents: [
        {
          uri: "weather://config",
          mimeType: "application/json",
          text: JSON.stringify({ version: "0.1.0", defaultUnit: "celsius" }),
        },
      ],
    };
  }

  throw new Error(`Resource not found: ${request.params.uri}`);
});

Testing Locally

Build the server

bash
npm run build

Configure Claude Desktop

Open your Claude Desktop configuration file:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Add your server:

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

Restart Claude Desktop. Open a new conversation and type something like "What's the weather in Tokyo?" — Claude should invoke your get_weather tool automatically.

Checking Logs

If the server does not appear, check Claude Desktop's MCP logs:

  • macOS: ~/Library/Logs/Claude/mcp-server-my-mcp-server.log

Most startup errors are caused by incorrect paths, missing node in PATH, or TypeScript compilation errors.

Using tsx for Development

During development, skip the build step and run TypeScript directly:

bash
npx tsx src/index.ts

Update your Claude Desktop config to use tsx:

json
{
  "mcpServers": {
    "my-mcp-server": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/my-mcp-server/src/index.ts"]
    }
  }
}

Handling Environment Variables

Credentials should never be hardcoded. Pass them as environment variables in the config:

json
{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/absolute/path/to/dist/index.js"],
      "env": {
        "WEATHER_API_KEY": "your-key-here"
      }
    }
  }
}

Access them in your server:

typescript
const apiKey = process.env.WEATHER_API_KEY;
if (!apiKey) {
  throw new Error("WEATHER_API_KEY environment variable is required");
}

Common Mistakes

Returning errors incorrectly. Return { content: [...], isError: true } from a tool call rather than throwing an exception. Thrown exceptions crash the server process; isError: true signals a recoverable tool failure.

Writing to stdout. Any console.log() in your server will corrupt the MCP protocol stream. Always use console.error() for logging.

Imprecise input schemas. If your schema says a field is optional but your implementation requires it, Claude will sometimes omit it and your tool will fail. Be accurate.

Large tool responses. Each tool response goes through the AI's context window. Keep responses focused — return the information the AI needs, not entire database dumps.

Reference Implementation

The everything-server from the official MCP repository implements every protocol feature and is the best reference when you are unsure how something should work. Review its source when adding resources, prompts, or sampling support.

Next Steps

Once your server works locally, you can:

  • Publish it to npm so others can install it with npx
  • Deploy it as an HTTP server using StreamableHTTPServerTransport for remote access
  • Add prompt templates to guide how Claude uses your tools
  • Register it on the MCP server directory for broader discovery

A working MCP server is typically 100-200 lines of TypeScript. The protocol is simple by design — focus on making your tools reliable and your schemas precise, and the rest takes care of itself.

Referenced in this post