ZB Field Notes

I didn't need an MCP gateway, I needed a smaller credential

I didn't need an MCP gateway, I needed a smaller credential

I read about MCP Gateway being the secure way to run MCP servers, and asked myself whether I needed one. Working through that question ended with a server on my own box, a hundred-line servlet filter, and a much better answer than the one I started with.

What a gateway actually buys you

An MCP gateway is a proxy between clients and N MCP servers. It federates tool lists, holds the upstream credentials, and gives you one place for audit logging, rate limiting and per-client tool allowlists.

The underrated win is credential custody. The standard MCP deployment model today is: every API key for every server sits in plaintext in a client config on a workstation, replicated per machine, rotated never. A gateway turns N long-lived provider secrets into one revocable token. That is worth more than most of the feature matrix.

The sleeper feature nobody talks about is tool-description pinning — hashing tool descriptions and alerting when they change. That is the only practical defence against a server that ships benign descriptions on day one and swaps in something else on day forty. Tool descriptions are untrusted input that lands directly in your model's prompt, and almost nothing else in the ecosystem watches them.

But a gateway federates. I had exactly one server. Federation across a set of size one is ceremony.

The thing I was actually building

I already run a private kanban board, FOCUS, on its own subdomain — owner-only, gated behind LinkedIn OAuth, served by the same Spring Boot jar as everything else. What I wanted was to stop context-switching to file a todo: say it to an AI client, have the card appear.

That became a third Spring Boot app in mcp/. Not a Maven module of the backend — its own pom, jar, image and deploy workflow. Unlike my Embabel agent app, which is pinned to an older Boot line by its framework, this one matches the backend exactly: Boot 4.1, Java 25, Spring AI 2.0.0. So the separation is not a classpath argument. It is a process boundary: a tool-execution endpoint an AI client can reach does not belong in the process that serves my public blog and my gated CV.

It has no database driver. It calls the backend over the internal Docker network and nothing else.

The dangerous shape

The board API already existed, and every route is owner-only. Auth is a browser session cookie, which an MCP server obviously cannot obtain. So it needed a machine credential.

I had solved this before. My blog has a BlogPublishTokenFilter that accepts a token header on /api/blog/admin/** and mints an authentication shaped exactly like a real owner login, so every downstream check stays untouched. Elegant, proven, already running. The obvious move was to copy it.

Copying it verbatim would have been the mistake of the whole project.

Prefix versus exact: the one decision that mattered

The blog filter can afford a path prefix because everything under /api/blog/admin/ is publishing. The board API is not uniform like that. Under the same prefix it also carries:

  • GET /state — which returns every task title, note and deadline on the board
  • DELETE /tasks/{id} and a permanent empty-bin route

A prefix grant would have handed an internet-reachable AI tool the ability to wipe my board and, worse, to stream its entire private contents into a language model's context on request. Both from a credential whose only job is "add a card".

So the allowlist is a set of exact method-and-path pairs:

static final Set<String> ALLOWED = Set.of(
        "GET /api/focus/topics",
        "POST /api/focus/tasks");

Note the POST entry is /api/focus/tasks exactly, which excludes /api/focus/tasks/{id}/move. And GET /api/focus/topics is a new endpoint I added specifically so the credential had something safe to be scoped to — the browser gets its topics inside /state, but that response carries task content and this one carries only names. Narrowing the endpoint is what keeps the credential narrow. Scoping a filter to a fat endpoint would not have.

A test pins it, listing every destructive and board-reading route and asserting each is refused. That rule is a test, not a comment.

Verified against production, not asserted

The same valid capture token, against the live box:

GET    /api/focus/topics                 200
GET    /api/focus/state                  401
DELETE /api/focus/tasks/{id}             401
DELETE /api/focus/topics/{id}/discarded  401
POST   /api/focus/tasks/{id}/move        401
GET    /api/analytics/summary            401

The worst case for a stolen token — or for a prompt injection that fully succeeds — is junk cards in my Backlog column. That is one drag to the bin. The whole design rests on that sentence, which is why I would weigh it hard before adding a third tool.

What the client sees

MCP server status: connected, authenticated, two tools
Two tools. Nothing else on the surface.

Now look at the second tool in the list.

Tool list showing Add a FOCUS todo and List FOCUS topics marked read-only
The read-only badge is not automatic.

Spring AI defaults every tool to readOnlyHint=false, destructiveHint=true — the MCP spec's conservative defaults. Left alone, my harmless listing tool would have been advertised as destructive, and a client would prompt for confirmation before it. That is worse than it sounds: a confirmation prompt on a harmless action trains you to click through the prompts that actually matter. So both tools declare the truth:

@McpTool(name = "list_focus_topics",
        annotations = @McpTool.McpAnnotations(
                readOnlyHint = true,
                destructiveHint = false,
                idempotentHint = true))

On the write tool, destructiveHint = false is a factual claim rather than optimism: its credential cannot reach a destructive route even if the model asked it to.

Three Spring AI 2.0.0 things that cost me time

  1. spring-ai-starter-mcp-server-streamable-webmvc does not exist in the 2.0.0 BOM. It appears in the 2.0-SNAPSHOT docs. On the release, use spring-ai-starter-mcp-server-webmvc and set spring.ai.mcp.server.protocol=STREAMABLE — the same starter carries the streamable transport.
  2. The annotations are org.springframework.ai.mcp.annotation, not org.springaicommunity. That package moved for 2.0.0, and plenty of material still points at the old one.
  3. Spring AI reports the root cause's message to the client. I threw new IllegalStateException(readableMessage, cause) and my tool cheerfully answered the literal string "null" — because the chain bottomed out in a ConnectException whose message is null. The fix is to log the stack server-side and rethrow unchained.

So: did I need a gateway?

Asking the assistant to add a todo; it lists topics then creates the card
It resolves "my Docker board" to a topic id, then files the card.

No — not at one server. Federation buys nothing, and a second process proxying a single tool on a 4 GB box is pure overhead. The controls that carry weight here are the bearer token on the endpoint and, far more importantly, the shape of what the tools can express.

But here is the honest ending. Connecting the client wrote that bearer token, in cleartext, into a config file on my machine. Rotating it now means editing that file on every machine I use it from. That is precisely the one problem a gateway genuinely solves, and it landed in my lap the moment the thing started working.

At one server it is a thirty-second job. At four or five, the arithmetic changes — and that, not tool federation or policy engines, is the moment a gateway starts paying for itself. Asking "do I need a gateway?" was the wrong question. "What is the smallest credential that still does the job?" was the right one, and it had a much cheaper answer.