Implementing Core Features
Build the server foundation and implement the four tools.
Assembling Your Modules
The capstone project brings together everything you have built in previous lessons. You will use your existing modules to create a complete Notes MCP Server.
This capstone imports from the modules you created in Lessons 3-9. Make sure all previous modules are in your project directory.
Your Complete Project Structure
your_project/
jsonrpc.py # Lesson 3
mcp_core.py # Lesson 4
tools.py # Lesson 5
resources.py # Lesson 6
prompts.py # Lesson 9
stdio_transport.py # Lesson 7
http_sse_transport.py # Lesson 8
notes_server.py # CAPSTONE - We'll create this
notes.json # Data storage (auto-created)
In this lesson:
- Filename in header = Write this code to that file
- "Add after X" = Append to your existing file
- bash blocks = Commands to run in your terminal
- "Expected Output" = What you should see (don't write this)
The Full Implementation
Below is the complete notes_server.py implementation. While this version
is self-contained for clarity, note that it follows the same patterns you learned:
- Tool registration patterns from Lesson 5
- Resource patterns from Lesson 6
- STDIO transport from Lesson 7
- Prompt patterns from Lesson 9
Section 1: Create notes_server.py
Create: notes_server.py
Create a new file called notes_server.py with the data model and storage:
#!/usr/bin/env python3
"""
notes_server.py - A complete MCP server for managing notes.
This capstone project demonstrates tools, resources, and prompts
working together in a single MCP server.
"""
import json
import sys
import os
import uuid
from datetime import datetime, timezone
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any
# =============================================================================
# Data Model
# =============================================================================
@dataclass
class Note:
"""Represents a single note."""
id: str
title: str
content: str
tags: List[str] = field(default_factory=list)
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"content": self.content,
"tags": self.tags,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@classmethod
def from_dict(cls, data: dict) -> "Note":
return cls(
id=data["id"],
title=data["title"],
content=data["content"],
tags=data.get("tags", []),
created_at=data.get("created_at", datetime.now(timezone.utc).isoformat()),
updated_at=data.get("updated_at", datetime.now(timezone.utc).isoformat()),
)
@staticmethod
def generate_id() -> str:
return str(uuid.uuid4())[:8]
# =============================================================================
# Storage
# =============================================================================
class NotesStorage:
"""Handle persistent storage of notes."""
def __init__(self, filepath: str = "notes.json"):
self.filepath = filepath
self.notes: List[Note] = []
self.load()
def load(self):
if os.path.exists(self.filepath):
try:
with open(self.filepath, "r") as f:
data = json.load(f)
self.notes = [Note.from_dict(n) for n in data]
except (json.JSONDecodeError, KeyError):
self.notes = []
else:
self.notes = []
def save(self):
with open(self.filepath, "w") as f:
json.dump([n.to_dict() for n in self.notes], f, indent=2)
def create(self, title: str, content: str, tags: Optional[List[str]] = None) -> Note:
note = Note(
id=Note.generate_id(),
title=title,
content=content,
tags=tags or [],
)
self.notes.append(note)
self.save()
return note
def get_by_id(self, note_id: str) -> Optional[Note]:
for note in self.notes:
if note.id == note_id:
return note
return None
def update(self, note_id: str, title: Optional[str] = None,
content: Optional[str] = None, tags: Optional[List[str]] = None) -> Optional[Note]:
note = self.get_by_id(note_id)
if not note:
return None
if title is not None:
note.title = title
if content is not None:
note.content = content
if tags is not None:
note.tags = tags
note.updated_at = datetime.now(timezone.utc).isoformat()
self.save()
return note
def delete(self, note_id: str) -> bool:
note = self.get_by_id(note_id)
if not note:
return False
self.notes.remove(note)
self.save()
return True
def search(self, query: Optional[str] = None, tag: Optional[str] = None) -> List[Note]:
results = self.notes
if tag:
results = [n for n in results if tag.lower() in [t.lower() for t in n.tags]]
if query:
query = query.lower()
results = [n for n in results
if query in n.title.lower() or query in n.content.lower()]
return results
def get_all_tags(self) -> List[str]:
tags = set()
for note in self.notes:
tags.update(note.tags)
return sorted(list(tags))
Section 2: Server Class
Edit: notes_server.py
Add the NotesServer class after Section 1:
# =============================================================================
# Section 2: MCP Server
# =============================================================================
class NotesServer:
"""MCP server for note management."""
PROTOCOL_VERSION = "2024-11-05"
def __init__(self, storage_path: str = "notes.json"):
self.storage = NotesStorage(storage_path)
self.initialized = False
def run(self):
"""Main loop: read from stdin, write to stdout."""
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
response = self.handle_request(request)
if response: # Don't respond to notifications
self.send_response(response)
except json.JSONDecodeError:
self.send_response(self.make_error(None, -32700, "Parse error"))
except Exception as e:
self.send_response(self.make_error(None, -32603, str(e)))
def send_response(self, response: dict):
"""Send a JSON-RPC response."""
print(json.dumps(response), flush=True)
def make_response(self, id: Any, result: Any) -> dict:
"""Create a successful JSON-RPC response."""
return {"jsonrpc": "2.0", "id": id, "result": result}
def make_error(self, id: Any, code: int, message: str, data: Any = None) -> dict:
"""Create a JSON-RPC error response."""
error = {"code": code, "message": message}
if data:
error["data"] = data
return {"jsonrpc": "2.0", "id": id, "error": error}
def handle_request(self, request: dict) -> Optional[dict]:
"""Route request to appropriate handler."""
method = request.get("method", "")
id = request.get("id")
params = request.get("params", {})
# Notifications (no id) don't get responses
if id is None and method == "notifications/initialized":
return None
handlers = {
"initialize": self.handle_initialize,
"tools/list": self.handle_tools_list,
"tools/call": self.handle_tools_call,
"resources/list": self.handle_resources_list,
"resources/read": self.handle_resources_read,
"prompts/list": self.handle_prompts_list,
"prompts/get": self.handle_prompts_get,
}
handler = handlers.get(method)
if handler:
try:
result = handler(params)
return self.make_response(id, result)
except Exception as e:
return self.make_error(id, -32603, f"Internal error: {e}")
else:
return self.make_error(id, -32601, f"Method not found: {method}")
Section 3: Initialization Handler
Edit: notes_server.py
Add the initialization handler to the NotesServer class:
# -------------------------------------------------------------------------
# Section 3: Initialization Handler
# -------------------------------------------------------------------------
def handle_initialize(self, params: dict) -> dict:
"""Handle the initialize request."""
self.initialized = True
return {
"protocolVersion": self.PROTOCOL_VERSION,
"capabilities": {
"tools": {},
"resources": {},
"prompts": {},
},
"serverInfo": {
"name": "notes-server",
"version": "1.0.0",
},
}
Section 4: Tools List Handler
Edit: notes_server.py
Add the tools list handler after Section 3:
# -------------------------------------------------------------------------
# Section 4: Tools List Handler
# -------------------------------------------------------------------------
def handle_tools_list(self, params: dict) -> dict:
"""Return list of available tools."""
return {
"tools": [
{
"name": "create_note",
"description": "Create a new note with title, content, and optional tags",
"inputSchema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The title of the note"
},
"content": {
"type": "string",
"description": "The content/body of the note"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional tags for categorization"
}
},
"required": ["title", "content"]
}
},
{
"name": "update_note",
"description": "Update an existing note by ID",
"inputSchema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the note to update"
},
"title": {"type": "string", "description": "New title"},
"content": {"type": "string", "description": "New content"},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "New tags (replaces existing)"
}
},
"required": ["id"]
}
},
{
"name": "delete_note",
"description": "Delete a note by ID",
"inputSchema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the note to delete"
}
},
"required": ["id"]
}
},
{
"name": "search_notes",
"description": "Search notes by keyword or tag",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search keyword for title/content"
},
"tag": {
"type": "string",
"description": "Filter by tag"
}
}
}
}
]
}
Section 5: Tools Call Handler
Edit: notes_server.py
Add the tools call handler and tool implementations after Section 4:
# -------------------------------------------------------------------------
# Section 5: Tools Call Handler
# -------------------------------------------------------------------------
def handle_tools_call(self, params: dict) -> dict:
"""Execute a tool and return the result."""
name = params.get("name", "")
arguments = params.get("arguments", {})
tool_handlers = {
"create_note": self.tool_create_note,
"update_note": self.tool_update_note,
"delete_note": self.tool_delete_note,
"search_notes": self.tool_search_notes,
}
handler = tool_handlers.get(name)
if not handler:
return {
"content": [{"type": "text", "text": f"Unknown tool: {name}"}],
"isError": True,
}
try:
return handler(arguments)
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error: {e}"}],
"isError": True,
}
# -------------------------------------------------------------------------
# Tool Implementations
# -------------------------------------------------------------------------
def tool_create_note(self, args: dict) -> dict:
"""Create a new note."""
title = args.get("title")
content = args.get("content")
tags = args.get("tags", [])
if not title or not content:
return {
"content": [{"type": "text", "text": "Error: title and content are required"}],
"isError": True,
}
note = self.storage.create(title, content, tags)
return {
"content": [{
"type": "text",
"text": f"Created note '{note.title}' with ID: {note.id}"
}]
}
def tool_update_note(self, args: dict) -> dict:
"""Update an existing note."""
note_id = args.get("id")
if not note_id:
return {
"content": [{"type": "text", "text": "Error: id is required"}],
"isError": True,
}
note = self.storage.update(
note_id,
title=args.get("title"),
content=args.get("content"),
tags=args.get("tags"),
)
if not note:
return {
"content": [{"type": "text", "text": f"Error: Note {note_id} not found"}],
"isError": True,
}
return {
"content": [{
"type": "text",
"text": f"Updated note '{note.title}' (ID: {note.id})"
}]
}
def tool_delete_note(self, args: dict) -> dict:
"""Delete a note."""
note_id = args.get("id")
if not note_id:
return {
"content": [{"type": "text", "text": "Error: id is required"}],
"isError": True,
}
if self.storage.delete(note_id):
return {
"content": [{"type": "text", "text": f"Deleted note {note_id}"}]
}
else:
return {
"content": [{"type": "text", "text": f"Error: Note {note_id} not found"}],
"isError": True,
}
def tool_search_notes(self, args: dict) -> dict:
"""Search notes by query or tag."""
query = args.get("query")
tag = args.get("tag")
results = self.storage.search(query=query, tag=tag)
if not results:
return {
"content": [{"type": "text", "text": "No notes found matching criteria"}]
}
text = f"Found {len(results)} note(s):\n\n"
for note in results:
tags_str = ", ".join(note.tags) if note.tags else "none"
text += f"- [{note.id}] {note.title} (tags: {tags_str})\n"
return {"content": [{"type": "text", "text": text}]}
Tool responses must include a content array with content
items. Each item has a type (usually "text") and the
corresponding data. Set isError: true when returning errors.
Progress Check
At this point, you have implemented:
- The Note data model with serialization
- The NotesStorage class with CRUD operations
- The NotesServer class with STDIO transport
- The initialization handshake
- Four complete tools with error handling
In the next step, we will add resources and prompts to complete the server. Resources will expose notes as readable data, and prompts will help Claude work with notes more effectively.