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

Lesson 2: The running project: notes, a store and a model

21 min read·13 Oct 2026
Module 1 · Lesson 2 · About 40 minutes · Needs: basic Python (functions, classes, dictionaries); Lesson 1 helps but is not required

The situation

It is Monday 7 September 2026, the first day of the project. Nare wants every AI application she uses to answer one question from her notes: "How many participants will the nap study have?" The answer is in her lab sync note from 2 September: 24, up from 16. Before any protocol, you need three plain things: the notes folder, code that reads and searches it, and one way to reach a language model. This lesson builds all three, then answers the question the old way, with native function calling and no MCP, so that Lesson 3 can show exactly what MCP changes.

THIS LESSON BUILDS THE BOTTOM OF THE STACK notes/ 8 Markdown files store.py NoteStore llm.py chat() a model Groq, Gemini, Ollama MCP server and host Lesson 3, Modules 5 and 6
This lesson builds three plain pieces: the notes folder, a store that reads it and one helper that reaches a model (amber); the MCP layers (grey) come later.

A real case, first

LiveKit makes an open-source framework for voice agents. On 19 December 2025 a user opened issue #4334 on its GitHub repository, "Google Realtime API fails with MCP tools due to $schema field in parameters" (livekit/agents #4334). Their MCP server described its tools in JSON Schema, and that schema included a $schema field. The framework cleaned schemas up for Gemini only for one kind of tool, not the kind MCP tools used. Gemini rejected the tools, and the agent failed to speak.

A contributor posted a patch that stripped the field and quoted the error, "Extra inputs are not permitted". On 23 December a maintainer replied that pull request #4344 switched to always using parameters_json_schema. The issue was closed on 24 June 2026. The lesson: a tool description one provider accepts, another can refuse, and the code that talks to providers is where that shows up.

MCP server schema with $schema voice framework cleans some tools Gemini realtime model tools rejected agent silent fix: PR #4344 new schema path
In livekit/agents #4334 a leftover $schema field reached Gemini unchanged; Gemini rejected the tools (red) and the voice agent did not speak until the framework changed how it sent schemas.

What you will be able to do

  • Set up the notes-assistant package in a notebook and write Nare's eight notes.
  • Build NoteStore, the notes logic, with no MCP in it, and explain why it is kept apart.
  • Reach Groq, Gemini or Ollama through one function, chat(), that never crashes on a refused reply.
  • Run one round of native function calling, and say what is tied to your application.
  • Name two search methods stronger than keyword counting, and when they are worth it.

1. Setting up notes-assistant in a notebook (Essential)

Every later module imports the files you write today, so where they live matters. The project is one repository, notes-assistant/, holding one Python package, notes_assistant/.

notes-assistant/ the repository notes/ 8 notes notes_assistant/__init__.py today notes_assistant/store.py today notes_assistant/llm.py today server.py, host.py, ... Modules 5 to 8
Today the repository gets a notes folder and three package files (blue); the server, host and other files (grey) join them in later modules.

This lesson writes the package files into the notebook's folder (/content in Colab); the Module 1 lab saves the same files into your Google Drive copy, so they survive a runtime reset. First, the standard install and setup cells.

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

  • PROVIDER is the one word you change; MODELS holds the model names (checked October 2026).
  • The if block copies your key from Colab's Secrets panel into an environment variable. init_chat_model builds llm; this lesson's chat() helper builds its own the same way.
python
from pathlib import Path

os.environ["LLM_PROVIDER"] = PROVIDER          # the package reads the provider from here
Path("notes_assistant").mkdir(exist_ok=True)
Path("notes_assistant/__init__.py").write_text('"""Nare\'s notes assistant."""\n')
print(sorted(p.name for p in Path("notes_assistant").iterdir()))

Code walkthrough

  • LLM_PROVIDER passes your choice to notes_assistant/llm.py, so the package never needs editing.
  • A folder with an __init__.py file is a package, something Python can import. The output is ['__init__.py'].

