Module 3: Tools over MCP

`tools/list`: advertising what the server offers

Description

The previous lesson confirmed that declaring the tools capability isn't enough — the method that fulfills it has to be implemented. This lesson implements exactly that method: tools/list, the first of the two that complete the tools capability. You're going to declare Reservo's four canonical tools as MCP Tool objects, add them to the server's dispatch, and run the complete request against a real client — reading, field by field, the response carrying the entire "menu."

Connection to the module

This lesson builds the first real piece of the reservo_tools_mcp_server.py you're going to use from here on throughout the module: the four declared Tools and the tools/list handler. Lesson 05 adds the second piece (tools/call) on top of this same base — nothing you write here gets discarded.


Analogy: the complete menu, served all at once

Following the module's analogy: tools/list is the moment the waiter hands the diner the entire menu, all at once — not one dish at a time, not an improvised verbal description, but a complete document with every option, its name, and what needs to be asked for to prepare it. The diner doesn't have to ask "what dishes do you have?" and wait for a partial answer; they receive the complete list in a single exchange, and decide from there.

That's exactly tools/list's shape: a request with no relevant parameters, and a response with all of the server's tools in a single array. There's no pagination in Reservo's case (the specification does allow for an optional cursor for servers with large catalogs, but with four tools it isn't needed) — the complete menu arrives all at once.


The message's exact shape

A tools/list request is, in JSON-RPC, one of the simplest you'll see in this guide — it needs almost no parameters:

{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}

And the response carries a result with a single key, tools, whose value is an array of Tool objects. Each Tool has this shape:

{
  "name": "get_quote",
  "description": "Quotes the price of a room...",
  "inputSchema": {
    "type": "object",
    "properties": { "...": "..." },
    "required": ["room", "tier", "hours"]
  }
}

Three fields, and all three should feel familiar if you already worked through the tool contract in agent-fundamentals: name (the exact identifier you'll later use in tools/call), description (the text telling the model, inside the host, when to use this tool — the same "use this tool when..." discipline you already knew), and inputSchema (the complete JSON Schema for the arguments — lesson 04 compares it byte for byte against the Messages API's input_schema).


Declaring the four canonical tools as MCP objects

Here are Reservo's four Tools, with the same description you'll use throughout this module, now under the inputSchema key (not input_schema) MCP requires:

MCP_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"],
        },
    },
]

Compare this list against agent-fundamentals's RESERVO_TOOLS (Module 2, lesson 07): same name, same description, and the same schema object inside — the only syntactic difference is the key that wraps it, inputSchema instead of input_schema. That's not a coincidence: lesson 04 devotes its whole space to confirming this similarity isn't superficial.

The handler that responds to tools/list is, with the list already built, nearly trivial:

def handle_tools_list(msg_id, params: dict) -> dict:
    log(f"[server] tools/list <- returning {len(MCP_TOOLS)} tools")
    return {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": MCP_TOOLS}}

And it connects to the server's message dispatch —the same for raw_line in sys.stdin you already know from M2— by adding one more branch to the if/elif:

elif method == "tools/list":
    send(handle_tools_list(msg_id, params))

Worked example: tools/list executed, with the complete response

With the handler connected, we run the handshake (M2) followed by a single tools/list, and ask for the response formatted for comfortable reading:

# tools_list_only_client.py
import subprocess, sys, json, itertools

request_ids = itertools.count(1)
proc = subprocess.Popen(
    [sys.executable, "reservo_tools_mcp_server.py"],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
    text=True, bufsize=1,
)

init = {"jsonrpc": "2.0", "id": next(request_ids), "method": "initialize",
        "params": {"protocolVersion": "2025-06-18", "capabilities": {},
                   "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}}
proc.stdin.write(json.dumps(init) + "\n"); proc.stdin.flush()
proc.stdout.readline()
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
proc.stdin.flush()

request = {"jsonrpc": "2.0", "id": next(request_ids), "method": "tools/list", "params": {}}
print(f"[client -> server] {json.dumps(request)}")
proc.stdin.write(json.dumps(request) + "\n"); proc.stdin.flush()
response = json.loads(proc.stdout.readline())

print()
print("[server -> client] (formatted for reading):")
print(json.dumps(response, indent=2, ensure_ascii=False))

print()
for tool in response["result"]["tools"]:
    required = tool["inputSchema"].get("required", [])
    props = list(tool["inputSchema"].get("properties", {}).keys())
    print(f"  {tool['name']:15} properties={props}  required={required}")

proc.stdin.close()
proc.wait(timeout=5)

What to expect (the raw response, on a single line of stdout, is what actually travels over the protocol — here it's printed formatted only so you can read it):

{
  "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"]
        }
      }
    ]
  }
}
  list_rooms      properties=[]  required=[]
  get_quote       properties=['room', 'tier', 'hours']  required=['room', 'tier', 'hours']
  book_room       properties=['room', 'tier', 'hours', 'member']  required=['room', 'tier', 'hours', 'member']
  cancel_booking  properties=['id']  required=['id']

Four tools, in the same order you declared them in MCP_TOOLS, each with its complete inputSchema. Notice list_rooms: properties: {} and no required — the only one of the four that needs no argument at all, consistent with what you already knew from agent-fundamentals.


