Module 6: Building an MCP Client and Discovery
Reading the server's capabilities
Description
Lesson 02 left connect() storing self.capabilities — the object the server returns inside result in its response to initialize. This lesson puts that data to real use: a supports() method the client consults before calling tools/list, resources/list, or prompts/list, instead of assuming all three are always available. You're going to see, executed, why this check isn't a formality — you're going to call a method without having checked it, and you're going to see exactly the error it produces.
Connection to the module
Up to now, the four servers you built in M2-M5 always declared all three categories (tools, resources, prompts) in their capabilities, so there was never a need to check anything — any method was always available. This lesson introduces the guide's first server that does not declare all of them: sunroom-cafe-mcp-server, which only supports tools. It's the first time "read capabilities before invoking" stops being an abstract best practice and becomes a check with real consequences.
What capabilities are, again, but now from the client
Module 2 (lesson 04) already defined capabilities from the server's side: an object declaring, inside initialize's response, which categories of primitives it supports — {"tools": {}, "resources": {}, "prompts": {}} for a server that supports all three, with an empty object in each key because, in revision 2025-06-18, the key's presence is enough to declare support (no additional content is needed inside each one).
From the client's side, capabilities is information you decide with, not just information you read. This lesson's central pattern is simple:
def supports(self, primitive: str) -> bool:
"""True if the server declared this category in initialize's capabilities."""
return primitive in self.capabilities
With this single method, any code using MCPClient can ask client.supports("resources") before calling client.list_resources() — and decide, accordingly, whether it makes sense to continue or not.
What to expect: two servers, two different capabilities
This is the first moment in the guide where comparing two servers side by side matters. reservo-mcp-server (M3-M5) declares all three categories; sunroom-cafe-mcp-server — this module's second provider, introduced in lesson 01 — declares only one:
from mcp_client import MCPClient
print("=== reservo-mcp-server ===")
reservo = MCPClient("reservo_full_mcp_server.py", verbose=False)
reservo.connect()
print(f"[client] {reservo.server_info['name']} declared capabilities: {reservo.capabilities}")
print(f"[client] supports('tools')={reservo.supports('tools')} "
f"supports('resources')={reservo.supports('resources')} "
f"supports('prompts')={reservo.supports('prompts')}")
reservo.close()
print()
print("=== sunroom-cafe-mcp-server ===")
cafe = MCPClient("sunroom_cafe_mcp_server.py", verbose=False)
cafe.connect()
print(f"[client] {cafe.server_info['name']} declared capabilities: {cafe.capabilities}")
print(f"[client] supports('tools')={cafe.supports('tools')} "
f"supports('resources')={cafe.supports('resources')} "
f"supports('prompts')={cafe.supports('prompts')}")
cafe.close()
Running python3.14 this_script.py:
=== reservo-mcp-server ===
[client] reservo-mcp-server declared capabilities: {'tools': {}, 'resources': {}, 'prompts': {}}
[client] supports('tools')=True supports('resources')=True supports('prompts')=True
=== sunroom-cafe-mcp-server ===
[client] sunroom-cafe-mcp-server declared capabilities: {'tools': {}}
[client] supports('tools')=True supports('resources')=False supports('prompts')=False
Same client, same supports() method, two completely different results — because capabilities isn't a property of MCP in general, it's a declaration of this particular server. The café server handles coffee orders, not cancellation policies or room-booking templates — there's no reason for it to declare resources or prompts it doesn't have, and the protocol gives it an explicit way to say "I don't offer this," instead of forcing it to pretend it does.
What happens if you ignore capabilities and call anyway
This is the experiment that justifies why this check genuinely matters, not just in theory. sunroom-cafe-mcp-server didn't declare resources — what happens if a careless client calls resources/list against it anyway?
cafe2 = MCPClient("sunroom_cafe_mcp_server.py", verbose=True)
cafe2.connect()
cafe2._send({"jsonrpc": "2.0", "id": next(cafe2.request_ids), "method": "resources/list", "params": {}})
bad_response = cafe2._recv()
print(f"[client] response to resources/list without support: {bad_response}")
cafe2.close()
Real output:
[client -> sunroom_cafe_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"}}}
[sunroom_cafe_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "sunroom-cafe-mcp-server", "version": "1.0.0"}}}
[client -> sunroom_cafe_mcp_server.py] {"jsonrpc": "2.0", "method": "notifications/initialized"}
[client -> sunroom_cafe_mcp_server.py] {"jsonrpc": "2.0", "id": 2, "method": "resources/list", "params": {}}
[sunroom_cafe_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 2, "error": {"code": -32601, "message": "Method not found: resources/list"}}
[client] response to resources/list without support: {'jsonrpc': '2.0', 'id': 2, 'error': {'code': -32601, 'message': 'Method not found: resources/list'}}
---- stderr from sunroom_cafe_mcp_server.py ----
[cafe] starting up, waiting for messages on stdin...
[cafe] initialize <- client {'name': 'reservo-mcp-client', 'version': '1.0.0'}
[cafe] notifications/initialized <- client confirms it can now operate
-32601 Method not found — the same protocol error code you already know from Module 2 (lesson 06), for the case of a method the server doesn't recognize at all. From the café server's point of view, resources/list isn't "a method that exists but returns empty" — it's a method that isn't in its dispatch at all, exactly as if you sent it a made-up method. The declaration in capabilities isn't decorative: it's the only way a client knows, ahead of time, which part of the server's if method == ... tree even exists.
Production note: what the official SDK does with capabilities, from the client side
In the official mcp SDK (PyPI; pip install "mcp<2"), the ClientSession class stores capabilities the same way self.capabilities does in this lesson, and exposes them so application code can consult them before operating — the if primitive in self.capabilities check you wrote by hand here is, conceptually, the same thing that SDK layer offers. What the SDK doesn't do for you is the business decision of what to do when a capability is missing — that, correctly, remains the responsibility of the code using the client, not the library's.
Common mistakes
-
Checking
capabilitieson the server, not on the client. The server DOES declare its capabilities ininitialize's response — but it's the CLIENT who has to read them and decide accordingly. A server doesn't prevent being called with an undeclared method (as you saw, it responds with an error, not with a prior refusal) — the responsibility for not calling it lies with whoever initiates the connection. -
Assuming an empty
capabilitiesvalue ({}) for a specific key means "no support." It's the other way around: the absence of the entire key ("resources"doesn't even appear in the dictionary) is what means "no support." A{}value inside a present key (as withreservo-mcp-server, with"resources": {}") means "yes, I support it, with no additional extensions" — the same nuance you already saw in Module 2, now relevant for deciding, not just for reading. -
Thinking
-32601in this context is a server bug. It isn't — it's the correct, expected behavior when a client calls a method outside the contract the server announced. The "bug," if any, is on the client's side for not checkingcapabilitiesbefore calling.
Exercises
Exercise 1: Predict before running (Easy)
Without running anything, predict what reservo.supports("prompts") and cafe.supports("prompts") would return, using only the capabilities you already saw in this lesson's "What to expect." Then confirm it by running the lesson's first script.
See solution
reservo.supports("prompts") → True (Reservo declared "prompts": {} in its capabilities). cafe.supports("prompts") → False (the café only declared "tools": {}", the "prompts" key doesn't even exist in its capabilities dictionary). This matches exactly the output quoted in the lesson's "What to expect": supports('prompts')=True for Reservo, supports('prompts')=False for the café.
Exercise 2: A function that detects what's missing (Medium)
Write a function missing_capabilities(client, wanted) that takes an already-connected MCPClient and a set of desired category names ({"tools", "resources", "prompts"}), and returns the subset of wanted the server did not declare it supports. Test it against Reservo and against the café.
See solution
from mcp_client import MCPClient
def missing_capabilities(client: MCPClient, wanted: set[str]) -> set[str]:
return wanted - set(client.capabilities)
reservo = MCPClient("reservo_full_mcp_server.py", verbose=False)
reservo.connect()
cafe = MCPClient("sunroom_cafe_mcp_server.py", verbose=False)
cafe.connect()
wanted = {"tools", "resources", "prompts"}
print("reservo -- missing capabilities:", missing_capabilities(reservo, wanted))
print("cafe -- missing capabilities:", missing_capabilities(cafe, wanted))
Expected output:
reservo -- missing capabilities: set()
cafe -- missing capabilities: {'resources', 'prompts'}
Explanation: wanted - set(client.capabilities) is a set subtraction — everything in wanted that's NOT among capabilities's keys. For Reservo, the result is the empty set (set(), not {} — in Python, {} is an empty dictionary, not an empty set) because it declared all three. For the café, the result is exactly the two it's missing. A function like this is the basis of a more general compatibility check: "does this server work for what I need to do?", even before attempting the first call.
Exercise 3: A different method, the same question (Hard)
This lesson's "What happens if you ignore capabilities" tested resources/list against the café. Repeat the experiment with prompts/get (requesting, say, plan_booking without arguments) against the same server. Is the error exactly the same type, or does something change? Run it and confirm.
See solution
from mcp_client import MCPClient
cafe = MCPClient("sunroom_cafe_mcp_server.py", verbose=False)
cafe.connect()
# The cafe did not declare "prompts" -- call prompts/get anyway.
cafe._send({"jsonrpc": "2.0", "id": next(cafe.request_ids), "method": "prompts/get",
"params": {"name": "plan_booking", "arguments": {}}})
response = cafe._recv()
print("response to prompts/get against a server without the prompts capability:")
print(response)
Real output:
response to prompts/get against a server without the prompts capability:
{'jsonrpc': '2.0', 'id': 2, 'error': {'code': -32601, 'message': 'Method not found: prompts/get'}}
Explanation: exactly the same type of error, -32601 Method not found, with the correct method (prompts/get) mentioned in the message. It doesn't matter what arguments the request carries (plan_booking is a prompt name that doesn't even exist at the café) — the server never gets to evaluate the prompt's name, because the prompts/get method itself isn't in its dispatch. This confirms a general point: when an entire capability is missing, the protocol error happens at the first level (the method), before any params data (like the prompt's name) has a chance to matter.
Summary and next step
capabilities, stored byconnect()in lesson 02, is now actively used withsupports(primitive): a one-line check,primitive in self.capabilities.- You compared two real servers, side by side:
reservo-mcp-serverdeclares all three categories;sunroom-cafe-mcp-serverdeclares onlytools— the guide's first case wherecapabilitiestruly makes a difference. - Ignoring
capabilitiesand calling a method anyway produces-32601 Method not found— the same protocol error from Module 2, now with a concrete, avoidable cause. - The absence of an entire key in
capabilitiesmeans "no support"; a{}value inside a present key means "supported, no extensions" — the distinction Exercise 1 puts to the test.
Next lesson: 04 — The discovery flow: listing everything. With supports() already available, the client can automatically decide which of the three */list calls to make against a server — the first complete MCPClient method that builds a real catalog.
Additional resources
- Model Context Protocol — Specification 2025-06-18: Lifecycle — The capability negotiation section within the handshake.
- Model Context Protocol — Specification 2025-06-18: Base Protocol — The
-32601error code, already defined by JSON-RPC 2.0 and reused by MCP. - JSON-RPC 2.0 Specification — The table of standard error codes, including
-32601 Method not found. - Python — Set operations (
set) — The set subtraction (-) used in Exercise 2 to detect missing capabilities.