| layout | default |
|---|---|
| title | Chapter 6: Customization and Extension Patterns |
| nav_order | 6 |
| parent | Create Python Server Tutorial |
This chapter covers practical strategies for extending the generated scaffold into a production-grade, domain-specific MCP server — replacing the in-memory notes store, adding new tools and resources, and maintaining protocol contracts as complexity grows.
- Extend default primitive handlers with domain logic without breaking protocol contracts
- Preserve MCP semantics (tool error handling, resource URI conventions) during extension
- Keep handler boundaries thin and protocol-focused
- Avoid coupling business logic to scaffold assumptions
flowchart TD
SCAFFOLD[Generated scaffold\nIn-memory notes dict]
SCAFFOLD --> STEP1[1. Replace data layer\nnotes dict → database/API client]
STEP1 --> STEP2[2. Expand primitives\nadd more tools, resources, prompts]
STEP2 --> STEP3[3. Extract domain logic\nserver.py imports domain modules]
STEP3 --> STEP4[4. Add validation and error handling\ntype checks, input sanitization]
STEP4 --> PROD[Production server]
The notes: dict[str, str] state variable is the single seam to replace. Extract it into a repository abstraction:
# Before (template)
notes: dict[str, str] = {}
@server.list_resources()
async def handle_list_resources() -> list[types.Resource]:
return [types.Resource(uri=AnyUrl(f"note://internal/{name}"), ...) for name in notes]# After (database-backed)
from myserver.db import NoteRepository
repo = NoteRepository(connection_string=os.environ["DATABASE_URL"])
@server.list_resources()
async def handle_list_resources() -> list[types.Resource]:
notes = await repo.list_all()
return [
types.Resource(
uri=AnyUrl(f"note://internal/{note.id}"),
name=f"Note: {note.title}",
description=note.summary,
mimeType="text/plain",
)
for note in notes
]Keep the URI scheme consistent (note://internal/) so clients that have cached resource URIs continue to work.
Extend handle_list_tools and handle_call_tool to support additional operations. Maintain the dispatch pattern:
TOOLS = {
"add-note": {
"description": "Add a new note",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"content": {"type": "string"},
},
"required": ["name", "content"],
},
},
"delete-note": {
"description": "Delete a note by name",
"inputSchema": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
"search-notes": {
"description": "Search notes by keyword",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 10},
},
"required": ["query"],
},
},
}
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
return [types.Tool(name=name, **spec) for name, spec in TOOLS.items()]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict | None) -> list[types.TextContent]:
args = arguments or {}
if name == "add-note":
return await _add_note(args)
elif name == "delete-note":
return await _delete_note(args)
elif name == "search-notes":
return await _search_notes(args)
else:
raise ValueError(f"Unknown tool: {name}")graph TD
DISPATCH[handle_call_tool dispatcher]
DISPATCH --> ADD[_add_note\nnotes.add + send_resource_list_changed]
DISPATCH --> DEL[_delete_note\nnotes.delete + send_resource_list_changed]
DISPATCH --> SEARCH[_search_notes\nreturns matching note list]
The template raises ValueError for unknown tools, which the MCP runtime converts to a protocol error response. For production, use structured error responses for known failure modes:
async def _add_note(args: dict) -> list[types.TextContent]:
name = args.get("name", "").strip()
content = args.get("content", "").strip()
if not name:
return [types.TextContent(type="text", text="Error: note name cannot be empty")]
if len(content) > 10_000:
return [types.TextContent(type="text", text="Error: note content exceeds 10,000 character limit")]
try:
await repo.save(name, content)
await server.request_context.session.send_resource_list_changed()
return [types.TextContent(type="text", text=f"Added note '{name}'")]
except DatabaseError as e:
return [types.TextContent(type="text", text=f"Storage error: {e}")]Key rule: never raise exceptions from call_tool for expected error conditions (validation failures, not-found cases). Return a TextContent with an error message so the LLM can communicate the failure to the user. Only raise for truly unexpected programming errors.
As server.py grows, extract domain logic into sibling modules:
src/my_notes_server/
├── __init__.py # Entry point — do not modify
├── server.py # Handler registration — keep thin
├── db.py # NoteRepository + database models
├── search.py # Full-text search logic
├── validators.py # Input validation helpers
└── notifications.py # Resource change notification helpers
graph LR
SERVER[server.py\nHandler registration + dispatch]
SERVER --> DB[db.py\nNoteRepository]
SERVER --> SEARCH[search.py\nSearchIndex]
SERVER --> VALID[validators.py\nInputValidator]
DB --> NOTES_TABLE[notes table]
SEARCH --> INDEX[full-text index]
server.py should remain a thin dispatch layer. Business logic goes in the domain modules, making them independently testable without the MCP server running.
Use environment variables for all deployment-specific configuration:
import os
from pathlib import Path
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///notes.db")
MAX_NOTES = int(os.environ.get("MAX_NOTES", "1000"))
LOG_LEVEL = os.environ.get("LOG_LEVEL", "WARNING")Add these to the Claude Desktop config:
{
"mcpServers": {
"my-notes-server": {
"command": "uv",
"args": ["--directory", "/path/to/server", "run", "my-notes-server"],
"env": {
"DATABASE_URL": "postgresql://localhost/notes",
"LOG_LEVEL": "INFO"
}
}
}
}Extend the scaffold by replacing the notes dict with a real data layer, expanding the tool dispatch table, and extracting domain logic into testable modules. Keep server.py as a thin registration and dispatch layer. Return structured TextContent errors for expected failure conditions rather than raising exceptions. Use environment variables for deployment-specific configuration passed via the Claude Desktop config.
Next: Chapter 7: Quality, Security, and Contribution Workflows