Building MCP Servers for Claude: Complete Guide

You've built something incredible with Claude-maybe it's a custom workflow, a specialized AI assistant, or an intelligent agent that handles complex tasks. But here's the challenge: Claude doesn't natively connect to your internal databases, APIs, or specialized tools. You're stuck manually feeding it context or building one-off integrations.
The Model Context Protocol (MCP) solves this. It's like a USB-C port for AI applications-a standardized way to connect Claude to literally anything: your databases, APIs, file systems, or custom services. Instead of hacking together brittle integrations, you build once and deploy forever.
In this guide, we're diving deep into building production-grade MCP servers. Whether you're using Python or TypeScript, and whether you're just getting started or hardening security, you'll learn everything needed to extend Claude's capabilities.
Table of Contents
- Understanding MCP Architecture
- Setting Up Your Python MCP Server
- Building TypeScript MCP Servers
- Security Best Practices: OAuth Integration
- Testing MCP Servers Locally
- Publishing and Distribution
- Advanced Patterns: Context and Progress Reporting
- Debugging and Monitoring
- Real-World Considerations
- Bringing It All Together
- Summary
Understanding MCP Architecture
Before we build, let's understand what we're building. MCP isn't magic-it's a protocol. Think of it this way:
Client (Claude or your app) → MCP Server (what we're building) → Backend Systems (your APIs, databases, services)
The server sits in the middle. It exposes three types of capabilities:
- Resources: Read-only data endpoints. Think "GET /api/data"-resources provide information without side effects.
- Tools: Actionable capabilities. These perform computations or create side effects. Like POST endpoints, they modify state.
- Prompts: Reusable interaction templates. These guide how Claude uses your tools and resources.
Here's the key insight: MCP is transport-agnostic. You can run servers over stdio (for local Claude Code), HTTP, or even SSE. This flexibility means you can test locally, then deploy anywhere.
Setting Up Your Python MCP Server
Let's build a practical example: a document management server that Claude can query and manage.
First, install the Python SDK:
pip install "mcp[cli]"Now, create your server foundation:
from mcp.server import Server
from mcp.server.stdio import stdio_server
from contextlib import asynccontextmanager
# Initialize the server
server = Server("document-manager")
# Server initialization
@server.event
async def on_list_resources():
"""Return available resources"""
return [
{
"uri": "doc://documents/{name}",
"name": "Document Resource",
"description": "Access documents by name",
"mimeType": "text/plain"
}
]
@server.event
async def on_read_resource(uri: str):
"""Read resource content"""
if uri.startswith("doc://documents/"):
name = uri.replace("doc://documents/", "")
# Load your document
return f"Content of {name}"
raise ValueError(f"Unknown resource: {uri}")
# Run the server
async def main():
async with stdio_server(server) as (read_stream, write_stream):
await server.run(read_stream, write_stream)
if __name__ == "__main__":
import asyncio
asyncio.run(main())What's happening here? We're creating a server that exposes resources. Claude can ask for doc://documents/user-guide, and your server returns the content. It's declarative-you describe what exists, and MCP handles the protocol.
Now let's add tools-the actionable stuff:
from pydantic import BaseModel
import json
class DocumentUpdate(BaseModel):
name: str
content: str
tags: list[str] = []
@server.tool()
async def update_document(name: str, content: str, tags: list[str] = []):
"""
Update or create a document.
Args:
name: Document filename
content: Document content
tags: Metadata tags
"""
document = DocumentUpdate(name=name, content=content, tags=tags)
# Your storage logic here
storage_path = f"/docs/{document.name}"
with open(storage_path, "w") as f:
f.write(document.content)
return {
"status": "success",
"message": f"Document '{name}' updated successfully",
"tags": tags
}
@server.tool()
async def search_documents(query: str, max_results: int = 10):
"""
Search documents by keyword.
Args:
query: Search query
max_results: Maximum results to return
"""
# Your search logic
results = [
{"name": "guide.md", "score": 0.95},
{"name": "tutorial.md", "score": 0.87}
]
return {
"query": query,
"results": results[:max_results],
"total": len(results)
}See the pattern? Tools use decorators and Pydantic for automatic validation. When Claude calls update_document, the SDK validates that name is a string and tags is a list. No manual parsing required.
Here's the full server startup:
import asyncio
from mcp.server.stdio import stdio_server
async def main():
async with stdio_server(server) as (read_stream, write_stream):
await server.run(read_stream, write_stream)
if __name__ == "__main__":
asyncio.run(main())When Claude Code connects, it:
- Spawns your Python process
- Opens stdin/stdout communication
- Discovers your resources and tools
- Starts making requests
Expected output when Claude connects:
Server initialized: document-manager
Resources discovered: 1 (doc://documents/{name})
Tools available: update_document, search_documents
Ready for requests...
Building TypeScript MCP Servers
For Node.js environments, TypeScript offers cleaner async handling. Install the SDK:
npm install @modelcontextprotocol/server zodHere's the equivalent TypeScript server:
import {
Server,
StdioServerTransport,
ResourceTemplate,
Tool,
} from "@modelcontextprotocol/server";
import { z } from "zod";
// Initialize server
const server = new Server({
name: "document-manager",
version: "1.0.0",
});
// Define resource templates
const resourceTemplates: ResourceTemplate[] = [
{
uriTemplate: "doc://documents/{name}",
name: "Document",
description: "Access documents",
mimeType: "text/plain",
},
];
// Handle resource reading
server.setRequestHandler(
ReadResourceRequest,
async (request: ReadResourceRequest): Promise<TextResourceContents> => {
const docName = request.params.uri.replace("doc://documents/", "");
const content = await readDocumentContent(docName);
return {
resourceType: "text",
text: content,
};
},
);
// Define tools with schema validation
const updateDocSchema = z.object({
name: z.string().describe("Document filename"),
content: z.string().describe("Document content"),
tags: z.array(z.string()).optional().describe("Metadata tags"),
});
const updateDocTool: Tool = {
name: "update_document",
description: "Update or create a document",
inputSchema: updateDocSchema,
};
// Handle tool calls
server.setRequestHandler(CallToolRequest, async (request: CallToolRequest) => {
if (request.params.name === "update_document") {
const { name, content, tags } = updateDocSchema.parse(
request.params.arguments,
);
// Your storage implementation
await saveDocument(name, content, tags || []);
return {
content: [
{
type: "text",
text: `Document '${name}' updated successfully`,
},
],
isError: false,
};
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.log("MCP server running on stdio transport");
}
main().catch(console.error);Key differences from Python? TypeScript requires explicit schema validation with Zod, and response handling is more verbose. But you gain better type safety and Node.js ecosystem access.
Security Best Practices: OAuth Integration
Here's where reality hits. Your server probably needs authentication. Let's add OAuth:
from authlib.integrations.httpx_client import AsyncOAuth2Session
import os
class SecureDocumentServer:
def __init__(self):
self.oauth_client = AsyncOAuth2Session(
client_id=os.getenv("OAUTH_CLIENT_ID"),
client_secret=os.getenv("OAUTH_CLIENT_SECRET"),
redirect_uri="http://localhost:8080/callback"
)
self.server = Server("secure-document-manager")
async def authenticate_request(self, token: str) -> dict:
"""Validate OAuth token and get user context"""
try:
user_info = await self.oauth_client.get(
"https://api.example.com/user",
headers={"Authorization": f"Bearer {token}"}
)
return user_info.json()
except Exception as e:
raise PermissionError(f"Authentication failed: {e}")
@server.tool()
async def get_user_documents(self, auth_token: str):
"""Get documents for authenticated user"""
user = await self.authenticate_request(auth_token)
user_id = user["id"]
# Query only documents belonging to this user
documents = await self.db.query(
"SELECT * FROM documents WHERE owner_id = ?",
(user_id,)
)
return {
"user": user["email"],
"documents": documents,
"count": len(documents)
}Critical practice: Never store tokens in code. Always use environment variables:
export OAUTH_CLIENT_ID="your_client_id"
export OAUTH_CLIENT_SECRET="your_client_secret"
export OAUTH_REDIRECT_URI="http://localhost:8080/callback"For Claude Code integration, configure it in your config file:
{
"mcpServers": {
"document-manager": {
"command": "python",
"args": ["server.py"],
"env": {
"OAUTH_CLIENT_ID": "${env:OAUTH_CLIENT_ID}",
"OAUTH_CLIENT_SECRET": "${env:OAUTH_CLIENT_SECRET}"
}
}
}
}Testing MCP Servers Locally
Before deploying, you need to verify your server works. Create a test harness:
import asyncio
from mcp.client import ClientSession
from mcp.client.stdio import StdioClientTransport
async def test_server():
# Spawn your server process
transport = StdioClientTransport(
command="python",
args=["server.py"]
)
async with ClientSession(transport) as session:
# Initialize connection
await session.initialize()
# List available resources
resources = await session.list_resources()
print(f"Resources: {[r.uri for r in resources.resources]}")
# List available tools
tools = await session.list_tools()
print(f"Tools: {[t.name for t in tools.tools]}")
# Call a tool
result = await session.call_tool(
"update_document",
{
"name": "test.md",
"content": "Test content",
"tags": ["test"]
}
)
print(f"Tool result: {result}")
# Read a resource
resource = await session.read_resource("doc://documents/test.md")
print(f"Resource content: {resource.contents[0].text}")
# Run tests
asyncio.run(test_server())Expected output:
Resources: ['doc://documents/{name}']
Tools: ['update_document', 'search_documents']
Tool result: {'status': 'success', 'message': "Document 'test.md' updated successfully", 'tags': ['test']}
Resource content: Test content
For TypeScript testing:
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
async function testServer() {
const transport = new StdioClientTransport({
command: "node",
args: ["server.js"],
});
const client = new Client(
{
name: "test-client",
version: "1.0.0",
},
{ capabilities: {} },
);
await client.connect(transport);
// List tools
const tools = await client.listTools();
console.log(
"Available tools:",
tools.tools.map((t) => t.name),
);
// Call a tool
const response = await client.callTool("update_document", {
name: "test.md",
content: "Test content",
tags: ["test"],
});
console.log("Tool response:", response.content[0].text);
}
testServer().catch(console.error);Publishing and Distribution
Once tested, you're ready to share your server. There are three distribution models:
Model 1: Package Registry (for packages)
Publish to npm:
npm version patch
npm publishUsers install with:
npm install @yourname/document-manager-mcpModel 2: GitHub Releases (for binaries)
Build your server as a standalone binary and release on GitHub:
# For Python
pyinstaller --onefile server.py
# Creates dist/server.exe
git tag v1.0.0
git push origin v1.0.0Users download and configure:
{
"mcpServers": {
"document-manager": {
"command": "/usr/local/bin/server"
}
}
}Model 3: Docker Container (for complex deployments)
Containerize your server:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY server.py .
ENTRYPOINT ["python", "server.py"]Claude Code can spawn containers:
{
"mcpServers": {
"document-manager": {
"command": "docker",
"args": ["run", "--rm", "-i", "your-org/document-manager:latest"]
}
}
}Advanced Patterns: Context and Progress Reporting
As your server grows complex, users need feedback. MCP supports progress reporting and request context:
@server.tool()
async def process_large_document(
name: str,
context: RequestContext
):
"""Process a large document with progress updates"""
# Get client context
client_id = context.client_id
# Simulate long-running task with progress
total_steps = 100
for step in range(total_steps):
# Do work...
await asyncio.sleep(0.1)
# Report progress
await context.progress(
progress=step + 1,
total=total_steps
)
return {
"status": "complete",
"processed_by": client_id,
"steps": total_steps
}Claude will display real-time progress:
Processing large-document.pdf
[████████████░░░░░░░░] 65% (65/100 steps)
Debugging and Monitoring
Production servers need visibility. Add structured logging:
import logging
from datetime import datetime
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("document-manager")
@server.tool()
async def update_document(name: str, content: str, tags: list[str] = []):
"""Update document with logging"""
logger.info(f"Tool called: update_document", extra={
"document_name": name,
"content_length": len(content),
"tags": tags,
"timestamp": datetime.utcnow().isoformat()
})
try:
# Your logic
logger.info(f"Document updated successfully: {name}")
return {"status": "success"}
except Exception as e:
logger.error(f"Update failed: {str(e)}", exc_info=True)
raiseLog files help you understand what Claude is actually doing with your tools.
Real-World Considerations
Performance: Keep tool responses under 5 seconds. Anything longer should report progress.
Error Handling: Always return meaningful error messages. Claude needs to understand what went wrong:
@server.tool()
async def delete_document(name: str):
if not name.endswith(".md"):
raise ValueError("Only .md files can be deleted")
# Your deletion logicRate Limiting: Protect your backend:
from datetime import datetime, timedelta
class RateLimiter:
def __init__(self, calls_per_minute=60):
self.calls_per_minute = calls_per_minute
self.calls = []
async def check(self):
now = datetime.utcnow()
self.calls = [c for c in self.calls if c > now - timedelta(minutes=1)]
if len(self.calls) >= self.calls_per_minute:
raise Exception(f"Rate limit exceeded: {self.calls_per_minute} calls/minute")
self.calls.append(now)
rate_limiter = RateLimiter(calls_per_minute=100)
@server.tool()
async def search_documents(query: str):
await rate_limiter.check()
# Your search logicBringing It All Together
Building MCP servers is about extending Claude's reach. You're creating a contract between Claude and your systems-"Here's what I can do, here's how to ask."
The workflow is straightforward:
- Define resources (what Claude can read)
- Define tools (what Claude can do)
- Implement handlers (make it real)
- Test locally (verify it works)
- Deploy and monitor (production readiness)
Whether you're building a document manager, connecting to enterprise databases, or exposing custom algorithms, the pattern remains the same. Start simple, test thoroughly, and iterate based on how Claude actually uses your tools.
The MCP ecosystem is young but growing fast. Every server you build makes Claude more capable in your specific domain. That's the real power here.
Summary
You now understand MCP architecture from the ground up. You've seen Python and TypeScript implementations, learned security best practices with OAuth, and discovered how to test and distribute your servers. The framework does the heavy lifting-protocol compliance, message routing, validation-leaving you free to focus on what matters: connecting Claude to your world.
The next step is clear: pick your first use case, build your server, and watch Claude handle tasks you previously thought required manual work.
Happy building.