Build a Custom MCP Server: Python and TypeScript Tutorial

 

You've probably installed a few MCP servers already -- the GitHub one, maybe Postgres, maybe the filesystem server. You added some JSON to a config file, restarted Claude Desktop, and suddenly your AI assistant could read your database. Magic.

But here's the thing: the 8,600+ community MCP servers can't cover your specific workflow. Your company's internal API. Your custom deployment pipeline. That weird data source only your team uses. For those, you need to build your own MCP server -- and it's significantly easier than you'd expect.

This tutorial walks you through building a custom MCP server from scratch in both Python and TypeScript. By the end, you'll have a working server that exposes tools, serves resources, and plugs into Claude Desktop, Cursor, or Claude Code with zero friction. If you've already read our MCP Servers Explained guide, this is where the theory becomes practice.


๐Ÿ“‹ What You'll Need

  • Python 3.10+ or Node.js 18+ -- Pick your language (or follow both)
  • uv (Python) or npm (TypeScript) -- Package managers for each ecosystem
  • An MCP-compatible client -- Claude Desktop, Cursor, Claude Code, or VS Code with Copilot
  • A text editor -- Any editor works, but Cursor gives you inline MCP testing
  • 15-20 minutes -- That's genuinely all it takes for your first working server
  • Basic API knowledge -- You should be comfortable making HTTP requests

๐Ÿ—๏ธ How MCP Servers Work (60-Second Recap)

If you've read the MCP explainer, skip ahead. For everyone else, here's the architecture in one diagram:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                  โ”‚         โ”‚                  โ”‚         โ”‚                  โ”‚
โ”‚   AI Host        โ”‚  MCP    โ”‚   MCP Server     โ”‚  HTTP   โ”‚   External API   โ”‚
โ”‚   (Claude/       โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚   (Your Code)    โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚   (GitHub, DB,   โ”‚
โ”‚    Cursor)       โ”‚         โ”‚                  โ”‚         โ”‚    custom API)   โ”‚
โ”‚                  โ”‚         โ”‚                  โ”‚         โ”‚                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Your MCP server is the bridge between the AI client and your data. It exposes three types of capabilities:

Capability What It Does Analogy
Tools Functions the AI can call (with user approval) API endpoints
Resources Read-only data the AI can access GET requests
Prompts Pre-built templates for common tasks Saved queries

Tools are by far the most common. Most MCP servers expose tools and nothing else. We'll start there and add resources later.


๐Ÿ Build Your First MCP Server in Python

We're building a GitHub Stars server -- an MCP server that lets your AI assistant check how many stars any GitHub repo has and compare repositories. It's simple enough to follow, useful enough to actually keep around.

Step 1: Set up the project

Python's MCP SDK uses FastMCP, a framework that auto-generates tool definitions from your type hints and docstrings. Install it with uv (the recommended package manager):

# Install uv if you don't have it
curl -LsSf https://astral.sh/uv/install.sh | sh

# Create and set up the project
uv init github-stars-mcp
cd github-stars-mcp
uv venv
source .venv/bin/activate

# Install dependencies
uv add "mcp[cli]" httpx

Step 2: Write the server

Create server.py with the following code. This is the complete, runnable server:

from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP

# Initialize the MCP server
mcp = FastMCP("github-stars")

GITHUB_API = "https://api.github.com"


async def fetch_repo(owner: str, repo: str) -> dict[str, Any] | None:
    """Fetch repository data from GitHub API."""
    headers = {
        "Accept": "application/vnd.github.v3+json",
        "User-Agent": "mcp-github-stars/1.0",
    }
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(
                f"{GITHUB_API}/repos/{owner}/{repo}",
                headers=headers,
                timeout=10.0,
            )
            response.raise_for_status()
            return response.json()
        except httpx.HTTPError:
            return None


