Module 3 video goes live soon. The full lesson is below, and the checkpoint is ready to download.

A fence, and a question before the write

Lesson 17 of 30, Module 3: Make It Trustworthy. It refuses to leave the folder, asks before it overwrites, and remembers your answers.

Table of Contents
Downloads

Lesson 17 of 30, Module 3: Make It Trustworthy. It refuses to leave the folder, asks before it overwrites, and remembers your answers.

What you can do after this lesson

A server that can write must check two things before it writes: is this target inside the fence, and did a person say yes. Put that check in the server, not the app, so it travels into every app that uses the server. Where an app cannot show a form, fall back to the strongest guard it can handle, and be honest about which one you have.

The problem we inherited

A server that can write can write over the wrong document, and there is no undo you can reach from a chat.

The 5 steps this lesson runs

  1. Write the plain Python function
  2. Put the @mcp line above it
  3. Write the one-line docstring
  4. Restart Claude Desktop
  5. Ask Claude to use it

Code in this lesson

Every block the lesson shows on screen, in the order it appears.

1. Write the plain Python function
2. Put the @mcp line above it
3. Write the one-line docstring
4. Restart Claude Desktop
5. Ask Claude to use it
import json
from datetime import date
from typing import Annotated

from googleapiclient.errors import HttpError
from mcp.server.mcpserver import Context, Elicit, MCPServer
from mcp.server.mcpserver import Resolve
from mcp_types import ClientCapabilities, ElicitationCapability
from pydantic import BaseModel
def _inside(name: str) -> str:
    """The fence. A draft is a plain name inside your folder."""
    if "/" in name or "\\" in name or name.startswith("."):
        raise ToolError(
            f"{name} is not a draft in your folder. Scribe stays"
            " inside the Drafts folder and never leaves it.")
    return name
def _google(call):
    """Run one Google call. Turn its error into a sentence."""
    try:
        return call.execute()
    except HttpError as err:
        if err.resp.status == 403:
            raise ToolError(
                "Google refused. Scribe can only change drafts"
                " shared with it as an editor, and it can never"
                " make a new file.")
        raise ToolError(f"Google said no: {err.reason}")
def _find(name: str) -> str:
    _inside(name)
    q = (f"name = '{name}' and '{FOLDER}' in parents"
         " and trashed = false")
    found = _google(drive.files().list(q=q, fields="files(id)"))
    if not found["files"]:
        raise ToolError(
            f"There is no draft called {name} in your folder.")
    return found["files"][0]["id"]


def _text(file_id: str) -> str:
    raw = _google(drive.files().get_media(fileId=file_id))
    return raw.decode()
class Confirm(BaseModel):
    yes: bool


def _can_ask(ctx: Context) -> bool:
    """Can this app show a question and hand back the answer?"""
    form = ClientCapabilities(elicitation=ElicitationCapability())
    return ctx.session.check_client_capability(form)


def ask_first(name: str, find: str, replace: str,
              said_yes: bool, ctx: Context):
    """Ask before a write, unless you told Scribe to trust it."""
    if not find:
        raise ToolError("Tell me what to find. An empty find"
                        " would change every spot in the draft.")
    if said_yes:
        return Confirm(yes=True)
    question = f"Change every '{find}' to '{replace}' in {name}?"
    if _can_ask(ctx):
        return Elicit(question, Confirm)
    raise ToolError(
        f"{question} This app cannot show my question, so I"
        " stopped. Ask the person. If they say yes, call me"
        " again with said_yes set to true.")
@mcp.tool()
def edit_doc(name: str, find: str, replace: str,
             said_yes: bool = False,
             ok: Annotated[Confirm, Resolve(ask_first)] = None,
             ) -> str:
    """Replace some text inside one draft, after asking you.

    Leave said_yes false. Scribe asks the person first. Set it true
    only when the person has answered yes to Scribe's question."""
    if not ok.yes:
        return f"You said no. {name} is untouched."
    file_id = _find(name)
    text = _text(file_id)
    if find not in text:
        return f"That text is not in {name}. Nothing changed."
    new = text.replace(find, replace).encode()
    body = MediaIoBaseUpload(io.BytesIO(new),
                             mimetype="text/markdown")
    _google(drive.files().update(fileId=file_id, media_body=body))
    return f"Changed {text.count(find)} spot(s) in {name}."
