MCP Library updated 20 min read

Building Your First MCP Server: Developer Tutorial

Learn how to build a custom MCP server from scratch. Step-by-step tutorial with TypeScript and Python examples. Covers tools, resources, prompts, and testing.

RP

Rajesh Praharaj

Jul 27, 2025 · Updated Dec 26, 2025

Building Your First MCP Server: Developer Tutorial

TL;DR - Build an MCP Server in 10 Minutes

Want to build a custom MCP server? Here’s the fastest path with TypeScript:

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

# Create server file (see full code below)
# Then build and test
npx tsc
npx @modelcontextprotocol/inspector node dist/index.js

What you’ll build:

  • 🔧 Tools: Actions the AI can perform
  • 📚 Resources: Data the AI can read
  • 💬 Prompts: Reusable prompt templates

Key takeaways:

  • MCP servers expose capabilities via a JSON-RPC protocol
  • Use the official SDK (TypeScript or Python)
  • Test with MCP Inspector before connecting to Claude
  • Publish to npm/PyPI for others to use

💡 Full tutorial below with complete working examples in both TypeScript and Python. For an introduction to what MCP is, see the MCP Introduction guide.

🆕 December 2025: MCP SDKs now have 97 million monthly downloads and the protocol is maintained by the Linux Foundation’s Agentic AI Foundation.


MCP Server Architecture

Before building, let’s understand how MCP servers work:

The Three Capabilities

MCP servers can expose three types of capabilities. For more on how AI agents use these capabilities, see the AI Agents guide.

CapabilityWhat It IsExample
ToolsActions the AI can performsend_email, create_issue, query_database
ResourcesData the AI can readFiles, database records, API data
PromptsReusable prompt templatesCode review template, bug report format
┌─────────────────────────────────────────────────────────────┐
│                      MCP Server                             │
├─────────────────┬─────────────────┬─────────────────────────┤
│     Tools       │    Resources    │        Prompts          │
│  (AI actions)   │   (AI reads)    │   (AI templates)        │
├─────────────────┼─────────────────┼─────────────────────────┤
│ • get_weather   │ • config://app  │ • code_review           │
│ • send_email    │ • logs://today  │ • bug_report            │
│ • create_task   │ • users://list  │ • feature_request       │
└─────────────────┴─────────────────┴─────────────────────────┘

Request/Response Flow

┌───────────────┐         ┌───────────────┐         ┌───────────────┐
│  AI Host      │         │  MCP Client   │         │  MCP Server   │
│ (Claude/      │◄───────►│  (in host)    │◄───────►│  (your code)  │
│  Cursor)      │         │               │         │               │
└───────────────┘         └───────────────┘         └───────────────┘
                                │                         │
                                ▼                         ▼
                          JSON-RPC 2.0              Your Logic
                          over stdio                (API calls,
                          or HTTP                   file access,
                                                    databases)

Transport Options

TransportWhen to UseProsCons
stdioLocal serversSimple, fastLocal only
HTTP/SSERemote serversNetwork-accessibleMore setup

For most use cases, stdio is recommended. For running AI tools in terminals, see the AI-Powered IDEs guide.


SDK Comparison

Choose the SDK that matches your ecosystem:

SDKLanguageMaintainerBest ForDownloads
typescript-sdkTypeScript/JSAAIFWeb devs, Node.js~50M/mo
python-sdkPythonAAIFData science, ML~30M/mo
go-mcpGoGoogleSystem tools, CLIGrowing
rust-sdkRustAAIFHigh-performanceGrowing
csharp-sdkC#Microsoft.NET applicationsGrowing
kotlin-sdkKotlinJetBrainsJVM/AndroidGrowing
ruby-sdkRubyCommunityRuby on RailsNew

💡 Recommendation: Start with TypeScript or Python - they have the best documentation and 97M+ combined monthly downloads. If you’re building tools for terminals, also check our CLI Tools for AI guide.

