Writing

One Listener, Two Protocols

One Listener, Two Protocols - abstract illustration

I wanted the tools in my MCP server reachable from things that don't speak MCP: a shell script, a shortcut on my phone, an integration platform. The obvious approach is a REST API over the same catalog. The whole risk in that is ending up with two servers wearing one binary, which is how you get an authorization model that's correct on one path and subtly wrong on the other.

The rule that made the rest of it easy

No tool logic in the REST layer. No second authorization chain. No second resource-scoping pass.

The REST handler resolves a route to an MCP tool name, authenticates, runs a few pre-checks, and then calls the exact same invoke function the MCP call handler and the scheduler's agent loop already call. It passes a dispatch-source tag so logging can attribute the call correctly, and that's the only thing that distinguishes it downstream.

Both surfaces sit on the same listener, on the same port, behind the same peer-address filter and the same per-key remote-access gate. Nothing new binds. The two are discriminated purely by path prefix inside the channel handler. If a request reaches the dispatch seam, it has passed identical machinery regardless of which door it came through.

That framing did most of the work. "Protocol adapter over the existing dispatch path" is a design constraint you can hold someone to in review; "add a REST API" isn't.

The one place I deliberately duplicated something

There's an exception, and it's worth explaining because it looks like exactly the drift I was trying to avoid.

REST wants a 403 for "your key isn't granted this" and a 422 for "the tool ran and returned an error." The MCP dispatch path collapses both into the same error result shape, because JSON-RPC has no equivalent distinction. So to produce a correct status code, the REST layer has to know the difference before it calls invoke.

The available options were sniffing the string "No grant for" out of an error message, or re-running the grant check as a typed pre-check. String sniffing is a second copy of the authorization contract that drifts the moment anyone rewords a message, and it fails open when it drifts. So the REST layer reruns the same three gates the MCP path already applies, in the same order, each one able only to reject and never to accept.

That "may only reject, never accept" property is what makes the duplication safe. The pre-checks can't grant anything. Worst case they deny something that would have been denied a few microseconds later anyway. The real authorization still happens exactly once, in the same place it always did.

The three gates are enablement, then license, then grant. One of them, the license gate, was extracted out of an inline expression in the offer-policy code specifically so the REST layer could share it rather than re-derive a second copy of which domains are license-gated. That's a two-line refactor that removes a whole category of future divergence.

CSRF protection that isn't a validator

The MCP path is CSRF-protected by an origin validator inside a validation pipeline. The REST path skips that pipeline entirely, and for a while that looked like a gap. It isn't, and the replacement guarantee is structural rather than another check.

The bearer token is read only from the Authorization header. Never a cookie, never a query parameter, never a custom header that a browser form can set. A cross-origin browser request can't set Authorization without a CORS preflight, and this server answers no OPTIONS and emits no CORS headers, ever. There's no code path that sets one.

So the attack requires a capability the browser doesn't have, rather than requiring a validator to notice and reject it. That's a stronger position, and it's also fragile in a specific way: it depends on the absence of something. A future contributor adding CORS headers to make a browser client work would silently reopen the exact surface this closes, and nothing would fail.

I wrote that into the docs as a prohibition with the reasoning attached, because it's the sort of thing that reads as an oversight to someone who wasn't there. If a browser client is ever wanted it needs a real, separately-reasoned CORS policy, not a header bolted on. This is the same instinct as the gotchas file: the places where deliberate absence carries the guarantee are exactly the places to write down, because they look like omissions.

Small decisions that had reasons

Auth before routing. An unauthenticated caller gets a 401 even on a path that doesn't exist, rather than a 404. Routing after auth means an unauthenticated caller learns nothing about which routes exist.

No path templating. Every argument travels in the JSON body or the query string. No id segments anywhere. That keeps routing a dictionary lookup, keeps the OpenAPI paths map a literal enumeration, and keeps request-schema generation a straight pass-through of each tool's existing input schema. A prettier URL shape would have cost a schema-translation layer, which is a place for the two surfaces to disagree about what a tool accepts.

403, not 402, for a license-gated tool. This isn't a payment protocol, and a 402 invites retry-on-payment semantics that nothing here implements. A 403 with a machine-readable code is the honest answer.

403, not 404, for a disabled tool. The exported OpenAPI spec is static and lists every registered tool, including ones currently switched off. A 404 there would read as "the spec is wrong" rather than "this route is temporarily off."

Those last two took longer to decide than to implement, which is normal for status codes and is usually time well spent. The code is the API's error vocabulary and you only get to pick it once.

Making "every response is a JSON object" true

MCP tool results put their payload as a JSON string inside a text content block. Unwrapping that generically, rather than special-casing each of the tools, took one rule that isn't obvious: parse the text as JSON without allowing fragments.

A tool that returns a bare number as a string would, with fragments allowed, parse into a JSON number and become a non-object response body. Disallowing fragments means that case falls through to being wrapped in an object with a text key instead. That single flag is what makes the claim "every 2xx body is a JSON object" actually true, which in turn is what makes the exported spec's generic response schema honest rather than a lie.

A handful of tools return two content blocks, a binary one plus a text metadata one. Those unwrap the metadata per the normal rules and merge the binary under a reserved sibling key. One rule, applied everywhere, rather than four special cases.

What's still unexplained

A large query-string GET takes eleven to fourteen seconds and I haven't located where. It might be pre-auth, which would make it rate-limiting-relevant rather than merely slow. It's filed with that framing, unfixed, because "I don't know if this is a performance issue or a denial-of-service surface" is a materially different thing to leave open than "this is slow."

I'd rather ship the surface with that written down than hold it while I chase a timing problem. But the distinction is the part worth recording, and a note reading "GET is slow" would have lost it.

The takeaway

If you're putting a second protocol over an existing dispatch path, decide up front what the adapter is allowed to know. Mine is allowed to know how to translate a route to a tool name, how to shape an error body, and how to unwrap a result. It isn't allowed to know anything about authorization except how to re-ask a question someone else already answers.

Everything that stayed simple about this stayed simple because of that line.

Building something like this?

This is the kind of work I do for clients. Tell me what you're building and I'll give you a straight read on the approach.

Book a 30-minute call