Module 8: Project — Reservo's Complete MCP Server

The complete client demo

Description

This is the anchor lesson of the whole module — and, in a real sense, of the entire guide. Lessons 02 through 05 ran the complete server primitive by primitive: discovery (L02), tools (L03), resources (L04), prompts (L05), each separately. This lesson brings them together in a single run, with no interruptions: the complete MCPClient (M6's entire class, with its eight methods) connects to reservo_full_mcp_server.py, does the handshake, discovers all three primitives, and uses one of each type — a tool, a resource, a prompt — all over real stdio, all quoted byte for byte.

Connection to the module

This lesson rebuilds the complete MCPClient, exactly as it stood at the end of M6 (lessons 02 through 04: __init__, _send, _recv, connect, supports, and the eight discovery-and-use methods). If any method feels unfamiliar, the reference in parentheses in each block tells you which M6 lesson to go back to. The demo's sequence — handshake, discover, use tools/call, resources/read, prompts/get — is the same one M6, lesson 05, already ran against this same server; this lesson repeats it here, inside the module that closes the guide, as the central piece of everything learned.


The complete MCPClient

# mcp_client.py
"""Reusable MCP client: ONE instance = ONE connection to ONE server, over real stdio
(subprocess.Popen + pipes). Stores the capabilities negotiated in the handshake to
decide, later, which methods make sense to call. Assembled from M6 (lessons 02-04),
without changing a single line already built."""
import subprocess
import sys
import json
import itertools

PROTOCOL_VERSION = "2025-06-18"
CLIENT_INFO = {"name": "reservo-mcp-client", "version": "1.0.0"}


class MCPClient:
    def __init__(self, server_path: str, verbose: bool = True):
        self.server_path = server_path
        self.verbose = verbose
        self.request_ids = itertools.count(1)   # <- a counter PER INSTANCE, not global
        self.capabilities = {}
        self.server_info = {}
        self.proc = subprocess.Popen(
            [sys.executable, server_path],
            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            text=True, bufsize=1,
        )

    def _send(self, message: dict) -> None:
        line = json.dumps(message)
        if self.verbose:
            print(f"[client -> {self.server_path}] {line}")
        self.proc.stdin.write(line + "\n")
        self.proc.stdin.flush()

    def _recv(self) -> dict:
        line = self.proc.stdout.readline().strip()
        if self.verbose:
            print(f"[{self.server_path} -> client] {line}")
        return json.loads(line)

    def connect(self) -> dict:
        """Complete handshake (M2): initialize -> notifications/initialized."""
        self._send({"jsonrpc": "2.0", "id": next(self.request_ids), "method": "initialize",
                     "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
                                "clientInfo": CLIENT_INFO}})
        response = self._recv()
        self.capabilities = response["result"]["capabilities"]
        self.server_info = response["result"]["serverInfo"]
        self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
        return response["result"]

    def supports(self, primitive: str) -> bool:
        """True if the server declared this category in initialize's capabilities."""
        return primitive in self.capabilities

    def list_tools(self) -> list:
        self._send({"jsonrpc": "2.0", "id": next(self.request_ids), "method": "tools/list", "params": {}})
        return self._recv()["result"]["tools"]

    def call_tool(self, name: str, arguments: dict) -> dict:
        self._send({"jsonrpc": "2.0", "id": next(self.request_ids), "method": "tools/call",
                     "params": {"name": name, "arguments": arguments}})
        return self._recv()["result"]

    def list_resources(self) -> list:
        self._send({"jsonrpc": "2.0", "id": next(self.request_ids), "method": "resources/list", "params": {}})
        return self._recv()["result"]["resources"]

    def read_resource(self, uri: str) -> dict:
        self._send({"jsonrpc": "2.0", "id": next(self.request_ids), "method": "resources/read",
                     "params": {"uri": uri}})
        return self._recv()["result"]

    def list_prompts(self) -> list:
        self._send({"jsonrpc": "2.0", "id": next(self.request_ids), "method": "prompts/list", "params": {}})
        return self._recv()["result"]["prompts"]

    def get_prompt(self, name: str, arguments: dict | None = None) -> dict:
        self._send({"jsonrpc": "2.0", "id": next(self.request_ids), "method": "prompts/get",
                     "params": {"name": name, "arguments": arguments or {}}})
        return self._recv()["result"]

    def discover(self) -> dict:
        """The complete discovery flow: ONLY the */list calls the server declared it
        supports in its capabilities. Returns a combined catalog for one server."""
        catalog = {"tools": [], "resources": [], "prompts": []}
        if self.supports("tools"):
            catalog["tools"] = self.list_tools()
        if self.supports("resources"):
            catalog["resources"] = self.list_resources()
        if self.supports("prompts"):
            catalog["prompts"] = self.list_prompts()
        return catalog

    def close(self) -> None:
        self.proc.stdin.close()
        self.proc.wait(timeout=5)
        if self.verbose:
            print(f"---- stderr from {self.server_path} ----")
            print(self.proc.stderr.read().rstrip())

