MCPManager connects to local or remote Model Context Protocol servers and discovers their tools. Pass the manager to Agent(mcp=...): the discovered tools are bridged into the agent's registry at construction, so the execution engine validates and authorizes them like any local tool.

Connect a server

Install the MCP extra first. A stdio server's executable must be installed and available to the Python process.

python -m pip install "harnessx[mcp]"
import asyncio
from harnessx import Agent, CliPermissionManager, MCPManager, MCPServerConfig

async def main():
    async with MCPManager() as mcp:
        await mcp.connect(MCPServerConfig.stdio(
            "files", "npx", args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
        ))
        async with Agent(mcp=mcp, permissions=CliPermissionManager()) as agent:
            print("MCP tools:", agent.mcp_tools)
            result = await agent.run("List the files in /tmp.")
            result.raise_for_status()
            print(result.output)

asyncio.run(main())

A remote server is the same code with a different config:

import os

await mcp.connect(MCPServerConfig.http(
    "remote", "http://localhost:8000/mcp",
    headers={"Authorization": f"Bearer {os.environ['MCP_TOKEN']}"},
))

MCPServerConfig.stdio(name, command, args=, env=, permission=) describes a subprocess server and MCPServerConfig.http(name, url, headers=, permission=) a remote one, so the transport is chosen by constructor. connect() also accepts the keyword form, connect("files", command="npx", args=[...]) or connect("remote", url=...), which builds the same config. A config with both or neither of command and url raises ConfigurationError. The name is yours to choose; it keys the connection and prefixes every bridged tool.

Pass the endpoint the server documents: modern servers expose streamable HTTP, commonly at /mcp; older ones expose SSE, commonly at /sse. The manager attempts streamable HTTP and falls back to SSE on the same URL; list_servers() reports which one connected. Compatibility depends on the installed MCP client and server; qualify the endpoint you plan to use.

async with MCPManager() as mcp: disconnects every server on exit. Connect servers before constructing the agent: Agent(mcp=mcp) bridges the tools discovered at that moment and lists their registered names on agent.mcp_tools. Connecting a server name twice raises ValueError.

Several servers connect in one call with connect_all, concurrently by default; it returns the discovered tools by server name. Servers that connected stay connected when others fail, and the failures are raised together as an ExceptionGroup:

tools = await mcp.connect_all([
    MCPServerConfig.http("tavily", "https://mcp.tavily.com/mcp", permission="allow"),
    MCPServerConfig.http("revenue", "https://datagol.example/mcp", permission="allow"),
])
print({name: len(found) for name, found in tools.items()})

Keep credentials on the server. Connecting a subprocess authorizes launching that program; tool approval applies when its discovered tools are invoked.

Tool names and permissions

Tools are registered with server-qualified, sanitized names such as files_read_file. The MCP tool policy defaults to ASK, and a direct ASK call without an approval callback is denied, so the examples above supply a CLI approval manager. Use agent.mcp_tools when your application needs per-tool overrides:

from harnessx import PermissionLevel

def require_approval(agent):
    for name in agent.mcp_tools:
        agent.permissions.set_permission(name, PermissionLevel.ASK)
    return agent.mcp_tools

permission=PermissionLevel.ALLOW on the server config, or on connect(), sets a per-server default instead. External tools remain subject to the same execution-path checks as local tools. manager.register_tools(registry) bridges tools into a ToolRegistry that was not constructed with the manager.

Retry policies for MCP tools

MCPServerConfig.http() and .stdio() accept replay_policy and retry as server-wide defaults. They default to "manual" and None, and manual prevents automatic retries. retry=None uses ToolRetry(attempts=3, backoff_seconds=1.0, retry_error_results=True), with the default 30-second cap on one wait. Choose safe only for a server whose operations are safe to repeat, and idempotent when the integration enforces deduplication. See Retries & fallback models for the execution rules.

Lifecycle

MCP connections are caller-owned, like provider= and memory=. Agent.aclose() does not disconnect the manager; leave the async with MCPManager() block, or call await mcp.disconnect_all(), during application shutdown and handle transport failures.