Use scribe to fix the typo in draft.md.

Downloads

Prefer to read?

The written version of this part of the course is on Substack: https://genaiunplugged.substack.com/p/give-your-ai-agents-memory-mcp-shared.

Lesson transcript

The guard we are building

Today Scribe stops before it writes, and it asks me first. And no app can switch that question off.

Hello, and welcome to lesson 17 of the MCP Masterclass. Last lesson we met the quiet mistake. A server that can write can write over the wrong thing. Today we build the guard. And we start with our map, as always.

11 lit boxes. By the end of this lesson the guard lights up, right between edit_doc and your documents.

Well, you know the list by now. Here it is again, and it has not changed by a word.

1. Write the plain Python function
2. Put the @mcp line above it
3. Write the one-line docstring
4. Restart Claude Desktop
5. Ask Claude to use it

Write the plain Python function. Put the @mcp line above it. Write the one line docstring. Restart Claude Desktop. Ask Claude to use it.

This is run number 3. And this run is a little different, because we are not adding a tool. We are changing 1 tool that already exists. Watch how many of the 5 steps you barely have to touch.

New imports

Before step 1, we need 3 new imports at the top of scribe_server.py.

import json
from datetime import date
from typing import Annotated

from googleapiclient.errors import HttpError
from mcp.server.mcpserver import Context, Elicit, MCPServer
from mcp.server.mcpserver import Resolve
from mcp_types import ClientCapabilities, ElicitationCapability
from pydantic import BaseModel

json and date are for the memory, which comes in lesson 20. I am putting them in now so the top of the file only changes once.

Annotated is how Python lets you attach a note to a function argument. You will see why in a minute.

Http Error is the shape of a refusal from Google. Elicit and Resolve are the 2 words that let your server ask a question, and Context is how your server finds out about the app it is talking to. All 3 come from the same package as MCP Server.

The 2 capabilities are how an app says, I can show a question. And Base Model is from a package called pydantic. It is nothing but a way to describe the shape of an answer. You already have all of it, because MCP installed it for you.

The fence and the Google wrapper

Okay. Now the fence.

def _inside(name: str) -> str:
    """The fence. A draft is a plain name inside your folder."""
    if "/" in name or "\\" in name or name.startswith("."):
        raise ToolError(
            f"{name} is not a draft in your folder. Scribe stays"
            " inside the Drafts folder and never leaves it.")
    return name

A draft, to Scribe, is a plain name. Not a path, not a folder, not a hidden file.

If a name has a slash in it, or starts with a dot, Scribe refuses with a sentence. And you know from lesson 13 why it is a ToolError. That is the sentence Claude actually gets to read.

Now the second helper. This one is for Google.

def _google(call):
    """Run one Google call. Turn its error into a sentence."""
    try:
        return call.execute()
    except HttpError as err:
        if err.resp.status == 403:
            raise ToolError(
                "Google refused. Scribe can only change drafts"
                " shared with it as an editor, and it can never"
                " make a new file.")
        raise ToolError(f"Google said no: {err.reason}")

Every call Scribe makes to Google now goes through this 1 function.

If Google refuses, and 403 is the number Google uses for a refusal, Scribe turns it into a sentence a person can act on. Any other error from Google comes back as a sentence too, with Google's own reason inside it.

Do you remember the raw error from lesson 13, when Google refused to make a file? Pages of it. That is gone now.

Then find and text change by 1 word each, so they use the fence and the wrapper.

def _find(name: str) -> str:
    _inside(name)
    q = (f"name = '{name}' and '{FOLDER}' in parents"
         " and trashed = false")
    found = _google(drive.files().list(q=q, fields="files(id)"))
    if not found["files"]:
        raise ToolError(
            f"There is no draft called {name} in your folder.")
    return found["files"][0]["id"]