November 2025 SDK Features

The latest MCP specification (November 2025) added:

FeatureWhat It DoesUse Case
Tasks PrimitiveHandle long-running async operationsBackground jobs, large data processing
Streamable HTTPReal-time streaming over HTTPLive data feeds, progress updates
Server IdentityCryptographic server authenticationEnterprise security, multi-tenant
Official ExtensionsStandardized extension mechanismCustom capabilities

Building a Tool Server (TypeScript)

Let’s build a complete MCP server that provides weather information. For prompt engineering tips when testing your server, see the Prompt Engineering Fundamentals guide.

Step 1: Project Setup

# Create project directory
mkdir weather-mcp-server
cd weather-mcp-server

# Initialize npm project
npm init -y

# Install dependencies
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

# Initialize TypeScript
npx tsc --init

Update tsconfig.json:

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

Update package.json:

{
  "name": "weather-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "bin": {
    "weather-mcp-server": "dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsc --watch"
  }
}

Step 2: Create the Server

Create src/index.ts:

#!/usr/bin/env node

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

// Define input schema for validation
const GetWeatherSchema = z.object({
  city: z.string().describe("City name to get weather for"),
  units: z
    .enum(["celsius", "fahrenheit"])
    .optional()
    .default("celsius")
    .describe("Temperature units"),
});

// Define available tools
const TOOLS: Tool[] = [
  {
    name: "get_weather",
    description:
      "Get the current weather for a city. Returns temperature, conditions, and humidity.",
    inputSchema: {
      type: "object",
      properties: {
        city: {
          type: "string",
          description: "City name to get weather for",
        },
        units: {
          type: "string",
          enum: ["celsius", "fahrenheit"],
          default: "celsius",
          description: "Temperature units",
        },
      },
      required: ["city"],
    },
  },
  {
    name: "get_forecast",
    description: "Get a 5-day weather forecast for a city.",
    inputSchema: {
      type: "object",
      properties: {
        city: {
          type: "string",
          description: "City name to get forecast for",
        },
        days: {
          type: "number",
          description: "Number of days (1-5)",
          default: 5,
        },
      },
      required: ["city"],
    },
  },
];

// Simulated weather data (replace with real API in production)
function getWeatherData(city: string, units: string) {
  // In a real server, you'd call a weather API here
  const weatherData: Record<string, { temp: number; condition: string; humidity: number }> = {
    "new york": { temp: 22, condition: "Partly Cloudy", humidity: 65 },
    "london": { temp: 15, condition: "Rainy", humidity: 80 },
    "tokyo": { temp: 28, condition: "Sunny", humidity: 70 },
    "paris": { temp: 18, condition: "Cloudy", humidity: 75 },
    "sydney": { temp: 25, condition: "Clear", humidity: 55 },
  };

  const cityLower = city.toLowerCase();
  const data = weatherData[cityLower];

  if (!data) {
    return { error: `Weather data not available for ${city}` };
  }

  let temp = data.temp;
  if (units === "fahrenheit") {
    temp = Math.round((temp * 9) / 5 + 32);
  }

  return {
    city: city,
    temperature: temp,
    units: units,
    condition: data.condition,
    humidity: data.humidity,
  };
}

function getForecastData(city: string, days: number) {
  const conditions = ["Sunny", "Cloudy", "Rainy", "Partly Cloudy", "Clear"];
  const forecast = [];

  for (let i = 0; i < Math.min(days, 5); i++) {
    const date = new Date();
    date.setDate(date.getDate() + i + 1);
    forecast.push({
      date: date.toISOString().split("T")[0],
      high: Math.round(15 + Math.random() * 15),
      low: Math.round(5 + Math.random() * 10),
      condition: conditions[Math.floor(Math.random() * conditions.length)],
    });
  }

  return { city, forecast };
}

