Skip to content

Add YEA to your MCP server (Python) ​

You have an MCP server, and one of its tools does something a person should see first: it deletes a file, sends an email, or moves money. This guide adds YEA to that tool. Before the tool runs, the server asks the person, through their client, to approve its plan by typing a phrase. The model can't approve it through the tool call. What else must stay out of the agent's reach, such as your client's config, is in the security model.

Steps 1–3 give you that tool, and need no keys, no policy and no account. Steps 4–6 are separate, later steps: undo, letting safe things run on their own, and clients that can't ask.

The code on this page comes from python/mcp/examples/mcp_quickstart.py, a small files server that the test suite runs. It uses the official Python MCP SDK (MCPServer); FastMCP works the same way.

1. Install ​

Unreleased: install from the repo

yea-mcp and yea-sdk aren't on PyPI yet. Until they are, install both from the repo into your server's project (yea-mcp depends on yea-sdk, so both come from git):

sh
uv add "yea-sdk @ git+https://github.com/yea-protocol/yea#subdirectory=python" \
       "yea-mcp @ git+https://github.com/yea-protocol/yea#subdirectory=python/mcp"

Steps 5 and 6 also use the yea command, which ships with the TypeScript packages. From a clone: git clone https://github.com/yea-protocol/yea ~/yea && cd ~/yea && npm ci && npm run build, then alias yea="node $HOME/yea/cli/bin/yea.js".

yea-mcp works with the official mcp SDK 2.2 or later and Python 3.10 or later.

2. Guard one tool you already have ​

Here is the whole change. delete_file is registered exactly as before, and one approvals.guard(...) call turns it into a tool that shows its plan and asks. The snippets below, in order, are a complete server.py:

py
import os
import sys
import uuid
from collections.abc import Mapping
from pathlib import Path

from mcp.server.mcpserver import MCPServer
from yea import Plan

from yea_mcp import Approvals, yea

The example's tools only touch files inside one folder, so a mistaken or hostile path can't reach anything else, including the server's own key and approval store. That folder comes from FILES_ROOT, which must be set and must not hold the server's own files:

py
def inside(root: str, path: str) -> Path:
    """``path`` inside ``root``. Anything that leads outside is refused, so the tools can't reach
    the server's own files (its key, its approval store) or yours; so is a symlink as the file
    itself, so the plan the person approves always names the file that changes."""
    base = Path(root).resolve(strict=True)
    if (base / path).is_symlink():
        raise ValueError(f"{path} is a symlink")
    full = (base / path).resolve(strict=True)
    if full == base or base not in full.parents:
        raise ValueError(f"{path} is outside {base}")
    return full


def files_root(env: Mapping[str, str] = os.environ, this: str = __file__) -> str:
    """The folder the tools may change: ``FILES_ROOT``, which must exist and must not hold this
    server's own files, or ``delete_file`` could remove them."""
    hint = 'set FILES_ROOT to a folder of files the tools may change, such as "$HOME/yea-scratch"'
    if not env.get("FILES_ROOT"):
        raise ValueError(f"FILES_ROOT is not set: {hint}")
    root = Path(env["FILES_ROOT"]).resolve()
    if not root.is_dir():
        raise ValueError(f"FILES_ROOT {root} doesn't exist: create it first")
    own = Path(this).resolve()
    if root == own or root in own.parents:
        raise ValueError(f"FILES_ROOT {root} holds this server's own files: {hint}")
    return str(root)
py
def add_delete_file(server: MCPServer, approvals: Approvals, root: str) -> None:
    """Your existing tool, registered as usual, then guarded."""

    @server.tool(description="Delete a file for good")
    def delete_file(path: str):
        inside(root, path).unlink()
        return f"deleted {path}"

    def describe(args: dict) -> dict:
        path = str(args.get("path"))
        if not inside(root, path).is_file():  # refuse before anyone is asked
            raise ValueError(f"{path} is not a regular file")
        return {"summary": f"Delete {path}", "effects": [{"op": "delete", "target": f"file/{path}"}]}

    # One call: now it shows its plan and asks the person before it runs.
    approvals.guard(server, "delete_file", describe=describe,
                    # The person types the file's name to approve.
                    confirm_with=lambda plan, args: Path(str(args.get("path"))).name)
  • describe(args) says what the call will do: a one-line summary and its effects. It must not change anything. Its args are the raw arguments, before the tool validates them, so convert fields with str(...).
  • confirm_with names the phrase the person types, here the file's name. Without it, the phrase is approve. It must be typeable: a phrase with control or invisible characters, or a tab or other unusual space inside, is refused as a tool error.
  • guard takes the tool's name. It installs YEA's check on that server, so a guard can't be forgotten or attached to the wrong one.