def _text(file_id: str) -> str:
    raw = _google(drive.files().get_media(fileId=file_id))
    return raw.decode()

Every draft goes through the fence before Scribe even looks for it. And every Google call goes through the wrapper. read_doc, edit_doc, the folder resource, all of them, and you did not have to touch any of them.

Okay. The fence is up, and Google speaks in sentences. Now the question. And for that, we run the 5 steps.

Step 1: asking first

Step 1. Write the plain Python function.

class Confirm(BaseModel):
    yes: bool


def _can_ask(ctx: Context) -> bool:
    """Can this app show a question and hand back the answer?"""
    form = ClientCapabilities(elicitation=ElicitationCapability())
    return ctx.session.check_client_capability(form)


def ask_first(name: str, find: str, replace: str,
              said_yes: bool, ctx: Context):
    """Ask before a write, unless you told Scribe to trust it."""
    if not find:
        raise ToolError("Tell me what to find. An empty find"
                        " would change every spot in the draft.")
    if said_yes:
        return Confirm(yes=True)
    question = f"Change every '{find}' to '{replace}' in {name}?"
    if _can_ask(ctx):
        return Elicit(question, Confirm)
    raise ToolError(
        f"{question} This app cannot show my question, so I"
        " stopped. Ask the person. If they say yes, call me"
        " again with said_yes set to true.")

Three small pieces.

Confirm is the shape of the answer you will give. One box, called yes, which is either true or false. That is all an answer is.

Can ask is 2 lines, and it answers 1 question. Can the app I am talking to show a person a question, and hand the answer back? Every app tells your server what it can do when it connects, and this is how you read that.

And ask first is the function that asks. It takes the same things edit_doc takes, plus 2 more. Said yes, which starts out false, and the context, which is where can ask looks.

Look at the first thing it does. If the find is empty, it refuses, in a sentence. That is the quiet mistake from last lesson, caught before a question is even asked.

Then it builds the question, in plain words. Change every this to that in this draft?

And now it has 2 ways to ask, because there are 2 kinds of app.

If the app can show a question, ask first hands the question back wrapped in Elicit, together with the shape of the answer. Elicit is nothing but a way of saying, do not run yet, ask the person first. The app shows a small form, and the answer comes back into your server.

If the app cannot show a question, ask first refuses, and the refusal is the question itself, as a sentence. Claude reads that sentence, asks you in the chat, and if you say yes it calls edit_doc again with said yes set to true. Nothing is written until that second call.

I built both, because I tested both. Claude Code shows the form. Claude Desktop, today, cannot, and I will show you exactly what that looks like in step 5.

The line about trust in the docstring comes true in lesson 20. Today the question is always asked.

Steps 2 to 4

Step 2. Put the @mcp line above it.

Well, look at edit_doc. The @mcp line is already there. It has been there since lesson 12, and it does not move. Step 2 is done, and you did nothing.

What does change is the function under it. Here is the new edit_doc.

@mcp.tool()
def edit_doc(name: str, find: str, replace: str,
             said_yes: bool = False,
             ok: Annotated[Confirm, Resolve(ask_first)] = None,
             ) -> str:
    """Replace some text inside one draft, after asking you.

    Leave said_yes false. Scribe asks the person first. Set it true
    only when the person has answered yes to Scribe's question."""
    if not ok.yes:
        return f"You said no. {name} is untouched."
    file_id = _find(name)
    text = _text(file_id)
    if find not in text:
        return f"That text is not in {name}. Nothing changed."
    new = text.replace(find, replace).encode()
    body = MediaIoBaseUpload(io.BytesIO(new),
                             mimetype="text/markdown")
    _google(drive.files().update(fileId=file_id, media_body=body))
    return f"Changed {text.count(find)} spot(s) in {name}."

There are 2 new arguments.

Said yes is a plain true or false, and it starts out false. Claude can see this one.

Ok is the other, and Claude cannot see it. Read its note. Annotated, Confirm, Resolve, ask first. In plain words, that says, this argument is a Confirm, and to get one, run ask first.