@mcp.tool()
async def get_repo_stars(owner: str, repo: str) -> str:
    """Get the star count and key stats for a GitHub repository.

    Args:
        owner: GitHub username or organization (e.g. 'anthropics')
        repo: Repository name (e.g. 'claude-code')
    """
    data = await fetch_repo(owner, repo)
    if not data:
        return f"Could not fetch data for {owner}/{repo}. Check the name and try again."

    return f"""Repository: {data['full_name']}
Stars: {data['stargazers_count']:,}
Forks: {data['forks_count']:,}
Open Issues: {data['open_issues_count']:,}
Language: {data.get('language', 'Not specified')}
Description: {data.get('description', 'No description')}
Last Updated: {data['updated_at'][:10]}"""


@mcp.tool()
async def compare_repos(repos: list[str]) -> str:
    """Compare star counts across multiple GitHub repositories.

    Args:
        repos: List of repos in 'owner/repo' format (e.g. ['facebook/react', 'vuejs/vue'])
    """
    results = []
    for repo_str in repos:
        parts = repo_str.split("/")
        if len(parts) != 2:
            results.append({"name": repo_str, "error": "Invalid format. Use owner/repo"})
            continue

        data = await fetch_repo(parts[0], parts[1])
        if data:
            results.append({
                "name": data["full_name"],
                "stars": data["stargazers_count"],
                "forks": data["forks_count"],
                "language": data.get("language", "N/A"),
            })
        else:
            results.append({"name": repo_str, "error": "Not found"})

    # Sort by stars descending
    valid = [r for r in results if "stars" in r]
    errors = [r for r in results if "error" in r]
    valid.sort(key=lambda x: x["stars"], reverse=True)

    output = "Repository Comparison (sorted by stars):\n\n"
    for i, r in enumerate(valid, 1):
        medal = ["๐Ÿฅ‡", "๐Ÿฅˆ", "๐Ÿฅ‰"][i - 1] if i <= 3 else f"{i}."
        output += f"{medal} {r['name']}: {r['stars']:,} stars | {r['forks']:,} forks | {r['language']}\n"

    if errors:
        output += "\nErrors:\n"
        for r in errors:
            output += f"  - {r['name']}: {r['error']}\n"

    return output


def main():
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()

That's it. Two tools, under 100 lines of code. FastMCP reads your type hints and docstrings to auto-generate the JSON schema that MCP clients need for tool discovery.

Step 3: Test it locally

Before connecting to any AI client, verify your server works with the MCP Inspector:

# The MCP Inspector opens a web UI for testing your server
uv run mcp dev server.py

This opens a browser-based inspector at http://localhost:6274 where you can:
- See your registered tools
- Call tools with test inputs
- Inspect the JSON-RPC messages being exchanged

Try calling get_repo_stars with owner: "anthropics" and repo: "claude-code". You should see the star count come back immediately.

Tip: The MCP Inspector is your best friend during development. Use it to test every tool before connecting to Claude Desktop. It shows the exact JSON-RPC messages, so you can spot schema issues early.

Step 4: Connect to Claude Desktop

Open your Claude Desktop config file:

# macOS
code ~/Library/Application\ Support/Claude/claude_desktop_config.json

# Windows
code %AppData%\Claude\claude_desktop_config.json

Add your server:

{
  "mcpServers": {
    "github-stars": {
      "command": "uv",
      "args": [
        "--directory", "/absolute/path/to/github-stars-mcp",
        "run", "server.py"
      ]
    }
  }
}

Restart Claude Desktop. You should see a hammer icon indicating your tools are available. Ask Claude: "How many stars does anthropics/claude-code have?" and watch your MCP server handle the request.


๐ŸŸฆ Build the Same Server in TypeScript

Prefer TypeScript? Here's the same GitHub Stars server using the official @modelcontextprotocol/sdk package.

Step 1: Set up the project

mkdir github-stars-mcp
cd github-stars-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D @types/node typescript
mkdir src

Update package.json:

{
  "type": "module",
  "bin": {
    "github-stars": "./build/index.js"
  },
  "scripts": {
    "build": "tsc && chmod 755 build/index.js"
  }
}

Create tsconfig.json:

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

Step 2: Write 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 GITHUB_API = "https://api.github.com";

const server = new McpServer({
  name: "github-stars",
  version: "1.0.0",
});

