MetaMCP proxies with a fresh outbound connection and forwards no client
identity — not User-Agent, not X-MCP-Client, not initialize.clientInfo.
A downstream server that logs client identity therefore sees only
MetaMCP's own Node UA, and every client behind the gateway collapses
into one undifferentiated bucket. Verified by probe: three distinct
identity values in, {"name": "node", "user_agent": "node"} logged out.
Not patchable at reasonable cost: downstream connections are pooled and
pre-warmed (idleSessions[serverUuid] / createIdleSessionAsync), so one
connection serves many inbound callers and headers bind at connection
creation. No per-request injection point exists without disabling
pooling, re-keying the pool by (server, caller), or threading
AsyncLocalStorage through the transport.
But MetaMCP already supports static per-server custom headers —
mcp_servers.headers is jsonb and flows into the outbound
requestInit.headers. So give each caller its own endpoint → namespace →
server chain, pointing at the SAME backend URL, differing only in a
stamped X-MCP-Client header. Same container, same corpus, nothing
duplicated downstream.
Adds sql/zsupport-endpoint.sql (the worked example, applied to the live
gateway 2026-07-27 — zSupport now attributes correctly) and a README
section covering the pattern and its two gotchas: the UNIQUE (name,
user_id) constraint forcing a new server name, and the resulting change
of tool-name prefix that any client allow-list must track.
sql/** added to the workflow's paths-ignore — config, not image.
metamcp-patched
A thin wrapper around ghcr.io/metatool-ai/metamcp
that hides the host-wide OAuth 2.1 discovery endpoints so spec-compliant MCP
clients don't force an interactive login on public endpoints.
The problem this fixes
MetaMCP unconditionally publishes RFC 9728 protected-resource metadata and RFC 8414 authorization-server metadata at:
/.well-known/oauth-protected-resource/.well-known/oauth-authorization-server
Both documents describe the whole MetaMCP host as OAuth-protected — even if every configured endpoint is public. Per the MCP Authorization spec (2026-03-26 revision), a spec-compliant client — Claude.ai's remote-MCP integration, Claude Desktop, and the reference SDKs — probes these paths on connect. When it sees them, it kicks off the OAuth flow (with dynamic client registration and PKCE) before calling any tool. Users get an interactive login page even when trying to connect to an endpoint that doesn't require auth.
The route mounts live in a compiled bundle with no env-var gate — the
line app.use(oauth_default) in apps/backend/dist/index.js pulls the
metadata router in unconditionally. There is no admin toggle for it in
the shipping build.
This repo produces a wrapper image that sed-patches the two discovery route registrations to bogus paths, so Express falls through to its default 404 for those requests. Nothing else in MetaMCP changes.
Endpoints that DO enforce auth still 401 correctly; they just can't be consumed via OAuth dynamic-discovery clients any more (consume them via a bearer token in the MCP client's config instead).
Using the pre-built image
# docker-compose.yml
services:
metamcp:
image: git.jpaul.io/justin/metamcp-patched:latest
pull_policy: always
# ... rest of your existing metamcp service config unchanged
Or build from source locally
services:
metamcp:
image: metamcp-patched:local
pull_policy: never
build:
context: https://git.jpaul.io/justin/metamcp-patched.git
# or a local clone / vendored path
Bump upstream at any time with:
docker compose build --pull metamcp && docker compose up -d metamcp
The Dockerfile has grep guards that fail the build loudly if a future
upstream release renames or restructures the OAuth routes, so a bad bump
never silently ships an unpatched image.
Verify it worked
UA="Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0"
# want 404, was 200 pre-patch
curl -sSo /dev/null -w "%{http_code}\n" -H "User-Agent: $UA" \
"https://<your-metamcp-host>/.well-known/oauth-protected-resource"
curl -sSo /dev/null -w "%{http_code}\n" -H "User-Agent: $UA" \
"https://<your-metamcp-host>/.well-known/oauth-authorization-server"
# want 200 (unchanged) — proves you didn't collaterally break the endpoint
curl -sSo /dev/null -w "%{http_code}\n" -H "User-Agent: $UA" \
-H "Accept: application/json, text/event-stream" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"diag","version":"1.0"}}}' \
"https://<your-metamcp-host>/metamcp/<a-public-endpoint>/mcp"
sql/ — per-caller usage attribution (config, not a patch)
A second MetaMCP quirk, solved with configuration rather than a patch.
The problem. MetaMCP terminates each inbound connection and opens a
fresh outbound request to the downstream MCP server. It forwards
neither the caller's User-Agent nor X-MCP-Client, nor the MCP
initialize.clientInfo. So a downstream server that logs client
identity sees only MetaMCP's own Node user-agent, and every client
behind the gateway — claude.ai, Claude Desktop, your own apps —
collapses into one undifferentiated bucket.
Verified by probe: a request carrying three distinct identity values
arrived downstream logged as {"name": "node", "user_agent": "node"}.
Why not patch it. Downstream connections are pooled and pre-warmed
(idleSessions[serverUuid], createIdleSessionAsync), so one
connection serves many different inbound callers. Headers are bound at
connection-creation time; there is no per-request injection point.
Making it dynamic would mean disabling pooling, keying the pool by
(server, caller), or threading AsyncLocalStorage through the
transport — all far more invasive than the Dockerfile patch above, and
all fragile across upstream bumps.
What works instead. MetaMCP does support static per-server custom
headers — mcp_servers.headers is a jsonb column that flows straight
into the outbound requestInit.headers. So give each caller its own
endpoint chain, where the server row stamps a header identifying it:
/metamcp/<name>/mcp → namespace <name> → server <name>
url: <same backend URL>
headers: {"X-MCP-Client": "<name>/1.0"}
Same backend container, same corpus, same index — only the stamped header differs. Nothing is duplicated downstream.
sql/zsupport-endpoint.sql is a worked example that adds such a chain
for the zSupport portal against a zerto-docs backend. Adapt the names
and the header value for other callers. Apply with:
docker cp sql/zsupport-endpoint.sql <postgres-container>:/tmp/
docker exec <postgres-container> \
psql -U <user> -d <db> -v ON_ERROR_STOP=1 -f /tmp/zsupport-endpoint.sql
Two gotchas.
mcp_servershasUNIQUE (name, user_id), so the new server row cannot reuse the existing name. And tools are namespaced<server-name>__<tool>, so the new chain exposes a different tool prefix — any client allow-list must be updated to match, or it will silently filter out every tool.- The downstream server has to actually read the header. This pairs
with a logger that prefers
X-MCP-ClientoverUser-Agent.
Delete this repo the day upstream ships a toggle
The right fix belongs in MetaMCP itself — either an env var like
DISABLE_OAUTH_DISCOVERY=true, or per-endpoint RFC 9728 metadata
scoping so public endpoints don't advertise. Track upstream at
https://github.com/metatool-ai/metamcp.
License
MIT for the wrapper contents (Dockerfile, workflow, docs). The base image and everything it contains remain under their upstream licenses.