Build the server. Leave out the add_move_to_trash line until step 4:

py
def create_server(approvals: Approvals, root: str) -> MCPServer:
    """The server, with both tools. ``root`` is the only folder the tools touch."""
    # request_state_security lets YEA seal the approval state it sends round the client.
    server = MCPServer("files", request_state_security=approvals.request_state_security())
    add_delete_file(server, approvals, root)
    add_move_to_trash(server, approvals, root)  # step 4
    return server

request_state_security lets YEA seal the approval state it sends round the client. Then check FILES_ROOT (the server refuses to start with a message saying what to set), create the approval context once per process, and serve:

py
try:
    root = files_root()
except ValueError as e:
    sys.exit(f"files: {e}")  # refuse to start
approvals = yea(name="files", transport="stdio")  # once per process
create_server(approvals, root).run("stdio")
Show the full file
py
"""A small `files` MCP server with YEA approval: the example in the "Add YEA to your MCP server"
guide for Python (site/guide/mcp-python.md). The guide shows its regions in order, and
python/mcp/tests/test_quickstart.py drives them with in-memory clients.

    FILES_ROOT=~/yea-scratch uv run python examples/mcp_quickstart.py
"""

# region imports
import os
import sys
import uuid
from collections.abc import Mapping
from pathlib import Path

from mcp.server.mcpserver import MCPServer
from yea import Plan

from yea_mcp import Approvals, yea

# endregion imports


# region root
def inside(root: str, path: str) -> Path:
    """``path`` inside ``root``. Anything that leads outside is refused, so the tools can't reach
    the server's own files (its key, its approval store) or yours; so is a symlink as the file
    itself, so the plan the person approves always names the file that changes."""
    base = Path(root).resolve(strict=True)
    if (base / path).is_symlink():
        raise ValueError(f"{path} is a symlink")
    full = (base / path).resolve(strict=True)
    if full == base or base not in full.parents:
        raise ValueError(f"{path} is outside {base}")
    return full


def files_root(env: Mapping[str, str] = os.environ, this: str = __file__) -> str:
    """The folder the tools may change: ``FILES_ROOT``, which must exist and must not hold this
    server's own files, or ``delete_file`` could remove them."""
    hint = 'set FILES_ROOT to a folder of files the tools may change, such as "$HOME/yea-scratch"'
    if not env.get("FILES_ROOT"):
        raise ValueError(f"FILES_ROOT is not set: {hint}")
    root = Path(env["FILES_ROOT"]).resolve()
    if not root.is_dir():
        raise ValueError(f"FILES_ROOT {root} doesn't exist: create it first")
    own = Path(this).resolve()
    if root == own or root in own.parents:
        raise ValueError(f"FILES_ROOT {root} holds this server's own files: {hint}")
    return str(root)


# endregion root


# region guard
def add_delete_file(server: MCPServer, approvals: Approvals, root: str) -> None:
    """Your existing tool, registered as usual, then guarded."""

    @server.tool(description="Delete a file for good")
    def delete_file(path: str):
        inside(root, path).unlink()
        return f"deleted {path}"

    def describe(args: dict) -> dict:
        path = str(args.get("path"))
        if not inside(root, path).is_file():  # refuse before anyone is asked
            raise ValueError(f"{path} is not a regular file")
        return {"summary": f"Delete {path}", "effects": [{"op": "delete", "target": f"file/{path}"}]}

    # One call: now it shows its plan and asks the person before it runs.
    approvals.guard(server, "delete_file", describe=describe,
                    # The person types the file's name to approve.
                    confirm_with=lambda plan, args: Path(str(args.get("path"))).name)


