Back to blog

Multi-Agent Architecture, Part 6: Integration — Secure Tool Access with MCP and Policies

March 21, 2026 · 8 min read

This is Part 6 of a series on multi-agent architecture. Read the series introduction for context on the full architecture, Part 5: Agents for how execution units are designed, or jump ahead to Part 7: External Tools.


Here is a scenario that keeps me up at night. You build a multi-agent customer support system. One agent handles billing inquiries. Another handles account management. Both need access to your internal APIs. You give them both the same set of tools because it is faster than thinking about permissions. Three weeks later, the billing agent — which should only read invoice data — calls the delete_account tool because a confused user asked it to "cancel everything." The LLM interpreted the instruction literally, had the capability available, and executed it.

This is not a hypothetical. In any system where agents have unrestricted tool access, it is a matter of time before a tool gets called in a context it was never designed for. The integration layer exists to prevent this. It is the permission boundary between what an agent can do and what it should do.

RoomKit treats tool access control as a first-class architectural concern: tool definitions follow a strict schema, policies enforce allow/deny rules at execution time, role overrides scope permissions per agent, and MCP provides a standardized protocol for connecting to external tool servers. None of this is optional in production.

The Problem: Tools Without Boundaries

Most agent frameworks treat tools as a flat list. You define functions, register them, and every agent in the system sees all of them. This works fine in a single-agent prototype. In a multi-agent system, it becomes a liability for three reasons.

First, blast radius. An agent with access to tools it does not need can cause damage that has nothing to do with its purpose. A search agent that can also write to the database is one hallucinated function call away from data corruption.

Second, prompt confusion. The more tools an agent sees, the harder it is for the LLM to pick the right one. I have seen agents call update_user_profile when they meant search_users simply because both appeared in a 40-tool list. Narrowing the visible tool set improves accuracy.

Third, credential exposure. If every tool shares the same runtime, a compromised or misbehaving agent can potentially access credentials intended for other tools. Isolation is not just about permissions — it is about limiting what is even reachable.

The Tool Protocol

Everything starts with how tools are defined. In RoomKit, a tool is any class that implements the Tool protocol: a .definition property (name, description, JSON Schema parameters) and an async .handler() method. The definition is the contract the LLM uses to generate valid function calls and that the policy engine uses to make access decisions.

class SearchOrders:
    @property
    def definition(self) -> dict:
        return {
            "name": "search_orders",
            "description": "Search customer orders by date range or status",
            "parameters": {
                "type": "object",
                "properties": {
                    "customer_id": {"type": "string"},
                    "status": {"type": "string", "enum": ["pending", "shipped", "delivered"]},
                },
                "required": ["customer_id"],
            },
        }

    async def handler(self, name: str, arguments: dict) -> str:
        orders = await db.search_orders(**arguments)
        return json.dumps([o.to_dict() for o in orders])

class DeleteAccount:
    @property
    def definition(self) -> dict:
        return {
            "name": "delete_account",
            "description": "Permanently delete a customer account and all data",
            "parameters": {
                "type": "object",
                "properties": {
                    "customer_id": {"type": "string"},
                    "confirmation": {"type": "string"},
                },
                "required": ["customer_id", "confirmation"],
            },
        }

    async def handler(self, name: str, arguments: dict) -> str:
        await db.delete_account(arguments["customer_id"])
        return json.dumps({"deleted": True})

The schema is not decorative. It drives validation, policy matching, and documentation generation. The policy engine matches tool names against allow/deny patterns — "search_orders" matches "search_*", while "delete_account" matches "delete_*". Tools are passed as instances to AIChannel(tools=[SearchOrders(), DeleteAccount()]).

MCPToolProvider: Connecting to External Tool Servers

Local tool handlers are fine for simple functions, but real systems integrate with external services: CRMs, databases, search engines, monitoring platforms. The Model Context Protocol (MCP) standardizes how agents discover and invoke tools hosted on remote servers.

RoomKit's MCPToolProvider connects to MCP-compliant servers over streaming HTTP or SSE transports. It fetches the tool catalog from the server and exposes the tools as standard Tool objects. You pass both local tools and MCP tools in the same tools list — the framework composes their handlers automatically.

from roomkit.tools import MCPToolProvider
from roomkit import AIChannel

# Connect to an MCP server that provides CRM tools
crm_mcp = MCPToolProvider(
    url="https://crm-tools.internal/mcp",
    transport="streamable_http",
)

# Mix local Tool objects with MCP tools in the same list
agent = AIChannel(
    "support-agent",
    provider=openai_provider,
    tools=[
        SearchOrders(),      # local Tool object
        ShippingStatus(),    # local Tool object
        *crm_mcp.tools(),    # MCP tools discovered from server
    ],
)

When the agent calls search_orders, the local handler runs. When it calls create_crm_ticket (discovered from the MCP server), the MCP handler forwards the call to the CRM server over the transport. The agent does not know or care where the tool lives. That is the point.

ToolPolicy: Allow/Deny at Execution Time

Giving agents access to tools is step one. Controlling which agents can call which tools is step two, and this is where most frameworks fall short. RoomKit enforces tool access through ToolPolicy, which uses glob patterns to define what is allowed and what is blocked.

Policies are evaluated at execution time, not at tool discovery. This is a deliberate design decision. The agent still sees the tool in its catalog (so the LLM knows it exists and can reference it in conversation), but if the agent tries to call a blocked tool, RoomKit intercepts the call and returns an error message to the AI. The agent learns that the tool is unavailable and adjusts its behavior.

