Dev Tools·
advanced
·16 min read·Apr 4, 2026·Updated Oct 10, 2026
By Rad Tome·Lead AI Systems Architect·How we verify this

How to Build a Custom MCP Server from Scratch

Comprehensive guide to building custom Model Context Protocol (MCP) servers using the official TypeScript and Python SDKs. Includes Zod schemas, inspector testing, and error handling.

custom serverTypeScriptPythonSDKdevelopmenttoolsarchitecture
Interactive Tool
1-Click Export

Generate & Validate Multi-Client MCP Config

One-click export with environment variables & path locators for Claude Desktop, Cursor, Windsurf, and OpenAI Codex CLI.

Open in Generator

Build a Custom MCP Server from Scratch

While the open-source community maintains dozens of pre-built Model Context Protocol (MCP) servers for databases, version control, and SaaS platforms, proprietary internal APIs and domain-specific workflows require building custom MCP servers.

This guide walks through building a production-grade custom MCP server from scratch. You will learn how to initialize an MCP server using the official @modelcontextprotocol/sdk, define strictly typed tools with Zod, expose cacheable read-only resources, test with the interactive MCP Inspector, and deploy across Claude Desktop, Cursor, and the OpenAI Codex CLI.


Architectural Principles of MCP Server Design

An MCP server is an isolated process that handles JSON-RPC 2.0 messages sent by an MCP client. When designing custom servers, adhere to four foundational principles:

  1. ▸Protocol Stream Isolation: In standard input/output (stdio) mode, stdout is exclusively reserved for framed JSON-RPC messages. Any application logging directed to stdout corrupts the protocol stream and immediately crashes the client. All logging, telemetry, and debugging must go to stderr.
  2. ▸Schema Declarations as Prompts: The description and parameter properties in your tool definition are not just documentation—they act as the system prompt guiding the LLM's tool selection logic. Clear, unambiguous descriptions prevent hallucinated invocations.
  3. ▸Strict Parameter Validation: LLMs occasionally pass stringified numbers or omit optional attributes. Always validate incoming parameters against a strict runtime schema validator (such as Zod in TypeScript or Pydantic in Python).
  4. ▸Idempotency and Error Isolation: Tools should fail gracefully by returning an error block (isError: true) with descriptive text rather than throwing unhandled process exceptions that terminate the server process.

Part 1: TypeScript Implementation with the Official SDK

1. Project Initialization

Create a dedicated directory and configure modern Node.js ESM packaging:

bash
mkdir mcp-custom-analytics
cd mcp-custom-analytics
npm init -y

Install the official protocol SDK and schema validation library:

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

Generate a tsconfig.json configured for NodeNext ESM module resolution:

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

Update your package.json to enable ES modules and expose an executable binary:

json
{
  "name": "mcp-custom-analytics",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "mcp-custom-analytics": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx src/index.ts"
  }
}

2. Complete Server Implementation (src/index.ts)

Create src/index.ts. This server exposes:

  • ▸A Tool (calculate_mortgage): Computes monthly loan amortization.
  • ▸A Resource (analytics://system/health): Returns live CPU, memory, and runtime metrics.
typescript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
  ErrorCode,
  McpError
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import os from 'os';

// 1. Initialize server metadata and capability manifest
const server = new Server(
  {
    name: 'custom-analytics-server',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
      resources: {},
    },
  }
);

// 2. Define Parameter Validation Schemas
const MortgageParamsSchema = z.object({
  principal: z.number().positive({ message: "Principal must be greater than 0" }),
  annualRatePct: z.number().min(0).max(100, { message: "Rate must be between 0 and 100" }),
  termYears: z.number().int().positive({ message: "Term must be a positive integer" }),
});

// 3. Declare Available Tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: 'calculate_mortgage',
        description: 'Calculate monthly mortgage payment, total interest, and full amortization cost based on principal, annual interest rate percentage, and loan term in years.',
        inputSchema: {
          type: 'object',
          properties: {
            principal: {
              type: 'number',
              description: 'The total loan amount in USD (e.g. 350000)',
            },
            annualRatePct: {
              type: 'number',
              description: 'Annual interest rate as a percentage (e.g. 6.5 for 6.5%)',
            },
            termYears: {
              type: 'number',
              description: 'Duration of the loan in years (e.g. 15 or 30)',
            },
          },
          required: ['principal', 'annualRatePct', 'termYears'],
        },
      },
    ],
  };
});

// 4. Handle Tool Execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === 'calculate_mortgage') {
    try {
      // Validate inputs using Zod
      const parsed = MortgageParamsSchema.safeParse(args);
      if (!parsed.success) {
        return {
          content: [
            {
              type: 'text',
              text: `Invalid parameters: ${parsed.error.issues.map((i) => i.message).join(', ')}`,
            },
          ],
          isError: true,
        };
      }

      const { principal, annualRatePct, termYears } = parsed.data;
      const monthlyRate = annualRatePct / 100 / 12;
      const totalPayments = termYears * 12;

      let monthlyPayment: number;
      if (monthlyRate === 0) {
        monthlyPayment = principal / totalPayments;
      } else {
        monthlyPayment =
          (principal * (monthlyRate * Math.pow(1 + monthlyRate, totalPayments))) /
          (Math.pow(1 + monthlyRate, totalPayments) - 1);
      }

      const totalCost = monthlyPayment * totalPayments;
      const totalInterest = totalCost - principal;

      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify(
              {
                principal,
                annualRatePct: `${annualRatePct}%`,
                termYears,
                monthlyPayment: Number(monthlyPayment.toFixed(2)),
                totalInterest: Number(totalInterest.toFixed(2)),
                totalCost: Number(totalCost.toFixed(2)),
              },
              null,
              2
            ),
          },
        ],
      };
    } catch (err: any) {
      return {
        content: [{ type: 'text', text: `Calculation error: ${err.message}` }],
        isError: true,
      };
    }
  }

  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
});

