Lab 05 — Small but complete 2.x MCP Notes Server
Localized walkthrough. Run the canonical public lab locally and preserve the result as evidence.
Localized walkthrough. Run the canonical public lab locally and preserve the result as evidence.
Run it#
python modulo-04-agentes/labs/05_mcp_server.py
Localized source walkthrough#
"""Lab 05 — Small but complete 2.x MCP Notes Server.
Exposes write/search tools and a read resource. IDs are opaque, inputs are
validated, and writes accept an idempotency key. No logs are written to stdout because it would
break the stdio transport.
Test with Inspector:
uv run mcp dev modulo-04-agentes/labs/05_mcp_server.py
Start via stdio:
uv run mcp run modulo-04-agentes/labs/05_mcp_server.py
"""
from __future__ import annotations
import hashlib
import logging
import re
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from threading import Lock
from mcp.server import MCPServer
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("nebula-notes-mcp")
mcp = MCPServer("nebula-notes")
TITLE_RE = re.compile(r"\s+")
@dataclass(frozen=True)
class Note:
note_id: str
title: str
content: str
created_at: str
class NoteStore:
def __init__(self) -> None:
self._notes: dict[str, Note] = {}
self._idempotency: dict[str, str] = {}
self._lock = Lock()
def create(self, title: str, content: str, idempotency_key: str) -> tuple[Note, bool]:
normalized_title = TITLE_RE.sub(" ", title.strip())
normalized_content = content.strip()
if not 2 <= len(normalized_title) <= 120:
raise ValueError("title debe tener entre 2 y 120 caracteres")
if not 1 <= len(normalized_content) <= 10_000:
raise ValueError("content debe tener entre 1 y 10000 caracteres")
if not 8 <= len(idempotency_key) <= 128:
raise ValueError("idempotency_key debe tener entre 8 y 128 caracteres")
with self._lock:
existing_id = self._idempotency.get(idempotency_key)
if existing_id:
return self._notes[existing_id], False
digest = hashlib.sha256(
f"{idempotency_key}\0{normalized_title}".encode()
).hexdigest()[:16]
note = Note(
note_id=digest,
title=normalized_title,
content=normalized_content,
created_at=datetime.now(UTC).isoformat(),
)
self._notes[note.note_id] = note
self._idempotency[idempotency_key] = note.note_id
return note, True
def get(self, note_id: str) -> Note:
note = self._notes.get(note_id)
if note is None:
raise ValueError("note_id no existe")
return note
def search(self, query: str, limit: int) -> list[Note]:
terms = set(query.casefold().split())
scored = []
for note in self._notes.values():
haystack = set(f"{note.title} {note.content}".casefold().split())
score = len(terms & haystack)
if score:
scored.append((score, note.created_at, note))
scored.sort(reverse=True, key=lambda item: (item[0], item[1]))
return [note for _, _, note in scored[:limit]]
store = NoteStore()
@mcp.tool()
def create_note(title: str, content: str, idempotency_key: str) -> dict:
"""Create a note. Repeating the same idempotency_key returns the original without duplicating it."""
note, created = store.create(title, content, idempotency_key)
logger.info("create_note id=%s created=%s", note.note_id, created)
return {"created": created, "note": asdict(note)}
@mcp.tool()
def search_notes(query: str, limit: int = 5) -> dict:
"""Search for notes by words in title and content; this is a read-only operation."""
normalized = query.strip()
if len(normalized) < 2:
raise ValueError("query debe tener al menos 2 caracteres")
if not 1 <= limit <= 20:
raise ValueError("limit debe estar entre 1 y 20")
notes = store.search(normalized, limit)
return {
"count": len(notes),
"notes": [
{"note_id": note.note_id, "title": note.title, "created_at": note.created_at}
for note in notes
],
}
@mcp.resource("notes://{note_id}")
def read_note(note_id: str) -> str:
"""Return a complete note by its opaque ID."""
note = store.get(note_id)
return f"# {note.title}\n\n{note.content}\n\nCreada: {note.created_at}"