Live MCP server instructions that survive a fresh clone

Local Fitness · No. 020

Shipped

v0.11.0 of the fitness agent closed a voice gap: the selectable coach persona already shaped the MCP slash-command prompts, but when Claude Code answered a fitness question by calling the MCP tools directly, no persona was in context and the reply came back in the default tone. Now the MCP server advertises the resolved persona as its server-level instructions, re-resolved on every client connect, so the tool-driven path picks up the active coach too. The transferable part is the mechanism: how to serve a dynamic value in a field most servers treat as static, without breaking a fresh clone’s first start. Here is the whole build, small enough to run in a scratch directory.

The field that carries a persona

The MCP handshake starts with an initialize request, and the server’s response can include an optional instructions string alongside its capabilities (MCP spec: Lifecycle). The schema describes the field as instructions for using the server that the client can use to improve the model’s understanding, adding that “this information MAY be added to the system prompt” (MCP schema: InitializeResult). That MAY matters and comes back in the gotchas, but the shape is right for a persona: one string, delivered at connect, scoped to the connection.

In the Python SDK, the low-level Server takes instructions as a constructor argument and copies it into the options object that create_initialization_options() returns, which both the stdio and HTTP transports call at connect time (MCP Python SDK: lowlevel server). The constructor argument is the trap here. Pass a static string and you have frozen the persona at whatever moment the server object was built. The rest of this post moves that resolution to the one place it belongs, the connect itself.

You need Python 3.10+, then pip install mcp pytest in a fresh directory (this build ran on SDK 1.28).

A settings store the server reads live

The persona choice lives in a SQLite settings table, one key-value row. The only structural decision worth calling out is that init_schema is a function you call at startup, never something that runs when the module is imported.

# store.py
import os
import sqlite3
from pathlib import Path

def db_path() -> Path:
    return Path(os.environ.get("PERSONA_DB", "app.db"))

def connect() -> sqlite3.Connection:
    return sqlite3.connect(db_path())

def init_schema() -> None:
    # Runs at app STARTUP, never at import. Everything below depends on that.
    with connect() as conn:
        conn.execute(
            "CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)"
        )

def get_setting(key: str, default: str | None = None) -> str | None:
    with connect() as conn:
        row = conn.execute(
            "SELECT value FROM settings WHERE key = ?", (key,)
        ).fetchone()
    return row[0] if row else default

def set_setting(key: str, value: str) -> None:
    with connect() as conn:
        conn.execute(
            "INSERT INTO settings (key, value) VALUES (?, ?) "
            "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
            (key, value),
        )

The Google Python style guide states the hazard plainly: “All code at the top level will be executed when the module is imported” (Google Python Style Guide: Main). In a web app that mounts an MCP server, the server object is often built at module import, before the app’s lifespan hook has created any tables. Any DB read that happens during that build is a read against a schema that does not exist yet.

Wrap the handshake, not the constructor

That import-versus-startup gap is exactly what bit this release’s first design. The obvious implementation set instructions eagerly while building the server, and review caught that on the HTTP path the build runs at import, before schema init, so a fresh clone would die on its first start with no such table: settings. The fix that shipped resolves the persona inside create_initialization_options instead, by wrapping the method on the instance. Build stays pure, and every connect re-reads the live setting.

# server.py
from mcp import types
from mcp.server.lowlevel import Server

import store

PERSONAS = {
    "hardass": "You are a blunt, demanding coach. No hedging. Push for one hard thing today.",
    "supportive": "You are a warm, encouraging coach. Celebrate progress, suggest gently.",
}

def resolve_instructions() -> str | None:
    """Resolve the active persona from live settings. Called at CONNECT time."""
    name = store.get_setting("persona", "supportive")
    return PERSONAS.get(name)

def install_live_instructions(server: Server, resolve) -> None:
    """Re-resolve server instructions on every client connect, fail-open."""
    orig = server.create_initialization_options

    def with_live_instructions(*args, **kwargs):
        try:
            server.instructions = resolve()
        except Exception:
            server.instructions = None  # fail-open: never break the handshake
        return orig(*args, **kwargs)

    server.create_initialization_options = with_live_instructions

def build_server() -> Server:
    server = Server("persona-demo")  # note: no instructions= here

    @server.list_tools()
    async def list_tools() -> list[types.Tool]:
        return [types.Tool(
            name="todays_focus",
            description="What should I focus on today?",
            inputSchema={"type": "object", "properties": {}},
        )]

    @server.call_tool()
    async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
        return [types.TextContent(type="text", text="One quality session, then rest.")]

    install_live_instructions(server, resolve_instructions)
    return server

The except Exception that swallows everything is a deliberate policy, not laziness. For a voice feature, the safe degraded state is a plain voice; a handshake that errors out is strictly worse than a persona that goes missing. Any failure in the resolve path, including the fresh-clone case where the table is not there yet, advertises instructions=None and lets the connection proceed.

The entry point does the startup work the modules refused to do at import.