// 5. Declare Available Resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return {
    resources: [
      {
        uri: 'analytics://system/health',
        name: 'Host System Health Metrics',
        description: 'Real-time CPU, RAM, and platform telemetry of the host execution environment.',
        mimeType: 'application/json',
      },
    ],
  };
});

// 6. Handle Resource Reads
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  if (request.params.uri === 'analytics://system/health') {
    const memoryUsage = process.memoryUsage();
    const metrics = {
      platform: os.platform(),
      arch: os.arch(),
      totalMemoryMB: Math.round(os.totalmem() / (1024 * 1024)),
      freeMemoryMB: Math.round(os.freemem() / (1024 * 1024)),
      processRssMB: Math.round(memoryUsage.rss / (1024 * 1024)),
      uptimeSeconds: Math.round(process.uptime()),
      timestamp: new Date().toISOString(),
    };

    return {
      contents: [
        {
          uri: 'analytics://system/health',
          mimeType: 'application/json',
          text: JSON.stringify(metrics, null, 2),
        },
      ],
    };
  }

  throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${request.params.uri}`);
});

// 7. Connect via Stdio Transport
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  // ALWAYS log to stderr — stdout is reserved for JSON-RPC 2.0 framing
  console.error('Custom Analytics MCP Server running on stdio transport.');
}

main().catch((error) => {
  console.error('Fatal server startup error:', error);
  process.exit(1);
});

Part 2: Interactive Testing with the MCP Inspector

Before registering your server in Claude Desktop or Cursor, test it with the official MCP Inspector tool. The inspector provides a visual browser interface to trigger initialization, list tools, and inspect JSON-RPC message payloads:

bash
npx @modelcontextprotocol/inspector npx tsx src/index.ts
  1. ▸Open the inspector URL in your browser ( or port shown in terminal).
  2. ▸Click Connect to trigger the initialization handshake.
  3. ▸Switch to the Tools tab and verify calculate_mortgage appears.
  4. ▸Input test parameters (principal: 300000, annualRatePct: 6.5, termYears: 30) and click Run Tool.
  5. ▸Verify the returned JSON payload and check the Raw Protocol Messages tab to see exact JSON-RPC request/response frames.

Part 3: Python Alternative with FastMCP

If your team builds AI infrastructure in Python, use the mcp Python package with the high-level FastMCP interface:

bash
pip install "mcp[cli]"

Create server.py:

python
from mcp.server.fastmcp import FastMCP
import sys

mcp = FastMCP("Python Analytics Server")

@mcp.tool()
def calculate_growth(principal: float, rate_pct: float, years: int) -> dict:
    """Calculate compound interest growth over time."""
    total = principal * ((1 + (rate_pct / 100)) ** years)
    return {
        "initial": principal,
        "rate": f"{rate_pct}%",
        "years": years,
        "projected_total": round(total, 2),
        "interest_earned": round(total - principal, 2)
    }

if __name__ == "__main__":
    # Runs on standard stdio
    mcp.run(transport="stdio")

Step 4: Registering in AI Clients

Claude Desktop Configuration

Add the absolute path to your compiled server in claude_desktop_config.json:

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

OpenAI Codex CLI Registration

Register using codex mcp add:

bash
codex mcp add --name custom-analytics --command "node /absolute/path/to/mcp-custom-analytics/dist/index.js"

Summary & Next Steps

Building custom MCP servers unlocks private databases, internal REST microservices, and specialized domain logic for AI agents. By enforcing strict parameter schemas, redirecting logs to stderr, and verifying with the MCP Inspector, you create reliable tools ready for production agent workflows.

Technical Changelog

Weekly Protocol Diffs & Verified Drops

New server guides, benchmarks, and security audits. No spam. Unsubscribe anytime.

Zero marketing spam. 1-click unsubscribe anytime.

Ready to Deploy?

Build your full agent toolstack in the Visual Generator

Combine How to Build a Custom with databases, search APIs, and memory graphs in a single configuration file.

Customize in Generator
Developer Verification & Feedback
0 reports

Did this setup guide work with your AI host?

Help test this configuration across Claude, Cursor, and Codex CLI. Be the first to report:

How to Build a Custom MCP Server from Scratch FAQ

What is the How to Build a Custom MCP Server from Scratch?

Comprehensive guide to building custom Model Context Protocol (MCP) servers using the official TypeScript and Python SDKs. Includes Zod schemas, inspector testing, and error handling.

How do I configure How to Build a Custom MCP Server from Scratch in Claude Desktop or Cursor?

You can copy the configuration JSON from our guide or launch the interactive MCP Codex Config Generator at https://mcp-codex.com/generator to export valid configs in 1 click.

Can I use How to Build a Custom MCP Server from Scratch with the OpenAI Codex CLI?

Yes, OpenAI Codex CLI supports Model Context Protocol. You can add it directly to ~/.codex/config.toml or pass arguments to codex mcp add.

RT

Written by Rad Tome

Lead AI Systems Architect & Maintainer, MCP Codex

@RadTome

Specializing in Model Context Protocol (MCP) integrations, autonomous AI agent orchestration, and distributed developer toolchains. Researches and benchmarks production MCP client-server architectures across OpenAI Codex, Claude, and Cursor.

Editorial Integrity: Package names are checked against the npm registry and repositories against the GitHub API. Where a package could not be verified, the guide says so instead of giving you a command to run.

Related Guides