2. Nare's notes folder (Essential)

The notes are the data for the whole course, and they are small on purpose: you can check every answer the assistant gives by reading them yourself.

--- 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 frontmatter body line 1 body line 2: 24
A note is a Markdown file: frontmatter (blue) with title, tags and date, then the body; the anchor note's 24 is on body line 2 (green).

Each note is one Markdown file named after its note id, such as lab-sync-2026-09-02. It starts with frontmatter, a header between two --- lines holding the title, tags and date; the body follows. In the anchor note the participant count is on the second body line. It matters in section 5.

NOTES/ 8 FILES, 416 WORDS OF BODY 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
Eight notes, one file each, 416 words of body in all; the anchor note lab-sync-2026-09-02 (green) is where most of the course's questions end up.

The next cells are the shared data from course-guide/06, copied exactly, split so no cell is too long.

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

  • NOTES maps each note id to the exact text of its file. This cell holds 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

  • NOTES.update(...) adds three more notes to the same dictionary.
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

  • The last two notes. The dictionary 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

  • write_notes writes one <note_id>.md file per note; running it again restores them. split("---\n")[2] keeps the body; [1] is its second line.
  • The output is 8 notes in notes, then body line 2: Decision: run the nap study with 24 participants instead of 16, budget approved by Priya., then 416 words of body text.

3. NoteStore: business logic with no MCP in it (Essential)

This is the project's most important design decision. The code that knows about notes knows nothing about MCP, so it can be tested without a protocol and stays still while the protocol changes.

host.py policy, Module 6 server.py thin, Module 5 store.py notes rules, today speaks MCP speaks MCP never imports mcp
Calls flow one way: host to server to store. Only store.py (blue) holds the rules about notes, and it never imports mcp.

The business logic, the rules about notes (what an id may be, how search scores, that nothing is overwritten), lives in store.py. The server and host are thin layers that call it, and store.py never imports mcp.

NoteStore notes/ folder list_notes() every Note get(note_id) one Note search(query) SearchHits create(...) a new Note or NoteNotFound, NoteExists, InvalidNote
NoteStore has four methods; each returns plain Note or SearchHit records or raises one of three named errors (red).

NoteStore has four methods: list_notes and get read, search ranks, create writes. The file is written in five cells; %%writefile -a appends instead of replacing.

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

  • %%writefile writes the cell to the file instead of running it.
  • NOTE_ID_PATTERN allows 1 to 64 lowercase letters, digits and hyphens. WORD_PATTERN defines a search word. The limits cap titles at 120 characters and bodies at 20,000.
  • Four error classes, all children of NoteError.
NoteError NoteNotFound no such id NoteExists id already taken InvalidNote bad id, title, query
Three refusals (red) share one parent, NoteError, so later layers catch exactly the store's refusals and let real bugs surface.

Every refusal is a NoteError. Later layers catch exactly these and turn them into messages a model can act on, while a real bug still surfaces as a bug.

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

  • @dataclass(frozen=True) makes a record that cannot change once built. Note.snippet returns the body's first line only, and SearchHit carries that, which keeps results small.
  • slugify lowercases a title and joins its words with hyphens. validate_note_id raises InvalidNote with a message saying what a valid id looks like.
lab-sync-2026-09-02 ../secrets validate_note_id file opened notes/ only InvalidNote nothing touched
Every id passes one check before it becomes a path: a slug opens a file in notes/ (green); ../secrets is refused with nothing touched (red).

That check guards against path traversal: an id such as ../secrets that would climb out of the folder if it became a path. It fails the pattern, so no file is opened.

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

  • _parse splits the file at the second --- line, reads key: value pairs and turns [meeting, sleep] into a tuple.
  • __init__ fails at once if the folder is missing. _path is the only place an id becomes a path, and it validates first.
  • list_notes sorts by id, so every machine sees the same order; get raises NoteNotFound for a missing note.