async function fetchRepo(owner: string, repo: string): Promise<any | null> {
  try {
    const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, {
      headers: {
        "Accept": "application/vnd.github.v3+json",
        "User-Agent": "mcp-github-stars/1.0",
      },
    });
    if (!response.ok) return null;
    return await response.json();
  } catch {
    return null;
  }
}

// Tool: Get stars for a single repo
server.tool(
  "get_repo_stars",
  {
    owner: z.string().describe("GitHub username or org (e.g. 'anthropics')"),
    repo: z.string().describe("Repository name (e.g. 'claude-code')"),
  },
  async ({ owner, repo }) => {
    const data = await fetchRepo(owner, repo);
    if (!data) {
      return {
        content: [{
          type: "text" as const,
          text: `Could not fetch data for ${owner}/${repo}.`,
        }],
      };
    }
    return {
      content: [{
        type: "text" as const,
        text: [
          `Repository: ${data.full_name}`,
          `Stars: ${data.stargazers_count.toLocaleString()}`,
          `Forks: ${data.forks_count.toLocaleString()}`,
          `Open Issues: ${data.open_issues_count.toLocaleString()}`,
          `Language: ${data.language ?? "Not specified"}`,
          `Description: ${data.description ?? "No description"}`,
          `Last Updated: ${data.updated_at.slice(0, 10)}`,
        ].join("\n"),
      }],
    };
  }
);

// Tool: Compare multiple repos
server.tool(
  "compare_repos",
  {
    repos: z.array(z.string()).describe("Repos in 'owner/repo' format"),
  },
  async ({ repos }) => {
    const results: Array<{ name: string; stars?: number; forks?: number; language?: string; error?: string }> = [];

    for (const repoStr of repos) {
      const [owner, repo] = repoStr.split("/");
      if (!owner || !repo) {
        results.push({ name: repoStr, error: "Invalid format" });
        continue;
      }
      const data = await fetchRepo(owner, repo);
      if (data) {
        results.push({
          name: data.full_name,
          stars: data.stargazers_count,
          forks: data.forks_count,
          language: data.language ?? "N/A",
        });
      } else {
        results.push({ name: repoStr, error: "Not found" });
      }
    }

    const valid = results.filter((r) => r.stars !== undefined);
    valid.sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0));
    const medals = ["๐Ÿฅ‡", "๐Ÿฅˆ", "๐Ÿฅ‰"];

    let output = "Repository Comparison (sorted by stars):\n\n";
    valid.forEach((r, i) => {
      const prefix = i < 3 ? medals[i] : `${i + 1}.`;
      output += `${prefix} ${r.name}: ${r.stars!.toLocaleString()} stars | ${r.forks!.toLocaleString()} forks | ${r.language}\n`;
    });

    return { content: [{ type: "text" as const, text: output }] };
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main();

Step 3: Build and test

npm run build
npx @modelcontextprotocol/inspector node build/index.js

Step 4: Connect to Claude Desktop

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

๐Ÿ vs ๐ŸŸฆ Python vs TypeScript MCP SDK Comparison

Both SDKs are official and well-maintained, but they have different strengths:

Feature Python (FastMCP) TypeScript (SDK)
Tool definition @mcp.tool() decorator + type hints server.tool() + Zod schemas
Schema generation Auto from docstrings Manual with Zod
Lines of code (our example) ~85 ~95
Async support Native async/await Native async/await
Package manager uv add "mcp[cli]" npm install @modelcontextprotocol/sdk
Best for Rapid prototyping, data scripts Production apps, existing Node stacks
Debugging tool uv run mcp dev server.py npx @modelcontextprotocol/inspector
Community servers ๐ŸŸข Large ecosystem ๐ŸŸข Large ecosystem

Pick Python if you want the fastest path from idea to working server. FastMCP's decorator pattern means less boilerplate. Pick TypeScript if your team already runs Node in production or you want compile-time type safety.


๐Ÿ“ฆ Adding Resources to Your MCP Server

Tools let the AI do things. Resources let the AI read things. If you have static or semi-static data your AI should always have access to, resources are the right choice.

Let's add a resource that exposes your GitHub profile as structured data:

Python

