Lab: Start the notes assistant repository
Module 1 · Lab · About 60 minutes · Needs: lessons 1 to 4 of this module, and a free Groq or Gemini key for Step 7 (course-guide/02)
What you are building
It is Thursday 10 September 2026, the end of week 1. Nare's notes, NoteStore, chat() and a first MCP server exist, but only in notebook folders that a Colab reset deletes. This lab moves them into one repository, notes-assistant, on your Google Drive, where every later lab adds to it: the eight notes, store.py and llm.py exactly as Lesson 2 left them, Lesson 3's server as examples/m01_first_server.py, and a script that answers Nare's question, "What did we decide about the nap study?", through that server. You reach the server in memory, over stdio and over HTTP, add tests that need no key, and commit.
Step 1: Open the repository on your Drive
A Colab runtime forgets its files when it stops, so the repository lives on Google Drive. First the standard install and setup cells, then the MCP cell with this lab's one extra pin, pytest.
%pip install -q -U langchain langgraph langchain-groq langchain-google-genai langchain-ollamaimport os
from langchain.chat_models import init_chat_model
PROVIDER = "groq" # "groq" or "gemini" in Colab; "ollama" on your own computer
MODELS = {"groq": "groq:openai/gpt-oss-20b", "gemini": "google_genai:gemini-3.5-flash-lite", "ollama": "ollama:llama3.2:3b"}
KEY = {"groq": "GROQ_API_KEY", "gemini": "GOOGLE_API_KEY", "ollama": None}[PROVIDER]
if KEY and KEY not in os.environ:
from google.colab import userdata # keys live in Colab's Secrets panel
os.environ[KEY] = userdata.get(KEY)
llm = init_chat_model(MODELS[PROVIDER], temperature=0, max_retries=6)Code walkthrough
- The setup cell from course-guide/02, unchanged. This lab never uses
llmdirectly; the cell's job here is to copy your key into the environment, wherenotes_assistant/llm.pyand the scripts it starts will find it.
%pip install -q mcp==2.3.0 pytest==9.1.1
import logging
for name in ("mcp", "httpx2", "uvicorn"):
logging.getLogger(name).setLevel(logging.WARNING)Code walkthrough
- The MCP cell from course-guide/02, plus
pytest, pinned, for Step 8. Colab ships apytest, but not necessarily this version.
import sys
from pathlib import Path
if Path.cwd().name != "notes-assistant": # safe to rerun: never nests a second copy
if "google.colab" in sys.modules: # in Colab, the repository lives on your Drive
from google.colab import drive
drive.mount("/content/drive")
os.chdir("/content/drive/MyDrive")
os.makedirs("notes-assistant", exist_ok=True) # on your own computer: where you started
os.chdir("notes-assistant")
REPO = Path.cwd()
for folder in ["notes", "notes_assistant", "examples", "tests"]:
(REPO / folder).mkdir(exist_ok=True)
os.environ["LLM_PROVIDER"] = PROVIDER # notes_assistant/llm.py reads the provider here
print(REPO.name, sorted(p.name for p in REPO.iterdir() if not p.name.startswith(".")))Code walkthrough
drive.mountasks once per session for access to your Drive; the repository isMyDrive/notes-assistant(course-guide/02). On your own computer the folder is made where you started. Theifmakes the cell safe to rerun.REPOis the repository folder; every path below is relative to it. The four folders match course-guide/03's file table.LLM_PROVIDERpasses your choice to the package, as in Lesson 2.
You should see notes-assistant ['examples', 'notes', 'notes_assistant', 'tests'] on a new repository.
Step 2: Add the %%ensure magic and the requirements
Later labs need this lab's files. A learner who skipped a lab, or whose Drive copy is missing a file, must get it back exactly as it was; a learner who already has it must not have it replaced. %%writefile cannot tell the two apart, so the course defines its own cell magic, a command that starts a cell and decides what happens to the rest of it. Every later lab pastes this cell first.
from IPython.core.magic import register_cell_magic
CREATED = set() # files this session wrote
@register_cell_magic
def ensure(line, cell):
"""%%ensure [-a] path: like %%writefile, but keeps a file the repository already has."""
*flags, path = line.split()
p = Path(path)
if flags == ["-a"]: # append only to a file this session created
if path in CREATED:
with p.open("a", encoding="utf-8") as f:
f.write(cell)
return
if p.exists():
print("kept", path)
return
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(cell, encoding="utf-8")
CREATED.add(path)
print("wrote", path)Code walkthrough
register_cell_magicturns the function into%%ensure. IPython calls it with the rest of the first line (line) and the cell's body (cell).- A missing file is written and remembered in
CREATED; an existing one is kept, withkeptprinted so you can see it happened. -aappends, but only to a file written in this session. A long file split over several cells is therefore either rebuilt whole or kept whole, never half appended.- To replace a file on purpose, delete it first.
%%ensure requirements.txt
# notes-assistant: libraries and versions from course-guide/02 (checked 13 October 2026)
mcp==2.3.0
pytest==9.1.1
langchain==1.4.5
langgraph==1.2.15
langchain-groq==1.1.3
langchain-google-genai==4.4.1
langchain-ollama==1.1.0Code walkthrough
- One line per library the repository imports, at course-guide/02's pins. Colab cells keep installing the LangChain packages with
-U; this file is for your own computer (pip install -r requirements.txt) and, in Module 9, the container.
You should see wrote requirements.txt, or kept requirements.txt on a rerun.
Step 3: Write Nare's eight notes
The notes are the project's data. These cells are course-guide/06's, copied exactly, as in Lesson 2; write_notes writes notes/ inside the repository.
from pathlib import Path
NOTES = {
"coffee-and-focus": """---
title: Caffeine and focus
tags: [caffeine, focus, sleep]
created: 2026-04-01
---
Caffeine blocks adenosine receptors, so sleep pressure is masked rather than removed.
Half-life is about 5 hours, so a 4 pm coffee still leaves half the dose at 9 pm.
Moderate doses (about 100 mg) improve reaction time; above 400 mg per day anxiety rises.
Personal rule: no coffee after 1 pm on days before an experiment.
""",
"deep-work": """---
title: Deep work blocks
tags: [focus, productivity]
created: 2026-07-08
---
Newport defines deep work as distraction-free concentration that pushes your abilities.
My best writing happens between 8 and 11 am before email.
Tracking: 14 deep hours last week, target is 15.
Phone in another room helps more than any app blocker.
""",
"lab-sync-2026-09-02": """---
title: Lab sync, 2 September 2026
tags: [meeting, sleep]
created: 2026-09-02
---
Attendees: Priya, Tomas, Nare.
Decision: run the nap study with 24 participants instead of 16, budget approved by Priya.
Action: Tomas books the sleep lab for the weeks of 5 and 12 October.
Action: Nare drafts the consent form by 15 September.
Risk: EEG caps are shared with another group, so booking conflicts are likely.
""",
}Code walkthrough: shared data, unchanged: three notes, including the anchor note.
NOTES.update({
"pomodoro-technique": """---
title: Pomodoro technique
tags: [focus, productivity]
created: 2026-04-15
---
Work for 25 minutes, then take a 5 minute break; after four rounds take a longer 20 minute break.
Works well for writing and reading papers, badly for debugging where context switching is costly.
For deep tasks I now use 50 minute blocks with 10 minute breaks instead.
""",
"reading-list": """---
title: Reading list
tags: [reading, sleep, memory]
created: 2026-06-20
---
Why We Sleep, Matthew Walker (2017): readable, but some claims are disputed.
Make It Stick, Brown, Roediger and McDaniel (2014): retrieval practice and spacing.
Deep Work, Cal Newport (2016): scheduling focus time.
Pending: a review paper on targeted memory reactivation during sleep.
""",
"sleep-and-memory": """---
title: Sleep and memory consolidation
tags: [sleep, memory, neuroscience]
created: 2026-03-02
---
Slow-wave sleep replays the day's hippocampal activity and moves it into long-term cortical storage.
Walker (2017) reports that a night without sleep cuts new memory formation by about 40 percent.
A 20 minute nap after learning helps recall of word pairs, but only if the nap reaches stage 2 sleep.
Open question: does REM sleep matter more for procedural skills than for facts?
""",
})Code walkthrough: shared data, unchanged: three more notes.
NOTES.update({
"spaced-repetition": """---
title: Spaced repetition
tags: [memory, learning]
created: 2026-03-10
---
Reviewing material at growing intervals (1 day, 3 days, 7 days, 21 days) beats massed study.
The forgetting curve from Ebbinghaus drops fastest in the first 24 hours after learning.
Anki uses the SM-2 algorithm; FSRS is a newer scheduler that predicts recall probability.
Combine with sleep: a review before bed seems to consolidate better than a morning review.
""",
"zettelkasten": """---
title: Zettelkasten note-taking
tags: [notes, productivity, learning]
created: 2026-05-05
---
One idea per note, written in your own words, linked to related notes by id.
Links matter more than folders: a note should be findable from several directions.
Luhmann kept about 90,000 index cards and cross-referenced them by hand.
This notes folder follows the same rule: one idea per Markdown file.
""",
})Code walkthrough: shared data, unchanged: the last two; NOTES now holds all eight.
def write_notes(folder="notes", notes=NOTES):
"""Write every note to <folder>/<note_id>.md and return the folder."""
root = Path(folder)
root.mkdir(parents=True, exist_ok=True)
for note_id, text in notes.items():
(root / f"{note_id}.md").write_text(text, encoding="utf-8")
return root
root = write_notes()
print(len(list(root.glob("*.md"))), "notes in", root)
body = NOTES["lab-sync-2026-09-02"].split("---\n")[2].splitlines()
print("body line 2:", body[1])
print(sum(len(t.split("---\n")[2].split()) for t in NOTES.values()), "words of body text")Code walkthrough: shared data, unchanged: write_notes writes one <note_id>.md file per note into notes/ and prints three checks.
You should see 8 notes in notes, the decision line, and 416 words of body text.
Step 4: Rebuild the store and the model helper
store.py and llm.py are Lesson 2's files, byte for byte, with %%writefile changed to %%ensure. Nothing in them changes in this lab. Keep them that way: course-guide/03's rule is that later modules add files or import from these, and store.py never imports mcp.
%%ensure notes_assistant/__init__.py
"""Nare's notes assistant."""Code walkthrough: Lesson 2's package marker, unchanged.
%%ensure notes_assistant/store.py
"""Nare's notes as a folder of Markdown files. This file knows nothing about MCP."""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date
from pathlib import Path
NOTE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
WORD_PATTERN = re.compile(r"[a-z0-9]+")
MAX_TITLE_LENGTH = 120
MAX_BODY_LENGTH = 20_000
class NoteError(Exception):
"""Base class for every error the store raises on purpose."""
class NoteNotFound(NoteError):
"""No note has this id."""
class NoteExists(NoteError):
"""A note with this id already exists. The store never overwrites."""
class InvalidNote(NoteError):
"""An id, title, body or query the store refuses."""Code walkthrough: Lesson 2's store.py, part 1: the limits and the four errors.
%%ensure -a notes_assistant/store.py
@dataclass(frozen=True)
class Note:
note_id: str
title: str
tags: tuple[str, ...]
created: str
body: str
def snippet(self, length: int = 120) -> str:
"""The first line of the body, cut to `length` characters."""
first = self.body.splitlines()[0] if self.body else ""
return first if len(first) <= length else first[: length - 3] + "..."
@dataclass(frozen=True)
class SearchHit:
note_id: str
title: str
score: int
snippet: str
tags: tuple[str, ...] = ()
def slugify(title: str) -> str:
"""'Nap study: consent form!' -> 'nap-study-consent-form'."""
return "-".join(WORD_PATTERN.findall(title.lower()))[:64].strip("-")
def validate_note_id(note_id: str) -> str:
"""Refuse any id that is not a simple slug, before a file is touched."""
if not NOTE_ID_PATTERN.fullmatch(note_id):
raise InvalidNote(f"Invalid note id {note_id!r}. Use lowercase letters, digits "
"and hyphens, for example 'sleep-and-memory'.")
return note_idCode walkthrough: part 2: Note, SearchHit, slugify and validate_note_id.
%%ensure -a notes_assistant/store.py
def _parse(note_id: str, text: str) -> Note:
"""Split a file into its frontmatter fields and its body."""
meta, body = {}, text
if text.startswith("---\n"):
header, _, body = text[4:].partition("\n---\n")
for line in header.splitlines():
key, _, value = line.partition(":")
meta[key.strip()] = value.strip()
tags = tuple(t.strip() for t in meta.get("tags", "").strip("[]").split(",") if t.strip())
return Note(note_id, meta.get("title", note_id), tags, meta.get("created", ""), body.strip())
class NoteStore:
"""A folder of Markdown notes, one file per note, named <note_id>.md."""
def __init__(self, root: Path | str) -> None:
self.root = Path(root)
if not self.root.is_dir():
raise NoteError(f"Notes folder not found: {self.root}")
def _path(self, note_id: str) -> Path:
return self.root / f"{validate_note_id(note_id)}.md" # the only place an id becomes a path
def list_notes(self) -> list[Note]:
return [self.get(p.stem) for p in sorted(self.root.glob("*.md"))]
def get(self, note_id: str) -> Note:
path = self._path(note_id)
if not path.is_file():
raise NoteNotFound(f"No note with id {note_id!r}.")
return _parse(note_id, path.read_text(encoding="utf-8"))Code walkthrough: part 3: _parse and the reading half of NoteStore.
%%ensure -a notes_assistant/store.py
def search(self, query: str, limit: int = 5, tag: str | None = None) -> list[SearchHit]:
"""Rank notes by query words: 1 per body match, 3 per title match, 2 per tag match."""
terms = WORD_PATTERN.findall(query.lower())
if not terms:
raise InvalidNote("The query must contain at least one letter or digit.")
hits = []
for note in self.list_notes():
if tag and tag not in note.tags:
continue
body = WORD_PATTERN.findall(note.body.lower())
title = WORD_PATTERN.findall(note.title.lower())
tags = [t.lower() for t in note.tags]
score = sum(body.count(t) + 3 * title.count(t) + 2 * tags.count(t) for t in terms)
if score > 0:
hits.append(SearchHit(note.note_id, note.title, score, note.snippet(), note.tags))
hits.sort(key=lambda h: (-h.score, h.note_id)) # ties broken by note id
return hits[:limit]Code walkthrough: part 4: search, the course's fixed keyword scoring.
%%ensure -a notes_assistant/store.py
def create(self, title: str, body: str, tags=(), *, created: date) -> Note:
"""Write a new note stamped with `created`. Never overwrites an existing note."""
title = title.strip()
if not 1 <= len(title) <= MAX_TITLE_LENGTH:
raise InvalidNote(f"The title must be 1 to {MAX_TITLE_LENGTH} characters.")
if not body.strip() or len(body) > MAX_BODY_LENGTH:
raise InvalidNote(f"The body must be 1 to {MAX_BODY_LENGTH:,} characters.")
note_id = slugify(title)
if not note_id:
raise InvalidNote("The title must contain at least one letter or digit.")
path = self._path(note_id)
if path.exists():
raise NoteExists(f"A note with id {note_id!r} already exists.")
clean_tags = [slugify(t) for t in tags if slugify(t)]
header = f"---\ntitle: {title}\ntags: [{', '.join(clean_tags)}]\ncreated: {created.isoformat()}\n---\n"
path.write_text(header + body.strip() + "\n", encoding="utf-8")
return self.get(note_id)Code walkthrough: part 5: create, which never overwrites and takes the date as an argument.
%%ensure notes_assistant/llm.py
"""chat(): one function for Groq, Gemini and Ollama, built on LangChain."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from typing import Any
from langchain.chat_models import init_chat_model
MODELS = {"groq": "groq:openai/gpt-oss-20b", "gemini": "google_genai:gemini-3.5-flash-lite", "ollama": "ollama:llama3.2:3b"}
KEYS = {"groq": "GROQ_API_KEY", "gemini": "GOOGLE_API_KEY", "ollama": None}
LLM_PROVIDER = os.environ.get("LLM_PROVIDER", "groq").lower()
LLM_MODEL = os.environ.get("LLM_MODEL") or MODELS.get(LLM_PROVIDER, "")
@dataclass
class ToolCall:
id: str
name: str
arguments: dict[str, Any]
@dataclass
class ChatReply:
content: str | None
tool_calls: list[ToolCall] = field(default_factory=list)
error: str | None = None # "provider_error: ..." after two refusals
def as_message(self) -> dict[str, Any]:
"""This reply as an assistant message, to add to the conversation."""
message = {"role": "assistant", "content": self.content or ""}
if self.tool_calls:
message["tool_calls"] = [{"id": c.id, "type": "function", "function": {
"name": c.name, "arguments": json.dumps(c.arguments)}} for c in self.tool_calls]
return messageCode walkthrough: Lesson 2's llm.py, part 1: MODELS, KEYS, LLM_PROVIDER, LLM_MODEL, ToolCall and ChatReply.
%%ensure -a notes_assistant/llm.py
_CACHE: dict[str, Any] = {}
def _model(provider: str):
"""Build the LangChain model for a provider once, then reuse it."""
if provider not in MODELS:
raise ValueError(f"Unknown LLM_PROVIDER {provider!r}. Choose one of {sorted(MODELS)}.")
key = KEYS[provider]
if key and not os.environ.get(key):
raise RuntimeError(f"Set {key} to use {provider}.")
if provider not in _CACHE:
name = LLM_MODEL if provider == LLM_PROVIDER else MODELS[provider]
_CACHE[provider] = init_chat_model(name, temperature=0, max_retries=6)
return _CACHE[provider]
def chat(messages: list[dict[str, Any]], tools: list[dict] | None = None,
provider: str | None = None) -> ChatReply:
"""Send messages and optional tools; return text or tool calls. Retries a refusal once."""
model = _model((provider or LLM_PROVIDER).lower())
if tools:
model = model.bind_tools(tools)
for attempt in range(2):
try:
message = model.invoke(messages)
break
except Exception as err:
last = err
else:
return ChatReply(content=None, error=f"provider_error: {type(last).__name__}")
calls = [ToolCall(c["id"] or f"call_{i}", c["name"], c["args"]) for i, c in enumerate(message.tool_calls)]
return ChatReply(content=message.text or None, tool_calls=calls)Code walkthrough: part 2: _model and chat(), which retries a refusal once and then returns provider_error.
import importlib
import notes_assistant.store, notes_assistant.llm
importlib.reload(notes_assistant.store); importlib.reload(notes_assistant.llm)
from notes_assistant.store import NoteStore
from notes_assistant.llm import LLM_PROVIDER, LLM_MODEL
store = NoteStore(REPO / "notes")
print([(h.score, h.note_id) for h in store.search("nap study participants")])
print("provider:", LLM_PROVIDER, "->", LLM_MODEL)Code walkthrough
importlib.reloadmakes Python read the files again on a rerun; it caches modules it has imported.- The search is Lesson 2's check: the anchor note first. Importing
llm.pybuilds no model and sends nothing.
You should see [(3, 'lab-sync-2026-09-02'), (2, 'sleep-and-memory'), (1, 'spaced-repetition')] and provider: groq -> groq:openai/gpt-oss-20b (or your provider).
Step 5: Save the first server and call it
Lessons 1, 3 and 4 each wrote a notes_server.py. The repository keeps Lesson 3's, because it searches through NoteStore rather than a dictionary (Lesson 1) or a stand-in word match (Lesson 4); Lesson 4's resources and prompt return in Module 2's server. One change: a command-line choice of transport.
%%ensure examples/m01_first_server.py
"""Module 1's first MCP server: one search_notes tool over NoteStore, served over stdio or HTTP."""
import logging, os, sys
from mcp.server import MCPServer
from notes_assistant.store import NoteStore
log = logging.getLogger("notes_server")
log.propagate = False # this server's log lines go only where main sends them
NOTES_DIR = os.environ.get("NOTES_DIR")
if not NOTES_DIR:
sys.exit("notes server: NOTES_DIR is not set, so there is no notes folder to serve")
store = NoteStore(NOTES_DIR)
mcp = MCPServer("notes", instructions="Search Nare's research notes.")
@mcp.tool()
def search_notes(query: str, limit: int = 5) -> list[dict]:
"""Search Nare's research notes by keyword and return the best matching notes."""
log.info("search_notes query=%r limit=%d", query, limit)
hits = store.search(query, limit=limit)
return [{"note_id": h.note_id, "title": h.title, "score": h.score, "snippet": h.snippet} for h in hits]
if __name__ == "__main__":
handler = logging.StreamHandler(sys.stderr) # logs to stderr, never stdout
handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
log.addHandler(handler)
log.setLevel(logging.INFO)
if sys.argv[1:] == ["http"]:
mcp.run("streamable-http", host="127.0.0.1", port=8010) # Module 2 explains this transport
else:
mcp.run() # stdio by defaultCode walkthrough
- Everything above
if __name__is Lesson 3's server:NOTES_DIRfrom the environment, a clear exit without it, andsearch_notesas one call to the store. python examples/m01_first_server.pyserves stdio; addinghttpserves Streamable HTTP on127.0.0.1:8010, the first port in Module 1's band.
from mcp import Client
os.environ["NOTES_DIR"] = str(REPO / "notes") # read when the server file is imported
from examples import m01_first_server
importlib.reload(m01_first_server)
async with Client(m01_first_server.mcp) as client: # in memory
tool = (await client.list_tools()).tools[0]
result = await client.call_tool("search_notes", {"query": "nap study", "limit": 2})
print(tool.name, "| required:", tool.input_schema["required"])
print("in memory:", [h["note_id"] for h in result.structured_content["result"]])Code walkthrough
from examples import m01_first_serverimports the file as a module;examplesneeds no__init__.pyfor that.- The in-memory client is Lesson 3's: the fastest check and the one the tests use.
You should see search_notes | required: ['query'] and in memory: ['lab-sync-2026-09-02', 'sleep-and-memory'].
Now as a real process. The client starts the server file with the notebook's Python. A child process gets only a short allow-list of your environment (Lesson 3), so env must carry two settings: the notes folder, and PYTHONPATH, the folder Python searches for notes_assistant.
from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client
SERVER = REPO / "examples" / "m01_first_server.py"
SERVER_ENV = {"NOTES_DIR": str(REPO / "notes"), "PYTHONPATH": str(REPO)}
params = StdioServerParameters(command=sys.executable, args=[str(SERVER)], env=SERVER_ENV)
with open("server.log", "w") as log:
async with Client(stdio_client(params, errlog=log)) as client:
result = await client.call_tool("search_notes", {"query": "nap study", "limit": 2})
print("stdio:", [h["note_id"] for h in result.structured_content["result"]])
print("server.log:", open("server.log").read().strip())Code walkthrough
- Absolute paths everywhere, as a desktop host's configuration needs:
SERVERand both settings are built fromREPO. errlog=logsends the child's stderr toserver.log, the fix for python-sdk issue #1103 from Lesson 3.
You should see the same two ids, then server.log: notes_server: search_notes query='nap study' limit=2.
Step 6: Serve the same file over HTTP
Priya and Tomas will reach the server from their own computers in Module 7, over HTTP. Module 2 explains the transport; here you only prove that the file you just saved serves it. The server runs as a separate process on port 8010, and the cell stops it at the end, whatever happens.
import socket, subprocess, time
http_log = open("server-http.log", "w")
proc = subprocess.Popen([sys.executable, str(SERVER), "http"], env={**os.environ, **SERVER_ENV},
stdout=subprocess.DEVNULL, stderr=http_log)
try:
for _ in range(100): # wait up to 10 s for the port to open
try:
socket.create_connection(("127.0.0.1", 8010), timeout=0.1).close()
break
except OSError:
time.sleep(0.1)
async with Client("http://127.0.0.1:8010/mcp") as client:
result = await client.call_tool("search_notes", {"query": "nap study", "limit": 2})
print("http:", [h["note_id"] for h in result.structured_content["result"]])
finally:
proc.terminate()
print("server stopped:", proc.wait(timeout=5) is not None)
http_log.close()Code walkthrough
subprocess.Popenstarts the file withhttp; the server's stderr, including the web server's own lines, goes toserver-http.log.- The loop tries to connect every 0.1 seconds until the server is listening.
- A URL makes
Clientspeak Streamable HTTP.finallystops the server even if the call fails, so port 8010 is free for the next run.
You should see http: ['lab-sync-2026-09-02', 'sleep-and-memory'] and server stopped: True. Three transports, one answer.
Step 7: Answer Nare's question through the server
This is Module 1 end to end: Lesson 2's native function calling, with the hand-written schema and dispatcher replaced by an MCP server. The script lists the server's tools, translates them with Lesson 1's as_function_tool, lets the model choose, and has the server run the choice. It is a script, not a cell, so you can run it from a terminal later; scripts start their own event loop with anyio.run.
%%ensure examples/m01_lab.py
"""Module 1 lab: Nare's question answered by a model through the first MCP server.
Run from the repository root: python examples/m01_lab.py"""
import sys
from pathlib import Path
import anyio
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO)) # so notes_assistant imports from the repository
from notes_assistant.llm import chat # noqa: E402
QUESTION = "What did we decide about the nap study?"
SERVER = StdioServerParameters(command=sys.executable, args=[str(REPO / "examples" / "m01_first_server.py")],
env={"NOTES_DIR": str(REPO / "notes"), "PYTHONPATH": str(REPO)})
def as_function_tool(tool):
"""Lesson 1's translation: an MCP tool definition in the function-calling format."""
return {"type": "function", "function": {"name": tool.name, "description": tool.description,
"parameters": tool.input_schema}}Code walkthrough
REPOis found from the script's own location, so it runs from any folder. Python puts the script's folder,examples/, on its search path, not the repository, hencesys.path.insert.as_function_toolis Lesson 1's, reading the SDK's snake_case fieldinput_schema.
%%ensure -a examples/m01_lab.py
async def main():
with open(REPO / "server.log", "w") as log:
async with Client(stdio_client(SERVER, errlog=log)) as client:
tools = [as_function_tool(t) for t in (await client.list_tools()).tools]
print("tools from the server:", [t["function"]["name"] for t in tools])
messages = [{"role": "user", "content": QUESTION}]
reply = chat(messages, tools=tools)
if reply.tool_calls:
messages.append(reply.as_message())
for call in reply.tool_calls: # the model chose; the server runs it
result = await client.call_tool(call.name, call.arguments)
print("model asked for:", call.name, call.arguments)
print("server returned:", [h["note_id"] for h in result.structured_content["result"]])
messages.append({"role": "tool", "tool_call_id": call.id, "content": result.content[0].text})
reply = chat(messages, tools=tools)
if reply.error:
print("no answer:", reply.error)
else:
print("answer:", reply.content or "(empty reply)")
if __name__ == "__main__":
anyio.run(main)Code walkthrough
- The loop is Lesson 2's, with one swap:
client.call_toolreplacesrun_tool, so the server, not this script, knows how to search. chat()retries a refused reply once and then returnsprovider_errorinstead of raising, so the last line always prints something useful.
!python examples/m01_lab.pyCode walkthrough
!runs the script as a separate program; it inherits the key the setup cell put in the environment. It makes up to two model calls.
You should see tools from the server: ['search_notes'], then usually the model's search arguments, the ids the server returned and answer: with the reply; after two refusals, no answer: provider_error: .... Wording differs between providers. Snippets carry only each note's first line, so if the reply says 24 participants, ask where that came from.
Step 8: Test it without a key, and commit
Tests that call a provider are slow, spend quota and fail when the provider does. These need no key: the store and server are tested directly, chat() with a scripted stand-in model.
%%ensure tests/test_m01_store.py
import shutil
from datetime import date
from pathlib import Path
import pytest
from notes_assistant.store import InvalidNote, NoteExists, NoteNotFound, NoteStore
REPO = Path(__file__).resolve().parent.parent
@pytest.fixture
def store(tmp_path):
shutil.copytree(REPO / "notes", tmp_path / "notes") # tests write to a copy, never notes/
return NoteStore(tmp_path / "notes")
def test_search_ranks_the_anchor_note_first(store):
hits = [(h.note_id, h.score) for h in store.search("nap study participants")]
assert hits == [("lab-sync-2026-09-02", 3), ("sleep-and-memory", 2), ("spaced-repetition", 1)]Code walkthrough
tmp_pathis a fresh folder pytest gives each test; the fixture copies the notes into it, so nothing a test does touchesnotes/.- The expected ranking is Lesson 2's output.
%%ensure -a tests/test_m01_store.py
def test_bad_and_missing_ids(store):
with pytest.raises(InvalidNote):
store.get("../secrets")
with pytest.raises(NoteNotFound):
store.get("no-such-note")
def test_create_never_overwrites(store):
note = store.create("Nap study consent form", "Draft due 15 September.", ["Meeting"], created=date(2026, 9, 15))
assert (note.note_id, note.tags, note.created) == ("nap-study-consent-form", ("meeting",), "2026-09-15")
with pytest.raises(NoteExists):
store.create("Nap study: consent form!", "Second try.", created=date(2026, 9, 15))
def test_store_knows_nothing_about_mcp():
source = (REPO / "notes_assistant" / "store.py").read_text()
assert "import mcp" not in source and "from mcp" not in sourceCode walkthrough
- Lesson 2's refusals and its no-overwrite rule, as tests. The last test holds course-guide/03's rule in code:
store.pynever importsmcp.
%%ensure tests/test_m01_server.py
import os
import sys
from pathlib import Path
import pytest
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
REPO = Path(__file__).resolve().parent.parent
os.environ.setdefault("NOTES_DIR", str(REPO / "notes")) # read when the server file is imported
from examples import m01_first_server # noqa: E402
pytestmark = pytest.mark.anyio
async def test_in_memory():
async with Client(m01_first_server.mcp) as client:
tools = (await client.list_tools()).tools
result = await client.call_tool("search_notes", {"query": "nap study", "limit": 2})
assert [t.name for t in tools] == ["search_notes"]
assert [h["note_id"] for h in result.structured_content["result"]] == ["lab-sync-2026-09-02", "sleep-and-memory"]
async def test_over_stdio(tmp_path):
params = StdioServerParameters(command=sys.executable, args=[str(REPO / "examples" / "m01_first_server.py")],
env={"NOTES_DIR": str(REPO / "notes"), "PYTHONPATH": str(REPO)})
with open(tmp_path / "server.log", "w") as log:
async with Client(stdio_client(params, errlog=log)) as client:
result = await client.call_tool("search_notes", {"query": "nap study", "limit": 1})
assert result.structured_content["result"][0]["note_id"] == "lab-sync-2026-09-02"Code walkthrough
pytestmark = pytest.mark.anyiolets every test beasync, using theanyioplugin that comes withmcp(course-guide/02).- One test in memory, one over stdio with a real child process, so a broken launch fails a test too.
%%ensure tests/test_m01_llm.py
import pytest
from langchain_core.messages import AIMessage
from notes_assistant import llm
class Scripted:
"""A stand-in model: returns a fixed reply, or raises like a provider that refuses."""
def __init__(self, reply=None):
self.reply, self.calls = reply, 0
def bind_tools(self, tools):
return self
def invoke(self, messages):
self.calls += 1
if self.reply is None:
raise RuntimeError("refused")
return self.reply
@pytest.fixture
def use(monkeypatch):
"""Put a stand-in model where chat() looks first; no request is ever sent."""
monkeypatch.setenv("GROQ_API_KEY", "not-used-by-tests")
def install(model):
monkeypatch.setitem(llm._CACHE, "groq", model)
return model
return installCode walkthrough
Scriptedis a stand-in model, not a real one: it answers with a fixed message or raises.useplaces it inllm._CACHE, wherechat()looks first, and sets a placeholder key so_modeldoes not stop.
%%ensure -a tests/test_m01_llm.py
def test_tool_call_becomes_a_toolcall(use):
use(Scripted(AIMessage(content="", tool_calls=[{"id": "c1", "name": "search_notes", "args": {"query": "nap"}}])))
reply = llm.chat([{"role": "user", "content": "nap?"}], tools=[{}], provider="groq")
assert (reply.content, reply.tool_calls, reply.error) == (None, [llm.ToolCall("c1", "search_notes", {"query": "nap"})], None)
def test_two_refusals_give_provider_error(use):
model = use(Scripted())
reply = llm.chat([{"role": "user", "content": "nap?"}], provider="groq")
assert (reply.content, reply.error, model.calls) == (None, "provider_error: RuntimeError", 2)Code walkthrough
- The first test checks that a tool call becomes a
ToolCall. The second proves the guard: two refusals, two calls, thenprovider_error, not an exception.
!python -m pytest -qCode walkthrough
python -m pytestruns from the repository root, which puts that folder on Python's search path, sonotes_assistantandexamplesimport without installing anything.-qprints one line per file and a summary.
You should see 8 passed and the time taken (0.54 s in the course's run).
!git init -q
!git config user.name >/dev/null || git config user.name "Your Name"
!git config user.email >/dev/null || git config user.email "you@example.com"
!git add requirements.txt notes notes_assistant/*.py examples/*.py tests/*.py
!git diff --cached --quiet || git commit -q -m "Module 1: notes, NoteStore, chat(), first MCP server, tests"
!git show --stat --format=%s HEAD | tail -n 1Code walkthrough
git initis safe to repeat; the twoconfiglines set a name and email only if Git has none.git addnames the project's files, so logs and Python's cache folders stay out. Module 11 adds a.gitignore.git diff --cached --quiet ||commits only when something changed, so a rerun does not stop on "nothing to commit".- The last line prints the commit's summary.
You should see 17 files changed, 463 insertions(+): the eight notes and nine Python and text files.
Check your work
This cell checks the repository against course-guide/03's file table for Module 1, and that store.py and llm.py are byte for byte what Lesson 2 wrote. The two fingerprints come from the course's run of Lesson 2.
import hashlib
MODULE_1 = ["requirements.txt", "notes_assistant/__init__.py", "notes_assistant/store.py", "notes_assistant/llm.py",
"examples/m01_first_server.py", "examples/m01_lab.py",
"tests/test_m01_store.py", "tests/test_m01_server.py", "tests/test_m01_llm.py"]
LESSON_2 = {"notes_assistant/store.py": "940abe2958fc", "notes_assistant/llm.py": "2f5770f0a208"}
missing = [f for f in MODULE_1 if not (REPO / f).is_file()]
changed = [f for f, h in LESSON_2.items() if hashlib.sha256((REPO / f).read_bytes()).hexdigest()[:12] != h]
tests = subprocess.run([sys.executable, "-m", "pytest", "-q"], capture_output=True, text=True).stdout.splitlines()[-1]
committed = subprocess.run(["git", "ls-files"], capture_output=True, text=True).stdout.split()
print("notes:", len(list((REPO / "notes").glob("*.md"))), "| missing:", missing, "| changed:", changed)
print("tests:", tests)
assert not missing and not changed and "failed" not in tests and "error" not in tests
assert all(f in committed for f in MODULE_1)
print("Module 1 repository: complete")Code walkthrough
sha256(...)[:12]is a short fingerprint of a file's bytes: one changed character gives a different one.git ls-fileslists what the commit holds; every Module 1 file must be in it.
You should see notes: 8 | missing: [] | changed: [], the test summary and Module 1 repository: complete.
Break it
Start the server without PYTHONPATH, the way a host configuration copied from a single-file example would.
broken = StdioServerParameters(command=sys.executable, args=[str(SERVER)], env={"NOTES_DIR": str(REPO / "notes")})
try:
with open("server.log", "w") as log:
async with Client(stdio_client(broken, errlog=log)) as client:
await client.list_tools()
except Exception as exc:
while isinstance(exc, BaseExceptionGroup): # unwrap to the first real error
exc = exc.exceptions[0]
print("client says:", type(exc).__name__, exc)
print("server.log ends:", open("server.log").read().strip().splitlines()[-1])Code walkthrough
brokendropsPYTHONPATH. Thewhileloop unwraps the exception groups the SDK raises, as in Lesson 3.
You should see client says: MCPError Connection closed and server.log ends: ModuleNotFoundError: No module named 'notes_assistant'.
The client learns only that the other end hung up; the server's stderr names the cause. Running a file puts its own folder, examples/, on Python's search path, not the repository, so the server cannot see the package. Step 5 worked because env said where to look, and course-guide/02's desktop host configuration passes PYTHONPATH for the same reason. Read the server's stderr first, every time.
What you added to notes-assistant
- A repository on your Drive, with
requirements.txtat course-guide/02's pins and a first commit. %%ensure, the cell magic every later lab uses to rebuild missing files without replacing yours.notes/: Nare's eight notes.notes_assistant/__init__.py,store.pyandllm.py, exactly as Lesson 2 wrote them.examples/m01_first_server.py: Lesson 3's server, serving stdio or HTTP on port 8010.examples/m01_lab.py: Nare's question answered by a model through the server.- Eight tests over the store, the server and
chat(), none of which needs a key.
Going further
- Connect the server to a desktop host on your own computer, using course-guide/02's configuration with
examples/m01_first_server.pyas the argument (not run here). - Add Lesson 2's optional
tagparameter tosearch_notesin a copy of the server, with a test for it. - Time
test_over_stdioagainsttest_in_memorywithpython -m pytest --durations=0and compare with Lesson 3's numbers. - Run
examples/m01_lab.pywithLLM_PROVIDER=geminiset, and compare which arguments each model chose.