MCP Integration
The project uses the mcp Python library directly to
manage Model Context Protocol stdio servers — there is no framework
wrapper. The implementation lives in
src/persona_agent/mcp/direct_mcp.py.
DirectMCPManager
DirectMCPManager owns:
- An
AsyncExitStackthat holds every successfully connected stdio session, so all of them are closed cleanly on shutdown. - A
dict[str, MCPServiceConnection]keyed by service name. - A flat tool registry
dict[str, str]mapping tool name to service name; tool names must therefore be unique across services. - A precomputed OpenAI tool list (
list[dict]) used by the LLM client.
The manager is created in create_app() and stored on the
FastAPI app state. lifespan() calls
load_config(mcp_config_path) once at startup and
close() at shutdown.
Config
config/mcp_config.json accepts two section names:
mcpServers— current name, used by the MCP CLI and most other hosts.services— legacy name kept for backwards compatibility. Entries must declaretype: "stdio"(default) andenabled: true(default).
Each entry supports:
{
"command": "uvx", // required
"args": ["mcp-server-fetch"], // optional, defaults to []
"env": { "FOO": "${FOO}" }, // optional; merged onto os.environ
"disabled": false, // optional skip flag
"description": "human-readable hint"
}
Environment variable substitution
${VAR_NAME} patterns inside command, every
args entry, and every env value are
substituted from os.environ at load time. Missing variables
expand to an empty string — set defaults in your shell or your
deployment unit when a value is required, or guard against it inside the
upstream MCP server.
The server process inherits the entire parent os.environ
(so PATH works out of the box) with the entry’s
env map merged on top.
Connection lifecycle
_connect_service(name, command, args, env) retries up to
MAX_RETRIES = 3 times. Each attempt uses a local
AsyncExitStack so a half-initialized session is closed
cleanly on failure; ownership is transferred to the shared exit stack
only after the session successfully calls initialize() and
list_tools() returns a non-empty list.
Services that report zero tools are dropped — their stdio process is
already terminated by the local stack — and
_connect_service returns False. The startup
continues with whichever services did connect.
After a successful connection, every tool is added to:
_all_tools— rawmcp.types.Toolobjects._tool_to_service— flat lookup table._openai_tools— OpenAI function-calling schema generated bymcp_tools_to_openai_functions().
Tool execution
call_tool(tool_name, arguments) is what the agent
executor uses inside its chat loop. The flow:
- Look up the owning service in
_tool_to_service. Unknown tool ⇒ return'{"error": "Tool \'…\' not found"}'. - Delegate to
MCPServiceConnection.call_tool(), which is just a thin wrapper aroundClientSession.call_tool(). - Extract
result.content[*].textjoined by newlines when available; otherwise stringify the raw result. - On any exception, log with
logger.exceptionand return a generic JSON error string — never raise into the LLM loop, since that would abort the entire chat turn.
The returned string is appended to the conversation as a
role: "tool" message keyed by the original
tool_call_id, exactly matching the OpenAI tool-calling
contract.
Schema conversion
mcp_tools_to_openai_functions(tools) converts each MCP
tool to:
{
"type": "function",
"function": {
"name": "<tool name>",
"description": "<tool description, or 'MCP tool: <name>' if missing>",
"parameters": "<tool.inputSchema, or empty object schema>"
}
}When a tool has no inputSchema, the manager injects
{"type": "object", "properties": {}} so OpenAI-compatible
providers don’t reject the request.
Shutdown
close() aborts the shared AsyncExitStack,
which terminates every stdio subprocess and cancels their stream
readers. The manager state is reset so the same instance could be
re-initialized if needed (though the server only does this once per
process).
Operational tips
- If you depend on a particular tool, log the
MCP initialized: N services, M toolsline at startup and check/health’smcp_toolsfield. - Tools surfaced by failing MCP servers won’t appear — the executor simply doesn’t get them. Persona behavior degrades silently to “no tools available.”
- Long-lived stdio sessions can drift if a server hangs. Restarting the API process re-establishes them cleanly.