Home
Blog
Putting an MCP Server Behind Tyk: Per-Agent Rate Limiting, a Go Token-Guard Plugin, and a Queryable Audit Trail

Putting an MCP Server Behind Tyk: Per-Agent Rate Limiting, a Go Token-Guard Plugin, and a Queryable Audit Trail

Putting an MCP Server Behind Tyk: Per-Agent Rate Limiting, a Go Token-Guard Plugin, and a Queryable Audit Trail

Per-agent rate limits, a Go token-guard plugin, and an audit trail you can query. All on the free, open-source Tyk gateway.

Teams are wiring AI agents up to internal tools through MCP, and a lot of them are doing it with no controls at all. Any agent can call any tool, as often as it likes, and nobody keeps a record. That's fine right up until an agent that was supposed to read orders starts refunding them.

James Hirst made this case on this blog, in "AI agents need API gateways", and the argument is hard to disagree with: agents recreate the exact problems that built the API gateway market in the first place, the ones where auth is a mess, there's no visibility, rate limiting is an afterthought, nothing gets audited, and the access rules live in someone's head. He's right about all of it. He also doesn't show you a single line of code.

This does. We're going to take a real MCP server, put it behind the open-source Tyk gateway, and hand two agents their own keys. Then we watch the gateway stop one of them from issuing refunds, throttle it when it floods us, cut it off when it burns through its token budget, and log every last call to a database you can query. No Dashboard, no license, no trial. Clone the repo: build the plugin, docker compose up, run one script, and watch it work.

What you actually have to enforce

Strip out the protocol talk and it's a short list. Five things:

Identity. Every call is tied to a specific agent. No anonymous traffic.

Access. An agent can only call the tools it's allowed to call.

Rate. One agent can't flood a tool and drag everyone else down with it.

Cost. Each agent gets a token budget, and when it's gone, it's gone.

Audit. You can answer "which agent did what, when, and what did it cost" without guessing.

Everything below wires up those five. Here's the shape of it.

Two things sit behind the gateway: an MCP server exposing a couple of internal tools, and the OpenAI API. The gateway handles identity, rate, and audit for both on its own. It can do per-tool access too, because Tyk's native MCP gateway reads the JSON-RPC and applies per-tool rules with no code. That path runs on OAS-format API definitions, though, and this guide sticks to the classic, file-based definitions the open-source gateway loads from disk, where the gateway sees MCP as plain HTTP. So per-tool access falls to us here, and the one job the gateway won't do out of the box in any setup is a per-agent token budget. We handle both in a Go plugin. A Go plugin is Go code you compile into a shared library and load into the gateway, which then runs it as custom middleware on the routes you choose.

That list of five isn't mine. Martin Buhr's twelve-point pre-production checklist for agents lands in the same place, running through identity, least privilege, rate limits and budgets, audit, and revocation, where his version is the checklist and this one is the wiring.

Before you start. You'll need Docker, since the whole stack runs in Compose, and Python 3 to drive the client. A real OPENAI_API_KEY is needed for one step only, the token budget. Everything else, the gateway, Redis, Postgres, Tyk Pump, and even the plugin compiler, comes down as a container. Clone the repo and you're set.

Stand up Tyk and put the MCP server behind it

The MCP server is a small FastMCP app with two tools, one that reads data and one that does the dangerous thing. An agent that can read your orders is a convenience; an agent that can refund them is a liability with a personality, and that split between the two is the whole point of per-tool access.

from fastmcp import FastMCP

mcp = FastMCP("internal-tools")

@mcp.tool
def lookup_order(order_id: str) -> dict:
    """Look up an internal order by its ID. Read-only."""
    ...

@mcp.tool
def issue_refund(order_id: str, amount: float) -> dict:
    """Issue a refund against an order. Sensitive / state-changing."""
    ...

if __name__ == "__main__":
    # transport="http" is the current name for Streamable HTTP.
    mcp.run(transport="http", host="0.0.0.0", port=8000)

Streamable HTTP only. Tyk is a network gateway, so it can't proxy a stdio MCP server. If yours speaks stdio, put a bridge in front of it. FastMCP serves Streamable HTTP already, so we're fine.

The docker-compose.yml brings up the gateway, Redis (Tyk needs it, even for one node), the MCP server, Tyk Pump, and Postgres. The gateway runs in open-source mode and reads its config from files. Putting the MCP server behind it means pointing a Tyk API at it:

// tyk/apps/internal-tools.json (excerpt)
"proxy": {
  "listen_path": "/internal-tools/",
  "target_url": "http://mcp-server:8000",
  "strip_listen_path": true
},
"use_keyless": false,
"use_standard_auth": true,
"auth": { "auth_header_name": "apikey" }

Now the gateway serves the MCP server at http://localhost:8080/internal-tools/mcp, and it asks for a key, because we turned keyless access off. One warning, learned the hard way: keep mcp out of the api_id. A classic def whose api_id held mcp loaded without complaint and then acted like it didn't exist, as if the name were being routed to the built-in MCP path. Renaming it fixed it at once. I lost an afternoon to that so you don't have to.