// Create server instance
const server = new Server(
  {
    name: "weather-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

// Handle list tools request
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return { tools: TOOLS };
});

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

  try {
    switch (name) {
      case "get_weather": {
        const validatedArgs = GetWeatherSchema.parse(args);
        const result = getWeatherData(validatedArgs.city, validatedArgs.units);

        if ("error" in result) {
          return {
            content: [{ type: "text", text: result.error }],
            isError: true,
          };
        }

        return {
          content: [
            {
              type: "text",
              text: `Weather in ${result.city}:
• Temperature: ${result.temperature}°${result.units === "celsius" ? "C" : "F"}
• Condition: ${result.condition}
• Humidity: ${result.humidity}%`,
            },
          ],
        };
      }

      case "get_forecast": {
        const city = (args as { city: string; days?: number }).city;
        const days = (args as { city: string; days?: number }).days || 5;
        const result = getForecastData(city, days);

        const forecastText = result.forecast
          .map(
            (day) =>
              `${day.date}: High ${day.high}°C, Low ${day.low}°C, ${day.condition}`
          )
          .join("\n");

        return {
          content: [
            {
              type: "text",
              text: `5-Day Forecast for ${result.city}:\n${forecastText}`,
            },
          ],
        };
      }

      default:
        return {
          content: [{ type: "text", text: `Unknown tool: ${name}` }],
          isError: true,
        };
    }
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : String(error);
    return {
      content: [{ type: "text", text: `Error: ${errorMessage}` }],
      isError: true,
    };
  }
});

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

main().catch(console.error);

Step 3: Build and Test

# Build the TypeScript
npm run build

# Test with MCP Inspector
npx @modelcontextprotocol/inspector node dist/index.js

