CourseModel Context Protocol: The Complete Course · Module 1: Foundations · part 11 of 77
Part 11 · Module 1: Foundations

Lab: Start the notes assistant repository

23 min read·13 Oct 2026
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

MYDRIVE/NOTES-ASSISTANT m01_lab.py examples/ m01_first_server examples/ store.py no MCP notes/ 8 notes stdio, in memory or HTTP 8010 llm.py chat() Groq, Gemini or Ollama tests/ 8 tests, no key requirements.txt course pins first commit 17 files
After this lab the repository holds the whole of Module 1: a script that reaches the server (blue), the store and notes, chat() for the model (amber), and tests and a commit (green).

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

/content Colab runtime deleted when it resets MyDrive/notes-assistant your Google Drive kept between sessions mount four folders: notes, notes_assistant, examples, tests
Lessons wrote files into the runtime, which a reset deletes (red); the lab writes them into your Drive, where they stay (green).

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.

python
%pip install -q -U langchain langgraph langchain-groq langchain-google-genai langchain-ollama
python
import 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 llm directly; the cell's job here is to copy your key into the environment, where notes_assistant/llm.py and the scripts it starts will find it.
python
%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 a pytest, but not necessarily this version.
python
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.mount asks once per session for access to your Drive; the repository is MyDrive/notes-assistant (course-guide/02). On your own computer the folder is made where you started. The if makes the cell safe to rerun.
  • REPO is the repository folder; every path below is relative to it. The four folders match course-guide/03's file table.
  • LLM_PROVIDER passes 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

%%ensure path file exists? keep it: kept write it: wrote yes no %%ensure -a path written this session? append skip quietly yes no
%%ensure writes a file only when it is missing (blue) and keeps one you already have (green); -a appends only to a file this session started, so a split file is never half rebuilt.

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.

python
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_magic turns 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, with kept printed so you can see it happened.
  • -a appends, 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.
python
%%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.0

Code 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

NOTES/ (416 WORDS OF BODY TEXT) coffee-and-focus.md deep-work.md lab-sync-2026-09-02.md pomodoro-technique.md reading-list.md sleep-and-memory.md spaced-repetition.md zettelkasten.md anchor note: 24 participants, on body line 2
write_notes puts the eight notes from course-guide/06 into notes/; the anchor note (green) holds the decision on its second body line.

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.

python
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.

python
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.

python
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.

python
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

LESSON 2 CELLS THIS LAB SHA256, FIRST 12 %%writefile store.py %%ensure store.py 940abe2958fc %%writefile llm.py %%ensure llm.py 2f5770f0a208 same bytes: later modules import, never replace
The lab pastes Lesson 2's cells with %%ensure, so store.py and llm.py come out byte for byte the same, with the same fingerprints (green).

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.

python
%%ensure notes_assistant/__init__.py
"""Nare's notes assistant."""

Code walkthrough: Lesson 2's package marker, unchanged.

python
%%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.

python
%%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_id

Code walkthrough: part 2: Note, SearchHit, slugify and validate_note_id.

python
%%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.

python
%%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.

python
%%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.

python
%%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 message

Code walkthrough: Lesson 2's llm.py, part 1: MODELS, KEYS, LLM_PROVIDER, LLM_MODEL, ToolCall and ChatReply.

python
%%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.

python
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.reload makes 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.py builds 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

ONE PROCESS: THE NOTEBOOK Client m01_first_server imported module NoteStore search 'nap study', limit 2: lab-sync-2026-09-02, sleep-and-memory
In memory, the client calls the imported server inside the notebook: the quickest check, and the one the tests use.

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.

python
%%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 default

Code walkthrough

  • Everything above if __name__ is Lesson 3's server: NOTES_DIR from the environment, a clear exit without it, and search_notes as one call to the store.
  • python examples/m01_first_server.py serves stdio; adding http serves Streamable HTTP on 127.0.0.1:8010, the first port in Module 1's band.
python
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_server imports the file as a module; examples needs no __init__.py for 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'].

