# 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:///.well-known/oauth-protected-resource" curl -sSo /dev/null -w "%{http_code}\n" -H "User-Agent: $UA" \ "https:///.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:///metamcp//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//mcp → namespace → server url: headers: {"X-MCP-Client": "/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 :/tmp/ docker exec \ psql -U -d -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 `__`, 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 . ## License MIT for the wrapper contents (Dockerfile, workflow, docs). The base image and everything it contains remain under their upstream licenses.