Handing an AI client a persona over MCP, and learning to resolve it late
Shipped
v0.11.0 taught the fitness agent’s coach voice to follow me into Claude Code. I had wired the selectable coach persona into the two fitness slash commands and called it done, but the other way I use the data, asking Claude Code a question and letting it call the tools, had no voice at all. It answered in the default tone, because nothing told it I had picked a coach. Now the fitness MCP server hands the persona to the client as server-level instructions, resolved fresh on every connect. Pick hardass, and the tool-driven answers read hardass too.
The feature is small. The part worth writing about is the mechanism: how an MCP server hands a client a persona at all, why that read has to happen at connect time rather than import time, and what to fall back to when the lookup misses. Here is the end-to-end build.
Setup: a coach profile to resolve
One prerequisite before any of this: pip install mcp (SDK 1.x), and pip install pytest for the tests at the end. SQLite ships with Python, so there is nothing else to add.
The persona lives in SQLite next to the rest of the fitness data. One table holds the available coaches and a flag for the active one, and a thin accessor returns whichever profile is active. This is the value the server will eventually advertise to the client.
# fitness/db.py
import os
import sqlite3
from dataclasses import dataclass
SCHEMA = """
CREATE TABLE IF NOT EXISTS coach_profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
persona_prompt TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 0
);
"""
@dataclass
class CoachProfile:
name: str
persona_prompt: str
def connect() -> sqlite3.Connection:
# One place resolves the DB path, so the server and the tests open the same file.
return sqlite3.connect(os.environ.get("FITNESS_DB", "fitness.db"))
def init_schema(conn: sqlite3.Connection) -> None:
conn.executescript(SCHEMA) # runs at app startup, not at import
def get_active_coach_profile(conn: sqlite3.Connection) -> CoachProfile | None:
row = conn.execute(
"SELECT name, persona_prompt FROM coach_profiles WHERE is_active = 1 LIMIT 1"
).fetchone()
return CoachProfile(row[0], row[1]) if row else None
The detail that matters for everything below is the ordering: init_schema runs when the app starts, which is a moment after the Python modules finish importing. Any code that calls get_active_coach_profile before startup is reading a table that does not exist yet.
Handing a persona over MCP
The Model Context Protocol is the open standard Anthropic introduced for connecting models to tools and data (Introducing MCP). The hook I leaned on lives in the connection handshake. When a client initializes a session, the server’s response can include an optional instructions field, described in the spec as guidance for the client to use during the session (MCP: Lifecycle). That is the right shape for a persona. Instead of the voice living only inside my slash commands, the server advertises it at connect time and it rides along with the connection.
In the Python SDK, FastMCP takes that instructions value and the low-level server returns it inside the initialize response via create_initialization_options (MCP Python SDK). The naive version passes a string straight into the constructor.
# fitness/server.py (the version that looks fine and is wrong)
from mcp.server.fastmcp import FastMCP
from fitness import db
# BUG: this runs at import, before init_schema has created the table.
profile = db.get_active_coach_profile(db.connect()) # reads a missing table -> crash
mcp = FastMCP("fitness", instructions=profile.persona_prompt if profile else None)
This crashes a fresh clone before anything runs, because the module imports before the schema exists. Not breaking the fresh-clone start is the one rule this repo has, so the read cannot happen here.
Resolve at connect, not at import
The fix is not to special-case startup. It is to move when the read happens, from module load to the moment a client connects. I wrap the lookup in a resolver that returns None on any failure, then override create_initialization_options so the resolver runs on every initialize rather than once.
# fitness/server.py
from contextlib import closing
from mcp.server.fastmcp import FastMCP
from mcp.server.lowlevel import Server
from mcp.server.models import InitializationOptions
from fitness import db
def server_instructions() -> str | None:
"""Resolve the active persona lazily, on each connect, and fail safe to None."""
try:
with closing(db.connect()) as conn: # closing() so the read can't leak a connection
profile = db.get_active_coach_profile(conn) # schema exists by connect time
except Exception:
return None # a missing voice is fine; a crashed connect is not
return profile.persona_prompt if profile else None
class FreshInstructionsServer(Server):
def create_initialization_options(self, *args, **kwargs) -> InitializationOptions:
opts = super().create_initialization_options(*args, **kwargs)
opts.instructions = server_instructions() # recomputed for every initialize
return opts
def build_server() -> FastMCP:
mcp = FastMCP("fitness") # no instructions read at construction
# Private-API seam (MCP SDK 1.x): FastMCP wraps a low-level Server on _mcp_server, and
# swapping its class is the smallest way to override create_initialization_options. This
# reaches past the public surface; on the 2.0 line the class was renamed and the attribute
# moved to _lowlevel_server, so confirm against your installed version and prefer a
# supported hook if the SDK exposes one.
mcp._mcp_server.__class__ = FreshInstructionsServer
return mcp
Building FastMCP with no instructions keeps import time cheap and side-effect free, which is the property that lets a fresh clone start. The override then runs server_instructions during each connection’s initialize. For a stdio client that spawns a new process per connection the read is naturally fresh; for a long-running HTTP server the override is what keeps it fresh per session. Deferring the read past startup carried a bonus I did not plan: the value is now live. Change the active profile and the next connection picks it up with no restart, so the staleness limitation I was about to write down as an accepted tradeoff simply disappeared.
The “schema exists by connect time” claim rests on the entry point, which creates the table at startup before serving anything. That ordering is the whole reason the deferred read is safe.
# fitness/transports.py -> python -m fitness.transports
from fitness import db
from fitness.server import build_server
def main() -> None:
db.init_schema(db.connect()) # startup: create the table before any connect resolves it
build_server().run() # serve MCP over stdio (FastMCP's default transport)
if __name__ == "__main__":
main()
Failing safe when the lookup misses
The except that returns None is doing real work, not papering over a bug. It encodes the fail-safe defaults principle from Saltzer and Schroeder, generalized past access control: when something goes wrong, fall back to the safe state rather than the broken one (The Protection of Information in Computer Systems). For a voice feature the safe state is easy to name. A missing personality is a perfectly fine degraded experience, while a connection that errors out is not. There is a second reason to design for the miss: the spec says instructions is optional and whether the client applies it is the client’s call, so this is a best-effort channel rather than a contract. That is why the slash command stays the guaranteed path for the coach voice and the MCP route is the bonus that lights up when the client honors the server’s instructions. A None return at either end, a failed read or a client that ignores the field, lands in the same harmless place: a plain voice.
Use it from a client
For a desktop or editor MCP client, point it at the stdio entry point and let the client launch the process (MCP: Connect to local servers). Nothing in the config mentions the persona, because the persona is resolved server-side at connect.
{
"mcpServers": {
"fitness": {
"command": "python",
"args": ["-m", "fitness.transports"],
"env": { "FITNESS_DB": "/home/me/fitness.db" }
}
}
}
On connect, the client’s initialize response now carries the active persona in its instructions field. With the hardass profile active, the relevant slice of that response looks like this, and a client that honors it shifts the tone of every tool-driven answer to match.
{
"protocolVersion": "2025-03-26",
"instructions": "You are a hardass strength coach. Be blunt, no hedging, push for one hard thing today."
}
Switch the active row to a gentler coach, reconnect, and the next initialize carries the new text. No restart, no client-side change.
Verify it
The two decisions worth defending are testable: the persona resolves at connect rather than at import, and the resolver fails safe. Both became regression tests so the deferral and the fallback cannot quietly rot.
# tests/test_persona_instructions.py
import sqlite3
from contextlib import closing
from fitness import db, server
def test_build_does_not_read_db(monkeypatch):
# Building the server must not touch the DB; a fresh clone has no schema yet.
def boom(*a, **k):
raise AssertionError("connect() called at construction")
monkeypatch.setattr(db, "connect", boom)
server.build_server() # must not raise
def test_instructions_resolve_at_connect(tmp_path, monkeypatch):
db_path = tmp_path / "fitness.db"
# The server resolves the persona through db.connect(), so the test's write and the
# server's read have to land in the same file; point connect() at it for both.
monkeypatch.setattr(db, "connect", lambda: sqlite3.connect(db_path))
with closing(db.connect()) as conn:
db.init_schema(conn)
conn.execute(
"INSERT INTO coach_profiles (name, persona_prompt, is_active) VALUES (?, ?, 1)",
("hardass", "Be blunt."),
)
conn.commit() # without the commit the row never reaches the server's connection
mcp = server.build_server()
opts = mcp._mcp_server.create_initialization_options()
assert opts.instructions == "Be blunt."
def test_failed_lookup_fails_safe(monkeypatch):
monkeypatch.setattr(db, "connect", lambda: (_ for _ in ()).throw(RuntimeError))
assert server.server_instructions() is None # safe state, not an exception
The first test fails the moment someone moves the profile read back into construction. The second proves the persona actually reaches the initialize response. The third fails if anyone removes the except that turns a bad read into a plain voice.
Next
The honest caveat is the one from the spec: whether Claude Code applies server instructions is the client’s decision, so the next step is confirming in a real session that the tool-driven tone actually shifts, with the slash command as the guaranteed path either way.
Sources
- Anthropic: Introducing the Model Context Protocol — what MCP is and why it exists.
- MCP Specification: Lifecycle — the optional
instructionsfield a server returns at initialize, applied at the client’s discretion. - MCP Python SDK — FastMCP and the low-level server’s
create_initialization_options, where theinstructionsvalue is produced. - MCP: Connect to local servers — pointing a desktop client at a local stdio server with command and args.
- Saltzer & Schroeder: The Protection of Information in Computer Systems — fail-safe defaults: fall back to the safe state.