CourseModel Context Protocol · Module 6: Clients and Hosts · part 36 of 83
Part 36 · Module 6: Clients and Hosts

Topic 3: Multi-server hosts

20 min read·22 Sept 2026

A second server with a colliding tool name

Nare's lab also keeps an equipment calendar: the sleep lab, the EEG caps, the meeting room. We give it its own small MCP server. Its author, who never saw our notes server, named the tool that searches the free-text notes on bookings search_notes. That is a perfectly reasonable name inside its own server, and it collides with ours.

python
"""A second, small MCP server: the lab's equipment calendar.

It deliberately has a tool called `search_notes` (it searches the notes attached to
bookings), the same name as the notes server's main tool, to show why hosts namespace.
Run over stdio (default) or HTTP: CALENDAR_TRANSPORT=streamable-http CALENDAR_PORT=8061.
Set CALENDAR_DELAY_SECONDS to make list_bookings slow, for the timeout examples.
"""
from __future__ import annotations

import os
from datetime import date, timedelta
from typing import Annotated

import anyio
from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)


class Booking(BaseModel):
    booking_id: str
    resource: str
    day: str
    booked_by: str
    note: str


BOOKINGS: dict[str, Booking] = {
    b.booking_id: b
    for b in [
        Booking(booking_id="b1", resource="sleep-lab", day="2026-10-05", booked_by="Tomas", note="Nap study, first cohort"),
        Booking(booking_id="b2", resource="sleep-lab", day="2026-10-12", booked_by="Tomas", note="Nap study, second cohort"),
        Booking(booking_id="b3", resource="eeg-caps", day="2026-10-06", booked_by="Motor lab", note="Shared caps, other group"),
        Booking(booking_id="b4", resource="eeg-caps", day="2026-10-13", booked_by="Priya", note="Pilot recordings for nap study"),
    ]
}
RESOURCES = {"sleep-lab", "eeg-caps", "meeting-room"}


def build_calendar() -> MCPServer:
    mcp = MCPServer("calendar", title="Lab calendar", version="0.1.0")

    @mcp.tool(title="Search booking notes", annotations=READ_ONLY)
    def search_notes(query: Annotated[str, Field(min_length=1, max_length=100)]) -> list[Booking]:
        """Search the free-text notes attached to lab bookings, for example 'nap study'."""
        words = query.lower().split()
        return [b for b in BOOKINGS.values() if all(w in b.note.lower() for w in words)]

    @mcp.tool(title="List bookings", annotations=READ_ONLY)
    async def list_bookings(
        week_of: Annotated[str, Field(description="Monday of the week, YYYY-MM-DD.")],
        resource: Annotated[str | None, Field(description="sleep-lab, eeg-caps, or meeting-room.")] = None,
    ) -> list[Booking]:
        """List bookings in the lab calendar for one week."""
        await anyio.sleep(float(os.environ.get("CALENDAR_DELAY_SECONDS", "0")))
        try:
            start = date.fromisoformat(week_of)
        except ValueError as exc:
            raise ToolError("week_of must be a date like 2026-10-05.") from exc
        days = {(start + timedelta(days=i)).isoformat() for i in range(7)}
        return [b for b in BOOKINGS.values() if b.day in days and (resource is None or b.resource == resource)]

    @mcp.tool(title="Book a slot", annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False))
    def book_slot(resource: str, day: str, booked_by: str, note: str) -> Booking:
        """Book a lab resource for a whole day. Fails if it is already booked."""
        if resource not in RESOURCES:
            raise ToolError(f"Unknown resource {resource!r}. Use one of {sorted(RESOURCES)}.")
        if any(b.resource == resource and b.day == day for b in BOOKINGS.values()):
            raise ToolError(f"{resource} is already booked on {day}.")
        booking = Booking(booking_id=f"b{len(BOOKINGS) + 1}", resource=resource, day=day, booked_by=booked_by, note=note)
        BOOKINGS[booking.booking_id] = booking
        return booking

    @mcp.tool(title="Cancel a booking", annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True))
    def cancel_booking(booking_id: str) -> str:
        """Cancel a booking by id. This cannot be undone."""
        if BOOKINGS.pop(booking_id, None) is None:
            raise ToolError(f"No booking {booking_id!r}.")
        return f"Cancelled {booking_id}."

    @mcp.resource("calendar://bookings", mime_type="text/markdown", title="All bookings")
    def all_bookings() -> str:
        """Every booking, one per line."""
        return "\n".join(f"- {b.day} {b.resource}: {b.booked_by} ({b.note})" for b in BOOKINGS.values())

    return mcp


if __name__ == "__main__":
    server = build_calendar()
    if os.environ.get("CALENDAR_TRANSPORT", "stdio") == "stdio":
        server.run(transport="stdio")
    else:
        server.run(transport="streamable-http", host="127.0.0.1", port=int(os.environ.get("CALENDAR_PORT", "8061")))

Code explained

  • In simple words: a tiny booking book for lab equipment, with one tool whose name happens to clash with the notes server.
  • What happens:
    • Booking is a Pydantic model, so tools that return list[Booking] get an output schema and structured results for free. BOOKINGS holds four bookings that match the lab sync note (Tomas booked the sleep lab for 5 and 12 October; the EEG caps are shared with another group).
    • search_notes (read-only) matches booking notes containing every query word. list_bookings (read-only) returns one week and sleeps for CALENDAR_DELAY_SECONDS first, which is how the timeout examples make it slow. book_slot writes and is annotated not read-only. cancel_booking is annotated destructive.
    • Errors the model can fix (Unknown resource, already booked) are raised as ToolError, so they reach the model as tool execution errors, as Module 5 taught.
    • The resource calendar://bookings uses the scheme calendar, which matches the key we will give this server in the host, so read_resource routing works.
    • At the bottom, the same file runs over stdio or, with CALENDAR_TRANSPORT=streamable-http, on port 8061 (the lab uses 8064).
  • Comes out: nothing when imported. Run over stdio, it waits silently for a client on stdin.

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.