Thirteen methods, each with its exact origin in M6: __init__/_send/_recv/connect (lesson 02), supports (lesson 03), the eight discovery-and-use methods plus discover/close (lesson 04). None has any special logic for "this is Reservo" — it's a generic MCP client that would talk exactly the same way to any server that respects the protocol.


The complete demo: handshake → discover → use all three primitives

from mcp_client import MCPClient

client = MCPClient("reservo_full_mcp_server.py")
server_info = client.connect()
print(f"[client] handshake OK with {server_info['serverInfo']['name']}, capabilities: {client.capabilities}")

catalog = client.discover()
print(f"[client] discovered {len(catalog['tools'])} tools, {len(catalog['resources'])} resources, "
      f"{len(catalog['prompts'])} prompts")

# 1) tools: get_quote Focus/pro/3h -- the guide's anchor, 6000 cents
quote_result = client.call_tool("get_quote", {"room": "Focus", "tier": "pro", "hours": 3})
print(f"[client] tools/call get_quote -> {quote_result['content'][0]['text']}")

# 2) resources: read the cancellation policy
policy_result = client.read_resource("reservo://policies/cancellation-policy")
policy_text = policy_result["contents"][0]["text"]
print(f"[client] resources/read cancellation-policy -> {len(policy_text)} characters of "
      f"{policy_result['contents'][0]['mimeType']}")

# 3) prompts: activate plan_booking for the Focus room
prompt_result = client.get_prompt("plan_booking", {"room": "Focus"})
prompt_message = prompt_result["messages"][0]
print(f"[client] prompts/get plan_booking(room=Focus) -> role={prompt_message['role']!r}, "
      f"content.type={prompt_message['content']['type']!r}")
print(f"[client] prompt text: {prompt_message['content']['text']}")

client.close()

What to expect — running python3.14 this_script.py, this is the complete transcript, without editing a single line:

