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:
Bookingis a Pydantic model, so tools that returnlist[Booking]get an output schema and structured results for free.BOOKINGSholds 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 forCALENDAR_DELAY_SECONDSfirst, which is how the timeout examples make it slow.book_slotwrites and is annotated not read-only.cancel_bookingis annotated destructive.- Errors the model can fix (
Unknown resource,already booked) are raised asToolError, so they reach the model as tool execution errors, as Module 5 taught. - The resource
calendar://bookingsuses the schemecalendar, which matches the key we will give this server in the host, soread_resourcerouting 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.