CourseModel Context Protocol · Module 6: Clients and Hosts · part 37 of 83
Part 37 · Module 6: Clients and Hosts

Topic 4: Host responsibilities

16 min read·22 Sept 2026

Consent UI that shows what a tool will actually do

console_approve shows the arguments as JSON. That is honest, but people do not read JSON; they read effects. A good consent prompt answers "what will change if I say yes?" in the terms of the domain: which file, what content, anything surprising. For create_note we can answer exactly, because the host knows the notes format: run the real NoteStore.create on a throwaway copy of the folder and show the result as a diff.

python
"""A consent prompt that shows what create_note will actually do: the file, and a diff."""
from __future__ import annotations

import difflib
import json
import re
import shutil
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import Any

import anyio

from mcp import Client

from m06_common import ROOT, ScriptedModel, notes_params
from notes_assistant.host import Host, console_approve
from notes_assistant.store import NoteError, NoteStore, slugify

HIDDEN = re.compile(r"[\u200b-\u200f\u202a-\u202e\u2060-\u2064]")  # zero-width and direction marks
URL = re.compile(r"https?://[^\s\u200b-\u200f\u202a-\u202e\u2060-\u2064]+")


def visible(text: str) -> str:
    """Make invisible characters visible, so the person sees what is really there."""
    return HIDDEN.sub(lambda m: f"<U+{ord(m.group()):04X}>", text)


def preview_create_note(notes_dir: Path, arguments: dict[str, Any]) -> list[str]:
    """Dry-run the real NoteStore on a scratch copy and return a unified diff plus warnings."""
    note_id = slugify(str(arguments.get("title", "")))
    lines = [f"Creates file: notes/{note_id}.md"]
    with tempfile.TemporaryDirectory() as scratch:
        shutil.copytree(notes_dir, scratch, dirs_exist_ok=True)
        try:
            note_path = NoteStore(scratch).create(arguments["title"], arguments["body"], arguments.get("tags", []))
            new_text = (Path(scratch) / f"{note_path.note_id}.md").read_text(encoding="utf-8")
        except (NoteError, KeyError) as exc:
            return lines + [f"WARNING: this call will fail: {exc}"]
    diff = difflib.unified_diff([], new_text.splitlines(), "/dev/null", f"notes/{note_id}.md", lineterm="")
    lines += [visible(line) for line in diff]
    body = str(arguments.get("body", ""))
    for url in URL.findall(body):
        lines.append(f"WARNING: the note contains a link: {url}")
    if HIDDEN.search(body):
        lines.append("WARNING: the note contains invisible characters.")
    return lines


def make_preview_approve(notes_dir: Path, answer: Callable[[str], str] = input) -> Callable[[str, dict[str, Any]], bool]:
    def approve(name: str, arguments: dict[str, Any]) -> bool:
        server, _, tool = name.partition("__")
        print(f"\n{server} wants to run {tool}.")
        if tool == "create_note":
            print("\n".join(preview_create_note(notes_dir, arguments)))
        else:
            for key, value in arguments.items():  # generic fallback: one readable line per argument
                print(f"  {key}: {value}")
        return answer("Allow? [y/N] ").strip().lower() == "y"
    return approve


BODY = (
    "Decision from the 2 September sync: 24 participants, not 16.\n"
    "Sleep lab booked for the weeks of 5 and 12 October.\n"
    "Consent form draft due 15 September (Nare).\n"
    "Protocol reference: https://example.org/nap-protocol\u200b"
)
ARGS = {"title": "Nap study plan", "body": BODY, "tags": ["sleep", "Nap Study"]}


