Files
metamcp-patched/README.md
T
justin 26b27b2d94
Build & push patched image / build (push) Successful in 1m8s
sql: per-caller usage attribution via dedicated endpoint chains
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.
2026-07-27 10:44:36 -04:00

6.3 KiB

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.

  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.