[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}, "resources": {}, "prompts": {}}, "serverInfo": {"name": "reservo-mcp-server", "version": "1.0.0"}}}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "method": "notifications/initialized"}
[client] handshake OK with reservo-mcp-server, capabilities: {'tools': {}, 'resources': {}, 'prompts': {}}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "list_rooms", "description": "Lists every Reservo room with its base hourly rate in cents. Use this tool when the user asks what rooms are available, without having chosen a specific room yet.", "inputSchema": {"type": "object", "properties": {}}}, {"name": "get_quote", "description": "Quotes the price of a room for a tier and a number of hours, without booking anything. Use this tool when the user asks how much a booking costs.", "inputSchema": {"type": "object", "properties": {"room": {"type": "string", "enum": ["Focus", "Studio", "Boardroom"]}, "tier": {"type": "string", "enum": ["basic", "pro"]}, "hours": {"type": "integer"}}, "required": ["room", "tier", "hours"]}}, {"name": "book_room", "description": "Creates a CONFIRMED booking for a room, a tier, and a number of hours, under a member's name. It has real effects: it generates an actual booking. Use this tool only when the user explicitly asks to book, not when they're just asking about the price.", "inputSchema": {"type": "object", "properties": {"room": {"type": "string", "enum": ["Focus", "Studio", "Boardroom"]}, "tier": {"type": "string", "enum": ["basic", "pro"]}, "hours": {"type": "integer"}, "member": {"type": "string"}}, "required": ["room", "tier", "hours", "member"]}}, {"name": "cancel_booking", "description": "Cancels an existing booking given its id. It's a destructive, irreversible action. Use this tool when the user asks to cancel or undo a booking they already made.", "inputSchema": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}]}}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 3, "method": "resources/list", "params": {}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 3, "result": {"resources": [{"uri": "reservo://policies/cancellation-policy", "name": "cancellation-policy", "description": "Reservo's cancellation window and refund rules", "mimeType": "text/markdown"}, {"uri": "reservo://policies/membership-tiers", "name": "membership-tiers", "description": "Basic vs Pro membership tiers and the Pro discount", "mimeType": "text/markdown"}]}}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 4, "method": "prompts/list", "params": {}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 4, "result": {"prompts": [{"name": "plan_booking", "description": "Guide the assistant through checking policy and quoting before booking a room", "arguments": [{"name": "room", "description": "Room to plan a booking for", "required": false}]}]}}
[client] discovered 4 tools, 2 resources, 1 prompts
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "get_quote", "arguments": {"room": "Focus", "tier": "pro", "hours": 3}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 5, "result": {"content": [{"type": "text", "text": "{\"price_cents\": 6000}"}], "isError": false}}
[client] tools/call get_quote -> {"price_cents": 6000}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 6, "method": "resources/read", "params": {"uri": "reservo://policies/cancellation-policy"}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 6, "result": {"contents": [{"uri": "reservo://policies/cancellation-policy", "mimeType": "text/markdown", "text": "# Cancellation Policy\n\nReservo bookings can be cancelled free of charge up to 24 hours before the reserved start time. Cancellations made within this window are refunded in full, with no penalty applied to the booking's account.\n\nCancellations made less than 24 hours before the start time are considered late cancellations. A late cancellation forfeits 50% of the quoted price; the remaining balance is refunded to the original payment method within 3-5 business days.\n\nNo-shows -- bookings that are never cancelled and never checked in -- forfeit the full quoted price. Reservo does not distinguish between a no-show and a same-day cancellation made after the reserved start time has already passed.\n\nPro members receive one exception per calendar month: a single late cancellation (within the 24-hour window) can be waived on request, refunded in full instead of the standard 50% penalty. Basic members do not have this exception available.\n\nCancellations are processed through the same channel used to make the original booking. There is no cancellation fee beyond the percentage forfeited under this policy -- Reservo does not charge a separate administrative fee.\n"}]}}
[client] resources/read cancellation-policy -> 1170 characters of text/markdown
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 7, "method": "prompts/get", "params": {"name": "plan_booking", "arguments": {"room": "Focus"}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 7, "result": {"description": "Guide the assistant through checking policy and quoting before booking a room", "messages": [{"role": "user", "content": {"type": "text", "text": "Before booking the Focus room, first read the cancellation policy resource (reservo://policies/cancellation-policy) and summarize the refund window for the user. Then call get_quote with the room, tier, and hours the user wants, and show them the price in cents. Only call book_room after the user has seen the quote and confirmed they want to proceed."}}]}}
[client] prompts/get plan_booking(room=Focus) -> role='user', content.type='text'
[client] prompt text: Before booking the Focus room, first read the cancellation policy resource (reservo://policies/cancellation-policy) and summarize the refund window for the user. Then call get_quote with the room, tier, and hours the user wants, and show them the price in cents. Only call book_room after the user has seen the quote and confirmed they want to proceed.
---- stderr from reservo_full_mcp_server.py ----
[server] starting up, waiting for messages on stdin...
[server] initialize <- client {'name': 'reservo-mcp-client', 'version': '1.0.0'}
[server] notifications/initialized <- client confirms it can now operate
[server] tools/list -> 4 tools
[server] resources/list -> 2 resources
[server] prompts/list -> 1 prompts
[server] tools/call <- get_quote({'room': 'Focus', 'tier': 'pro', 'hours': 3})
[server] resources/read <- uri=reservo://policies/cancellation-policy
[server] prompts/get <- plan_booking({'room': 'Focus'})

Reading the transcript: seven messages, one continuous id

Count the ids: they run from 1 to 7, in a single sequence with no resets — 1 for initialize, 2-4 for the three discovery */list calls, 5-7 for the three usage operations, in the order tools/callresources/readprompts/get. Seven requests, seven responses, one single notification (notifications/initialized) with no id — fifteen JSON-RPC messages in total, all over the same pair of pipes of a single subprocess, all generated by a single self.request_ids instance attribute that never resets between different methods of the same class.

And every result confirms exactly what you already expected from this module's lessons 02 through 05:

  • tools/call get_quote with {"room": "Focus", "tier": "pro", "hours": 3}{"price_cents": 6000} — the whole guide's anchor, in its sixth independent confirmation (M3 lesson 05, M3 lesson 07, M3 lesson 08, M6 lesson 05, this module's lesson 03, and now this one).
  • resources/read cancellation-policy1170 characters of text/markdown — the exact same length as M4 and this module's lesson 04.
  • prompts/get plan_booking(room=Focus)role='user', content.type='text', the complete text mentioning "the Focus room" once, exactly as in this module's lesson 05.

