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.
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.
๐ 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"]
}
}
}
}
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"
- Check your config path. macOS:
~/Library/Application Support/Claude/claude_desktop_config.json. This is a common typo. - Use absolute paths. Replace
./server.pywith/Users/you/projects/my-server/server.py. - Restart Claude Desktop fully. Quit from the dock, don't just close the window.
- Check the logs. macOS:
~/Library/Logs/Claude/mcp*.logshows server startup errors.
"Tool calls return errors"
- Don't use
print()in Python stdio servers. Writing to stdout corrupts JSON-RPC messages. Useprint("debug", file=sys.stderr)or Python'sloggingmodule instead. - Don't use
console.log()in TypeScript stdio servers. Useconsole.error()for debug output. - 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"
- Check your decorators/registrations. Python: Make sure
@mcp.tool()has the parentheses. TypeScript: Ensure you callserver.tool()beforeserver.connect(). - 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"
- Add timeouts to your HTTP requests. Default
httpxtimeout is 5s, which is tight for slow APIs. Usetimeout=30.0. - Check your network. MCP servers making outbound API calls need internet access -- obvious, but easy to miss in Docker containers.
๐ What's Next
- ๐ Read the official MCP specification for advanced features like sampling and roots
- ๐งช Browse the 8,600+ community MCP servers on GitHub for inspiration and patterns
- ๐ก Learn about MCP prompts -- pre-built templates that guide your AI through multi-step workflows
- ๐ก๏ธ Explore OAuth 2.1 authentication for securing remote MCP servers
- ๐๏ธ Check our guide on Claude Code workflows to see how MCP servers integrate into daily development
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.