Skip to main content
Glama

gatehouse

The guardrail layer you would otherwise rebuild inside every customer's MCP server.

An agent platform attaches tools per server. The customer's server exposes everything it has: the refund tool next to the lookup tool, the IBAN next to the name. So the rules that matter in a regulated deployment (bank changes go to a person, the model never reads a document number, a retried write never runs twice) end up hand-written into each customer's tool code, or into a prompt, where a model can talk its way past them.

gatehouse is an MCP proxy that sits between the agent and the customer's MCP server and enforces those rules in code, from one YAML file:

Profiles

Each gatehouse instance serves one profile. Tools outside it are not listed and cannot be called. Attaching the profile is the permission grant.

Rules

allow, deny or handoff, per tool, with conditions on the arguments. First match wins. A handoff returns the reason as data and tells the agent to call its human-handoff tool.

Masking

Every result is masked before the model sees it: named fields (iban: last4, date_of_birth: drop) anywhere in the tree, and PII inside free text (IBANs, Luhn-valid card numbers, emails, phone numbers).

Idempotency

A retried write with the same arguments returns the first result instead of running again.

Audit

One JSON line per call: profile, tool, masked arguments, decision, rule, latency.

Shadow mode

mode: shadow records what every rule would have done and blocks nothing (results are still masked), so a first rollout can be measured before it is enforced.

Auth

Checks the bearer key on every request (MCP_API_KEY), because the platform does not.

The customer's tools do not change. The agent's prompt does not change.

Where it sits

flowchart LR
    subgraph agent["Agent runtime (unchanged)"]
        direction TB
        P["Policies and routines<br/>decide what the agent should do"]
        R["LLM router"]
        M1["mcps: operator-read"]
        M2["mcps: operator-write"]
    end

    subgraph gh["gatehouse, one process per profile"]
        direction TB
        A["1 Bearer check<br/>MCP_API_KEY on every request"]
        F["2 Profile<br/>only granted tools are listed"]
        X["3 Rules<br/>allow, deny or handoff"]
        I["4 Idempotency<br/>a retried write returns the first result"]
        K["5 Mask<br/>fields and PII in free text"]
        L["6 Audit<br/>one JSON line per call"]
        A --> F --> X --> I
        K --> L
    end

    subgraph up["Customer's MCP server (unchanged)"]
        direction TB
        U1["Player accounts"]
        U2["Support desk"]
        U3["KYC provider"]
        U4["Payments"]
    end

    M2 -- "tools/call" --> A
    I -- "allowed call" --> up
    up -- "raw result" --> K
    L -- "masked result" --> M2
    X -. "handoff: needs_human as data,<br/>upstream untouched" .-> M2

A call the rules refuse never reaches the customer's systems. A call they allow comes back masked. Every call leaves one audit line.

Related MCP server: cordon

One call, decided

flowchart TD
    C(["tools/call from the agent"]) --> K{"Bearer key valid?"}
    K -- no --> E1["401, nothing runs"]
    K -- yes --> P{"Tool in this profile?"}
    P -- no --> D1["not_allowed, rule: profile<br/>(enforced even in shadow mode)"]
    P -- yes --> R{"First matching rule"}
    R -- "deny or handoff" --> S{"mode: shadow?"}
    S -- no --> D2["return the error as data<br/>handoff names initiate_human_handoff"]
    S -- yes --> Q
    R -- "no match: allow" --> Q{"Same write seen<br/>inside the window?"}
    R -- "rule cannot read an argument" --> D3["deny, fail closed"]
    Q -- yes --> RP["return the first result, run nothing"]
    Q -- no --> UP["call the upstream tool"]
    UP --> MK["mask the result:<br/>named fields, PII in text,<br/>withhold images and files"]
    MK --> OUT(["masked result to the agent"])
    D1 & D2 & D3 & RP & OUT --> AU[("audit.jsonl<br/>masked arguments, decision, rule, ms")]

A handoff, end to end

sequenceDiagram
    autonumber
    participant Player
    participant Agent as Agent (policies, router)
    participant Gate as gatehouse (support-write)
    participant PAM as Player accounts
    participant Human as Support person

    Player->>Agent: "Please pay my winnings to a new IBAN"
    Agent->>Gate: tools/call update_bank_account(P-1001, ES76...)
    Gate->>Gate: rule bank-account-changes-go-to-a-human matches
    Gate-->>Agent: {"error": "needs_human", "rule": "...", "next": "initiate_human_handoff"}
    Note over Gate,PAM: The player account is never called
    Agent->>Human: built-in:initiate_human_handoff(reason)
    Human->>PAM: verifies identity, changes the IBAN
    Agent->>Gate: tools/call get_player(P-1001)
    Gate->>PAM: get_player(P-1001)
    PAM-->>Gate: full record, IBAN, phone, date of birth
    Gate-->>Agent: iban ****5766, phone ****5678, date of birth dropped

Two minutes, no keys

pip install -e .
python examples/igaming/demo.py

A support agent on a regulated iGaming desk (player accounts, tickets, KYC; all data fake):