What you just demonstrated

A generic MCPClient — with no special method for "this is Reservo," with no if server_name == "reservo" anywhere in mcp_client.py — discovered a real server's three primitives and used one of each type, in the correct order (discover first, then use), with a single connection, with no interruptions. This is the final proof that the client built in M6 isn't a "Reservo" client: it's an MCP client, period. Any server speaking the same protocol — the one you wrote, or a third party's you register following lesson 07 — would work with exactly this same code, without changing a line.

If a real host needed to talk to more than one server at once (for example, Reservo and a second, completely different provider), the missing piece wouldn't be a different MCPClient — it would be M6, lesson 06's Host class: a {server_name: MCPClient} dictionary that keeps an independent instance for each server, never a shared connection. This module doesn't run it again (M6, lessons 06 and 07, already did it in depth, with two real subprocesses running at once) — but it's worth keeping in mind: when lesson 07 connects reservo-mcp-server with Claude Code, what Claude Code does internally is, structurally, exactly this: a Host with one MCPClient per server you declare in .mcp.json.


Common mistakes

  1. Calling read_resource/get_prompt with a misspelled URI or name, without having looked at discover()'s catalog first. The correct flow — the one this lesson follows — always goes through discover() before using anything: the exact names come from there, not from memory.

  2. Forgetting get_prompt receives arguments as a dictionary, even when there's only one optional one. client.get_prompt("plan_booking", {"room": "Focus"}) — never the bare value ("Focus").

  3. Confusing the fifth JSON-RPC message's id: 5 with some business data. The id (itertools.count(1) on the client side, counting messages) has no relation whatsoever to price_cents, booking_id, or any other number appearing in a response's content.


Exercises

Exercise 1: Count the primitives used (Easy)

From this lesson's transcript, how many times was each type of method called (*/list, tools/call, resources/read, prompts/get)? Confirm the total number of messages with id matches the number of distinct ids you counted (from 1 to 7).

See solution
  • initialize: 1 time (id: 1)
  • tools/list, resources/list, prompts/list: 1 time each (id: 2, 3, 4)
  • tools/call: 1 time (id: 5)
  • resources/read: 1 time (id: 6)
  • prompts/get: 1 time (id: 7)

Seven requests with id, from 1 to 7 with no gaps or repeats — matches exactly what a single itertools.count(1) advancing over a single connection would produce, without ever resetting during the run.

Exercise 2: Use all four tools, not just get_quote (Medium)

Extend this lesson's script to call Reservo's four tools in the same run — list_rooms, get_quote, book_room, cancel_booking — capturing the real booking_id book_room returns so you can cancel it afterward.

