initial commit: wrapper Dockerfile + CI
Build & push patched image / build (push) Failing after 1m13s

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.
This commit is contained in:
2026-07-22 12:04:58 -04:00
commit d5cd602d85
5 changed files with 267 additions and 0 deletions
+83
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
.DS_Store
*.swp
*.tmp
+61
View File
@@ -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"
+21
View File
@@ -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.
+99
View File
@@ -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://<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"
```
## 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.