The MCP Inspector provides a web UI to test your server:

  1. Open the URL shown in terminal (usually http://localhost:5173)
  2. Click “Tools” to see your available tools
  3. Click “get_weather” and enter a city name
  4. See the response from your server

Step 4: Connect to Claude Desktop

Add to your Claude Desktop config:

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

Now ask Claude:

You: What's the weather in Tokyo?
Claude: Let me check the weather for you...

Weather in Tokyo:
• Temperature: 28°C
• Condition: Sunny
• Humidity: 70%

Building a Resource Server (Python)

Now let’s build a Python server that exposes resources (data the AI can read).

Step 1: Project Setup

# Create project directory
mkdir notes-mcp-server
cd notes-mcp-server

# Create virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install dependencies
pip install mcp pydantic

Step 2: Create the Server

Create server.py:

#!/usr/bin/env python3

import asyncio
import json
from datetime import datetime
from pathlib import Path
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import (
    Resource,
    TextContent,
    Tool,
    CallToolResult,
)

# Initialize server
app = Server("notes-server")

# In-memory notes storage (use a database in production)
notes: dict[str, dict] = {
    "note-1": {
        "id": "note-1",
        "title": "Meeting Notes",
        "content": "Discussed Q4 roadmap. Key decisions: Launch feature X by December.",
        "created": "2024-12-20T10:00:00Z",
        "tags": ["meetings", "roadmap"],
    },
    "note-2": {
        "id": "note-2",
        "title": "Project Ideas",
        "content": "1. Build an AI assistant\n2. Create a dashboard\n3. Automate reports",
        "created": "2024-12-21T14:30:00Z",
        "tags": ["ideas", "projects"],
    },
    "note-3": {
        "id": "note-3",
        "title": "Reading List",
        "content": "- Clean Code by Robert Martin\n- Designing Data-Intensive Applications\n- The Pragmatic Programmer",
        "created": "2024-12-22T09:00:00Z",
        "tags": ["books", "learning"],
    },
}


# === RESOURCES ===

@app.list_resources()
async def list_resources() -> list[Resource]:
    """List all available resources."""
    resources = [
        Resource(
            uri="notes://all",
            name="All Notes",
            description="List of all notes with titles and IDs",
            mimeType="application/json",
        ),
        Resource(
            uri="notes://recent",
            name="Recent Notes",
            description="Notes from the last 7 days",
            mimeType="application/json",
        ),
    ]
    
    # Add individual note resources
    for note_id, note in notes.items():
        resources.append(
            Resource(
                uri=f"notes://{note_id}",
                name=note["title"],
                description=f"Note: {note['title']}",
                mimeType="text/plain",
            )
        )
    
    return resources


@app.read_resource()
async def read_resource(uri: str) -> str:
    """Read a specific resource by URI."""
    
    if uri == "notes://all":
        # Return list of all notes
        note_list = [
            {"id": n["id"], "title": n["title"], "tags": n["tags"]}
            for n in notes.values()
        ]
        return json.dumps(note_list, indent=2)
    
    elif uri == "notes://recent":
        # Return recent notes (simplified - would check dates in production)
        recent = list(notes.values())[-3:]
        return json.dumps(recent, indent=2)
    
    elif uri.startswith("notes://"):
        # Return individual note
        note_id = uri.replace("notes://", "")
        if note_id in notes:
            note = notes[note_id]
            return f"""# {note['title']}

Created: {note['created']}
Tags: {', '.join(note['tags'])}

{note['content']}"""
        else:
            raise ValueError(f"Note not found: {note_id}")
    
    raise ValueError(f"Unknown resource URI: {uri}")


# === TOOLS ===

@app.list_tools()
async def list_tools() -> list[Tool]:
    """List available tools."""
    return [
        Tool(
            name="create_note",
            description="Create a new note",
            inputSchema={
                "type": "object",
                "properties": {
                    "title": {
                        "type": "string",
                        "description": "Note title",
                    },
                    "content": {
                        "type": "string",
                        "description": "Note content (markdown supported)",
                    },
                    "tags": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Tags for organizing the note",
                    },
                },
                "required": ["title", "content"],
            },
        ),
        Tool(
            name="search_notes",
            description="Search notes by keyword or tag",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Search query",
                    },
                    "tag": {
                        "type": "string",
                        "description": "Filter by tag",
                    },
                },
            },
        ),
        Tool(
            name="delete_note",
            description="Delete a note by ID",
            inputSchema={
                "type": "object",
                "properties": {
                    "note_id": {
                        "type": "string",
                        "description": "ID of the note to delete",
                    },
                },
                "required": ["note_id"],
            },
        ),
    ]


@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    """Handle tool calls."""
    
    if name == "create_note":
        # Generate new ID
        new_id = f"note-{len(notes) + 1}"
        
        # Create note
        new_note = {
            "id": new_id,
            "title": arguments["title"],
            "content": arguments["content"],
            "created": datetime.now().isoformat() + "Z",
            "tags": arguments.get("tags", []),
        }
        notes[new_id] = new_note
        
        return [
            TextContent(
                type="text",
                text=f"Created note '{arguments['title']}' with ID: {new_id}",
            )
        ]
    
    elif name == "search_notes":
        query = arguments.get("query", "").lower()
        tag = arguments.get("tag", "").lower()
        
        results = []
        for note in notes.values():
            # Match by query in title or content
            if query and (
                query in note["title"].lower() or 
                query in note["content"].lower()
            ):
                results.append(note)
            # Match by tag
            elif tag and tag in [t.lower() for t in note["tags"]]:
                results.append(note)
            # If no filters, return all
            elif not query and not tag:
                results.append(note)
        
        if not results:
            return [TextContent(type="text", text="No notes found")]
        
        result_text = "Found notes:\n" + "\n".join(
            f"- [{n['id']}] {n['title']}" for n in results
        )
        return [TextContent(type="text", text=result_text)]
    
    elif name == "delete_note":
        note_id = arguments["note_id"]
        if note_id in notes:
            deleted = notes.pop(note_id)
            return [
                TextContent(
                    type="text",
                    text=f"Deleted note: {deleted['title']}",
                )
            ]
        else:
            return [
                TextContent(
                    type="text",
                    text=f"Note not found: {note_id}",
                )
            ]
    
    return [TextContent(type="text", text=f"Unknown tool: {name}")]