support-read   sees 3 of 9 upstream tools: get_player, get_ticket, list_transactions
support-write  sees 8 of 9 upstream tools: add_ticket_comment, apply_bonus, close_ticket, get_player, ...

  look up the player                     get_player           -> iban ****1332, phone ****5678, email ***@example.com, dropped date_of_birth, document_number
  read a ticket with an IBAN in it       get_ticket           -> "Withdrawal of 40 EUR pending 3 days. Please send it to ****1332, or call me on ****5678."
  player asks to change the payout IBAN  update_bank_account  -> NEEDS_HUMAN  [bank-account-changes-go-to-a-human]
  10 EUR goodwill bonus                  apply_bonus          -> {"bonus_id": "B-9001", ..., "new_balance_eur": 52.5}
  model retries after a timeout          apply_bonus          -> {"bonus_id": "B-9001", ..., "new_balance_eur": 52.5}
  120 EUR bonus                          apply_bonus          -> NEEDS_HUMAN  [bonus-above-50-needs-a-supervisor]
  player asks to self-exclude            set_self_exclusion   -> NEEDS_HUMAN  [self-exclusion-goes-to-a-human]
  close with no resolution               close_ticket         -> NOT_ALLOWED  [close-only-with-a-resolution]
  a tool in no profile                   export_player_data   -> NOT_ALLOWED  [profile]

balance after two identical 10 EUR calls: 52.50 EUR (started at 42.50)

The config

upstream: http://operator-mcp:8765/mcp    # any streamable-http MCP server, or a local .py file
api_key: ${MCP_API_KEY}
audit_log: audit.jsonl

profiles:
  support-read:  { tools: [get_player, list_transactions, get_ticket] }
  support-write: { tools: [get_player, list_transactions, get_ticket, add_ticket_comment,
                           close_ticket, apply_bonus, update_bank_account, set_self_exclusion] }

mask:
  fields: { iban: last4, phone: last4, email: domain, date_of_birth: drop, document_number: drop }
  patterns: [iban, card, email, phone]

rules:
  - id: bank-account-changes-go-to-a-human
    tools: [update_bank_account]
    action: handoff
    reason: Bank account changes are always handled by a person.
  - id: bonus-above-50-needs-a-supervisor
    tools: [apply_bonus]
    when: { amount_eur: { gt: 50 } }
    action: handoff

idempotency:
  tools: [apply_bonus, add_ticket_comment, update_bank_account]
  window_seconds: 600

Conditions: gt, gte, lt, lte, equals, in, matches (regex), missing. The full example is examples/igaming/gatehouse.yaml.

Running it in front of a real server

gatehouse examples/igaming/gatehouse.yaml --profile support-write --port 8766

It serves streamable-http at /mcp. In an InteractiveAI agent manifest it is an ordinary MCP entry, one per profile:

agent_config:
  mcps:
    - id: operator                       # tools appear as operator:get_player
      hostname: http://gatehouse-support-write
      port: 8766
      api_key: ${GATEHOUSE_KEY}

Or as a platform-hosted MCP, from the included Dockerfile:

iai mcps create gatehouse-support-write --image-name gatehouse --image-tag v1 --port 8766

Tests

python -m unittest discover -s tests

29 tests: every rule; shadow mode; masking edge cases (only Luhn-valid card numbers, amounts and ticket ids untouched); the idempotency window; the full proxy in memory; a real streamable-http run that refuses a call without the bearer key and still applies the rules with it; and a set of bypass regressions.

Fails closed

A guardrail that can be argued past is a suggestion. gatehouse reads arguments the way the upstream's validator will execute them, and refuses whatever it cannot read:

  • "true", "1", "yes" are true and "120" is 120, so a rule never decides on a string while the tool runs on the coerced value.

  • NaN, infinities and booleans in an amount are refused, not compared (NaN > 50 is false).

  • A rule that cannot evaluate an argument denies the call; whitespace counts as missing.

  • IBAN, iban and dateOfBirth / date_of_birth are the same field to the masker.

  • Content it cannot inspect (an image of an ID document, an embedded file) is withheld.

  • It proxies tools only. Upstream resources and prompts would bypass every rule and mask, so they are neither listed nor readable through gatehouse.

  • Shadow mode softens rules, never the profile: a tool outside the profile is refused in every mode.

  • The bearer key is compared in constant time, and if the config asks for a key that resolves empty, gatehouse refuses to start rather than serving unauthenticated.

Limits, honestly

  • Masking is pattern- and field-based. A secret paraphrased into prose with no recognisable shape gets through; the field rules are the first line, a human on irreversible actions the last.

  • Idempotency is an in-process dict: correct for one replica, Redis for several.

  • One process per profile. Per-tool permissions inside the platform's own manifest would make that unnecessary, and would be the better long-term home for this.

MIT. Built by Sri Krishna Vamsi D.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    A
    maintenance
    Open-source MCP proxy that enforces security policies, content scanning, and audit logging between AI agents and tool servers
    25
    AGPL 3.0
  • A
    license
    Not graded
    quality
    A
    maintenance
    Security gateway for MCP tool calls. Sits between your LLM client and MCP servers, enforcing per-tool policies (allow/block/approve/read-only), logging every call, and pausing dangerous operations for human approval in terminal or Slack.
    4 npm
    1
    MIT
  • A
    license
    A
    quality
    C
    maintenance
    Provides a security governance layer for AI agents to safely access upstream MCP servers, enforcing tool-level RBAC, parameter constraints, authentication via static tokens or OIDC, and tamper-evident audit logging.
    2
    1
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables developers to wrap existing MCP servers with a deterministic policy enforcement gate that intercepts tool discovery and tools/call requests over stdio, allowing only authorized calls to reach the downstream server. It supports dynamic tool exposure or hiding, allow/deny/approval-required decisions, configurable error disclosure modes, and Ed25519-signed retry credentials for approved workflows.
    Apache 2.0