Build & push patched image / build (push) Successful in 1m8s
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.
158 lines
6.3 KiB
Markdown
158 lines
6.3 KiB
Markdown
# metamcp-patched
|
|
|
|
A thin wrapper around [`ghcr.io/metatool-ai/metamcp`](https://github.com/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
|
|
|
|
```yaml
|
|
# 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
|
|
|
|
```yaml
|
|
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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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.**
|
|
|
|
1. `mcp_servers` has `UNIQUE (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.
|
|
2. The downstream server has to actually read the header. This pairs
|
|
with a logger that prefers `X-MCP-Client` over `User-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.
|