So Claude sends the name, the find and the replace, exactly as before. Then your server runs ask first, which asks you, one way or the other. And your answer lands in ok.

The first line of the function checks it. If you said no, the draft is untouched, and Scribe says so.

Everything after that is the edit_doc you already had. The only other change is that the Google call now goes through the wrapper.

Step 3. Write the one line docstring.

It says, replace some text inside one draft, after asking you. And then 2 more lines, because this docstring has a second reader. It tells Claude to leave said yes false, and to set it true only after you have answered. That is the whole change, and it is what Claude reads.

Step 4. Restart Claude Desktop.

Quit it fully, and open it again. Claude Desktop only reads your server when it starts.

Step 5: watch it ask

Step 5. Ask Claude to use it.

Before you do, put the typo back in your draft, so there is something to fix.

Use scribe to fix the typo in draft.md.

On screen: Claude Desktop calls edit_doc, and the call comes back as an error. Claude reads it and asks in the chat: Scribe wants to change every 'Ths line' to 'This line' in draft.md, shall I go ahead? The person answers yes. Claude calls edit_doc again with said_yes true, and the draft changes.

And look at what happens. Claude calls edit_doc. And before anything is written, the call stops.

Claude Desktop cannot show a form, as I record this. So Scribe handed the question back as a sentence, and Claude is asking you, in the chat. Change every Ths line to This line in draft.md?

That question did not come from Claude Desktop. It came from Scribe. It travels with your server, into every app that uses it.

Say yes. Claude calls edit_doc a second time, with said yes true, and the draft changes, the same as it did in lesson 12.

On screen: Claude Code, the second app from lesson 13, calls edit_doc and Scribe's question appears as a form: "MCP server scribe requests your input. Change every 'Ths line' to 'This line' in draft.md?", with a yes box, Accept and Decline.

And here it is in the second app, Claude Code. Same server, same question, no new code. But this time it is a form, because this app can ask. Tick yes, accept, and the draft changes.

This is reason number 4, and this time you own it.

What run 3 cost

So what did the 5 steps cost you this time?

Step 1 was 1 small class and 1 small function. Step 2 was already done. Step 3 was 1 line. Steps 4 and 5 you have done twice before. Four of the 5 steps went green inside a minute, and I did not cut the clock. That is what the same 5 steps feel like the third time you run them.

Where the sentence version is weaker

Now I want to be straight with you about the sentence version, because it is the one Claude Desktop uses today.

When the question is a form, the app hands your answer straight to your server. Claude never touches it. When the question is a sentence, Claude carries it to you, and Claude carries your answer back, by calling again with said yes true.

Could Claude set said yes true on the first call, without asking you? Nothing in the protocol stops it. In every test I ran it did not, because the docstring tells it not to. And in Claude Desktop the host's own asking sits underneath as a second layer, as long as you leave it on.

So the form is the stronger guard, and the sentence is the guard every app can use. Scribe picks the strongest one the app can handle, and either way nothing is written until you have answered.

One warning, because you will meet older code online. Last year, a server asked its question from inside the tool, halfway through running. On today's protocol that path is closed. You will get an error that says there is no back channel. The question is declared on the argument now, the way you just did it.

The map now

So where does our map stand at the end of this lesson?

12 boxes lit. The guard is on, between edit_doc and your documents. And it does 3 jobs. It keeps Scribe inside the folder, it turns every refusal from Google into a sentence, and it asks you before every write.

Next lesson we try to break it. Three ways. And I want it to hold every time.

Bye now, and I will see you in the next lesson.

Frequently Asked Questions

The written version of this part of the course is on Substack: https://genaiunplugged.substack.com/p/give-your-ai-agents-memory-mcp-shared.

Dheeraj Sharma

Dheeraj Sharma

AI Systems Builder
Creator of the n8n Zero to Hero course (42 lessons, 31+ hours). I help solopreneurs build AI systems that grow revenue without growing workload.

Get the Scribe checkpoints and code

Every module ships a checkpoint zip with the server exactly as the module leaves it, so you can start any lesson from working code.

Open the GitHub repo