SEARCH("NAP STUDY PARTICIPANTS") lab-sync-2026-09-02 3 sleep-and-memory 2 spaced-repetition 1 weights: body 1, title 3, tag 2
For "nap study participants" the anchor note scores 3 (one body match per word), sleep-and-memory 2 ("nap" twice) and spaced-repetition 1 ("study").

Search is keyword scoring, fixed for the course: each query word scores 1 per appearance in the body, 3 in the title and 2 in the tags. For "nap study participants" the anchor note scores 3.

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

  • The method is indented: it continues the NoteStore class. An empty query raises InvalidNote.
  • tag filters notes first; the score line applies the three weights. Sorting by minus the score, then the id, breaks ties the same way every time.
Nap study consent form create, 15 September Nap study: consent form! create again slug nap-study-consent-form file written NoteExists
Two titles give the same id: the first create writes the file (green), the second raises NoteExists (red) and the file is left as it was.

create writes a new note and never overwrites one. The date is an argument, not the computer's clock, so everyone gets the same file.

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

  • The * makes created keyword-only, so the date is never passed by accident. The id comes from slugify(title).
  • path.exists() is the no-overwrite rule: an existing id raises NoteExists. The new note is read back with get.
python
from datetime import date
from notes_assistant.store import NoteStore, NoteError

store = NoteStore("notes")
for hit in store.search("nap study participants"):
    print(f"{hit.score}  {hit.note_id:<20} {hit.snippet}")
for bad in ["../secrets", "no-such-note"]:
    try:
        store.get(bad)
    except NoteError as err:
        print(type(err).__name__, "|", err)

import shutil
shutil.rmtree("scratch-notes", ignore_errors=True)   # start the copy afresh on every run
scratch = NoteStore(write_notes("scratch-notes"))    # writes go to a copy, never notes/
note = scratch.create("Nap study consent form", "Draft due 15 September.", ["Meeting"], created=date(2026, 9, 15))
print("created", note.note_id, note.tags, note.created)
try:
    scratch.create("Nap study: consent form!", "Second try.", created=date(2026, 9, 15))
except NoteError as err:
    print(type(err).__name__, "|", err)

Code walkthrough

  • The search prints 3 lab-sync-2026-09-02, 2 sleep-and-memory and 1 spaced-repetition. ../secrets raises InvalidNote; no-such-note raises NoteNotFound.
  • shutil.rmtree and write_notes("scratch-notes") make a fresh copy, so writes never touch notes/. The note is stamped 15 September, the day Nare saves her consent form draft.
  • The output is created nap-study-consent-form ('meeting',) 2026-09-15, then NoteExists: the second title slugifies to the same id.

Break it: ask the store to climb out

Change "../secrets" to "lab-sync-2026-09-02/../../secrets" and run the cell again. It still raises InvalidNote: / and . are not in the pattern. Without validate_note_id in _path, Python would open whatever file the id pointed at.

4. The chat() helper: one function, three providers (Essential)

The host you build in Module 6 needs a language model, and every learner may have a different one. chat() hides the provider behind one function, so nothing else in the package ever names Groq, Gemini or Ollama.

chat() init_chat_model LLM_PROVIDER Groq gpt-oss-20b Gemini 3.5 Flash-Lite Ollama llama3.2:3b
chat() reads one setting, LLM_PROVIDER, and init_chat_model builds the connector for Groq, Gemini or Ollama (amber, the model).

chat() uses LangChain's init_chat_model, which reads a string such as groq:openai/gpt-oss-20b and builds the matching connector, the code that speaks that provider's format. The provider comes from one environment variable, LLM_PROVIDER.

ChatReply content text answer or None tool_calls ToolCall: id, name, arguments error provider_error: ... or None
Whatever the provider, chat() returns one ChatReply: text, tool calls the model asked for (amber), or a provider_error reason (red).