# endregion guard


# region job
def add_move_to_trash(server: MCPServer, approvals: Approvals, root: str) -> None:
    """A job written for YEA: the undoable way to get rid of a file."""

    def revert(receipt: dict, ctx) -> None:
        file, trashed = receipt["result"]["file"], receipt["result"]["trashedAs"]
        # Fails, and changes nothing, if a new file took the old one's place. It needs hard links,
        # which some filesystems (exFAT, some network mounts) don't have: undo fails there.
        os.link(trashed, file)
        os.unlink(trashed)

    # With revert and an undo window, the plan is undoable: YEA adds an `undo` tool.
    @approvals.job(server, risk="low", revert=revert)
    def move_to_trash(path: str) -> list[Plan]:
        """Move a file to the trash. It can be undone for a day."""
        file = inside(root, path)  # the plan only reads
        if not file.is_file():  # revert can't put back a directory, so refuse one here
            raise ValueError(f"{path} is not a regular file")

        def apply() -> dict:
            trash = file.parent / ".trash"
            trash.mkdir(exist_ok=True)
            trashed = trash / f"{uuid.uuid4()}-{file.name}"
            file.rename(trashed)
            return {"file": str(file), "trashedAs": str(trashed)}  # what revert gets back as `result`

        return [Plan(f"Move {path} to the trash", [{"op": "update", "target": f"file/{path}", "detail": "moved to .trash"}],
                     apply=apply, undo_window=86_400)]  # one day, in seconds


# endregion job


# region serve
def create_server(approvals: Approvals, root: str) -> MCPServer:
    """The server, with both tools. ``root`` is the only folder the tools touch."""
    # request_state_security lets YEA seal the approval state it sends round the client.
    server = MCPServer("files", request_state_security=approvals.request_state_security())
    add_delete_file(server, approvals, root)
    add_move_to_trash(server, approvals, root)  # step 4
    return server


# endregion serve

if __name__ == "__main__":
    # region start
    try:
        root = files_root()
    except ValueError as e:
        sys.exit(f"files: {e}")  # refuse to start
    approvals = yea(name="files", transport="stdio")  # once per process
    create_server(approvals, root).run("stdio")
    # endregion start

3. Run it in your client ​

The tools only act inside FILES_ROOT, so give them a folder of files you can lose. The server refuses to start without it, or when it holds the server's own files, since delete_file could then remove your project's files, such as server.py and pyproject.toml. Make the folder, with a file for step 3 to delete and one for step 4 to move to the trash:

sh
mkdir -p "$HOME/yea-scratch/docs"
echo "Q3 report" > "$HOME/yea-scratch/docs/report.pdf"
echo "meeting notes" > "$HOME/yea-scratch/docs/notes.txt"

Then, in Claude Code, from your server's project folder:

sh
claude mcp add files -e FILES_ROOT="$HOME/yea-scratch" -- uv run python server.py
Cursor and VS Code

Cursor, in .cursor/mcp.json (project) or ~/.cursor/mcp.json (global):

json
{
  "mcpServers": {
    "files": {
      "command": "uv",
      "args": ["run", "--directory", "/absolute/path/to/project", "python", "server.py"],
      "env": { "FILES_ROOT": "/absolute/path/to/yea-scratch" }
    }
  }
}

VS Code, in .vscode/mcp.json. The top-level key is servers:

json
{
  "servers": {
    "files": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "--directory", "${workspaceFolder}", "python", "server.py"],
      "env": { "FILES_ROOT": "/absolute/path/to/yea-scratch" }
    }
  }
}

Ask the agent to delete docs/report.pdf. Before anything happens, the server sends the client a form (MCP form elicitation) with the plan and a field to type in. How a client shows it is up to the client; we've tested the exchange with the MCP SDK's own client, not yet in a named app. What it says is this:

text
Approval needed: delete_file can't be undone.

[1] Delete docs/report.pdf
  - delete file/docs/report.pdf
  risk: medium · undo: never
  to approve, type: report.pdf

