16.5 Building Custom MCP Servers
Course: Claude Code - Enterprise Development
Section: Advanced MCP Integrations
Video Length: 4-5 minutes
Presenter: Daniel Treasure
Opening Hook
"So far, we've connected Claude to existing services via official MCP adapters. But what about your proprietary systems—internal APIs, legacy databases, custom tools? Today, we're building a custom MCP server to safely expose internal systems to Claude without leaking credentials or bypassing security."
Key Talking Points
What to say:
- "Not every system has an official MCP adapter—but you can build one."
- "Custom MCP servers are the secure bridge between Claude and your internal systems."
- "Why build instead of using raw APIs? Security, scope control, credential isolation, audit logging."
- "We'll build a simple internal user service MCP and talk about the design principles."
- "Components: server process, defined operations, authentication, permission enforcement."
What to show on screen:
- Internal API documentation (example)
- MCP server code from scratch
- Server running and registering with Claude Code
- Claude calling MCP operations
- Audit logging and permission enforcement
Demo Plan
[00:00 - 01:00] Custom MCP Architecture 1. Explain the three layers: Transport (stdio/HTTP), MCP Protocol (request/response), Your Logic 2. Show: MCP server receives request → validates auth → calls your API → returns sanitized response 3. Draw/show diagram: Claude → MCP Server → Your Internal API → Database 4. Emphasize: Claude never touches credentials, API URLs, or raw data
[01:00 - 02:00] Build Simple MCP Server
1. Create Python file with mcp.server.Server
2. Define first tool: get_user(user_id: str) returns {name, email, status}
3. Show schema definition (input validation)
4. Implement: fetch from internal API with service account credentials
5. Return only necessary fields (strip internal data)
[02:00 - 03:15] Security & Least Privilege 1. Show: MCP server has ONE credential (service account) → Claude never sees it 2. Demonstrate: Input validation (only allow numeric user IDs, prevent SQL injection) 3. Audit logging: every MCP call logged with: timestamp, operation, user_id, result 4. Show error handling: never leak stack traces to Claude, return safe error messages
[03:15 - 04:00] Register & Test MCP Server
1. Start custom MCP server (show process running)
2. Run claude mcp add --transport stdio mycustom -- python server.py
3. In Claude Code, ask: "Get details for user 123"
4. Show Claude calling MCP tool, returning sanitized data
5. Ask: "Get details for user 123; DROP TABLE users"
6. Show MCP validation blocking malicious input
[04:00 - 04:30] Best Practices 1. Least privilege: expose only what Claude needs 2. Input validation: strict type checking, range limits 3. Output sanitization: strip internal fields, no error details 4. Audit everything: log all operations for compliance
Code Examples & Commands
Python MCP Server (complete example):
import os
import json
import logging
from mcp.server import Server
from mcp.types import Tool, TextContent
import httpx
import logging
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Initialize server
server = Server("internal-user-service")
internal_api_url = os.getenv("INTERNAL_API_URL")
service_account_token = os.getenv("INTERNAL_API_TOKEN")
@server.tool(
name="get_user",
description="Get user details by ID",
inputSchema={
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "Numeric user ID",
"pattern": "^[0-9]+$"
}
},
"required": ["user_id"]
}
)
async def get_user(user_id: str) -> TextContent:
try:
# Log the request
logger.info(f"MCP get_user called with user_id={user_id}")
# Validate input
if not user_id.isdigit():
return TextContent(text="Error: Invalid user ID format")
# Call internal API with service credentials
async with httpx.AsyncClient() as client:
response = await client.get(
f"{internal_api_url}/users/{user_id}",
headers={"Authorization": f"Bearer {service_account_token}"}
)
if response.status_code == 404:
return TextContent(text="User not found")
response.raise_for_status()
data = response.json()
# Sanitize output: return only safe fields
safe_data = {
"id": data["id"],
"name": data["name"],
"email": data["email"],
"status": data["status"],
"department": data.get("department")
}
logger.info(f"Successfully retrieved user {user_id}")
return TextContent(text=json.dumps(safe_data, indent=2))
except Exception as e:
logger.error(f"Error in get_user: {str(e)}")
return TextContent(text="Error: Unable to retrieve user")
@server.tool(
name="list_users_by_department",
description="List users in a department",
inputSchema={
"type": "object",
"properties": {
"department": {
"type": "string",
"enum": ["engineering", "product", "sales", "operations"]
}
},
"required": ["department"]
}
)
async def list_users_by_department(department: str) -> TextContent:
try:
logger.info(f"MCP list_users_by_department called with department={department}")
async with httpx.AsyncClient() as client:
response = await client.get(
f"{internal_api_url}/departments/{department}/users",
headers={"Authorization": f"Bearer {service_account_token}"}
)
response.raise_for_status()
data = response.json()
# Sanitize: return only names and IDs
safe_data = [
{"id": u["id"], "name": u["name"]}
for u in data.get("users", [])
]
return TextContent(text=json.dumps(safe_data, indent=2))
except Exception as e:
logger.error(f"Error in list_users_by_department: {str(e)}")
return TextContent(text="Error: Unable to list users")
if __name__ == "__main__":
server.run(transport="stdio")
Register & test:
# Set environment variables
export INTERNAL_API_URL="https://api.internal.example.com"
export INTERNAL_API_TOKEN="serviceaccount-xxx"
# Add to Claude Code
claude mcp add --transport stdio user-service -- python /path/to/server.py
# Test in Claude Code:
# "List all engineering team members"
# "Get details for user 5"
Gotchas & Tips
Gotcha 1: Credential Leakage
- Never store credentials in code, config files, or share them with Claude
- Solution: Use environment variables, secrets managers (.env never in repo)
- In demo: show .env file on screen, but don't expand it (keep tokens hidden)
Gotcha 2: Missing Input Validation - If you don't validate input, Claude might send unexpected data - Example: user_id as string instead of int, special characters, very large IDs - Solution: Define strict inputSchema and validate before calling internal API
Gotcha 3: Overly Broad Permissions - Don't expose your entire API—expose only what Claude needs - Bad: "get_anything(path: str)" → Claude can call /admin/secrets - Good: "get_user(user_id: str)" → only users, only by ID
Gotcha 4: Error Message Information Leakage - Don't return stack traces or internal error details to Claude - Bad: "ValueError: Connection refused at api.internal.com:5432" - Good: "Error: Unable to retrieve user"
Tip 1: Schema Drives Behavior - Use inputSchema enums to restrict choices - Example: department = ["eng", "product", "sales"] → Claude can't request invalid departments
Tip 2: Pagination for Large Results - If Claude asks for "all users," return paginated results - Schema: limit (default 50, max 100), offset - Claude will iterate if needed
Tip 3: Versioning & Backward Compatibility - Plan for schema changes early - Example: version your tools (get_user_v2) to avoid breaking Claude workflows
Tip 4: Rate Limiting - Implement backoff in your MCP server if internal API is slow - Prevent Claude from hammering your API
Tip 5: Audit Logging is Gold - Log every MCP call: who (service account), what (operation), when, result - Useful for: debugging, compliance, detecting misuse
Lead-out
"You've now built a production-ready MCP server—the secure bridge between Claude and your internal systems. Security, validation, and logging are built in. We've covered reading external systems (16.1-16.4) and building custom adapters (16.5). Next section: we shift from CLI to SDK—building Claude agents programmatically in your applications."
Reference URLs
- Anthropic MCP Specification: https://modelcontextprotocol.io/
- MCP Python SDK: https://github.com/anthropics/python-sdk/tree/main/mcp
- Example MCP Servers: https://github.com/anthropics/mcp-servers
- Security Best Practices for MCPs: [would be in official docs]
Prep Reading
- Review MCP specification (basics, 10 min)
- Identify internal API you'd like to expose via MCP (5 min)
- Read example MCP server code in anthropics/mcp-servers (5 min)
Notes for Daniel
- Code pace: This is dense—go slow on the code. Let viewers read each line before moving to the next section.
- Highlight security: Emphasize repeatedly that this design prevents Claude from ever seeing credentials or calling APIs directly.
- Real example: If possible, use an example related to your own infrastructure (HR system, asset tracking, etc.)
- Demo confidence: Practice the MCP server setup before recording. Network issues are common in live demos.
- Tone: Position MCP as the "right way" to integrate Claude with internal systems. Not a hack, but a deliberate architecture choice.
- Wrap-up: Mention that MCP servers can be deployed as microservices, Docker containers, or serverless functions. This sets up 17.10 (Hosting & Deployment).