From d5cd602d85cdc3b4dca5266dfe5fd847dd8eedc3 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Wed, 22 Jul 2026 12:04:58 -0400 Subject: [PATCH] initial commit: wrapper Dockerfile + CI The wrapper sed-patches the two host-wide OAuth 2.1 discovery route registrations in MetaMCP's compiled backend bundle so spec-compliant MCP clients do not force an interactive login flow on public endpoints. See README for full rationale (RFC 9728, MCP Authorization spec 2026-03-26 revision) and the diagnostic evidence. Guarded with grep pre/post-conditions so a future upstream release that renames or restructures these routes fails the build loudly rather than silently shipping an unpatched image. --- .gitea/workflows/build.yml | 83 ++++++++++++++++++++++++++++++++ .gitignore | 3 ++ Dockerfile | 61 +++++++++++++++++++++++ LICENSE | 21 ++++++++ README.md | 99 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 267 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..644e256 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,83 @@ +name: Build & push patched image + +# Wraps ghcr.io/metatool-ai/metamcp:latest with our sed-patch to hide +# the OAuth 2.1 discovery endpoints, then pushes the resulting image +# to the git.jpaul.io registry. +# +# Runs on push to main, on the nightly cron so we automatically pick up +# upstream :latest updates, and on demand via workflow_dispatch. The +# Dockerfile has grep guards that fail the build loudly if upstream +# renames or restructures the OAuth routes, so a bad bump is loud — +# nightly failures show up as red workflow runs, not a silently broken +# unpatched image. + +on: + push: + branches: [main] + paths-ignore: + - "README.md" + - "LICENSE" + - ".gitignore" + schedule: + # Every day at 08:00 UTC — pulls upstream :latest and rebuilds. + - cron: "0 8 * * *" + workflow_dispatch: + +env: + # Push to the plain-HTTP LAN endpoint (bypasses Cloudflare's body cap + # on multi-hundred-MB layers). Pull consumers use the FQDN over TLS. + REGISTRY_PUSH: 192.168.0.2:1234 + REGISTRY_PULL: git.jpaul.io + IMAGE: justin/metamcp-patched + +jobs: + build: + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: image=moby/buildkit:latest + buildkitd-config-inline: | + [registry."192.168.0.2:1234"] + http = true + insecure = true + + - name: Registry login (hand-written config — login-action 403s on plain HTTP) + shell: bash + run: | + AUTH=$(printf '%s' "justin:${{ secrets.REGISTRY_TOKEN }}" | base64 -w0) + mkdir -p ~/.docker + printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY_PUSH}" "$AUTH" > ~/.docker/config.json + + - name: Compute tag + id: tags + shell: bash + run: | + SHA="$(echo "${{ github.sha }}" | cut -c1-12)" + echo "sha=${SHA}" >> "$GITHUB_OUTPUT" + + - name: Build & push + shell: bash + run: | + docker buildx build \ + --push \ + --pull \ + -t "${REGISTRY_PUSH}/${IMAGE}:latest" \ + -t "${REGISTRY_PUSH}/${IMAGE}:${{ steps.tags.outputs.sha }}" \ + --label "org.opencontainers.image.source=https://git.jpaul.io/${IMAGE}" \ + --label "org.opencontainers.image.description=MetaMCP with host-wide OAuth 2.1 discovery routes hidden" \ + . + echo "Pushed ${REGISTRY_PULL}/${IMAGE}:{latest,${{ steps.tags.outputs.sha }}}" + + - name: Link package to repo (idempotent) + shell: bash + run: | + curl -s -o /dev/null -w "link HTTP %{http_code}\n" \ + -H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \ + -X POST "http://${REGISTRY_PUSH}/api/v1/packages/justin/container/metamcp-patched/-/link/metamcp-patched" || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9707e23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.swp +*.tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b16f4c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +# Patch: hide RFC 9728 / RFC 8414 OAuth discovery on the MetaMCP host. +# +# Why: MetaMCP unconditionally publishes /.well-known/oauth-protected-resource +# and /.well-known/oauth-authorization-server for the WHOLE host, even when +# every configured endpoint is public. Spec-compliant MCP clients (claude.ai, +# Claude Desktop, per MCP Authorization spec 2026-03-26 + RFC 9728) probe +# those paths on connect, see "resource requires OAuth", and pop the +# MetaMCP/Authentik login before calling any tool — even a public one. +# +# The route mounts live in a compiled bundle with no env-var gate +# (app.use(oauth_default) at line 10560 of dist/index.js pulls the metadata +# router in unconditionally). Fix here is a surgical string swap on the +# path literals so Express falls through to its default 404 for those +# requests. All 4 references get swapped (2 route registrations + 2 +# WWW-Authenticate/resource_metadata string interpolations) so the set of +# references is self-consistent. The WWW-Authenticate change is harmless +# because it only fires on 401 responses from ENFORCED endpoints, and our +# only enforced endpoint (OB1) is consumed with static bearers set in +# client config, never via OAuth discovery. +# +# The RUN block is guarded — it verifies the target strings exist and that +# the exact expected number of substitutions happens. A future upstream +# rewrite that renames or restructures these paths will FAIL THE BUILD +# LOUDLY rather than silently ship a container that still leaks OAuth +# discovery. When that happens, re-inspect the bundle and update this file. +# +# Delete this whole file the day upstream ships an env-var toggle to +# disable OAuth discovery (or per-endpoint metadata scoping per RFC 9728). +FROM ghcr.io/metatool-ai/metamcp:latest + +USER root +RUN set -eux; \ + F=/app/apps/backend/dist/index.js; \ + test -f "$F"; \ + # Sanity: both discovery route registrations must exist before we patch. + grep -Fq "\"/.well-known/oauth-protected-resource\"" "$F"; \ + grep -Fq "\"/.well-known/oauth-authorization-server\"" "$F"; \ + # Refuse to double-patch (idempotency guard). + if grep -Fq "/__jpaul_disabled_oauth" "$F"; then \ + echo "already patched — aborting"; exit 1; \ + fi; \ + # Count occurrences to catch upstream drift (expect 2 of each today). + B1=$(grep -Fc "\"/.well-known/oauth-protected-resource\"" "$F"); \ + B2=$(grep -Fc "\"/.well-known/oauth-authorization-server\"" "$F"); \ + [ "$B1" = "1" ] || { echo "unexpected count $B1 for oauth-protected-resource; upstream changed"; exit 2; }; \ + [ "$B2" = "1" ] || { echo "unexpected count $B2 for oauth-authorization-server; upstream changed"; exit 2; }; \ + sed -i \ + -e "s#\"/\\.well-known/oauth-protected-resource\"#\"/__jpaul_disabled_oauth_pr\"#g" \ + -e "s#\"/\\.well-known/oauth-authorization-server\"#\"/__jpaul_disabled_oauth_as\"#g" \ + "$F"; \ + # Post-condition: no more references to the original discovery paths. + if grep -Fq "\"/.well-known/oauth-protected-resource\"" "$F"; then \ + echo "patch failed: oauth-protected-resource still present"; exit 3; \ + fi; \ + if grep -Fq "\"/.well-known/oauth-authorization-server\"" "$F"; then \ + echo "patch failed: oauth-authorization-server still present"; exit 3; \ + fi; \ + # Patched markers present. + grep -Fq "/__jpaul_disabled_oauth_pr" "$F"; \ + grep -Fq "/__jpaul_disabled_oauth_as" "$F"; \ + echo "OAuth discovery routes disabled — patch verified" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63767b6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Justin Paul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0944e98 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# 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" +``` + +## 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.