Confirm: Type "report.pdf" to approve.

Type report.pdf and the file is deleted. Decline, or type something else three times, and nothing is. That's the tool guarded.

Three things to know at this point:

  • The server's stderr warns yea: no pinned principal key (YEA_PRINCIPAL_PUB is not set): nothing auto-runs and no consent is accepted. That's expected: it only matters for steps 5 and 6.
  • The client must support form elicitation to show the form. The client table says which clients we've run and what their vendors document. A client without it gets step 6's consent codes instead.
  • A plan at risk: "high", or an outOfBand rule in ~/.yea/policy.json, is never approved in the chat. It goes to consent codes, which need step 5's pinned key.

4. Make it undoable ​

Deleting can't be undone, so it always asks. Moving a file to a trash folder can, and YEA can offer undo for it. A job is a tool written for YEA from the start: its function returns plans, and each plan's apply does the work.

py
def add_move_to_trash(server: MCPServer, approvals: Approvals, root: str) -> None:
    """A job written for YEA: the undoable way to get rid of a file."""

    def revert(receipt: dict, ctx) -> None:
        file, trashed = receipt["result"]["file"], receipt["result"]["trashedAs"]
        # Fails, and changes nothing, if a new file took the old one's place. It needs hard links,
        # which some filesystems (exFAT, some network mounts) don't have: undo fails there.
        os.link(trashed, file)
        os.unlink(trashed)

    # With revert and an undo window, the plan is undoable: YEA adds an `undo` tool.
    @approvals.job(server, risk="low", revert=revert)
    def move_to_trash(path: str) -> list[Plan]:
        """Move a file to the trash. It can be undone for a day."""
        file = inside(root, path)  # the plan only reads
        if not file.is_file():  # revert can't put back a directory, so refuse one here
            raise ValueError(f"{path} is not a regular file")

        def apply() -> dict:
            trash = file.parent / ".trash"
            trash.mkdir(exist_ok=True)
            trashed = trash / f"{uuid.uuid4()}-{file.name}"
            file.rename(trashed)
            return {"file": str(file), "trashedAs": str(trashed)}  # what revert gets back as `result`

        return [Plan(f"Move {path} to the trash", [{"op": "update", "target": f"file/{path}", "detail": "moved to .trash"}],
                     apply=apply, undo_window=86_400)]  # one day, in seconds
  • The decorated function returns the plans. It must not change anything: it runs on preview and on every retry. Its parameters are the tool's input, as with @server.tool().
  • apply() runs at most once, and only after the plan is approved or allowed.
  • revert plus an undo_window make the plan undoable. The first job with revert adds an undo tool to the server.

Ask the agent to move docs/notes.txt to the trash. After approval, the call returns a receipt:

text
✓ Move docs/notes.txt to the trash (receipt r_Ny6CTkeKuDVI) · undo until 2026-09-29T02:37:30Z
  result:
    file: /Users/me/yea-scratch/docs/notes.txt
    trashedAs: /Users/me/yea-scratch/docs/.trash/b0974c8d-9a52-4c19-acd0-ccb8bc0cef3f-notes.txt

The plan refuses anything that isn't a regular file, such as a directory, since revert couldn't put it back. undo(receipt="r_Ny6CTkeKuDVI") puts the file back, within the window. Undo doesn't ask, because it restores what the person already approved changing. Any job call also takes "preview": true, which returns the plans and does nothing.

5. Let safe things run on their own ​

So far everything asks. A signed policy lets undoable plans run without asking: for example, move_to_trash, at low risk, for the next seven days. How the policy works covers what one can limit and what happens past it.

The policy is only as strong as the place where your private principal key lives: whoever can use that key can sign any policy. So there are two setups.