Whatever the provider, the helper returns one record, a ChatReply: text, a list of ToolCalls, or an error reason.

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

  • LLM_PROVIDER defaults to groq; LLM_MODEL overrides the model name.
  • ToolCall is one request to run a tool: an id to match the result back, a name and arguments. ChatReply.error is this course's field for a refused reply.
  • as_message turns the reply back into a history message, because the next request must include what the model asked for.
invoke attempt 1 invoke attempt 2 refused ChatReply text or tool calls ChatReply(error= provider_error: X) ok refused
A refused reply gets one more try; a second refusal becomes a ChatReply carrying provider_error (red), so the next step can stop cleanly.

Providers sometimes refuse the model's own reply, for example when it names a tool it was never given. Course-guide/02 sets the rule: try once more, then return a clear "no answer" with the reason provider_error.

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

  • _model raises for an unknown provider or a missing key, naming what to set: those are setup mistakes. _CACHE keeps one model per provider.
  • bind_tools(tools) offers the tools; each connector translates them for its provider.
  • The for ... else runs invoke at most twice; the else branch runs only if neither attempt reached break.
  • message.tool_calls holds each requested call's name, args and id; they become ToolCalls.

5. Native function calling, no MCP (Essential)

Before MCP, this is how a model used a tool, and many applications still work this way. Building it by hand shows which parts MCP later moves out of your application.

name: "search_notes" description: Search the user's research notes... the model reads this to decide parameters (JSON Schema): query: string required limit: integer optional, default 5
A hand-written tool: a name, a description the model reads to decide, and a JSON Schema with a required query and an optional limit (grey).

In native function calling you describe the tool inside the model request, its arguments written in JSON Schema. The model reads the description and may ask for the tool. It never runs anything itself.

python
import json
from notes_assistant.llm import ToolCall, chat

SEARCH_TOOL = {
    "type": "function",
    "function": {
        "name": "search_notes",
        "description": "Search the user's research notes by keyword and return the best matching notes.",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Words to look for, for example 'sleep memory'."},
                "limit": {"type": "integer", "description": "Maximum number of notes to return.", "default": 5},
            },
            "required": ["query"],
        },
    },
}

Code walkthrough

  • type: "function" with a function inside is the OpenAI-style format all three connectors accept. parameters says query is a required string, limit an optional integer.
  • Nothing checks this dictionary against store.search. Add a store parameter and forget it here, and the model never learns of it.
ToolCall search_notes run_tool dispatcher store.search 3 hits JSON text back to the model
Your dispatcher maps the model's tool call (amber) to store.search and sends the hits back as JSON text.

When the model asks for a tool, your code runs it. The function that maps a tool name to real code is the dispatcher.

python
def run_tool(call: ToolCall) -> str:
    """Map the tool the model chose to Python code; return text for the model."""
    if call.name != "search_notes":
        return f"Unknown tool {call.name!r}"
    try:
        hits = store.search(call.arguments["query"], limit=call.arguments.get("limit", 5))
    except NoteError as err:
        return f"Error: {err}"
    return json.dumps([{"note_id": h.note_id, "title": h.title, "snippet": h.snippet} for h in hits])

result = run_tool(ToolCall("demo", "search_notes", {"query": "nap study participants"}))
print(len(result), "characters")
print(json.loads(result)[0])
print("contains 24:", "24" in result)

Code walkthrough

  • An unknown tool name or a NoteError becomes text the model can read, not an exception. The hits become JSON text, because a tool result is text.
  • The output is 482 characters, then the first hit (lab-sync-2026-09-02 with the snippet Attendees: Priya, Tomas, Nare.), then contains 24: False.

Break it: the answer is on line 2

WHAT THE NOTE SAYS Attendees: Priya, Tomas, Nare. ...24 participants instead of 16... WHAT THE MODEL RECEIVES Attendees: Priya, Tomas, Nare. line 2 missing: no 24
The snippet is the first body line only, so the 24 on line 2 (green) never reaches the model (red).

