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:

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:

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:

Tool execution

call_tool(tool_name, arguments) is what the agent executor uses inside its chat loop. The flow:

  1. Look up the owning service in _tool_to_service. Unknown tool ⇒ return '{"error": "Tool \'…\' not found"}'.
  2. Delegate to MCPServiceConnection.call_tool(), which is just a thin wrapper around ClientSession.call_tool().
  3. Extract result.content[*].text joined by newlines when available; otherwise stringify the raw result.
  4. On any exception, log with logger.exception and 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