Skip to main content
Glama
README.md
# toolgate

An MCP gateway on Cloudflare Workers that sits between AI agents and the tools they call. Each agent gets its own key and its own policy: which tools it can see, what arguments it can pass, how often it can call, and which calls wait for a person to approve them. Everything a tool returns is checked for secrets and personal data, scanned for text that tries to give the agent instructions, and fenced off as untrusted before the agent sees it. Every call leaves a row in a hash-chained audit log, and an operator can switch any agent off.

The tools behind it are demo backends: a support desk in D1, feature flags in KV, files in R2, and a web fetch limited to hosts the policy lists. The seed data includes a ticket with a prompt injection in it, one with a leaked AWS key and password, and one with a customer's phone and card number (all well-known documentation examples, not real ones).

## Quickstart

Needs Node 22 or newer and pnpm.

```
pnpm install
pnpm dev                     # local D1 + wrangler dev on :8787
open http://localhost:8787   # admin token: local-admin
```

Press "Load the demo" in the console to create two agents and copy their keys. Then point any MCP client at it:

```json
{
  "mcpServers": {
    "toolgate": {
      "url": "http://localhost:8787/mcp",
      "headers": { "Authorization": "Bearer tg_..." }
    }
  }
}
```

`pnpm test` runs the suite inside workerd, with an MCP client talking to the Worker over streamable HTTP. `pnpm deploy` applies the migrations and deploys; set the D1, KV and R2 ids in `wrangler.jsonc`, and `wrangler secret put ADMIN_TOKEN` first.

## How it works

```
agent ──► /mcp ──► key ──► MCP server built for that agent ──► tools/call
                                                                 │
          kill switch ─► policy ─► argument rules ─► rate limits ─► approval? ─► tool ─► filter ─► fence ─► agent
                                                                                                 │
                                                                            every step ─► audit chain
```

**Built on the Agents SDK and MCP v2.** `/mcp` is `createMcpHandler` from `agents/mcp` with an SDK v2 `McpServer`. It is stateless: each request is checked against its key and then served by a server built for that agent, holding only the tools its policy allows. A tool an agent may not use isn't refused at call time; it isn't in `tools/list`, and calling it by name is a protocol error. Clients on the 2025 protocol work too, through the SDK's stateless fallback.

**Keys.** An agent key is `tg_` plus 32 base32 characters (160 random bits). Only its SHA-256 is stored, and the console shows it once. Revoking a key refuses its next request.

**Policy.** JSON, validated with zod before it is saved. It names the tools an agent may use, and per tool: argument rules (`oneOf`, `pattern`, `prefix`, `min`, `max`, `maxLength`), a per-minute limit, and whether to ask for approval. A policy that names a tool or argument that doesn't exist is rejected, so a typo can't silently allow or constrain nothing. A path with a prefix rule can't contain `.` or `..` segments, so `docs/../secrets` doesn't get past `docs/`.

**Rate limits and budgets.** One Durable Object per agent holds token buckets (one for all its calls, one per limited tool) and a count of calls today. The check and the take happen in one synchronous method, so 25 concurrent calls against a bucket of 10 get exactly 10.

**Approval.** Calls that change something (comment on or close a ticket, set a flag, write a file) wait for a person by default. The first call returns `pending_approval` with an id and runs nothing. The id is a hash of the agent, the tool and the arguments, so asking again finds the same request instead of queueing a second one. When a person approves, the call runs right away; whoever moves the row from approved to running in D1 runs it, and everyone after gets the stored result. So an approved write runs once, however often the agent retries. A declined call tells the agent not to retry. A request nobody answers in 24 hours expires. If the agent is switched off between the request and the yes, the approved call doesn't run.

**Output filter.** Runs on every string in every tool result, never on numbers, so ids and counts aren't mangled.
- Secrets are always redacted: private key blocks, AWS, GitHub and Slack tokens, `sk_live_` style API keys, JWTs, toolgate keys, and `password: ...` style assignments (the label is kept).
- Emails, phone numbers and card numbers that pass the Luhn check are redacted unless the policy sets `"pii": "allow"`.
- Zero-width, bidi and Unicode tag characters are stripped and flagged.
- Text that looks like instructions is flagged: "ignore previous instructions", role changes, chat markup, telling the agent to call a tool, asking for data to be sent somewhere. With `"onInjection": "block"` the output is withheld instead.
- The result is wrapped in `<tool_output trust="untrusted">`, with any closing tag inside the data broken up so the data can't end the fence early. Flagged output gets a note in front saying what was found.

