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 local server

Install the MCP extra first. The command'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())

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.

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.

The MCP tool policy defaults to ASK. This terminal example deliberately supplies a CLI approval manager. Direct ASK calls without an approval callback are denied.

Connect a remote server

import asyncio
import os
from harnessx import Agent, CliPermissionManager, MCPManager, MCPServerConfig

async def main():
    async with MCPManager() as mcp:
        await mcp.connect(MCPServerConfig.http(
            "remote",
            "http://localhost:8000/mcp",
            headers={"Authorization": f"Bearer {os.environ['MCP_TOKEN']}"},
        ))
        async with Agent(mcp=mcp, permissions=CliPermissionManager()) as agent:
            print("MCP tools:", agent.mcp_tools)
            result = await agent.run("What tools do you have, and what does the first one do?")
            result.raise_for_status()
            print(result.output)

asyncio.run(main())

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.

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. 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.

Do not follow Agent(mcp=mcp) with an explicit mcp.register_tools(agent.tools): for the same manager it is a no-op, and a name already held by another tool raises ValueError. manager.register_tools(registry) remains available for a ToolRegistry that was not constructed with the manager.

Lifecycle and web setup

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.

In harness-web, General chat → Conversation setup supports local commands and remote URLs, discovered tool lists, and per-server Ask/Allow/Deny selection. Connections belong to that conversation and disappear on server restart.