from roomkit.tools import ToolPolicy

# Billing agent: can search and read, cannot modify or delete
billing_policy = ToolPolicy(
    allow=["search_*", "get_*", "list_*"],
    deny=["delete_*", "update_*", "create_*"],
)

# Admin agent: full access except destructive operations
admin_policy = ToolPolicy(
    allow=["*"],
    deny=["delete_account", "drop_*"],
)

The glob matching is straightforward: * matches any sequence of characters within a tool name. When both allow and deny patterns match a tool, deny wins. This follows the principle of least privilege — if there is any ambiguity, the tool is blocked.

One important exception: skill tools are never filtered by policy. The three built-in skill tools — activate_skill(), read_skill_reference(), and run_skill_script() — are always visible to every agent. Skills are RoomKit's mechanism for structured multi-step workflows, and blocking them would break the orchestration layer. Policy controls what domain tools an agent can invoke, not whether it can participate in the system's coordination protocol.

RoleOverride: Per-Agent Permission Scoping

Policies define the rules. RoleOverride applies them differently depending on who the agent is. This is where integration meets orchestration: different agents in the same room can have different tool permissions, enforced by their role.

RoleOverride supports two modes. Restrict mode intersects the role's tool set with the base policy — the agent can only use tools that both the role and the policy allow. This is a dual-constraint model, useful when you want a policy as a baseline and roles as further restrictions. Replace mode ignores the base policy entirely and uses only the role's tool set. This is useful for privileged roles like admin or supervisor that need capabilities outside the normal policy.

from roomkit.tools import ToolPolicy, RoleOverride

# Policy with role-specific overrides
policy = ToolPolicy(
    allow=["search_*", "get_*", "create_ticket"],
    deny=["delete_*"],
    role_overrides={
        # Tier-1 support: restrict mode — intersection with base policy
        "tier1_support": RoleOverride(
            allow=["search_*", "get_*"],
            mode="restrict",
        ),
        # Supervisor: replace mode — full access, bypasses base policy
        "supervisor": RoleOverride(
            allow=["*"],
            mode="replace",
        ),
    },
)

When the tier-1 support agent tries to call create_ticket, it is blocked: the base policy allows it, but the role override in restrict mode only permits search_* and get_*. The intersection is enforced. When the supervisor agent calls delete_account, it succeeds: replace mode bypasses the base policy entirely. Role overrides are defined inline on the ToolPolicy via the role_overrides dictionary, keyed by role name.

This layered model — base policy plus role overrides — means you define the security posture once and customize per agent without duplicating rules.

Secure Credential Handling

Tool access control is not just about which tools an agent can call. It is also about how credentials flow through the system. In RoomKit, provider credentials use SecretStr types. This is a small but critical detail: SecretStr values are masked in logs, exception tracebacks, and debug output. You will never see an API key in a stack trace or an accidentally logged configuration dump.

At the transport boundary, webhook signatures are verified before any message enters the system. Telnyx uses ED25519 signature verification. Twilio and Sinch use HMAC-SHA1. Each provider implements its own verification scheme, and RoomKit checks signatures at ingestion — before the payload is deserialized, before it reaches any tool handler, and long before any agent sees it. A forged webhook never becomes a tool invocation.

Putting It Together: The Integration Boundary

The integration layer is where security and functionality intersect. Here is the mental model: tools flow inward from MCP servers and local handlers, get merged by compose_tool_handlers(), and are presented to the AI channel. When the agent calls a tool, the call flows outward through the policy engine. The policy checks the tool name against allow/deny patterns, applies any role override, and either permits execution or returns an error to the agent.

[MCP Server] ──┐ ┌── [Allow] ──→ Execute ├── compose_tool_handlers() ──→ Agent ──→ ToolPolicy ──┤ [Local Tools] ──┘ └── [Deny] ──→ Error to AI

This is not middleware. It is not an afterthought. It is the architectural decision that determines whether your multi-agent system is safe to deploy. Every tool call passes through the policy engine. There are no shortcuts, no backdoors, no "but this agent is trusted so we skip the check."

Well, almost. Skill tools bypass policy because they are part of the orchestration infrastructure, not domain functionality. That distinction matters: activate_skill() lets an agent start a workflow. delete_account() destroys data. They belong in different security tiers.

Why This Matters for Multi-Agent Systems

In a single-agent system, you might get away with giving the agent access to everything and relying on prompt engineering to prevent misuse. In a multi-agent system, you cannot. Each agent has a different role, different knowledge, different judgment. The billing agent should not have the same tools as the account management agent, even if they run in the same room.

RoomKit's integration layer enforces this structurally, not with prompts. Policies are code, not suggestions. Role overrides are configuration, not conventions. MCP tool access follows the same rules as local tools. And credentials never leak into contexts where they do not belong.

The next pillar — External Tools — covers the other side of the coin: the provider ecosystem that connects your agents to SMS, voice, email, CRMs, and any external API. Integration decides whether a tool can be called. External tools are what your agents actually connect to.


This article is part of a 9-part series on production-ready multi-agent architecture. Next up: Part 7: External Tools.

Series: Introduction · Part 1: User Interaction · Part 2: Orchestration · Part 3: Knowledge · Part 4: Storage · Part 5: Agents · Part 6: Integration · Part 7: External Tools · Part 8: Observability · Part 9: Evaluation