initial commit: wrapper Dockerfile + CI
Build & push patched image / build (push) Failing after 1m13s
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:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.tmp
|
||||
+61
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user