This guide walks you through building an MCP server from scratch. We'll use Python with FastMCP as the primary path (the fastest way to get to a working server), then show the equivalent TypeScript SDK approach for teams that prefer Node.
By the end, you'll have a working MCP server that exposes tools, resources, and prompts — running locally and connected to Claude Desktop. The example we'll build is small but realistic: a personal task manager that Claude can query and update.
Prerequisites: basic Python or TypeScript familiarity, Claude Desktop installed, and ~30 minutes.
Step 1: choose your stack
You have three main options for building MCP servers:
FastMCP (Python). The fastest path. Built by Jeremiah Lowin, FastMCP wraps the official MCP SDK with a Flask-like decorator API. The full library shipped version 3.0 in February 2026 with 100,000+ downloads. Pick this if you want minimum boilerplate.
Official Python SDK. More verbose, but closer to the protocol. Useful if you want fine-grained control over every aspect of the server lifecycle.
Official TypeScript SDK. The right choice for teams already using Node.js or wanting strong type checking. The SDK is mature and well-maintained.
For this tutorial, we'll use FastMCP as the main path and show the TypeScript equivalent at the end.
Step 2: install the dependencies
Create a new project directory and install FastMCP:
mkdir my-mcp-server
cd my-mcp-server
# Using uv (recommended in 2026)
uv init
uv add "mcp[cli]"
# Or with pip
pip install "mcp[cli]"
The mcp[cli] install includes both the SDK and the MCP Inspector — a development tool we'll use to test our server without going through Claude Desktop.
Step 3: write a minimal server
Create server.py:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Task Manager")
# In-memory task storage
tasks = []
@mcp.tool()
def add_task(title: str, priority: str = "normal") -> str:
"""Add a new task to the task list.
Args:
title: The task description
priority: One of 'low', 'normal', 'high'
"""
task_id = len(tasks) + 1
tasks.append({
"id": task_id,
"title": title,
"priority": priority,
"done": False
})
return f"Added task #{task_id}: {title}"
@mcp.tool()
def list_tasks() -> str:
"""List all current tasks."""
if not tasks:
return "No tasks yet."
lines = []
for t in tasks:
status = "[x]" if t["done"] else "[ ]"
lines.append(f"{status} #{t['id']} [{t['priority']}] {t['title']}")
return "\n".join(lines)
@mcp.tool()
def complete_task(task_id: int) -> str:
"""Mark a task as completed.
Args:
task_id: The numeric ID of the task to complete
"""
for t in tasks:
if t["id"] == task_id:
t["done"] = True
return f"Marked task #{task_id} as done."
return f"Task #{task_id} not found."
if __name__ == "__main__":
mcp.run()
That's a working MCP server. Three tools, ~30 lines including the in-memory storage. The decorators tell FastMCP: "this function is a tool, here's its description (the docstring), use type hints to generate the input schema."
Step 4: test with the MCP Inspector
Before connecting to Claude Desktop, validate the server with the official MCP Inspector. This tool gives you a UI to list tools, invoke them, and inspect the JSON-RPC traffic.
npx @modelcontextprotocol/inspector python server.py
This launches the Inspector in your browser. You should see:
- The "Tools" tab listing add_task, list_tasks, complete_task
- Input schemas auto-generated from your type hints
- An "Invoke" button to test each tool with sample inputs
Run a few invocations. Add a task, list tasks, complete one. Confirm everything works before going further.
Step 5: connect to Claude Desktop
Edit your Claude Desktop config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
Add your server to the mcpServers section:
{
"mcpServers": {
"tasks": {
"command": "python",
"args": ["/full/path/to/your/server.py"]
}
}
}
Use the absolute path to your script. If you're using a virtual environment, use the absolute path to that environment's Python binary (e.g., /Users/you/myenv/bin/python).
Restart Claude Desktop. Your server's tools should appear in the connector list (look for the icon next to the message input).
Try a prompt: "Add a task to call mom tomorrow with high priority." Claude should call your add_task tool and confirm.
Step 6: add a resource
Tools are great for actions. Resources are for reading data. Let's expose the task list as a resource:
@mcp.resource("tasks://all")
def all_tasks_resource() -> str:
"""All current tasks as a formatted list."""
if not tasks:
return "No tasks."
import json
return json.dumps(tasks, indent=2)
@mcp.resource("tasks://pending")
def pending_tasks_resource() -> str:
"""Only pending (not done) tasks."""
pending = [t for t in tasks if not t["done"]]
import json
return json.dumps(pending, indent=2)
Resources are addressed by URI. The URI scheme (tasks://) is arbitrary — pick something meaningful for your domain.
The difference between tools and resources: a tool is invoked by the AI autonomously based on conversation context. A resource is loaded by the user or host into the conversation context. Both work; pick based on who should initiate.
Step 7: add a prompt template
Prompts are reusable templates the user can invoke from the host's UI. Let's add one for end-of-day task review:
@mcp.prompt()
def daily_review() -> str:
"""End-of-day task review template."""
return """Please review my tasks for today:
1. List all my tasks
2. Identify which high-priority tasks remain incomplete
3. Suggest which 3 tasks I should focus on tomorrow
4. Format the response as a concise daily summary"""
In Claude Desktop, this prompt appears in the slash-command menu. The user selects it, and the prompt text gets injected into the conversation as if they'd typed it.
Step 8: add persistence (real-world)
The in-memory storage we've used resets every time the server restarts. For a real server, you'd persist data. Quick example with SQLite:
import sqlite3
from contextlib import contextmanager
DB_PATH = "tasks.db"
@contextmanager
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.commit()
conn.close()
# Initialize schema on startup
with db() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
priority TEXT DEFAULT 'normal',
done INTEGER DEFAULT 0
)
""")
@mcp.tool()
def add_task(title: str, priority: str = "normal") -> str:
"""Add a new task."""
with db() as conn:
cursor = conn.execute(
"INSERT INTO tasks (title, priority) VALUES (?, ?)",
(title, priority)
)
return f"Added task #{cursor.lastrowid}: {title}"
This pattern (context manager for the DB, SQL parameterization, transaction commit on exit) is the bare minimum for a persistent server. For production deployments, swap SQLite for PostgreSQL or whatever your stack uses.
Step 9: add error handling
What happens when something goes wrong? By default, FastMCP catches exceptions and returns them to the AI. But it's worth being explicit:
from mcp.server.fastmcp import FastMCP
from mcp import McpError, ErrorData
@mcp.tool()
def complete_task(task_id: int) -> str:
"""Mark a task as completed."""
with db() as conn:
cursor = conn.execute(
"UPDATE tasks SET done = 1 WHERE id = ?",
(task_id,)
)
if cursor.rowcount == 0:
raise McpError(ErrorData(
code=-32602,
message=f"Task #{task_id} not found"
))
return f"Marked task #{task_id} as done."
The McpError pattern lets you return structured errors with appropriate JSON-RPC error codes. Clients can handle these gracefully.
Step 10: TypeScript equivalent
For teams preferring Node.js, here's the equivalent minimal server using the official TypeScript SDK:
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: "Task Manager", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
const tasks: Array<{ id: number; title: string; priority: string; done: boolean }> = [];
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "add_task",
description: "Add a new task to the task list",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "The task description" },
priority: { type: "string", enum: ["low", "normal", "high"], default: "normal" },
},
required: ["title"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "add_task") {
const args = request.params.arguments as { title: string; priority?: string };
const taskId = tasks.length + 1;
tasks.push({
id: taskId,
title: args.title,
priority: args.priority ?? "normal",
done: false,
});
return {
content: [{ type: "text", text: `Added task #${taskId}: ${args.title}` }],
};
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
Equivalent functionality, more boilerplate. Use TypeScript when you want strong types and the rest of your stack is Node. Use FastMCP/Python when you want minimum code and AI-tool prototyping speed.
Step 11: deploy as a remote server
Everything so far has been local stdio. To make your server accessible to remote clients (or shared across a team), you need HTTP transport.
FastMCP supports this with one-line changes:
if __name__ == "__main__":
# Local stdio (default): mcp.run()
# Remote HTTP server:
mcp.run(transport="streamable-http", port=8000)
Run it: python server.py — your server now listens on port 8000. Clients can connect via http://your-server:8000/mcp.
For production, you'd put this behind a reverse proxy (nginx, Caddy), terminate TLS, and add authentication. The remote deployment story is similar to any FastAPI/Express service.
Common pitfalls
Things that trip up first-time MCP server builders:
Forgetting absolute paths in Claude Desktop config. Relative paths don't work — always use absolute. If your Python is in a virtual env, point to that env's binary explicitly.
Missing docstrings on tools. The docstring becomes the tool description that Claude reads. Empty or vague docstrings = the AI never invokes your tool. Write good ones.
Vague type hints. FastMCP generates the input schema from type hints. def add(a, b) with no types means the schema is generic and the AI may pass wrong values. Always use type hints.
Logging to stdout in stdio servers. If your server prints to stdout, it corrupts the JSON-RPC stream. Use stderr for logs (or the logging module configured to stderr).
Not restarting Claude Desktop after config changes. Config is read at startup. Edit + restart.
Next steps
Now you can:
- Build MCP servers for any internal system at your company
- Replace one-off Claude integrations with a portable MCP server
- Read existing community servers and contribute back
If you want to avoid building a server entirely and just expose existing automation workflows as MCP tools, see our MCP with Make.com tutorial — Make.com gives you visual scenario building, then exposes scenarios as MCP tools with one configuration step.
For the broader picture, return to the MCP complete guide.
Frequently asked questions
How long does it take to build a basic MCP server?
About 30-60 minutes if you follow this tutorial. Less if you've used Python decorators or Express middleware before. The FastMCP API is intentionally similar to Flask, so web developers ramp up quickly.
Can I write MCP servers in languages other than Python and TypeScript?
Yes. Official SDKs exist for Python, TypeScript, Go, Rust, and Java. Community SDKs exist for more languages. Any language that can speak JSON-RPC over stdio or HTTP can implement MCP.
Do I need to redeploy when I add new tools?
For local stdio servers, just restart the server (and Claude Desktop). For HTTP servers, restart the server process. The MCP notification protocol can advertise new tools to connected clients without disconnection, but most setups simply restart.
How do I authenticate users in my MCP server?
For local stdio servers, authentication is implicit (same-user processes). For HTTP servers, use OAuth 2.0 or API keys in HTTP headers. The MCP spec delegates auth to the transport layer.
What's the performance overhead of MCP vs calling the API directly?
Negligible. Most overhead is JSON serialization, which is microseconds. End-to-end latency is dominated by your underlying business logic (database queries, API calls).
Can MCP servers call other MCP servers?
Yes, this is a useful pattern. Your MCP server can be both a server (exposing tools to AI clients) and a client (calling other MCP servers). Useful for composition and aggregation patterns.