async def main() -> None:
    print("=== console_approve (the Module 6 default) ===")
    console_approve("notes__create_note", ARGS)
    print("\n=== preview approve ===")
    with tempfile.TemporaryDirectory() as tmp:
        notes_dir = Path(tmp) / "notes"
        shutil.copytree(ROOT / "notes", notes_dir)
        approve = make_preview_approve(notes_dir, answer=lambda prompt: print(prompt + "y") or "y")
        model = ScriptedModel([[("notes__create_note", ARGS)], [("notes__create_note", ARGS)], "Saved."], verbose=False)
        async with Client(notes_params(notes_dir)) as notes:
            answer = await Host({"notes": notes}, chat_fn=model, approve=approve).ask("Save the nap study plan.")
        print(f"\n=> {[(c.name, c.ok, c.note) for c in answer.calls]}")
        print(f"files now: {sorted(p.name for p in notes_dir.glob('nap*'))}")


if __name__ == "__main__":
    anyio.run(main)

Code explained

  • In simple words: before signing, show the person the actual page that will be filed, with anything hidden made visible.
  • What happens:
    • HIDDEN matches zero-width and text-direction characters, which are invisible on screen but can hide text from a reviewer. visible() replaces each one with a readable marker such as <U+200B>. URL finds links while stopping at those hidden characters.
    • preview_create_note() copies the notes folder to a temporary directory and calls the real NoteStore.create there. That is a dry run: the same code path, no real effect. It reads back the exact file that would be written and turns it into a unified diff against /dev/null (a new file). If the store raises (for example the note already exists) the preview says the call will fail. Links and invisible characters produce warnings.
    • make_preview_approve() returns an approval function the host can use. create_note gets the rich preview; any other tool gets a one-line-per-argument fallback. The answer parameter defaults to input, and the demo passes a function that prints and answers y.
    • main() first shows console_approve on the same arguments (stdin supplies the y), then runs the host twice on the same create_note call through a stdio server pointed at a temporary copy of the notes.
  • Comes out (run as echo y | PYTHONPATH=. python examples/m06_consent.py):
    text
    === console_approve (the Module 6 default) ===
    
    The assistant wants to call notes__create_note with:
    {
      "title": "Nap study plan",
      "body": "Decision from the 2 September sync: 24 participants, not 16.\nSleep lab booked for the weeks of 5 and 12 October.\nConsent form draft due 15 September (Nare).\nProtocol reference: https://example.org/nap-protocol\u200b",
      "tags": [
        "sleep",
        "Nap Study"
      ]
    }
    Allow this call? [y/N] 
    === preview approve ===
    
    notes wants to run create_note.
    Creates file: notes/nap-study-plan.md
    --- /dev/null
    +++ notes/nap-study-plan.md
    @@ -0,0 +1,9 @@
    +---
    +title: Nap study plan
    +tags: [sleep, nap-study]
    +created: 2026-09-21
    +---
    +Decision from the 2 September sync: 24 participants, not 16.
    +Sleep lab booked for the weeks of 5 and 12 October.
    +Consent form draft due 15 September (Nare).
    +Protocol reference: https://example.org/nap-protocol<U+200B>
    WARNING: the note contains a link: https://example.org/nap-protocol
    WARNING: the note contains invisible characters.
    Allow? [y/N] y
    
    notes wants to run create_note.
    Creates file: notes/nap-study-plan.md
    WARNING: this call will fail: A note with id 'nap-study-plan' already exists.
    Allow? [y/N] y
    
    => [('notes__create_note', True, ''), ('notes__create_note', False, 'tool_error')]
    files now: ['nap-study-plan.md']

    Compare the two prompts for the same call. The JSON version squeezes the body into one line with \n escapes, shows the tags as the model wrote them ("Nap Study"), and shows the invisible character only as \u200b at the end of a long line. The preview shows the file name the note will get, the tag as it will be stored (nap-study), today's date in the header, and flags the hidden character and the link explicitly. On the second call it predicted the failure before anyone said yes, and the host record confirms it (tool_error). This kind of preview is only possible when the host understands the tool. For tools it does not understand, the fallback still beats JSON: one readable line per argument, with the server named first.

The rest of this course is yours to keep

This course is bought on its own, once, and stays readable afterwards, including the parts added to it later.