See solution
from mcp_client import MCPClient
import json

client = MCPClient("reservo_full_mcp_server.py", verbose=False)
client.connect()

rooms = client.call_tool("list_rooms", {})
print("list_rooms ->", rooms["content"][0]["text"])

quote = client.call_tool("get_quote", {"room": "Studio", "tier": "basic", "hours": 2})
print("get_quote Studio/basic/2h ->", quote["content"][0]["text"])

booking = client.call_tool("book_room", {"room": "Studio", "tier": "basic", "hours": 2, "member": "Marta"})
print("book_room ->", booking["content"][0]["text"])
booking_id = json.loads(booking["content"][0]["text"])["booking_id"]

cancel = client.call_tool("cancel_booking", {"id": booking_id})
print("cancel_booking ->", cancel["content"][0]["text"])

client.close()

Expected output:

list_rooms -> [{"room": "Focus", "rate_cents": 2500}, {"room": "Studio", "rate_cents": 4000}, {"room": "Boardroom", "rate_cents": 8000}]
get_quote Studio/basic/2h -> {"price_cents": 8000}
book_room -> {"booking_id": 1, "confirmed": true}
cancel_booking -> {"cancelled": true}

Explanation: 4000 * 2 = 8000 cents, no discount because the tier is basic — consistent with the guide's anchor formula. The real booking_id (1, this run's first booking) is captured before using it in cancel_booking, the same care practiced throughout the guide.

Exercise 3: plan_booking without the optional argument, compared line by line (Hard)

Call client.get_prompt("plan_booking", {}) — without room — and compare the generated text with the lesson's (which did carry room="Focus"), using the standard library's difflib to show exactly which words change.

See solution
from mcp_client import MCPClient
import difflib

client = MCPClient("reservo_full_mcp_server.py", verbose=False)
client.connect()

with_room = client.get_prompt("plan_booking", {"room": "Focus"})["messages"][0]["content"]["text"]
without_room = client.get_prompt("plan_booking", {})["messages"][0]["content"]["text"]

diff = list(difflib.unified_diff(
    with_room.split(), without_room.split(), lineterm="", n=0,
))
for line in diff:
    print(line)

client.close()

Real output (trimmed to the relevant diff lines):

-Focus
+the
+room
+the
+user
+wants

Explanation: difflib.unified_diff over the words (.split()) confirms, with a standard text-comparison tool, exactly what this module's lesson 05 already showed manually: the only difference between the two responses is the substitution of the word "Focus" for the phrase "the room the user wants" — everything else, word for word, stays identical. This is a more rigorous way to confirm a claim like "only one phrase changes" than reading two long blocks of text at a glance, and it's the same kind of tool (difflib, from Python's standard library) that would be useful for debugging unexpected differences between two responses from a real production MCP server.


Summary and next step

  • The complete MCPClient (thirteen methods, assembled from M6 with no changes) connected to Reservo's complete server and ran, in a single uninterrupted run: handshake → discover all three primitives → use a tool, a resource, and a prompt.
  • Fifteen JSON-RPC messages, id from 1 to 7 in continuous sequence, all quoted byte for byte.
  • The guide's anchor — get_quote Focus/pro/3h = 6000 cents — was confirmed for the sixth time, now within the whole guide's most complete demo.
  • A generic client, with no special code for Reservo, is proof the same mcp_client.py would work, unchanged, against any real MCP server — including a third party's, next lesson's topic.

Next lesson: 07 — Registering with Claude Code. This module's same reservo_full_mcp_server.py, registered in .mcp.json — the way a real host, not your own script, would end up launching it and talking to it.


Additional resources

  1. Model Context Protocol — Specification 2025-06-18 — The complete specification, run end to end in this lesson.
  2. Model Context Protocol — Specification 2025-06-18: Architecture — The client's role, demonstrated complete for the last time in this guide.
  3. Python — subprocess — The basis of all client↔server communication in this lesson.
  4. Python — difflib — The tool used in Exercise 3 to compare two texts generated by the same prompt.