Topic 4: Host responsibilities
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.
"""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:
HIDDENmatches 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>.URLfinds links while stopping at those hidden characters.preview_create_note()copies the notes folder to a temporary directory and calls the realNoteStore.createthere. 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_notegets the rich preview; any other tool gets a one-line-per-argument fallback. Theanswerparameter defaults toinput, and the demo passes a function that prints and answersy.main()first showsconsole_approveon the same arguments (stdin supplies they), then runs the host twice on the samecreate_notecall 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
\nescapes, shows the tags as the model wrote them ("Nap Study"), and shows the invisible character only as\u200bat 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.