MCP Tool Gateway
HEAD
MCP Tool Gateway
A minimal control plane between AI agents and organizational tools, with a human-in-the-loop approval gate for high-risk actions.
Low-risk tools (read-only SQL) run instantly. High-risk tools (sending email) pause and wait for a human to approve or deny from an admin dashboard before the agent is allowed to proceed. Every call is written to an audit log.
Design goal: keep it linear and synchronous. The approval "pause" is a plain
await on an in-memory asyncio.Event under a timeout — not a distributed
pause/resume state machine. No Docker sandbox, no arbitrary code execution, no
multi-tenancy, no Redis. Those are deliberate v1 exclusions.
How it works
Agent --POST /call--> Gateway
low risk -> run tool -> audit(auto) -> result
high risk -> PendingApproval(pending)
await decision (APPROVAL_TIMEOUT)
approved -> run tool -> audit(approved) -> result
denied -> audit(denied) -> {"status":"denied"}
timeout -> audit(timeout) -> {"status":"timeout"}The core is one function, gateway.run_tool: check the tool's risk flag, then
either run it now or create a pending row and await a decision.
Tech stack
FastAPI · SQLAlchemy (async) + asyncpg · PostgreSQL · mcp SDK (Streamable HTTP) · uvicorn
File layout
mcp-gateway/
├── app/
│ ├── main.py # FastAPI app + routes wired together
│ ├── db.py # Postgres connection/session (rw + ro engines)
│ ├── models.py # PendingApproval, AuditLog
│ ├── registry.py # tool name -> {fn, risk}
│ ├── tools.py # sql_read (low), send_email (high)
│ ├── gateway.py # core: run a tool + enforce the gate
│ ├── approvals.py # in-memory events + wait/approve/deny
│ └── mcp_server.py # Step 6: registry -> MCP tools, mounted at /mcp
├── static/
│ └── dashboard.html # admin page (polls for pending requests every 2s)
├── .env
├── requirements.txt
└── README.mdSetup
Prerequisites: Python 3.11+, PostgreSQL running locally.
Install
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txtConfigure — .env (already present):
DATABASE_URL=postgresql+asyncpg://gateway:password@localhost:5432/mcp_gateway
READONLY_DATABASE_URL=postgresql+asyncpg://gateway_ro:password@localhost:5432/mcp_gateway
APPROVAL_TIMEOUT=300Create the database and the read-only role. sql_read uses gateway_ro —
SELECT-only is enforced by the database and by a READ ONLY transaction, never
by parsing the query string.
Option A — Docker (what this checkout is set up with):
docker run -d --name mcp_gateway_postgres \
-e POSTGRES_USER=gateway -e POSTGRES_PASSWORD=password -e POSTGRES_DB=mcp_gateway \
-p 127.0.0.1:5432:5432 postgres:15-alpine
docker exec -i mcp_gateway_postgres psql -U gateway -d mcp_gateway <<'SQL'
CREATE ROLE gateway_ro LOGIN PASSWORD 'password';
GRANT CONNECT ON DATABASE mcp_gateway TO gateway_ro;
GRANT USAGE ON SCHEMA public TO gateway_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO gateway_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO gateway_ro;
SQLStop/start it later with docker stop mcp_gateway_postgres / docker start mcp_gateway_postgres.
Option B — local Postgres install:
createdb mcp_gateway
psql mcp_gateway -c "CREATE ROLE gateway_ro LOGIN PASSWORD 'password';
GRANT CONNECT ON DATABASE mcp_gateway TO gateway_ro;
GRANT USAGE ON SCHEMA public TO gateway_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO gateway_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO gateway_ro;"The ALTER DEFAULT PRIVILEGES line matters: the gateway creates its tables on
startup, after the GRANT ... ON ALL TABLES above has already run, so without
it gateway_ro would be granted nothing.
Run — from inside mcp-gateway/, not your home directory. app.main:app is
resolved relative to the current directory:
cd mcp-gateway
.venv/bin/uvicorn app.main:app --reload # or: source .venv/bin/activate firstIf you see
externally-managed-environmentfrom pip, the venv isn't active — use.venv/bin/pipexplicitly. If you seeModuleNotFoundError: No module named 'app', you're in the wrong directory.
Dashboard: http://localhost:8000/ · API: http://localhost:8000/call
Build order
Each step runs before you start the next.
1. Skeleton + DB — FastAPI app, async SQLAlchemy engine,
AuditLog+PendingApprovalcreated on startup.2. Registry + tools —
sql_read(risklow) andsend_email(riskhigh) registered by name.3. Gateway + gate —
run_tool()runs low-risk now, blocks high-risk; exposed viaPOST /call.4. Approval store — in-memory
asyncio.Eventper pending id;wait_for_decision()/resolve().5. Dashboard —
GET /approvals, approve/deny endpoints,dashboard.htmlpolling every 2s.6. MCP endpoint — the registry exposed over MCP via the
mcpSDK (Streamable HTTP) at/mcp.
API
Method | Path | Purpose |
POST |
|
|
GET |
| List pending approvals (dashboard feed) |
POST |
| Approve a pending call |
POST |
| Deny a pending call |
GET |
| Liveness + registered tool names |
GET |
| Admin dashboard |
ALL |
| MCP endpoint (Streamable HTTP) |
Demo / acceptance test
Low-risk tool → returns instantly, AuditLog.decision = "auto":
curl -X POST localhost:8000/call -H "content-type: application/json" \
-d '{"tool":"sql_read","args":{"query":"select 1 as ok"}}'High-risk tool → the request hangs:
curl -X POST localhost:8000/call -H "content-type: application/json" \
-d '{"tool":"send_email","args":{"to":"a@b.com","subject":"hi","body":"test"}}'Open http://localhost:8000/ → the request is listed → click Approve → the
curl call returns its result and AuditLog.decision = "approved". Repeat with
Deny, and leave one sitting for APPROVAL_TIMEOUT seconds to see the
timeout path.
SELECT tool_name, risk, decision, created_at FROM audit_log ORDER BY id DESC LIMIT 5;Notes and known edges
Single process only. The event lives in one worker's memory, so don't run
--workers 2— the approver and the waiter must share an event loop. Redis Pub/Sub is the fix when that changes.Restart loses waiters. A pending row survives a restart, but the request awaiting it does not. Resolving an orphaned row updates the DB and returns cleanly; nothing is left hanging.
Timeout closes the row. On timeout the
PendingApprovalrow is markeddenied(the schema only haspending|approved|denied) so it leaves the dashboard, while theAuditLogrow records the more precisedecision="timeout".Failed tools are audited too. If a tool raises (bad args, or the DB refusing a write), the gateway writes an
AuditLogrow withdecision="error"and returns HTTP 400 with the message — a refused call is exactly what the audit log is for.No auth on the dashboard. Anyone who can reach the port can approve. Put it behind your own auth before it leaves localhost.
MCP endpoint
The same registry is served over MCP at http://localhost:8000/mcp (Streamable
HTTP). Each MCP tool handler is a thin wrapper around gateway.run_tool, so the
approval gate applies automatically — an agent calling send_email simply sees a
tool that takes a while to return.
Connect MCP Inspector:
npx @modelcontextprotocol/inspector
# transport: Streamable HTTP URL: http://localhost:8000/mcpInitialize → list tools shows sql_read and send_email → calling sql_read
returns rows immediately → calling send_email hangs until you click Approve on
the dashboard.
SDK note: this uses mcp 2.x, where FastMCP was renamed to
MCPServer (from mcp.server.mcpserver import MCPServer) and the wire models
moved to snake_case (result.is_error, tool.input_schema). Most tutorials
online still show the v1 names. The handler signatures are generated from the
real tool functions via functools.wraps + __signature__, so the JSON schema
stays in sync with tools.py automatically.
A denied call returns {"status": "denied"} as a successful tool result, not
an MCP error — the agent is told the action was refused rather than that
something broke. Flip this in gateway.run_tool if you'd rather it raise.
Out of scope (v1, on purpose)
Excluded | Why / when to add |
Docker sandbox | Real isolation is hard (gVisor/Firecracker territory); add only with arbitrary code exec. |
Arbitrary code exec | Highest-risk, easiest to do insecurely — keep to a fixed tool set first. |
Multi-tenancy | Add a |
Redis Pub/Sub | Only needed when approver and waiter live in different processes. |
Roadmap
Approval token expiry + idempotency keys.
WebSocket push to the dashboard (replace polling).
Redis Pub/Sub so the gate works across multiple workers.
Sandboxed code-execution tool with real isolation (gVisor/Firecracker, no network, non-root).
Per-tenant policies and role-based approvals.
MCP-Tool-Gateway
The MCP Tool Gateway is a centralized proxy and orchestration service that connects LLM clients seamlessly to multiple Model Context Protocol (MCP) servers. It provides unified tool discovery, authentication, and secure request routing across distributed tools and external APIs.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/smileyDDx1/MCP-Tool-Gateway'
If you have feedback or need assistance with the MCP directory API, please join our Discord server