@mcp.resource("github://profile/{username}")
async def get_profile(username: str) -> str:
    """Fetch a GitHub user's profile information."""
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"{GITHUB_API}/users/{username}",
            headers={"User-Agent": "mcp-github-stars/1.0"},
        )
        if response.status_code != 200:
            return f"User {username} not found."
        data = response.json()
        return f"""Name: {data.get('name', 'N/A')}
Bio: {data.get('bio', 'N/A')}
Public Repos: {data['public_repos']}
Followers: {data['followers']}
Location: {data.get('location', 'N/A')}"""

TypeScript

server.resource(
  "github-profile",
  "github://profile/{username}",
  async (uri) => {
    const username = uri.pathname.split("/").pop();
    const response = await fetch(`${GITHUB_API}/users/${username}`, {
      headers: { "User-Agent": "mcp-github-stars/1.0" },
    });
    const data = await response.json();
    return {
      contents: [{
        uri: uri.href,
        mimeType: "text/plain",
        text: `Name: ${data.name}\nRepos: ${data.public_repos}\nFollowers: ${data.followers}`,
      }],
    };
  }
);

Resources use URI templates (like github://profile/{username}) so clients can discover what data is available. The AI client can then read these resources without needing explicit tool calls.

Tip: Use tools when the AI needs to take action (call APIs, run queries, modify data). Use resources when the AI needs to read context (config files, documentation, user profiles). When in doubt, use tools -- they're more flexible.

๐Ÿ”Œ Connect Your MCP Server to Any Client

You've built and tested your server. Now connect it to whatever AI tool you use daily.

Claude Desktop

Config file: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)

{
  "mcpServers": {
    "github-stars": {
      "command": "uv",
      "args": ["--directory", "/path/to/project", "run", "server.py"]
    }
  }
}

Claude Code (Terminal)

Add to your project's .claude/settings.json:

{
  "mcpServers": {
    "github-stars": {
      "command": "uv",
      "args": ["--directory", "/path/to/project", "run", "server.py"]
    }
  }
}

Or configure globally in ~/.claude/settings.json to make it available in every project.

Cursor

Go to Settings โ†’ Tools & Integrations โ†’ New MCP Server and paste:

{
  "mcpServers": {
    "github-stars": {
      "command": "uv",
      "args": ["--directory", "/path/to/project", "run", "server.py"]
    }
  }
}

VS Code (GitHub Copilot)

Add to your .vscode/settings.json:

{
  "mcp": {
    "servers": {
      "github-stars": {
        "command": "uv",
        "args": ["--directory", "/path/to/project", "run", "server.py"]
      }
    }
  }
}
Warning: Always use absolute paths in MCP server configs. Relative paths break because the AI client's working directory isn't your project directory. Run pwd in your project folder and use that full path.

๐ŸŒ Deploy Your MCP Server Remotely

Local MCP servers work great for personal use, but teams need remote servers that everyone can connect to. The MCP spec supports three transport types:

Transport How It Works Best For
stdio Standard input/output on local machine Local development, single-user
HTTP + SSE Server-sent events over HTTP Team servers, cloud deployment
Streamable HTTP Enhanced HTTP streaming (spec v2025.03+) Production, high-throughput

Making Your Python Server Remote

Switch from stdio to SSE by changing one line:

def main():
    # Local: mcp.run(transport="stdio")
    mcp.run(transport="sse", host="0.0.0.0", port=8080)

Then deploy to any platform that supports long-lived HTTP connections:

Platform Cold Start Cost Setup Effort
Cloudflare Workers ~0ms (edge) ๐Ÿ†“ Free tier generous ๐ŸŸข Low
Railway ~2s ~$5/mo ๐ŸŸข Low
Fly.io ~1s ๐Ÿ†“ Free tier available ๐ŸŸก Medium
Google Cloud Run ~3s (set min=1 for warm) Pay per request ๐ŸŸก Medium
AWS ECS ~5s (configurable) Varies ๐Ÿ”ด High

Connect Clients to a Remote Server

For remote servers, the client config changes to use a URL instead of a command:

{
  "mcpServers": {
    "github-stars": {
      "url": "https://your-mcp-server.example.com/sse"
    }
  }
}

