What this is

No new concepts in this lesson. This is a checkpoint: an interactive terminal chatbot built entirely out of ideas from Lessons 11 through 18, combined into one thing. If you can read the code and explain why the client holds two persistent sessions instead of calling get_tools(), you've mastered the Intermediate tier. If any piece feels unfamiliar, revisit the lesson it came from before continuing to Advanced.

What it does

Two small servers live in this lesson: a notes server (add_note and list_notes, backed by a lifespan-managed NotesState) and a calculator server (the same add tool from earlier lessons). lesson.py connects to both, binds their combined tools to Gemini, and loops, reading a line of input, running the model, running whatever tools it asks for, printing the answer, repeating.

Where each piece came from

client = MultiServerMCPClient(
{
"notes": {
"transport": "stdio",
"command": sys.executable,
"args": [str(HERE / "notes_server.py")],
},
"calculator": {
"transport": "stdio",
"command": sys.executable,
"args": [str(HERE / "calculator_server.py")],
},
}
)

Lesson 17: two servers under one MultiServerMCPClient.

async with AsyncExitStack() as stack:
notes_session = await stack.enter_async_context(client.session("notes"))
calculator_session = await stack.enter_async_context(client.session("calculator"))
tools = [
*(await load_mcp_tools(notes_session)),
*(await load_mcp_tools(calculator_session)),
]

A wrinkle from Lesson 16, resolved: client.get_tools() (Lessons 15-17) opens a fresh session per tool call, fine for the stateless calculator, but wrong for the notes server, its lifespan state (Lesson 18) would reset every call, and a second add_note would get id 1 again instead of 2. This checkpoint instead opens one persistent session per server with client.session(name) + load_mcp_tools(session), held open for the whole conversation via an AsyncExitStack, so the notes server's state survives across every tool call in the chat.

async def process_query(model, tools_by_name, messages, query):
messages.append(HumanMessage(query))
response = model.invoke(messages)
messages.append(response)
for call in response.tool_calls:
tool = tools_by_name[call["name"]]
result = await tool.ainvoke(call["args"])
messages.append(ToolMessage(content=str(result), tool_call_id=call["id"]))
if response.tool_calls:
response = model.invoke(messages)
messages.append(response)
return str(response.content)

Lessons 15-16: bind_tools plus the full ask/call/respond loop, wrapped as a reusable function called on every line of user input, with messages accumulating across the whole session so later questions can refer back to earlier answers.

This is deliberately close to the official MCP quickstart's own client, the difference is the LLM side uses LangChain/Gemini (this course's convention) instead of the raw Anthropic SDK, everything else, connect, list tools, loop on user input, is the same shape.

Checkpoint

  • A working chatbot backed by two MCP servers at once, one stateless, one lifespan-managed.
  • Persistent sessions (client.session(name)) instead of get_tools(), because the notes server's state has to survive across calls.
  • The same ask/call/respond loop from Lesson 16, now driven by real user input instead of a scripted question.

Try asking it to add a note, then ask it to add two numbers, then ask it something that refers back to an earlier answer in the same session, all three should work without restarting the script.

If this ran cleanly, you're ready for the Advanced tier, starting at Lesson 20: transports beyond stdio, and running a server safely in front of tools you didn't write yourself.