# === MAIN ===

async def main():
    """Run the server."""
    async with stdio_server() as (read_stream, write_stream):
        await app.run(
            read_stream,
            write_stream,
            app.create_initialization_options(),
        )


if __name__ == "__main__":
    asyncio.run(main())

Step 3: Run and Test

# Make executable
chmod +x server.py

# Test with MCP Inspector
npx @modelcontextprotocol/inspector python server.py

Step 4: Connect to Claude Desktop

{
  "mcpServers": {
    "notes": {
      "command": "python",
      "args": ["/path/to/notes-mcp-server/server.py"]
    }
  }
}

Now you can ask Claude:

You: What notes do I have?
Claude: Let me check your notes...

Found notes:
- [note-1] Meeting Notes
- [note-2] Project Ideas
- [note-3] Reading List

You: Create a new note titled "Shopping List" with content "Milk, Eggs, Bread"
Claude: Created note 'Shopping List' with ID: note-4

Adding Prompts to Your Server

Prompts are reusable templates that help users interact with your server more effectively. For more on crafting effective prompts, see the Advanced Prompt Engineering guide.

TypeScript Example

Add to your TypeScript server:

import {
  ListPromptsRequestSchema,
  GetPromptRequestSchema,
  Prompt,
} from "@modelcontextprotocol/sdk/types.js";

// Define available prompts
const PROMPTS: Prompt[] = [
  {
    name: "weather_report",
    description: "Generate a formatted weather report for a city",
    arguments: [
      {
        name: "city",
        description: "City to generate report for",
        required: true,
      },
      {
        name: "style",
        description: "Report style: brief, detailed, or casual",
        required: false,
      },
    ],
  },
  {
    name: "travel_advisory",
    description: "Generate travel advice based on weather conditions",
    arguments: [
      {
        name: "destination",
        description: "Travel destination city",
        required: true,
      },
    ],
  },
];

// Update server capabilities
const server = new Server(
  { name: "weather-server", version: "1.0.0" },
  {
    capabilities: {
      tools: {},
      prompts: {},  // Add prompts capability
    },
  }
);

// Handle list prompts
server.setRequestHandler(ListPromptsRequestSchema, async () => {
  return { prompts: PROMPTS };
});

// Handle get prompt
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  switch (name) {
    case "weather_report": {
      const city = args?.city || "Unknown City";
      const style = args?.style || "detailed";
      
      return {
        messages: [
          {
            role: "user",
            content: {
              type: "text",
              text: `Generate a ${style} weather report for ${city}. 
First use the get_weather tool to fetch current conditions, 
then format a ${style === "casual" ? "friendly, conversational" : "professional"} report.`,
            },
          },
        ],
      };
    }

    case "travel_advisory": {
      const destination = args?.destination || "Unknown";
      
      return {
        messages: [
          {
            role: "user",
            content: {
              type: "text",
              text: `I'm planning to travel to ${destination}. 
Please check the weather forecast and provide travel advice including:
- What to pack
- Best activities for the weather
- Any weather warnings`,
            },
          },
        ],
      };
    }

    default:
      throw new Error(`Unknown prompt: ${name}`);
  }
});

Testing with MCP Inspector

The MCP Inspector is essential for development. For more AI development tools, see the AI-Powered Workflows guide.

Installation & Usage

# Run inspector with your server
npx @modelcontextprotocol/inspector node dist/index.js

# For Python servers
npx @modelcontextprotocol/inspector python server.py

# With arguments
npx @modelcontextprotocol/inspector node dist/index.js -- --port 8080

Inspector Features

