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