Back to Agno

MCP

cookbook/05_agent_os/14_mcp/README.md

3.0.212.2 KB
Original Source

MCP

AgentOS can expose its agents, teams, and workflows as an MCP server at /mcp. These examples cover the server side of that boundary: the default operator surface, agents served directly as tools, custom tools, toolkits, PAT authentication, tool scoping, and two OAuth deployment choices. Examples where an Agno agent consumes another MCP server belong in cookbook/91_tools/mcp.

Files

FileWhat it teaches
basic.pyServe the eight default AgentOS MCP tools.
agents_as_tools.pyTurn the default tools off and expose agents directly as named MCP tools.
mcp_client.pyDiscover, pause, continue, cancel, and inspect runs with a protocol-level client.
custom_tools.pyDisable the default tools and expose one purpose-built tool.
toolkit_tools.pyServe a whole toolkit, flattened into one MCP tool per method.
secure_mcp.pyMint a PAT, authorize its principal, restrict hosts and tool tags, and return full results.
oauth_builtin.pyRun AgentOS's database-backed OAuth authorization server.
oauth_authkit.pyUse WorkOS AuthKit as an external authorization server.

Prerequisites

Install the MCP extras through the demo environment and set the model key:

bash
./scripts/demo_setup.sh
export OPENAI_API_KEY=...

The examples use the current mcp= / MCPConfig API. The deprecated spellings (mcp_server=, MCPServerConfig, enable_builtin_tools) are still accepted as silent aliases.

Default MCP tools

Plain mcp=True exposes eight tools:

TagTools
coreget_agentos_config, run_agent, run_team, run_workflow, continue_run, cancel_run
sessionget_sessions, get_session_runs
lifecyclecontinue_run, cancel_run (also tagged core) -- the pair rides along whenever components are exposed; include the tag explicitly to serve just the pair

Run the server and client in separate terminals:

bash
.venvs/demo/bin/python cookbook/05_agent_os/14_mcp/basic.py
.venvs/demo/bin/python cookbook/05_agent_os/14_mcp/mcp_client.py

The client calls the tools directly. It continues one confirmation-required run, cancels a second paused run, and reads the continued session from SQLite. Run tools return a trimmed result by default: answer content plus run_id, session_id, status, and unresolved requirements when paused.

Agents as tools

agents_as_tools.py serves the deployment's agents as the whole MCP surface:

python
agent_os = AgentOS(
    agents=[chief, researcher],
    mcp=MCPConfig(
        default_tools=False,
        tools=[
            chief,
            researcher.as_tool(
                name="deep_research",
                description="Thorough, sourced research. Send one clear question.",
            ),
        ],
    ),
)

tools/list then shows chief and deep_research, plus the riding continue_run/cancel_run pair (see the HITL section). A bare component is named after its id and described by its own description; as_tool(name=..., description=...) publishes it under a model-facing name and pitch instead -- a tool description is a prompt for the calling model, so it often wants to be different from the component's human-facing description. Either way the call runs through the same machinery as run_agent (fresh session minting, RBAC scopes such as agents:run -- keyed on the component id, not the tool name -- per-step progress), and the result's structuredContent carries the component id for continue_run/get_sessions. Teams and workflows expose the same way. Exposed components must be part of the AgentOS roster, and tool-name collisions fail at startup.