The anchor note ranks first, but its snippet is the first body line, the attendees. The 24 is on line 2, so no model can read it from this result: a careful one says so, a careless one guesses. Module 4 lets the model open a whole note.

your code model store question + SEARCH_TOOL call search_notes store.search(...) 3 hits tool result answer, or no answer
One round of native function calling: the model (amber) only asks; your code runs the search and carries the result back.

Now one round with a real model. If it asks for a search, your code runs it, adds the request and result to the conversation, and asks again.

python
messages = [{"role": "user", "content": "How many participants will the nap study have?"}]
reply = chat(messages, tools=[SEARCH_TOOL])
if reply.tool_calls:
    messages.append(reply.as_message())
    for call in reply.tool_calls:
        print("model asked for:", call.name, call.arguments)
        messages.append({"role": "tool", "tool_call_id": call.id, "content": run_tool(call)})
    reply = chat(messages, tools=[SEARCH_TOOL])
if reply.error:
    print("no answer:", reply.error)
else:
    print("answer:", reply.content or "(empty reply)")

Code walkthrough

  • If the reply holds tool calls, the assistant message goes into the history once, then one tool message per call, matched by tool_call_id. The second call lets the model answer.
  • A refused reply prints no answer: provider_error: ... and the cell ends cleanly.

Read the answer against the tool result. If it says 24, ask where that came from, because the search result did not contain it.

your notebook SEARCH_TOOL schema run_tool dispatcher a second application copy of the schema copy of the dispatcher
Schema and dispatcher live inside one application; each further application copies both (red), the N times M problem in miniature.

Count what is tied to this notebook: the schema and the dispatcher. A second AI application wanting the same search must copy both. That is Lesson 1's N times M problem in miniature; Lesson 3 moves both into an MCP server.

In production

SEARCH_TOOL one definition bind_tools per connector Groq request Gemini request Ollama request LiveKit's bug lived here
One definition goes in and each connector translates it for its provider (amber); that translation is the layer where LiveKit's bug lived.

LiveKit's bug sat in the layer chat() hands to LangChain: turning one tool definition into each provider's format. The fix changed how the framework passed schemas to Gemini, not the tools. Keep one definition and test it on every provider you support.

17 Jun 2026 shutdown announced 16 Aug 2026 model shut down 13 Oct 2026 course checked llama-3.3-70b-versatile on Groq
Groq announced on 17 June 2026 that llama-3.3-70b-versatile would shut down on 16 August 2026 (red), so model names live in one setting.

Model names change too. Groq's deprecations page says it emailed users on 17 June 2026 that llama-3.3-70b-versatile would be shut down; it was, on 16 August 2026 (checked October 2026). One MODELS table makes that a one-line edit.

6. Search backends beyond keyword scoring (Optional)

Keyword scoring is transparent, so you can check every result by hand. Its limits are worth seeing before you rely on it.

keyword count counts every word today's store BM25 rare words weigh more than 'the' embeddings match meaning Module 10 spaced-repetition: 4 3 of them from 'the'
Keyword counting (blue) lets "the" add points; BM25 and embeddings (grey, optional) rank by rarity and by meaning.

Ask the store Nare's whole question, "how many participants will the nap study have", and words such as "the" add points: spaced-repetition scores 4, three of them from "the". BM25 fixes this by weighting rare words above common ones (the rank_bm25 library implements it). Embeddings compare meaning, so "people in the nap experiment" can match "participants". Module 10 builds a retrieval server with both.

python
for hit in store.search("how many participants will the nap study have"):
    print(hit.score, hit.note_id)

Code walkthrough

  • The output is 7 lab-sync-2026-09-02, 4 sleep-and-memory, 4 spaced-repetition, 1 coffee-and-focus, 1 zettelkasten. The last two score only from "the".