Common mistakes

  1. Writing input_schema instead of inputSchema out of Messages API habit. It's the most likely transcription mistake if you're coming from agent-fundamentals with that key burned into your fingers. MCP uses inputSchema (camelCase, no underscore) throughout its specification — a purely syntactic detail, but one that breaks the client's parsing if you get it wrong.

  2. Forgetting params: {} in the request, even though it's empty. JSON-RPC doesn't require params when the method needs no arguments, but MCP and real implementations often include it anyway by convention. This lesson's server accepts it with or without that field (message.get("params", {}) covers both cases), but it's good practice to include it explicitly.

  3. Thinking tools/list executes something. No — it's purely informational. Calling it a hundred times in a row doesn't book any room or change any state on the server; it just returns, every time, the same list of contracts.


Exercises

Exercise 1: Read the menu (Easy)

Looking at this lesson's complete tools/list response, without running anything: which of the four tools has the inputSchema with the most fields in properties? How many fields are required in that same tool?

See solution

book_room, with four fields in properties (room, tier, hours, member) — and all four are required. It's the only one of the four tools that needs to know whose name the booking is under, on top of the three pieces of data get_quote already asked for.

Exercise 2: Count the bytes of the complete menu (Medium)

Using the worked example's tools/list response, calculate how many bytes the complete response JSON takes up (without the reading-friendly formatting, the raw line exactly as it travels over stdout) via json.dumps. Compare that size against the sum of the four contracts declared separately in agent-fundamentals (that module's lesson 07: 283 + 438 + 578 + 314 bytes) and explain why they should be practically equal.

See solution
import json

# MCP_TOOLS is the same list from the lesson, with inputSchema instead of input_schema
mcp_list_response = {"tools": MCP_TOOLS}
mcp_bytes = len(json.dumps(mcp_list_response, ensure_ascii=False))
agent_fundamentals_bytes = 283 + 438 + 578 + 314

print("bytes of result.tools (MCP):", mcp_bytes)
print("sum of the 4 contracts (agent-fundamentals):", agent_fundamentals_bytes)

Expected output (the exact value can vary by a handful of bytes depending on the separator your json.dumps uses, but the order of magnitude matches):

bytes of result.tools (MCP): 1652
sum of the 4 contracts (agent-fundamentals): 1613

Explanation: the two numbers are close because the content is, essentially, the same — four objects with name, description, and an input schema, with the only real difference being the key inputSchema (10 characters) versus input_schema (12 characters, with an underscore) repeated four times, plus MCP's extra {"tools": [...]} wrapper. The handful-of-bytes difference confirms, in another way, what lesson 04 is going to show more rigorously: it's the same content, packaged with a slightly different naming convention.

Exercise 3: A server with a variable catalog (Hard)

Modify MCP_TOOLS (in your own copy of the server) so list_rooms has a second version with an optional city argument ({"type": "string"}, not included in required), without touching the other three tools. Run tools/list against your modified version and confirm the only tool that changed is list_rooms, comparing the other three's inputSchema against the original byte for byte.

See solution
# In your copy of reservo_tools_mcp_server.py, replace only the list_rooms entry:
MCP_TOOLS[0] = {
    "name": "list_rooms",
    "description": (
        "Lists every Reservo room with its base hourly rate in cents, "
        "optionally filtered by city. Use this tool when the user asks "
        "what rooms are available."
    ),
    "inputSchema": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": [],
    },
}
import json
import reservo_tools_mcp_server as original  # the UNMODIFIED version, for comparison

# ... run tools/list against your modified version as in the worked example ...
modified_tools = {t["name"]: t for t in response["result"]["tools"]}
original_tools = {t["name"]: t for t in original.MCP_TOOLS}

for name in ("get_quote", "book_room", "cancel_booking"):
    unchanged = json.dumps(modified_tools[name], sort_keys=True) == json.dumps(original_tools[name], sort_keys=True)
    print(f"{name:15} unchanged: {unchanged}")

print("list_rooms changed:", modified_tools["list_rooms"] != original_tools["list_rooms"])

Expected output:

get_quote       unchanged: True
book_room       unchanged: True
cancel_booking  unchanged: True
list_rooms changed: True

Explanation: tools/list returns exactly what's in MCP_TOOLS at the moment of the call — modifying one entry in the list has no effect on the other three, because each Tool is an independent object inside the array. This exercise anticipates a real pattern: a production MCP server can evolve its tools catalog over time (add an optional argument, add a new tool) without that breaking the contract of the tools that didn't change — as long as the change is additive (a new optional argument, not a new required one that would break existing clients that don't send it).


Summary and next step

  • tools/list is a simple request (params: {}) that returns result.tools, a complete array of Tool objects — no pagination needed for a catalog as small as Reservo's.
  • Each Tool has three fields: name, description, and inputSchema (complete JSON Schema) — we declared Reservo's four canonical tools with this exact shape.
  • We ran the complete request and confirmed, field by field, that all four tools arrive with their inputSchema intact — list_rooms with no arguments, the other three with their correct fields and required.
  • tools/list is purely informational: it executes nothing, it changes no server state.

Next lesson: 04 — The inputSchema: same JSON Schema, new protocol. We confirm, comparing objects byte for byte, that the inputSchema you just saw is exactly the same JSON Schema you already knew as Claude's Messages API input_schema.


Additional resources

  1. Model Context Protocol — Specification 2025-06-18: Tools — Listing Tools — The exact shape of tools/list, the Tool object, and its fields.
  2. Anthropic — Tool use (function calling) overview — The tool contract you already knew, to compare against MCP's Tool.
  3. JSON Schema — The complete specification of the format describing each inputSchema.
  4. Python — jsonjson.dumps with indent=2, used in this lesson to format the raw response for readability.