# main.py  ->  python main.py
import anyio
from mcp.server.stdio import stdio_server

import store
from server import build_server

async def run() -> None:
    store.init_schema()  # startup: the table exists before any connect resolves it
    server = build_server()
    async with stdio_server() as (read, write):
        await server.run(read, write, server.create_initialization_options())

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

Connect a client and flip the voice

You can watch the field arrive without any MCP-capable editor, using the SDK’s own client. This script spawns the server over stdio and prints what the initialize response carried.

# check_handshake.py  ->  python check_handshake.py
import anyio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main() -> None:
    params = StdioServerParameters(command="python", args=["main.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            result = await session.initialize()
            print("instructions:", result.instructions)

anyio.run(main)

Run it once, then change the setting and run it again. This is real output from the four files above:

$ python check_handshake.py
instructions: You are a warm, encouraging coach. Celebrate progress, suggest gently.
$ python -c "import store; store.init_schema(); store.set_setting('persona', 'hardass')"
$ python check_handshake.py
instructions: You are a blunt, demanding coach. No hedging. Push for one hard thing today.

The second run picked up the new persona with no restart and no client-side change, because each connect spawns the process fresh and the wrap re-resolves regardless. For a real editor or desktop client, the config is the usual stdio stanza, and nothing in it mentions the persona:

{
  "mcpServers": {
    "persona-demo": {
      "command": "python",
      "args": ["main.py"],
      "env": { "PERSONA_DB": "/home/me/app.db" }
    }
  }
}

Lock the two decisions in with tests

The two properties worth defending forever are the fail-open miss and the live re-resolution. Both are one-liners to regress, so both get a test.

# test_instructions.py  ->  pytest test_instructions.py -q
from mcp.server.lowlevel import Server

import store
from server import build_server, install_live_instructions

def _use_tmp_db(tmp_path, monkeypatch):
    monkeypatch.setenv("PERSONA_DB", str(tmp_path / "app.db"))

def test_missing_table_fails_open(tmp_path, monkeypatch):
    # Fresh DB, init_schema never ran: the handshake must still succeed.
    _use_tmp_db(tmp_path, monkeypatch)
    opts = build_server().create_initialization_options()
    assert opts.instructions is None  # no persona, no crash

def test_instructions_change_between_connects(tmp_path, monkeypatch):
    # The regression guard: live per-connect, not cached at build.
    _use_tmp_db(tmp_path, monkeypatch)
    store.init_schema()
    server = build_server()
    store.set_setting("persona", "hardass")
    first = server.create_initialization_options().instructions
    store.set_setting("persona", "supportive")
    second = server.create_initialization_options().instructions
    assert "blunt" in first
    assert "encouraging" in second

def test_resolver_error_fails_open():
    server = Server("bare-demo")

    def boom():
        raise RuntimeError("db down")

    install_live_instructions(server, boom)
    opts = server.create_initialization_options()  # must not raise
    assert opts.instructions is None
$ pytest test_instructions.py -q
...                                                                      [100%]
3 passed in 0.31s

The first test is the fresh-clone guard: it points the store at a database where init_schema never ran and demands a clean handshake anyway. The second fails the moment someone caches the resolution at build. The third proves the fail-open branch by handing the wrap a resolver that always raises.

Gotchas

  • Eager resolution at construction breaks a fresh clone. This release’s first design set instructions while building the server, and review caught it before merge: on the HTTP path the server builds at module import, ahead of the lifespan hook that creates the schema, so the first start from a clean checkout crashes on a missing table. The escape is the wrap above, and the rule behind it is no DB I/O at import.
  • Every entry point needs the startup step, not just the main one. The HTTP app initialized the schema in its lifespan, but the separate stdio command did not, and fail-open masked the difference: a fresh clone’s first stdio connect silently ran without a persona instead of erroring. The symptom is a feature that works on one transport and quietly no-ops on another. This release added the schema init to the stdio command for parity; main.py above bakes it in.
  • The wrap is race-free only while it stays synchronous. It mutates one shared attribute on the server and then delegates, and concurrent connects on a stateless HTTP transport all run through it. That is safe because create_initialization_options is synchronous end to end, so the set and the snapshot happen in one frame. Introduce an await between setting server.instructions and calling the original, say an async DB read, and you open a real race where one client is greeted with another client’s persona. The release’s design doc records this as an invariant; treat it as one in your version too.
  • The client is allowed to ignore you. The spec only says the client MAY fold instructions into the system prompt, and at least one report showed a major client storing the field without ever reading it (claude-code issue #43749). Treat this channel as best-effort: keep a guaranteed path for the behavior you care about (here, an explicit MCP prompt the user can invoke), and let instructions be the upgrade that lights up when the client honors it.

Sources

Changelog

  • feat: coach profile carries into tool-driven Claude Code chat (MCP instructions, 0.11.0) (780c9cb)
  • docs: revise MCP-persona design per quality-gate (2 rounds + look-harder, 4->0) (fbf3b44)
  • docs: design for coach persona as MCP server instructions (559c2e9)