# MCPServer.Manager Singleton GenServer that bridges HTTP/WebSocket/A2A callers to the MCP server callbacks. Every MCP operation -- tool calls, resource reads, tool/resource listing -- flows through this process. **File:** `lib/mcp_server_manager.ex` **Registered name:** `MCPServer.Manager` **Supervision:** started by `Mcp.Supervisor` (one_for_one) --- ## Why It Exists `MCPServer` implements `Hermes.Server` callbacks but those callbacks expect a `Hermes.Server.Frame` state struct with user context injected. The Manager: 1. Holds the long-lived MCP server state (Frame with counter, assigns, etc.) 2. Injects the authenticated user into the Frame per-request via `Auth.AuthContext.for_user!/1` 3. Cleans up user context from the Frame after each request (prevents leaking between users) 4. Wraps tool calls in telemetry spans (`MCP.Telemetry.span_tool_call/3`) for audit logging Without the Manager, each caller would need to manage its own Frame state and handle user injection/cleanup -- duplicating logic across the router, chat, A2A server, and MCP client. --- ## Client API ### `call_tool(name, arguments, user_id)` Calls an MCP tool by name with the given arguments, loading the user from the database. ```elixir {:ok, result} = MCPServer.Manager.call_tool("add_numbers", %{"a" => 5, "b" => 3}, user_id) # result => %{content: [%{type: "text", text: "Result: 8"}]} {:error, %{code: -32000, message: "Unauthorized: Platform"}} = MCPServer.Manager.call_tool("increment_counter", %{}, unprivileged_user_id) ``` ### `call_tool_as(name, arguments, user)` Calls a tool with a pre-loaded user map -- skips the database lookup. Used when the caller already has the authenticated user with permissions (e.g., A2A server where the router already resolved the user). ```elixir user = %{id: 123, username: "james", permissions: %{10001 => true}, timezone: "Asia/Ho_Chi_Minh"} {:ok, result} = MCPServer.Manager.call_tool_as("get_greeting", %{"name" => "World"}, user) ``` ### `list_tools(user_id)` Returns MCP tool definitions filtered by the user's permissions. Tools the user can't access are excluded. ```elixir {:ok, %{tools: tools}} = MCPServer.Manager.list_tools(user_id) # tools => [%{name: "add_numbers", description: "...", inputSchema: %{...}}, ...] ``` ### `list_resources(user_id)` Returns MCP resource definitions filtered by the user's permissions. ```elixir {:ok, %{resources: resources}} = MCPServer.Manager.list_resources(user_id) ``` ### `read_resource(uri, user_id)` Reads an MCP resource by URI. ```elixir {:ok, result} = MCPServer.Manager.read_resource("file:///config/settings.json", user_id) # result => %{contents: [%{uri: "...", mimeType: "application/json", text: "..."}]} ``` --- ## Internal Flow ``` Caller (Router / ServerChat / A2AServer / MCPClient) │ ▼ Manager.call_tool("tool_name", args, user_id) │ ├── load_user(user_id) → Auth.AuthContext.for_user!(user_id) │ loads user + merged permissions from AccessControl │ ├── Frame.assign(:current_user, user) → injects user into MCP state │ ├── MCP.Telemetry.span_tool_call() → wraps in telemetry span for audit │ │ │ └── MCPServer.handle_tool_call(name, args, state) │ │ │ ├── get_permissions(state) → extracts permissions from Frame assigns │ └── Permissions.*.function(perms, ...) → permission-gated business logic │ ├── cleanup: delete :current_user from Frame assigns │ └── return {:ok, result} or {:error, error} ``` --- ## Callers | Caller | Function Used | Context | |--------|---------------|---------| | `MCPServer.Router` | `call_tool/3`, `list_tools/1`, `list_resources/1`, `read_resource/2` | HTTP JSON-RPC and SSE endpoints | | `ServerChat` | `call_tool/3`, `list_tools/1` | LLM tool-use loop during chat | | `WhatsAppClaude` | `call_tool/3`, `list_tools/1` | WhatsApp auto-responder AI | | `A2AServer` | `call_tool_as/3` | Agent-to-agent protocol (pre-loaded user) | | `MCPClient` | `call_tool/3`, `list_tools/1`, `list_resources/1`, `read_resource/2` | Per-user MCP client GenServer | --- ## State Shape ```elixir %{ mcp_state: %Hermes.Server.Frame{ assigns: %{counter: 0}, # persistent MCP state (counter, etc.) ... }, current_user: nil # always nil between requests } ``` The `current_user` field in the manager state is vestigial -- user context is injected into and removed from the Frame's assigns per-request, never stored between calls. --- ## Key Design Decisions **Single process for all MCP calls.** All tool calls serialize through one GenServer. This is intentional -- the MCP Frame state (counter, etc.) is mutable and shared. The bottleneck is acceptable because tool calls are I/O-bound (database queries, HTTP requests), not CPU-bound. **User cleanup after every call.** The `:current_user` assign is deleted from the Frame after each request to prevent one user's context from leaking into another's request. This is critical for security in a multi-user system. **Two call_tool variants.** `call_tool/3` loads the user from the database on every call (fresh permissions). `call_tool_as/3` skips the DB lookup when the caller already has a resolved user -- used by A2A where the router already authenticated. **Telemetry wrapping.** Tool calls are wrapped in `MCP.Telemetry.span_tool_call/3` which emits `:telemetry` events consumed by `ToolAudit` for persistent audit logging. Resource reads and list operations are not audited. --- ## IEx Usage ```elixir # Call a tool directly {:ok, result} = MCPServer.Manager.call_tool("monitor_health", %{}, user_id) # List available tools for a user {:ok, %{tools: tools}} = MCPServer.Manager.list_tools(user_id) Enum.map(tools, & &1.name) # Check the process Process.whereis(MCPServer.Manager) # => #PID<0.450.0> ``` --- *Last updated: 2026-03-01*