TabWhat You Can Do
ToolsList tools, call them with inputs, see responses
ResourcesBrowse resources, read their contents
PromptsView prompts, test with arguments
LogsSee all JSON-RPC messages

Testing Workflow

  1. Start Inspector with your server
  2. Test Tools: Try each tool with valid and invalid inputs
  3. Test Resources: Read each resource, verify content
  4. Check Errors: Try edge cases, verify error handling
  5. Review Logs: Ensure clean JSON-RPC communication

Error Handling Best Practices

For broader AI safety and error handling considerations, see the Understanding AI Safety, Ethics, and Limitations guide.

Input Validation

Always validate inputs before processing:

import { z } from "zod";

const CreateTaskSchema = z.object({
  title: z.string().min(1).max(200),
  priority: z.enum(["low", "medium", "high"]).optional(),
  dueDate: z.string().datetime().optional(),
});

// In your tool handler
try {
  const validated = CreateTaskSchema.parse(args);
  // Process validated input
} catch (error) {
  if (error instanceof z.ZodError) {
    return {
      content: [{
        type: "text",
        text: `Invalid input: ${error.errors.map(e => e.message).join(", ")}`,
      }],
      isError: true,
    };
  }
  throw error;
}

Error Response Format

Return helpful error messages:

// Good error response
return {
  content: [{
    type: "text",
    text: `Error: Task not found with ID '${taskId}'. 
Use the list_tasks tool to see available tasks.`,
  }],
  isError: true,
};

// Bad error response
return {
  content: [{ type: "text", text: "Error" }],
  isError: true,
};

Common Error Types

ErrorWhenHow to Handle
ValidationBad inputReturn isError with details
Not FoundResource missingSuggest alternatives
Auth FailedBad credentialsClear fix instructions
Rate LimitToo many requestsReturn retry-after
NetworkAPI unreachableSuggest retry

Publishing Your Server

Publishing to npm (TypeScript)

  1. Update package.json:
{
  "name": "@yourusername/mcp-server-weather",
  "version": "1.0.0",
  "description": "MCP server for weather data",
  "main": "dist/index.js",
  "bin": {
    "mcp-server-weather": "dist/index.js"
  },
  "files": ["dist"],
  "repository": {
    "type": "git",
    "url": "https://github.com/yourusername/mcp-server-weather"
  },
  "keywords": ["mcp", "mcp-server", "weather", "model-context-protocol"]
}
  1. Build and publish:
npm run build
npm login
npm publish --access public
  1. Users can now install:
npx @yourusername/mcp-server-weather

Publishing to PyPI (Python)

  1. Create pyproject.toml:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mcp-server-notes"
version = "1.0.0"
description = "MCP server for note management"
requires-python = ">=3.10"
dependencies = ["mcp>=1.0.0", "pydantic>=2.0.0"]

[project.scripts]
mcp-server-notes = "mcp_server_notes:main"
  1. Build and publish:
pip install build twine
python -m build
twine upload dist/*

Submitting to MCP Registry

For visibility, submit your server to:

  1. Official list: Submit PR to github.com/modelcontextprotocol/servers
  2. mcp.so registry: Submit at mcp.so

Real-World Example: Company API Server

Here’s a more realistic example that connects to an external API. For more on building complete AI applications, see the Building Your First AI Application guide.

#!/usr/bin/env node

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

// API configuration from environment
const API_BASE_URL = process.env.API_BASE_URL || "https://api.example.com";
const API_KEY = process.env.API_KEY;

if (!API_KEY) {
  console.error("Error: API_KEY environment variable required");
  process.exit(1);
}

// API helper
async function apiRequest(endpoint: string, options: RequestInit = {}) {
  const response = await fetch(`${API_BASE_URL}${endpoint}`, {
    ...options,
    headers: {
      "Authorization": `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
      ...options.headers,
    },
  });

  if (!response.ok) {
    throw new Error(`API Error: ${response.status} ${response.statusText}`);
  }

  return response.json();
}

const server = new Server(
  { name: "company-api-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "list_customers",
      description: "List all customers with optional filters",
      inputSchema: {
        type: "object",
        properties: {
          status: { type: "string", enum: ["active", "inactive", "all"] },
          limit: { type: "number", default: 10 },
        },
      },
    },
    {
      name: "get_customer",
      description: "Get customer details by ID",
      inputSchema: {
        type: "object",
        properties: {
          customer_id: { type: "string" },
        },
        required: ["customer_id"],
      },
    },
    {
      name: "create_ticket",
      description: "Create a support ticket for a customer",
      inputSchema: {
        type: "object",
        properties: {
          customer_id: { type: "string" },
          subject: { type: "string" },
          description: { type: "string" },
          priority: { type: "string", enum: ["low", "medium", "high"] },
        },
        required: ["customer_id", "subject", "description"],
      },
    },
  ],
}));

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

  try {
    switch (name) {
      case "list_customers": {
        const params = new URLSearchParams();
        if (args?.status) params.set("status", args.status);
        if (args?.limit) params.set("limit", String(args.limit));
        
        const customers = await apiRequest(`/customers?${params}`);
        return {
          content: [{
            type: "text",
            text: `Found ${customers.length} customers:\n` +
              customers.map((c: any) => `- ${c.name} (${c.id})`).join("\n"),
          }],
        };
      }

      case "get_customer": {
        const customer = await apiRequest(`/customers/${args.customer_id}`);
        return {
          content: [{
            type: "text",
            text: JSON.stringify(customer, null, 2),
          }],
        };
      }

      case "create_ticket": {
        const ticket = await apiRequest("/tickets", {
          method: "POST",
          body: JSON.stringify({
            customer_id: args.customer_id,
            subject: args.subject,
            description: args.description,
            priority: args.priority || "medium",
          }),
        });
        return {
          content: [{
            type: "text",
            text: `Created ticket #${ticket.id}: ${ticket.subject}`,
          }],
        };
      }

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

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

Configure with API key:

{
  "mcpServers": {
    "company-api": {
      "command": "node",
      "args": ["/path/to/dist/index.js"],
      "env": {
        "API_BASE_URL": "https://api.yourcompany.com",
        "API_KEY": "your-api-key-here"
      }
    }
  }
}

Next Steps

Congratulations! You’ve learned how to build MCP servers. Here’s what to explore next:

Advanced Topics

Check out our Building Advanced MCP Servers guide for:

  • Streaming responses: Using Streamable HTTP for long-running operations
  • Async Tasks: The new Tasks primitive for background jobs
  • HTTP transport: For remote server deployments
  • Authentication: OAuth flows and token refresh
  • Performance: Caching and connection pooling
  • Publishing: npm/PyPI distribution and mcp.so registry
ArticleWhat You’ll Learn
GitHub MCP GuideStudy a production server
PostgreSQL MCP GuideDatabase integration patterns
Best MCP CombinationsMulti-server workflows

Resources


Summary

In this tutorial, you learned:

  • MCP Architecture: Tools, Resources, and Prompts
  • TypeScript Server: Complete weather server example
  • Python Server: Notes server with CRUD operations
  • Adding Prompts: Reusable templates for users
  • Testing: Using MCP Inspector
  • Error Handling: Validation and helpful messages
  • Publishing: npm and PyPI distribution
  • 2025 Features: Tasks, Streamable HTTP, and more

The key insight: MCP servers are just programs that speak JSON-RPC. The SDKs handle the protocol; you focus on your business logic. With 10,000+ servers in the ecosystem and 97M monthly SDK downloads, there’s never been a better time to build MCP servers.

Next: Explore GitHub MCP Server → to see how a production server works, or check out Building Advanced MCP Servers → for production patterns.


Questions about building MCP servers? Join the MCP Discord or file an issue on GitHub.

Was this page helpful?

Let us know if you found what you were looking for.