**Kill switch.** Switching an agent off writes a KV key in a namespace of its own (`CONTROL`). No tool can reach that namespace, so an agent with `flags_set` can't switch itself back on.

**Audit chain.** One Durable Object appends every entry in order. Each row's hash covers its fields and the hash of the row before it. The object keeps the chain in its own SQLite and copies rows to D1 in order, retrying from an alarm if D1 is down. `GET /api/audit/verify` re-hashes the D1 copy from the first row and compares the end with the head the object holds. It reports the first row that was edited, deleted, reordered or forged, and whether rows were cut off the end. Arguments in the log have secrets and PII redacted.

## API

Agents use `POST /mcp` with `Authorization: Bearer tg_...`. Everything under `/api` needs `Authorization: Bearer <ADMIN_TOKEN>`.

| | |
|---|---|
| `GET /api/overview` | agents with policy, key count, today's usage, waiting approvals, kill state; the tool list |
| `POST /api/principals` | `{name, policy?}` |
| `GET /api/principals/:id` | one agent with its keys and usage |
| `PUT /api/principals/:id/policy` | `{policy, by}`; 422 with the errors if it doesn't validate |
| `POST /api/principals/:id/keys` | issue a key (shown once) |
| `POST /api/principals/:id/keys/:keyId/revoke` | |
| `POST /api/principals/:id/kill` | `{by, reason?}` |
| `POST /api/principals/:id/revive` | `{by}` |
| `POST /api/principals/:id/check` | `{tool, args}`: what would happen, without running anything or using up limits |
| `GET /api/approvals?status=` | |
| `POST /api/approvals/:id` | `{approved, by}`; an approved call runs now |
| `GET /api/audit?principal=&before=&limit=` | newest first |
| `GET /api/audit/verify` | re-check the whole chain |
| `POST /api/seed` | demo agents, tickets, files and flags; new keys are returned once |

## Measured vs Claimed

| Claim | Value | How measured | Date |
|---|---|---|---|
| Approved write retried 5 times at once | runs once, all 5 get the stored result | `test/approvals.test.ts` | 2026-10-20 |
| Concurrent calls against a 10-token bucket | 10 of 25 allowed | `test/meter.test.ts` | 2026-10-20 |
| Concurrent audit appends | 30 appends, seq 1 to 30 with no gaps, chain verifies | `test/audit.test.ts` | 2026-10-20 |
| Tampering found by verify | an edited row, a deleted row, a cut tail and a forged last row, each found | `test/audit.test.ts` | 2026-10-20 |
| Seeded injection ticket | flagged on 3 patterns, withheld under `onInjection: block` | `test/gate.test.ts`, and over curl against `wrangler dev` | 2026-10-20 |

## Status

Works: keys, per-agent tool lists, policy validation, argument rules, rate limits and daily budgets, approvals, the output filter, the kill switch, the audit chain and its verifier, all four backends, and the console. 38 tests cover the filter, policy parsing and argument rules, the fetch allowlist, the meter, the audit chain and tampering, and the gate end to end through a real MCP client (allowed and hidden tools, prefix escapes, redaction, flagged and blocked injection, rate limits, the kill switch, approvals approved, declined, expired and replayed, keys and revocation, policy changes, dry runs). It was also run under `wrangler dev` with curl: 2025-protocol initialize, tools/list, the injection ticket, a real `web_fetch` to developers.cloudflare.com and a pending write.

Limits:

- The injection scan is a list of patterns. It catches the common phrasings and markup, and someone who reads the source can write around it. The fence and the note matter more than the scan; neither stops a model that decides to follow the text anyway.
- PII detection is regex based: emails, phone numbers in common formats, and Luhn-valid card numbers. Names and addresses go through.
- The kill switch is a KV read, and KV is eventually consistent, so switching an agent off can take up to about 60 seconds to reach every location.
- If a Worker dies between claiming an approved call and storing its result, the approval stays `running` and nobody runs it again. That's the safe way round for a write, but it needs a person to look.
- One audit Durable Object serializes every append, which caps throughput at what one object can hash and write. Fine for a team's agents; a large fleet would need a chain per agent.
- `web_fetch` doesn't follow redirects, returns text only, and cuts bodies at 100 KB.
- The console has a single shared admin token. Put Cloudflare Access in front of it before giving it a public URL.
- It hasn't been deployed.