One key per agent: identity and rate limits

Every request runs the same set of checks, and any one of them can stop it before it reaches a tool or the model.

With keyless off, every call needs a key, so we hand one to each agent, and now every request has a name on it. Each key carries its own access rights, its own rate limits, and some metadata the plugin reads later.

curl -H "x-tyk-authorization: $SECRET" -X POST http://localhost:8080/tyk/keys -d '{
  "org_id": "default",
  "alias": "agent-alpha",
  "access_rights": {
    "internal-tools": { "api_id": "internal-tools", "versions": ["Default"],
                        "limit": { "rate": 20, "per": 60 } },
    "openai-llm":     { "api_id": "openai-llm", "versions": ["Default"],
                        "limit": { "rate": 1000, "per": 60 } }
  },
  "meta_data": { "token_budget": 150, "allowed_tools": "lookup_order" }
}'

agent-alpha is the junior account, on a tight rate limit with only lookup_order on its list, while agent-beta gets room to move and both tools. Throw a burst at the gateway and the two stay out of each other's way:

agent-alpha 30 rapid calls -> [400 400 400 400 400 429 429 429 ...]
    5 reach the server, then the rest are rejected with 429 (rate limit tripped)
agent-beta call during alpha's burst -> reaches the server, unaffected

(Those 400s are the MCP server, not the gateway: the raw burst skips the MCP handshake, so the server turns it away. What matters is the gateway's part: it lets a few through, then throttles the rest with 429. The key allows 20 a minute, but Tyk meters over a sliding window, so a tight, all-at-once loop trips the limit after only a handful of calls rather than a clean 20.) One agent hammers a tool and trips its own 429s while the other, calling at the same moment, doesn't feel a thing. That's the isolation, and the gateway hands it to you for free.

Per-tool access in a Go plugin

Rate limits count requests, but they can't tell a call for issue_refund from a call for lookup_order. On a file-based gateway with classic definitions, the gateway sees MCP as plain HTTP, so we teach it to read the difference: a small hook reads the JSON-RPC body and checks the tool name against the agent's list. (Tyk's native MCP gateway does this without code, on OAS definitions.)

func McpAccessControl(rw http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    r.Body = io.NopCloser(bytes.NewReader(body)) // restore for the upstream

    var msg struct {
        Method string `json:"method"`
        Params struct{ Name string `json:"name"` } `json:"params"`
    }
    json.Unmarshal(body, &msg)
    if msg.Method != "tools/call" { return }

    allow, restricted := allowedTools(r) // from key meta_data.allowed_tools
    if restricted && !allow[msg.Params.Name] {
        rw.WriteHeader(http.StatusForbidden)
        io.WriteString(rw, `{"jsonrpc":"2.0","error":{"code":-32001,"message":"tool not permitted"}}`)
    }
}

Run the two agents and alpha's refund gets turned away at the door. Beta's goes through:

agent-alpha  lookup_order(A-1001) -> {"status": "shipped", ...}
agent-alpha  issue_refund(A-1001) -> BLOCKED by Tyk (403)
agent-beta   issue_refund(A-1001) -> {"status": "refund_issued", ...}

A token guard on the LLM call

What an LLM call actually costs is tokens, not requests, and that math is specific to your setup, so the gateway leaves it to you. The same plugin takes care of it on a second route that fronts OpenAI, where a response hook reads total_tokens off each reply and adds it to that agent's running total.

func MeterTokens(rw http.ResponseWriter, res *http.Response, req *http.Request) {
    body, _ := io.ReadAll(res.Body)
    res.Body = io.NopCloser(bytes.NewReader(body)) // restore for the client
    var parsed struct{ Usage struct{ TotalTokens int64 `json:"total_tokens"` } `json:"usage"` }
    json.Unmarshal(body, &parsed)
    mu.Lock(); spent[agentID(req)] += parsed.Usage.TotalTokens; mu.Unlock()
}

A second hook runs before the call goes out, and if the agent is already over budget the request stops right there and never reaches OpenAI. That same hook swaps in the real OpenAI key, so the agents only ever talk to Tyk and never hold the key themselves.

func EnforceBudget(rw http.ResponseWriter, r *http.Request) {
    id, budget := agentID(r), budgetFor(r)
    mu.Lock(); used := spent[id]; mu.Unlock()
    if used >= budget {
        rw.WriteHeader(http.StatusTooManyRequests)
        io.WriteString(rw, `{"error":"token budget exhausted"}`)
        return // upstream is never called
    }
    r.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY"))
}

Give alpha a 150-token budget and it gets only a few calls before the gate shuts. How many it gets depends on what each reply spends, since the guard counts real total_tokens. Beta, on a big budget, keeps going. One representative run:

agent-alpha (budget 150):  call 1: 200 (+52)  call 2: 200 (+49)  call 3: 200 (+55)  call 4: 429 budget exhausted
agent-beta  (budget 100000): 200 200 200 200 200 200 200

A few things to remember. The guard meters on the response, after the tokens are already spent, so an agent's last call can land a little over budget before the gate shuts. That's why alpha's run tops out at 156 against a 150 budget. OpenAI also leaves usage out of streamed replies unless you ask for it with stream_options, so the guard sticks to plain, non-streamed calls. And this counter lives in the gateway's memory, which is fine for one node. Run two and they'll each think the agent still has budget, which rather defeats the point. Move the count into Redis before you scale out.

Build the plugin for your exact gateway

Here's the part that catches everyone. A Go plugin has to be built against the same gateway version, the same build flags, and the same CPU type. Get any of those wrong and the gateway won't load it, and it won't tell you why. Tyk ships a compiler image pinned to each version so the match is exact:

docker run --rm -v "$(pwd)":/plugin-source --platform=linux/amd64 \
  -e GO_GET=1 -e GO_TIDY=1 \
  tykio/tyk-plugin-compiler:v5.14.0 token_guard.so plugin-v5.14.0

GO_GET=1 fetches the exact Tyk gateway dependency for you (Tyk marks it a dev-only convenience, but it works). The compiler writes an arch- and version-suffixed file, token_guard_v5.14.0_linux_amd64.so; rename it to token_guard.so, the path the API definitions reference in custom_middleware.path. The repo's Makefile does the build and the rename in one step with make -C plugin plugin. Bump the gateway version and you bump the compiler tag with it.

An audit trail you can query

The gateway writes its logs to Redis. Tyk Pump moves them somewhere permanent. Point it at Postgres:

// pump/pump.conf (excerpt)
"pumps": { "postgres": { "type": "sql", "meta": {
  "type": "postgres",
  "connection_string": "host=postgres port=5432 user=tyk password=tyk dbname=tyk_analytics sslmode=disable"
} } }

Now every call is a row in tyk_analytics, tagged with the agent's alias. Turn on detailed recording and the request and response bodies ride along too, which means the tool name and the token count are both in there. One query gives you spend per agent:

SELECT alias AS agent,
       COUNT(*) AS llm_calls,
       SUM((regexp_match(convert_from(decode(rawresponse,'base64'),'UTF8'),
            'total_tokens"?\s*:\s*(\d+)'))[1]::int) AS total_tokens
FROM tyk_analytics
WHERE api_name = 'openai-llm' AND responsecode = 200
GROUP BY alias;
 agent    | llm_calls | total_tokens
------------+-----------+--------------
 agent-alpha|         3 |          156
 agent-beta |         7 |          361

(Figures from one run; your exact token totals will differ, since each reply's token count is real.) That's the question compliance asks: which agent did what, when, and what did it cost. "We didn't log it" is not an answer they enjoy. Here it's one query.

The five controls, and what enforces each

That's the whole build. Below, each control is mapped to the piece of the stack that holds it:

Identity. Keyless access off, one auth key per agent. Every call has a name on it. (Tyk, out of the box.)

Access. A per-tool allow-list checked against the JSON-RPC body, so agent-alpha can read orders but never refund them. (Go plugin, McpAccessControl.)

Rate. Per-key rate limits, isolated per agent, so one flooder can't drag the rest down. (Tyk, out of the box.)

Cost. A per-agent token budget, metered off the model's own total_tokens, that shuts the gate when it's spent. (Go plugin, EnforceBudget and MeterTokens.)

Audit. Every call a row in Postgres, tagged by agent, answerable in one SQL query. (Tyk Pump into Postgres.)

Three of the five come free with the open-source gateway, and the other two are a couple hundred lines of Go. That's the entire bill.

Rough edges, and where to go next

A few gotchas: match the plugin to your gateway version and rebuild on upgrade, put a bridge in front of any stdio server, keep mcp out of your api_id, and move the token counter to Redis before you run a second node.

The one real choice is per-tool MCP control, and we put it in a plugin because that runs on the free gateway anywhere today. Tyk's native MCP gateway does the same job with no code, and it's the upgrade for when the plugin starts to feel like a second job. If you want cost and model governance handled for you instead of in your own code, that's what Tyk AI Studio is for.

You don't need any of that to start. One free gateway turns those five problems into rules that hold. The refund never happens. The flood gets throttled. The budget runs out. And every call is on the record.

Further reading

The companion repo: the full stack from this post. Build the plugin, docker compose up, run the client.

James Hirst, "AI agents need API gateways": the argument this post answers with code.

The Model Context Protocol spec: what MCP actually is.

FastMCP: the Python framework used for the demo server.

Tyk MCP Gateway docs: the native path.

Leave a Reply

Your email address will not be published. Required fields are marked *
✨ Message sent! I’ll respond as soon as possible.
⚡ Submission failed. Please refresh the page or try again later.
Ready to Elevate Your Technical Content & Blog?

Great things happen when engineering and storytelling unite, let’s create content that educates, inspires, and drives results.

See My Work
See My Work
Trusted by Top AI & SaaS Brands Worldwide
150+
Technical Articles Written
500K+
Readers Across Platforms