January 5, 2026
Claude API Development Tutorial Automation

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
  1. Understanding MCP Architecture
  2. Setting Up Your Python MCP Server
  3. Building TypeScript MCP Servers
  4. Security Best Practices: OAuth Integration
  5. Testing MCP Servers Locally
  6. Publishing and Distribution
  7. Advanced Patterns: Context and Progress Reporting
  8. Debugging and Monitoring
  9. Real-World Considerations
  10. Bringing It All Together
  11. 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:

  1. Resources: Read-only data endpoints. Think "GET /api/data"-resources provide information without side effects.
  2. Tools: Actionable capabilities. These perform computations or create side effects. Like POST endpoints, they modify state.
  3. 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:

bash
pip install "mcp[cli]"

Now, create your server foundation:

python
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:

python
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:

python
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:

  1. Spawns your Python process
  2. Opens stdin/stdout communication
  3. Discovers your resources and tools
  4. 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:

bash
npm install @modelcontextprotocol/server zod

Here's the equivalent TypeScript server:

typescript
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:

python
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:

bash
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:

json
{
  "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:

python
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:

typescript
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:

bash
npm version patch
npm publish

Users install with:

bash
npm install @yourname/document-manager-mcp

Model 2: GitHub Releases (for binaries)

Build your server as a standalone binary and release on GitHub:

bash
# For Python
pyinstaller --onefile server.py
# Creates dist/server.exe
 
git tag v1.0.0
git push origin v1.0.0

Users download and configure:

json
{
  "mcpServers": {
    "document-manager": {
      "command": "/usr/local/bin/server"
    }
  }
}

Model 3: Docker Container (for complex deployments)

Containerize your server:

dockerfile
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:

json
{
  "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:

python
@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:

python
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)
        raise

Log 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:

python
@server.tool()
async def delete_document(name: str):
    if not name.endswith(".md"):
        raise ValueError("Only .md files can be deleted")
 
    # Your deletion logic

Rate Limiting: Protect your backend:

python
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 logic

Bringing 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:

  1. Define resources (what Claude can read)
  2. Define tools (what Claude can do)
  3. Implement handlers (make it real)
  4. Test locally (verify it works)
  5. 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.

Need help implementing this?

We build automation systems like this for clients every day.

Discuss Your Project