CourseModel Context Protocol · Module 11: Capstone · part 76 of 83
Part 76 · Module 11: Capstone

Topic 3: The server, requirement by requirement

9 min read·22 Sept 2026

Module 5 presented server.py in full and Module 7 added authorization. Here we read it against the capstone requirements, one excerpt per requirement. Each excerpt is copied exactly from notes_assistant/server.py; the nested functions are shown dedented (in the file they sit inside build_server()).

Two tools with honest annotations

A tool annotation is a hint the server attaches to a tool so hosts can decide how carefully to treat it. The two capstone tools differ in exactly one hint that matters to our host: read_only_hint.

python
# Excerpt from notes_assistant/server.py, lines 65 to 112, dedented (inside build_server)
@mcp.tool(
    title="Search notes",
    annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False),
)
def search_notes(
    query: Annotated[str, Field(min_length=1, max_length=200, description="Words to look for, for example 'sleep memory'.")],
    limit: Annotated[int, Field(ge=1, le=20, description="Maximum number of notes to return.")] = 5,
    tag: Annotated[str | None, Field(description="Only return notes with this tag, for example 'meeting'.")] = None,
) -> SearchResult:
    """Search the user's research notes by keyword and return the best matching notes.

    Use this first for any question about what the user has written down.
    Each hit has a `uri`; read it with the notes resource to see the full note.
    Returns an empty list when nothing matches, which means the notes do not cover the topic.
    """
    try:
        hits = store.search(query, limit=limit, tag=tag)
    except InvalidNote as exc:
        raise ToolError(str(exc)) from exc
    return SearchResult(
        query=query,
        hits=[NoteHit(note_id=h.note_id, title=h.title, score=h.score, snippet=h.snippet, uri=f"notes://{h.note_id}") for h in hits],
    )

@mcp.tool(
    title="Create note",
    annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False, open_world_hint=False),
)
def create_note(
    title: Annotated[str, Field(min_length=1, max_length=120, description="Short title; it becomes the note id.")],
    body: Annotated[str, Field(min_length=1, max_length=20000, description="Markdown body of the note.")],
    tags: Annotated[list[str], Field(max_length=10, description="Lowercase tags, for example ['sleep'].")] = [],
) -> CreatedNote:
    """Create a new note in the user's notes folder.

    Only call this when the user explicitly asks to save or create a note.
    Never overwrites: fails if a note with the same title already exists.
    """
    token = get_access_token()
    if token is not None and WRITE_SCOPE not in token.scopes:
        # Over stdio there is no token; the launching user is trusted.
        raise ToolError(f"This token lacks the {WRITE_SCOPE} scope, so notes cannot be created.")
    try:
        note = store.create(title, body, tags)
    except (InvalidNote, NoteExists) as exc:
        raise ToolError(str(exc)) from exc
    logger.info("created note id=%s", note.note_id)
    return CreatedNote(note_id=note.note_id, title=note.title, uri=f"notes://{note.note_id}")

Code explained

  • In simple words: two functions become two tools; their type hints become the input schema, their docstrings become the description the model reads, and their annotations tell the host which one is safe to run without asking.
  • What happens: Annotated[..., Field(...)] puts limits into the JSON Schema (minLength, maximum, and so on), so the SDK rejects bad input before our code runs. The return types (SearchResult, CreatedNote) are pydantic models, which gives both tools an output schema and structured content. Each hit carries a notes:// URI, so the model reads full notes through the resource instead of receiving every body inline (progressive disclosure from Module 10). create_note checks the notes:write scope when a token is present (HTTP) and relies on the launching user when there is none (stdio). Store errors become ToolError, which the SDK returns as a tool result with is_error=True and our sentence as the text, so the model can read it and retry.
  • Comes out: nothing on its own; this is the definition. The validation example below shows what the model receives when it gets the arguments wrong.

The rest of this course is yours to keep

This course is bought on its own, once, and stays readable afterwards, including the parts added to it later.