Authentication for Remote Servers

The MCP spec standardizes on OAuth 2.1 for remote transports. For simple internal tools, you can start with API key auth:

import os
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("github-stars")

API_KEY = os.environ.get("MCP_API_KEY")

@mcp.tool()
async def get_repo_stars(owner: str, repo: str, api_key: str) -> str:
    """Get stars for a repo. Requires valid API key."""
    if api_key != API_KEY:
        return "Invalid API key."
    # ... rest of the tool logic

For production deployments, implement the full OAuth 2.1 flow -- the official spec has detailed guidance.


๐Ÿ’ก Real-World MCP Server Ideas

Stuck on what to build? Here are practical MCP servers that solve real problems:

Server Idea What It Does Difficulty
Internal API wrapper Expose your company's REST API as MCP tools ๐ŸŸข Easy
Database query tool Let AI run read-only SQL against your staging DB ๐ŸŸข Easy
Deployment status Check Railway/Vercel/AWS deployment status ๐ŸŸข Easy
Jira/Linear integration Create tickets, update status, read sprint boards ๐ŸŸก Medium
Log searcher Query Datadog/Sentry/CloudWatch logs by time range ๐ŸŸก Medium
CI/CD trigger Kick off GitHub Actions workflows with parameters ๐ŸŸก Medium
Feature flag manager Read/toggle LaunchDarkly flags from your AI assistant ๐ŸŸก Medium
Documentation indexer Serve your internal docs as MCP resources ๐Ÿ”ด Advanced
Multi-service orchestrator Chain multiple APIs into compound operations ๐Ÿ”ด Advanced

The best MCP servers wrap things you do daily in your terminal or browser. If you find yourself switching tabs to check something, that's a candidate for an MCP server.


๐Ÿ”ง Troubleshooting Common MCP Server Issues

"Server not showing up in Claude Desktop"

  1. Check your config path. macOS: ~/Library/Application Support/Claude/claude_desktop_config.json. This is a common typo.
  2. Use absolute paths. Replace ./server.py with /Users/you/projects/my-server/server.py.
  3. Restart Claude Desktop fully. Quit from the dock, don't just close the window.
  4. Check the logs. macOS: ~/Library/Logs/Claude/mcp*.log shows server startup errors.

"Tool calls return errors"

  1. Don't use print() in Python stdio servers. Writing to stdout corrupts JSON-RPC messages. Use print("debug", file=sys.stderr) or Python's logging module instead.
  2. Don't use console.log() in TypeScript stdio servers. Use console.error() for debug output.
  3. Validate your tool return format. Python FastMCP handles this automatically, but TypeScript servers must return { content: [{ type: "text", text: "..." }] }.

"Server starts but tools aren't discovered"

  1. Check your decorators/registrations. Python: Make sure @mcp.tool() has the parentheses. TypeScript: Ensure you call server.tool() before server.connect().
  2. Run the MCP Inspector. It shows exactly what tools your server advertises. If the inspector sees them but Claude doesn't, the issue is in your client config.

"Timeout errors on tool calls"

  1. Add timeouts to your HTTP requests. Default httpx timeout is 5s, which is tight for slow APIs. Use timeout=30.0.
  2. Check your network. MCP servers making outbound API calls need internet access -- obvious, but easy to miss in Docker containers.
Important: Never log sensitive data (API keys, tokens, user data) in your MCP server, even to stderr. MCP Inspector and client logs may capture these outputs. Use environment variables for secrets and keep debug logging minimal in production.

๐Ÿš€ What's Next


Already using MCP servers? Learn how to configure them across all your tools in our MCP Servers Explained guide, or explore AI-assisted code review workflows that pair perfectly with custom MCP integrations.





Thanks for feedback.

Share Your Thoughts




Read More....
AI Coding Agents Compared: Cursor vs Copilot vs Claude Code vs Windsurf in 2026
AI Coding Agents and Security Risks: What You Need to Know
AI Pair Programming: The Productivity Guide for 2026
AI-Assisted Code Review: Tools and Workflows for 2026
AI-Native Documentation
Agentic Workflows vs Linear Chat