What you added to the notes assistant

  • notes/: Nare's eight notes, written by write_notes.
  • notes_assistant/store.py: NoteStore, Note, SearchHit, slugify, validate_note_id and four errors, with no MCP in it.
  • notes_assistant/llm.py: chat(), ToolCall, ChatReply, LLM_PROVIDER and LLM_MODEL, on top of init_chat_model.
  • A native function-calling baseline: SEARCH_TOOL and run_tool, kept in the notebook, not the package.

Check yourself

  1. Why must store.py never import mcp?

So the notes logic can be tested without a protocol and stays unchanged when the protocol or SDK changes.

  1. What stops store.get("../secrets") from reading a file outside the folder?

_path calls validate_note_id first; ../secrets fails the pattern, so InvalidNote is raised before any path is built.

  1. Why can no model answer "how many participants?" correctly from run_tool's result?

Each hit carries only the body's first line; the count is on line 2.

  1. What does chat() return when the provider refuses twice, and why not raise?

A ChatReply with error set to provider_error: <ErrorName>, so one bad reply ends a step cleanly instead of stopping a notebook or a host's loop.

Exercise

Nare wants to search only meeting notes. Write SEARCH_TOOL_V2, a copy of SEARCH_TOOL with an optional string parameter tag, and run_tool_v2(call), which passes tag through to store.search.

python
import copy

SEARCH_TOOL_V2 = copy.deepcopy(SEARCH_TOOL)
SEARCH_TOOL_V2["function"]["parameters"]["properties"]["tag"] = {
    "type": "string", "description": "Only search notes with this tag, for example 'meeting'."}


def run_tool_v2(call: ToolCall) -> str:
    """Like run_tool, with an optional tag filter."""
    if call.name != "search_notes":
        return f"Unknown tool {call.name!r}"
    args = call.arguments
    try:
        hits = store.search(args["query"], limit=args.get("limit", 5), tag=args.get("tag"))
    except NoteError as err:
        return f"Error: {err}"
    return json.dumps([{"note_id": h.note_id, "title": h.title, "snippet": h.snippet} for h in hits])

Code walkthrough: deepcopy copies the nested dictionaries, so SEARCH_TOOL is unchanged; tag is not added to required, so it stays optional.

python
found = json.loads(run_tool_v2(ToolCall("ex", "search_notes", {"query": "sleep", "tag": "meeting"})))
assert [h["note_id"] for h in found] == ["lab-sync-2026-09-02"], found
assert "tag" not in SEARCH_TOOL["function"]["parameters"]["properties"]
assert "tag" not in SEARCH_TOOL_V2["function"]["parameters"]["required"]
print("exercise passed:", found[0]["note_id"])

Code walkthrough: "sleep" with the tag meeting must return only the anchor note; the original tool must be unchanged and tag must be optional.

Key terms

TermMeaning
Note idA note's name and file name: 1 to 64 lowercase letters, digits and hyphens
FrontmatterThe header holding a note's title, tags and date
Path traversalUsing ../ in a name to reach files outside the intended folder
ConnectorProvider-specific code that turns one request into that provider's format
ChatReplyWhat chat() returns: text, tool calls, or a provider_error reason
Native function callingA tool described in the model request and run by your own code
DispatcherYour code that maps a tool name to the function that runs it
BM25A keyword ranking that weights rare words more than common ones

Sources

  • muhammadkhalid-03 and others, "Google Realtime API fails with MCP tools due to $schema field in parameters", livekit/agents issue #4334, opened 19 December 2025, closed 24 June 2026. https://github.com/livekit/agents/issues/4334
  • Groq, "Deprecations" (the llama-3.3-70b-versatile row), fetched 13 October 2026. https://console.groq.com/docs/deprecations
  • LangChain, "Models" (init_chat_model, bind_tools, tool_calls), fetched 13 October 2026. https://docs.langchain.com/oss/python/langchain/models