Tool names (a bare component's id, or the as_tool override) must start with a letter or underscore and contain only letters, digits, hyphens, and underscores (at most 128 characters) -- the shape OpenAI, Anthropic, and Gemini all accept. A name outside that shape fails at startup with a suggested clean one; auto-derived ids from names like "Research & Writing Team" are the usual trip. Pick MCP-safe ids before a deployment accumulates sessions: sessions and memories are keyed by the id, so changing it later is a migration. The exposed tool list is fixed at startup: components added to a live deployment (resync) are immediately runnable through the generic run tools where those are served, but appear as named tools only after a restart -- and with default_tools=False a component added after boot is unreachable over MCP until the restart (the riding continue_run/cancel_run are bounded to the components published at build time).

HITL works out of the box: whenever components are exposed, continue_run and cancel_run ride along -- even with default_tools=False -- so a run that pauses on a confirmation-required tool is resumable over MCP (the paused result's structuredContent carries the component id, run_id, session_id, and requirements that continue_run needs). The riding pair only acts on runs of the published components: on an exposure-only server, runs of roster components you left off tools= cannot be resumed or cancelled over MCP. Set lifecycle_tools=False for a tools/list that shows exactly the configured tools; paused runs then say to resume over the REST API.

Custom and scoped surfaces

custom_tools.py passes an Agno @tool through MCPConfig(tools=[...]) and sets default_tools=False, leaving a single client-visible tool.

toolkit_tools.py passes a whole Toolkit instead of a single tool. AgentOS flattens it into one MCP tool per method -- MemoryTools(enable_think=False, enable_analyze=False) becomes get_memories, add_memory, update_memory, and delete_memory -- the way an agent takes a toolkit apart. Each flattened name goes through the same collision check as a hand-written custom tool, so a toolkit method named like a default tool (WorkflowTools really does register run_workflow) fails at startup instead of silently replacing it. Narrow the published set with the toolkit's own include_tools / exclude_tools.

Toolkit methods take framework arguments: every MemoryTools method declares run_context: RunContext. Those are kept out of the client-facing schema and filled server-side at call time, so add_memory publishes just memory and topics while its body still receives a context carrying the authenticated caller. This is not only about tidiness -- pydantic cannot build a schema for a RunContext, so a visible one would stop the server from starting. The same rule hides Agent- and Team-typed arguments, which arrive as None because an MCP call runs outside any component. Media arguments stay visible: nothing on this surface has run media to inject, so hiding one would leave it fillable by nobody. So does a toolkit method's own user_id -- agno fills identity from the RunContext, never by that name, so a user_id argument on a toolkit method is a domain value (ZoomTools asks which account to read) and stays the caller's to send. user_id on a tool you wrote for this surface is still filled from the JWT subject, as before.

The identity in that RunContext is only as good as the deployment's authorization: without AgentOS(authorization=True, ...) there is no JWT subject, the resolved caller is None, and every client shares one identity. Configure authorization before serving a toolkit whose data is per-user.

This server runs each tool call directly, so a toolkit's connect() and close() never fire and every call is handed a fresh RunContext. The shipped connection-managing toolkits (PostgresTools, RedshiftTools) connect themselves on use and are unaffected. A toolkit whose state is keyed on the run is not: CodeMode keys its kernel by session_id, so over MCP it would start a kernel per call and accumulate nothing. Serve that one over REST, where a run owns the session.

A tool whose approval gate this surface cannot honour is refused at startup rather than published without it. requires_confirmation, requires_user_input and external_execution all live in the call path an MCP request bypasses, so Workspace(root=".") -- whose delete_file and run_command are confirmation-gated -- fails fast and names the knob that frees it. Serve the read-only surface (allowed=["read", "list", "search"]) instead.

secure_mcp.py demonstrates the full security configuration:

  • include_tags={"core", "session"} followed by exclude_tags={"session"} leaves the six core tools.
  • result_mode="full" returns the complete run object for programmatic clients.
  • allowed_hosts=[] in the default environment enables host and Origin validation with only the built-in localhost allowances. Set MCP_ALLOWED_HOSTS=agentos.example.com for a deployment or tunnel.
  • authorize= receives the authenticated principal and rejects callers outside the sa:secure-mcp-client-* integration namespace before a tool or model runs.

Set a root key, then run the server and its client in separate terminals:

bash
export OS_SECURITY_KEY=$(openssl rand -base64 32)
.venvs/demo/bin/python cookbook/05_agent_os/14_mcp/secure_mcp.py

.venvs/demo/bin/python cookbook/05_agent_os/14_mcp/secure_mcp.py --client

The client authenticates POST /service-accounts with OS_SECURITY_KEY, receives the one-time agno_pat_ value, and passes it to FastMCP as a bearer token. The PAT resolves to sa:<account-name>; that verified identity is what the authorize callback sees. This uses a synchronous OS-level SqliteDb because service accounts live on AgentOS(db=...), not merely on an agent-attached database. See ../07_security/service_accounts.py for mint, scope, and revocation details.

Claude Desktop and stdio-only clients

Store the PAT outside the JSON file and bridge the remote streamable-HTTP server with mcp-remote:

json
{
  "mcpServers": {
    "agentos": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://agentos.example.com/mcp",
        "--header",
        "Authorization:${AUTH_HEADER}"
      ],
      "env": {
        "AUTH_HEADER": "Bearer agno_pat_replace_me"
      }
    }
  }
}

Clients with native remote-MCP support can send the same Authorization: Bearer agno_pat_... header directly.

OAuth connectors

Claude.ai and ChatGPT custom connectors use OAuth rather than a pasted bearer token. Both OAuth examples pass an AuthProvider object through mcp_auth=. Unauthenticated /mcp requests receive an RFC 9728 challenge, while discovery is served at /.well-known/oauth-protected-resource/mcp.

Built-in authorization server

oauth_builtin.py uses AgentOSBuiltinAuth.from_env():

bash
export AGENTOS_URL=https://agentos.example.com
export MCP_CONNECT_SECRET=$(openssl rand -base64 32)
export AGENTOS_MCP_SIGNING_KEY=$(openssl rand -base64 32)  # optional
.venvs/demo/bin/python cookbook/05_agent_os/14_mcp/oauth_builtin.py

AGENTOS_URL must be the public origin the connector reaches. MCP_CONNECT_SECRET must contain at least 16 characters. The optional signing key must contain at least 32 high-entropy characters; otherwise AgentOS generates and persists one. SQLite is suitable for this local lesson. Production should pass a synchronous PostgresDb at the AgentOS level so OAuth clients, codes, signing keys, and rotating refresh tokens survive restarts and are shared by replicas. Async databases and agent-only databases cannot back the built-in authorization server.

The built-in server owns /register, /authorize, /token, /revoke, and /mcp-auth/consent, along with its OAuth metadata routes. Paste the public https://agentos.example.com/mcp URL into the connector and enter MCP_CONNECT_SECRET on the consent page.

WorkOS AuthKit

oauth_authkit.py leaves authorization to an AuthKit tenant:

bash
export AUTHKIT_DOMAIN=https://your-tenant.authkit.app
export AGENTOS_URL=https://agentos.example.com
.venvs/demo/bin/python cookbook/05_agent_os/14_mcp/oauth_authkit.py

Enable Dynamic Client Registration in AuthKit, register the public /mcp resource indicator, and emit AgentOS scopes in the token's scope or scp claim. A token carrying only openid, profile, and email authenticates but cannot call the AgentOS tools; typical connector scopes are config:read, agents:run, teams:run, workflows:run, and sessions:read.