notebook client python m01_first_server.py child process stdin stdout env: NOTES_DIR, PYTHONPATH server.log stderr
Over stdio the server is a child process: env must carry both settings it needs (green), and its stderr goes to server.log (grey).

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.

python
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: SERVER and both settings are built from REPO.
  • errlog=log sends the child's stderr to server.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

start file + http wait port 8010 open POST /mcp call_tool stop in finally 127.0.0.1 only, never 0.0.0.0 same two notes as in memory and stdio
The HTTP check starts the same file as a web server on 127.0.0.1:8010, calls it, and always stops it (red) so the port is free next time.

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.

python
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.Popen starts the file with http; the server's stderr, including the web server's own lines, goes to server-http.log.
  • The loop tries to connect every 0.1 seconds until the server is listening.
  • A URL makes Client speak Streamable HTTP. finally stops 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

Nare's question chat() model chooses call_tool MCP client first server search_notes chat() with the result answer provider_error
The model (amber) chooses the tool, the MCP server (blue) runs it, and the run ends in an answer (green) or a clear provider_error (red), never a crash.

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.

python
%%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

  • REPO is 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, hence sys.path.insert.
  • as_function_tool is Lesson 1's, reading the SDK's snake_case field input_schema.
python
%%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_tool replaces run_tool, so the server, not this script, knows how to search.
  • chat() retries a refused reply once and then returns provider_error instead of raising, so the last line always prints something useful.
python
!python examples/m01_lab.py

Code 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

PYTHON -M PYTEST -Q: 8 PASSED, NO KEY test_m01_store 4 tests test_m01_server 2 tests test_m01_llm 2 tests Scripted stand-in model git commit 17 files
Eight tests pass without a key (green); the chat() tests use a scripted stand-in (grey), not a real model, and then everything is committed (blue).

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.

python
%%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_path is a fresh folder pytest gives each test; the fixture copies the notes into it, so nothing a test does touches notes/.
  • The expected ranking is Lesson 2's output.
python
%%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 source

Code walkthrough

  • Lesson 2's refusals and its no-overwrite rule, as tests. The last test holds course-guide/03's rule in code: store.py never imports mcp.
python
%%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.anyio lets every test be async, using the anyio plugin that comes with mcp (course-guide/02).
  • One test in memory, one over stdio with a real child process, so a broken launch fails a test too.
python
%%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 install

Code walkthrough

  • Scripted is a stand-in model, not a real one: it answers with a fixed message or raises.
  • use places it in llm._CACHE, where chat() looks first, and sets a placeholder key so _model does not stop.
python
%%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, then provider_error, not an exception.
python
!python -m pytest -q

Code walkthrough

  • python -m pytest runs from the repository root, which puts that folder on Python's search path, so notes_assistant and examples import without installing anything. -q prints one line per file and a summary.

You should see 8 passed and the time taken (0.54 s in the course's run).

python
!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 1

Code walkthrough

  • git init is safe to repeat; the two config lines set a name and email only if Git has none.
  • git add names 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

store.py bytes sha256 940abe2958fc matches Lesson 2 one character changed sha256 different check fails
A fingerprint changes if a single byte changes, so the check proves store.py and llm.py are exactly Lesson 2's (green), or fails (red).

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.

python
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-files lists 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

PYTHON EXAMPLES/M01_FIRST_SERVER.PY search path starts at examples/ notes_assistant not found PYTHONPATH = repository repository on the path notes_assistant found Connection closed search_notes answers
Run as a file, the server searches examples/ first and cannot find the package (red); PYTHONPATH set to the repository fixes it (green).

Start the server without PYTHONPATH, the way a host configuration copied from a single-file example would.

python
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

  • broken drops PYTHONPATH. The while loop 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.txt at 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.py and llm.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.py as the argument (not run here).
  • Add Lesson 2's optional tag parameter to search_notes in a copy of the server, with a test for it.
  • Time test_over_stdio against test_in_memory with python -m pytest --durations=0 and compare with Lesson 3's numbers.
  • Run examples/m01_lab.py with LLM_PROVIDER=gemini set, and compare which arguments each model chose.