Front a stock MCP server with resource-proxy¶
Verify tokens, publish the protected resource metadata and write an audit line for an MCP server you cannot change. To write the server yourself, see Connect an MCP server. Declare the resource and its clients there first.
Run the sidecar ghcr.io/truvity/sluis/resource-proxy:<version>, pinned by digest, beside the server.
client ──▶ gateway ──▶ :8080 resource-proxy ──▶ 127.0.0.1:8081 stock MCP server
verifies the token │
serves the PRM ▼
writes the audit line 127.0.0.1:8429 resource-proxy (outbound)
adds the workload's OWN token ──▶ backend
Every request except metadata and health needs a token for RESOURCE_URL. The proxy reads no group and strips the caller's Authorization header.
Each flag has an upper-case environment variable of the same name.
| Flag | Default | Meaning |
|---|---|---|
--listen |
:8080 |
inbound listener |
--upstream |
required | the stock server, such as http://127.0.0.1:8081/mcp |
--issuer-url |
required | the issuer, as in a token's iss |
--resource-url |
required | the token audience, byte for byte as in resources |
--scope |
openid |
advertised, never checked; empty omits it |
--max-request-bytes |
4194304 |
larger bodies get 413 |
--body-read-timeout |
30s |
time to read one body |
--upstream-timeout |
2m |
wait for response headers; streams are unbounded |
--outbound-listen |
off | loopback address of the outbound forwarder |
--outbound-target |
where the forwarder sends requests | |
--outbound-token-endpoint |
the issuer's token endpoint, ending in /token |
|
--outbound-client-id |
the exchange client this workload presents | |
--outbound-audience |
the audience to exchange for | |
--outbound-sa-token-file |
projected ServiceAccount token (audience: the issuer), re-read for every exchange | |
--outbound-ca-file |
system roots | PEM CAs trusted only for OUTBOUND_TARGET; read once at start |
--outbound-allow-non-loopback |
false |
allow a non-loopback --outbound-listen |
--refresh-before |
1m |
exchange again this long before expiry, at most half a token's life |
Any OUTBOUND_* value without OUTBOUND_LISTEN, or OUTBOUND_LISTEN without the five it needs, is refused at start.
Route the gateway¶
The gateway rewrites nothing. For RESOURCE_URL=https://mcp.example.com/metrics, route these paths to the pod:
| Request path | Handled as |
|---|---|
/metrics and /metrics/... |
authenticated and proxied. The prefix becomes the path of UPSTREAM, so /metrics/x reaches /mcp/x. The query string is kept |
/.well-known/oauth-protected-resource/metrics |
the PRM, unauthenticated |
/.well-known/oauth-protected-resource |
the same PRM. Route it to one pod only |
/healthz, /readyz |
kubelet probes. Do not route them |
A host with several resources needs one well-known route per resource.
Call a backend as the server¶
Point the stock server's backend URL at OUTBOUND_LISTEN. The proxy adds this workload's own bearer and forwards to OUTBOUND_TARGET. It never forwards the caller's token, and acting as the user is not provided.
The proxy exchanges the ServiceAccount token at OUTBOUND_TOKEN_ENDPOINT (service to service). If a refresh fails and no valid token remains, the call is 502. /readyz waits for the first token.
A non-loopback listener is refused at start unless OUTBOUND_ALLOW_NON_LOOPBACK=true.
Read the audit line¶
{"time":"…","level":"INFO","msg":"mcp_request","http_method":"POST","path":"/metrics",
"status":200,"duration_ms":41.3,"sub":"[email protected]","email":"[email protected]",
"name":"Ada L","client":"https://client.example/cimd.json",
"method":"tools/call","tool":"query"}
One line per request goes to standard output, refused requests included. A batch logs "method":"batch" with a calls array. The line never holds a token, header, query string, tool argument or response body.