The real setup: the key on another OS user or device ​

  1. On the other user or device, create the principal key and copy its public key:

    sh
    yea init
    yea whoami          # principal ed25519:…
  2. On the server's machine, pin that public key where the server's user can't change it:

    sh
    sudo mkdir -p /etc/yea && echo 'ed25519:…' | sudo tee /etc/yea/principal.pub

    The server refuses a pinned key file that its own user could change, or replace through a directory above it.

  3. Get the server's id from its start-up line on stderr (or approvals.service_id() in your code):

    text
    yea: service id ed25519:… (name files)
  4. On the key's side, sign a policy for that id:

    sh
    yea grant --to ed25519:… --can move_to_trash --risk low --exp 7d

    It prints only the pg1. token on stdout. Copy it into a file on the server's machine, for example /Users/me/.config/yea/files.policy.

  5. In the client config, point the server at both:

    sh
    claude mcp add files \
      -e FILES_ROOT="$HOME/yea-scratch" \
      -e YEA_PRINCIPAL_PUB=/etc/yea/principal.pub \
      -e YEA_POLICY="$HOME/.config/yea/files.policy" \
      -- uv run python server.py

    ~ isn't expanded in client config files, so use a full path there, or $HOME in a shell as above. The policy is read on every call, so a new one applies without a restart.

Now move_to_trash runs straight away and returns its receipt. delete_file still asks.

Trying it on one account (best effort) ​

The same steps, with yea init and yea grant run on this account:

sh
yea init && yea whoami
sudo mkdir -p /etc/yea && echo 'ed25519:…' | sudo tee /etc/yea/principal.pub
mkdir -p "$HOME/.config/yea"
yea grant --to ed25519:… --can move_to_trash --risk low --exp 7d > "$HOME/.config/yea/files.policy"

For trying YEA, not for protecting anything

The principal key is now in ~/.yea on the account the agent runs as. An agent that can run commands or read files, as Claude Code can, could sign its own policy. Move the key to another OS user or device before you rely on it.

What a policy can't do ​

  • Only undoable plans run on their own. That's why the grant names the step-4 job. The step-2 guard never auto-runs unless its describe returns an undo_window and it has a revert: without them there's no way back from a mistake, so the person decides each time.
  • The person signs the policy, not the agent. The server trusts only the pinned key, and the agent never holds the private key in the real setup. Unsigned settings in ~/.yea/policy.json can only tighten it, and a file there that can't be read refuses every job until it's fixed.
  • A plan over the policy's risk, a tool it doesn't name, or an expired policy all go back to asking.

6. Clients that can't ask ​

Some clients don't support form elicitation. For them, a call that needs approval fails closed. The model gets the plan and a consent code, and nothing runs:

text
✗ approval needed: delete_file can't be undone; nothing was run
1 plan:
[1] Delete docs/report.pdf
  - delete file/docs/report.pdf
  risk: medium · undo: never
Ask the user to run `yea approve <code>` in their terminal, then call again.
  code for [1]: pc1.eyJjYXBhYmlsaXR5Ijoi…

yea approve <code> shows the plan, has the person type its phrase, and signs a one-time consent for that exact plan with the principal key. It saves the consent in the store the server reads (YEA_STORE, else $YEA_HOME/store, else ~/.yea/store). When the model calls again with the same input, the call runs once.

So yea approve needs both the principal key and the server's store. In the one-account setup, both are on your account, and that's the case our tests cover. With the key on another OS user, that user would run it on the server's machine with YEA_STORE set to the server's store; we haven't yet tested a store written by two OS users. A key on another device can't answer a consent code yet.

Why the pinned key matters here: the server accepts only consents signed by the pinned principal key. Without one it can't accept any, so it gives no codes and says why:

text
No consent can be accepted: YEA_PRINCIPAL_PUB is not set.

The same happens on a server whose store is in memory (the default over HTTP), because yea approve can't reach it.

FastMCP ​

yea-mcp takes a FastMCP 4 server wherever it takes an MCPServer: @approvals.job(mcp, ...) and approvals.guard(mcp, "tool_name", ...) work the same, with FastMCP("files", request_state_security=approvals.request_state_security()). Install the extra: yea-mcp[fastmcp] (until the release, "yea-mcp[fastmcp] @ git+https://github.com/yea-protocol/yea#subdirectory=python/mcp"). Guard a mounted server's tool on the server that defines it.

Where to next ​