Module 7: Connecting to Claude Code and Third-Party Servers
Registering the Reservo server
Description
The previous lesson gave you .mcp.json's general shape. This one makes it concrete: the real, complete entry that registers reservo_full_mcp_server.py — the server with its four tools (M3), its two resources (M4), and the plan_booking prompt (M5), exactly as it ended up assembled in Module 6's mini-project — so a host like Claude Code can launch it. You're going to write that entry, validate it, and then demonstrate something you've only claimed so far: when a host reads .mcp.json and launches the command/args it finds there, the process that starts up is, exactly, the same server you already know by heart — not a simulation, not a different version. You're also going to see the claude mcp add that would produce this same entry, explained field by field.
Connection to the module
This lesson closes the first half of the module (registering your own, fully trusted server) before lesson 05 onward starts talking about servers you didn't write. Everything that follows in lessons 05-08 uses this same mechanics — an mcpServers entry — applied to a case with a different trust profile.
The complete entry
{
"mcpServers": {
"reservo-mcp-server": {
"type": "stdio",
"command": "python3.14",
"args": ["reservo_full_mcp_server.py"],
"env": {}
}
}
}
Every field, justified by what you already know about the server:
"reservo-mcp-server"(the key) — the same name asSERVER_INFO["name"]inside the server, fixed since Module 2 and reused unchanged in every following module. It's not a style coincidence: it's useful for the name you register a server under in.mcp.jsonto match the one that server declares in its ownserverInfoduring the handshake, so there's no ambiguity about which process corresponds to which entry when you're debugging a.mcp.jsonwith several servers (lesson 08 sets up exactly that case)."type": "stdio"—reservo_full_mcp_server.pyalways runs locally, as a subprocess, as Module 1's lesson 04 established. Never"http"for this server, in this guide."command": "python3.14"— the executable, exactly as you'd type it in your terminal to run the script yourself. It's notsys.executable(that's a Python runtime expression, not a fixed string in a configuration file) — it's the interpreter's name as the operating system is going to look it up in thePATH."args": ["reservo_full_mcp_server.py"]— a single argument: the path to the script. If the file weren't in the same directory Claude Code launches the process from, this path would need to be absolute or relative to a known directory — a real deployment detail this guide names but doesn't dive into, because it depends on where each project lives."env": {}— empty, and on purpose:reservo_full_mcp_server.pydoesn't need any credential or external configuration. It keeps all its state (BOOKINGS, theitertools.count(1)counters) in the process's own memory. A real MCP server talking to a database, on the other hand, would typically need something like{"DATABASE_URL": "..."}here.
Worked example: confirming Claude Code would launch the same server as always
Up to now, this module only validated .mcp.json's shape — it never confirmed that, if a real host took command and args from that entry and launched the process, the result would be Reservo's real server. This lesson demonstrates it: a script that simulates exactly what a host does — read .mcp.json, launch command+args with subprocess.Popen, do the initialize handshake — and confirms, with the protocol itself, that the serverInfo.name the process responds with matches the name you registered it under.
# verify_registration.py
"""Simulates what Claude Code would do reading .mcp.json: launch the server with
the EXACT command+args from the entry, and confirm the name negotiated in the
handshake matches the key used to register it. Real wire protocol, against the
same reservo_full_mcp_server.py from Modules 2-6, with nothing changed."""
import json
import subprocess
import itertools
with open("mcp_config.json", "r", encoding="utf-8") as file:
config = json.load(file)
request_ids = itertools.count(1)
for server_name, entry in config["mcpServers"].items():
assert entry["type"] == "stdio", "this check is only for stdio"
proc = subprocess.Popen(
[entry["command"], *entry["args"]],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1,
)
request = {"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(request) + "\n")
proc.stdin.flush()
response = json.loads(proc.stdout.readline())
negotiated_name = response["result"]["serverInfo"]["name"]
print(f"[.mcp.json] registered name: {server_name!r}")
print(f"[handshake] serverInfo.name: {negotiated_name!r}")
print(f"match: {server_name == negotiated_name}")
proc.stdin.close()
proc.wait(timeout=5)
What to expect (running python3.14 verify_registration.py in a directory with mcp_config.json and reservo_full_mcp_server.py):
[.mcp.json] registered name: 'reservo-mcp-server'
[handshake] serverInfo.name: 'reservo-mcp-server'
match: True
Notice what this script did not do: it didn't import anything from Claude Code, it didn't simulate any complete application. It only took entry["command"] and entry["args"] — two values pulled from a dictionary that came from json.load — and passed them straight to subprocess.Popen([entry["command"], *entry["args"]], ...). It's, literally, the same mechanism you already used in Module 6 inside MCPClient.__init__ (subprocess.Popen([sys.executable, server_path], ...)), with one minimal difference: there, the command was fixed in the code (sys.executable); here, it comes from an external file, read at runtime. That's, in essence, the entirety of .mcp.json's magic — there's no new process-launching mechanism, just a different configuration source for the same subprocess.Popen you already know.
The equivalent claude mcp add
# CONTENT -- explained in detail, NEVER run in this guide.
claude mcp add reservo-mcp-server --scope project -- python3.14 reservo_full_mcp_server.py
Taken apart, field by field:
reservo-mcp-server— the server's name, the same one that ends up as the key insidemcpServers.--scope project— decides where the resulting.mcp.jsonlives and who sees it. Claude Code defines three scopes:local(the default if the flag is omitted — personal, not shared or versioned),project(a.mcp.jsonat the repository root, meant to be versioned with git and shared with the whole team working on that project), anduser(available to you in any project you open, regardless of the repository). For a Reservo server the whole team is going to use the same way,projectis the natural choice — that way it's documented, in the repository itself, which MCP servers the project assumes.--— the standard separator that tells the CLI "everything after this is the command to run, not moreclaude mcp addflags." Without this separator,claude mcp addcould misinterpretpython3.14as if it were one of its own flags.python3.14 reservo_full_mcp_server.py— the command and its arguments, in the same format you'd type them in a terminal — this is exactly what ends up, split apart, incommandandargsinside the JSON.
Running this command, inside a real project with Claude Code installed, would produce — or extend, if the file already existed with other entries — a .mcp.json with the exact entry you wrote by hand at the start of this lesson. Environment variables would be added with additional flags (--env DATABASE_URL=postgres://..., repeatable for each variable) that end up populating the env field — not needed here, because reservo_full_mcp_server.py doesn't need any.
What happens after registration (concept, not run)
With reservo-mcp-server registered in .mcp.json, what Claude Code does next is, structurally, exactly what you built in Module 6: it behaves like just another MCPClient. It launches the process with command/args, does initialize/notifications/initialized, and then runs the complete discovery flow (tools/list + resources/list + prompts/list) to learn what the server offers — the same order, the same three methods, that you already ran end to end in Module 6's lesson 05. The real difference isn't in the protocol — that's identical — but in who decides, after discovery, what to use: in your previous modules' scripts, your own client code decided which tools/call to make; inside Claude Code, it's the model (claude-sonnet-5, in a real conversation) that sees the four discovered tools and decides, in the moment, whether it's worth calling get_quote to answer whatever you're asking it. That model decision — what to invoke and when, in a real conversation — is exactly what this guide names as concept, without running it: there's no call to the Claude API anywhere in this guide, only the MCP protocol that makes it possible.
Common mistakes
-
Using
sys.executableinside.mcp.json..mcp.jsonis a plain text file, not a Python script —sys.executableis an expression that only makes sense inside an already-running Python process. Incommand, you always put the literal name (or path) of the executable, like"python3.14"or"/usr/local/bin/python3.14". -
Forgetting the path in
argsis relative to the directory Claude Code launches the process from, not to.mcp.json's location. Ifreservo_full_mcp_server.pyisn't in that working directory,args: ["reservo_full_mcp_server.py"]would fail to start with a "file not found" error — the fix is an absolute path, or a relative one that's consistent with where the project runs. -
Thinking this lesson's
claude mcp addwould change the server's behavior. It doesn't, and it couldn't:claude mcp addonly writes (or edits).mcp.json— it never touchesreservo_full_mcp_server.py. The server remains exactly the same code you already built and tested in Modules 2 through 6, no matter whether you launch it withsubprocess.Popenyourself or Claude Code launches it by reading.mcp.json. -
Choosing
--scope localfor a server the whole team needs. Withlocal(or without the flag, its default value), the configuration stays only on your machine — a teammate cloning the same repository wouldn't seereservo-mcp-serverregistered, because that.mcp.jsonwas never generated where git versions it.--scope projectis the correct choice when the goal is for the server to be available to anyone working on that project.
Exercises
Exercise 1: Find the error in this entry (Easy)
{
"mcpServers": {
"reservo-mcp-server": {
"type": "stdio",
"command": "python3.14",
"args": "reservo_full_mcp_server.py"
}
}
}
What's wrong, and what would lesson 02's validate_server_entry report if you ran this entry against it?
See solution
args is written as a string ("reservo_full_mcp_server.py"), not as a list (["reservo_full_mcp_server.py"]). Lesson 02's validate_server_entry explicitly checks isinstance(entry["args"], list), so it would report: reservo-mcp-server: args must be a list. Even though the file is still perfectly valid JSON — there's no syntax error — the shape Claude Code expects for args is always a list, even when there's a single argument.
Exercise 2: Write the claude mcp add for a different scope (Medium)
Rewrite this lesson's claude mcp add command to register reservo-mcp-server with --scope local instead of --scope project (to test it only on your own machine, without sharing it with the team yet), and add a hypothetical environment variable RESERVO_LOG_LEVEL=debug using the --env flag.
See solution
# CONTENT -- explained, not run.
claude mcp add reservo-mcp-server --scope local --env RESERVO_LOG_LEVEL=debug -- python3.14 reservo_full_mcp_server.py
This would produce (if reservo_full_mcp_server.py read that variable, which this guide's version doesn't) an entry equivalent to:
{
"mcpServers": {
"reservo-mcp-server": {
"type": "stdio",
"command": "python3.14",
"args": ["reservo_full_mcp_server.py"],
"env": {"RESERVO_LOG_LEVEL": "debug"}
}
}
}
Explanation: changing --scope project to --scope local doesn't change a single field of the JSON entry itself — it's still the same type/command/args — it only changes where that entry gets saved (a personal .mcp.json, outside version control, instead of a shared one at the repository root). The --env flag is repeatable: each occurrence adds one more key to the final env object.
Exercise 3: Verify the registration with a real tool, not just the handshake (Hard)
Extend this lesson's verify_registration.py so that, after confirming serverInfo.name matches, it also does notifications/initialized, tools/list, and confirms with an assert that list_rooms shows up among the discovered tools — demonstrating that the server launched from .mcp.json doesn't just respond to the handshake, but has Reservo's complete catalog available.
See solution
import json
import subprocess
import itertools
with open("mcp_config.json", "r", encoding="utf-8") as file:
config = json.load(file)
request_ids = itertools.count(1)
entry = config["mcpServers"]["reservo-mcp-server"]
proc = subprocess.Popen(
[entry["command"], *entry["args"]],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1,
)
def send(message):
proc.stdin.write(json.dumps(message) + "\n")
proc.stdin.flush()
def recv():
return json.loads(proc.stdout.readline())
send({"jsonrpc": "2.0", "id": next(request_ids), "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}})
init_response = recv()
assert init_response["result"]["serverInfo"]["name"] == "reservo-mcp-server"
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
send({"jsonrpc": "2.0", "id": next(request_ids), "method": "tools/list", "params": {}})
tools_response = recv()
tool_names = [tool["name"] for tool in tools_response["result"]["tools"]]
assert "list_rooms" in tool_names
print(f"OK -- server launched from .mcp.json, {len(tool_names)} tools discovered: {tool_names}")
proc.stdin.close()
proc.wait(timeout=5)
Expected output:
OK -- server launched from .mcp.json, 4 tools discovered: ['list_rooms', 'get_quote', 'book_room', 'cancel_booking']
Explanation: this exercise connects, with code, the module's two halves so far: lesson 02's validation (does the JSON have the correct shape?) and this lesson's confirmation (does launching that JSON actually produce Reservo's complete server?). The two assert statements are the programmatic version of a question any real host needs to be able to answer before trusting a .mcp.json entry: not just "does it start without errors?", but "does the correct server start, with the expected catalog?"
Summary and next step
- Reservo's real
.mcp.jsonentry:type: "stdio",command: "python3.14",args: ["reservo_full_mcp_server.py"],env: {}— every field justified by what you already know about the server from Modules 2 through 6. - A host that reads that entry and launches
command+argswithsubprocess.Popenis running the exact same mechanism as Module 6'sMCPClient.__init__— confirmed with real protocol: the negotiatedserverInfo.namematches the registry key. claude mcp add reservo-mcp-server --scope project -- python3.14 reservo_full_mcp_server.pyproduces this same entry —--scopedecides where the resulting file lives and who shares it.- What Claude Code does after registration — discovering with
tools/list/resources/list/prompts/list, and letting the model decide what to invoke — is, structurally, the same flow from Module 6, with the model in the spot your own client code used to occupy.
Next lesson: 04 — stdio vs. HTTP transports in production. With Reservo already registered over stdio, the complete criterion for deciding when a new server should use Streamable HTTP instead.
Additional resources
- Claude Code — Model Context Protocol (MCP) — The official documentation for
claude mcp add, its flags (--scope,--env,--transport), and the three scopes (local/project/user). - Model Context Protocol — Specification 2025-06-18: Lifecycle — The handshake this lesson's script runs against the server launched from
.mcp.json. - Python —
subprocess.Popen— The process-launching mechanism, identical to the one you already used in Module 6'sMCPClient. - Python —
json— The.mcp.jsonreading that feedscommand/argsin this lesson.