Visitar URL original
docs(auth): document HTTP verification requirements and pin them with… · modelcontextprotocol/registry@c34626b · GitHub
Skip to content

Commit c34626b

Browse files
authored
docs(auth): document HTTP verification requirements and pin them with tests (#1640)
## Summary Answers the documentation ask in #1625: when HTTP verification fails there is no way to tell whether the fault is the key, the file, or the Registry's ability to reach the endpoint, and the Registry's egress addresses are not a fixed set that can be allowlisted. ### Documented fetch contract (`docs/modelcontextprotocol-io/authentication.mdx`) New "Troubleshooting failed HTTP verification" subsection describing what the Registry actually requests when `mcp-publisher login http` runs, why a successful local `curl` proves nothing, and how to reproduce the fetch from outside your own network: - `GET https://<domain>/.well-known/mcp-registry-auth` with `Accept: text/plain` and `User-Agent: mcp-registry/1.0` - `200 OK` only — redirects are not followed, so a `3xx` fails verification - proof record of at most 4096 bytes, with surrounding whitespace ignored - 10 second budget for the whole request - the request originates from Google Cloud, where the hosted Registry runs, so per-request IP allowlisting is not a workable fix It then lists the edge rules that break verification in practice (WAF/bot-management challenges, geo-blocking or allowlists that exclude cloud ranges, an unreachable `AAAA` record, redirects, an HTML error page served with `200 OK`), how to read the Registry's own fetch error out of the login failure, and where to fix it. ### The same contract is pinned by tests (`internal/api/handlers/v0/auth/http_internal_test.go`) These properties were previously untested: the existing fetcher tests inject a custom `http.Client`, so the production client's redirect policy was never exercised, and nothing asserted the request headers or whitespace trimming. - `TestHTTPKeyFetcherClient_DoesNotFollowRedirects` — a `301` must fail verification and the redirect target must never be requested. - `TestHTTPKeyFetcherClient_TimeoutAndRequestHeaders` — pins the 10 second timeout, the two documented request headers, and `TrimSpace` on the proof record. - `TestHTTPKeyFetcher_RejectsResponseAboveDocumentedLimit` — a `200 OK` body above 4096 bytes fails. To make the production client testable, client construction moved into `newHTTPKeyFetcherClient(transport http.RoundTripper)`; `NewDefaultHTTPKeyFetcher` passes the same cloned transport with `safeDialContext`, so production behavior is unchanged. This PR changes no production behavior. ## Verification - `go build ./...` — clean. - `go vet ./...` — clean. - `gofmt -l internal/` — clean. - `go test ./internal/api/handlers/v0/auth/ -count=1` — `ok` (whole package passes). - The redirect test is load-bearing: deleting the `CheckRedirect` policy from `newHTTPKeyFetcherClient` makes it fail with `stopped after 10 redirects`, which is exactly the regression it guards. - `go test ./internal/api/handlers/v0/ -count=1` fails on this machine only because that suite needs PostgreSQL from `docker-compose up -d postgres`; unrelated to this change. `golangci-lint` is not installed locally, so `make lint` was not run. Related to #1625. > AI assistance disclosure: AI was used to discover this opportunity and draft the change or text. The submission was checked against the prepared artifact and recorded verification evidence. Co-authored-by: ump45nose <ump45nose@users.noreply.github.com>
1 parent cd511f4 commit c34626b

3 files changed

Lines changed: 190 additions & 9 deletions

File tree

‎docs/modelcontextprotocol-io/authentication.mdx‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,42 @@ mcp-publisher login http azure-key-vault --domain="${MY_DOMAIN}" --vault "${MY_K
330330
```
331331

332332
</CodeGroup>
333+
334+
### Troubleshooting failed HTTP verification
335+
336+
The Registry fetches your verification file from its own infrastructure when you run `mcp-publisher login http`, so a `curl` that succeeds on your machine does not prove that login will work. The Registry itself must be able to reach the endpoint.
337+
338+
<Warning>
339+
If the Registry cannot use the endpoint, login fails with `token exchange failed with status 401` even though the file is served correctly to every other client.
340+
</Warning>
341+
342+
The fetch is a plain HTTPS request with these properties:
343+
344+
| Property | Requirement |
345+
| --- | --- |
346+
| Request | `GET https://<domain>/.well-known/mcp-registry-auth`, sent with `Accept: text/plain` and `User-Agent: mcp-registry/1.0` |
347+
| Status | `200 OK`. Redirects are not followed, so a `3xx` fails verification |
348+
| Body | the proof record, at most 4096 bytes. Surrounding whitespace is ignored |
349+
| Timeout | 10 seconds for the whole request |
350+
| Origin | the hosted Registry runs on Google Cloud, so the request arrives from Google Cloud addresses rather than a fixed address you can allowlist per request |
351+
352+
Because the request originates from cloud infrastructure rather than a browser on a residential network, the usual causes of failure are edge rules that block it:
353+
354+
- a WAF, bot-management, or "under attack" rule that challenges or blocks non-browser clients
355+
- a geo-block or IP allowlist that excludes Google Cloud address ranges
356+
- an `AAAA` (IPv6) record pointing at an address that is not reachable. Verification fails if none of the domain's public addresses can be reached, even when IPv4 works in a browser
357+
- a `301`/`302` redirect to another host or path, which the Registry does not follow
358+
- `/.well-known/mcp-registry-auth` answering `200 OK` with an HTML error page instead of the proof record
359+
360+
To see what the Registry sees, request the file the same way it does, ideally from a network other than your own:
361+
362+
```bash
363+
curl -sS -o - -w '\n%{http_code}\n' \
364+
-H 'Accept: text/plain' \
365+
-A 'mcp-registry/1.0' \
366+
https://example.com/.well-known/mcp-registry-auth
367+
```
368+
369+
The response body must be the proof record printed by the login command (`v=MCPv1; k=...; p=...`). When the Registry cannot use the endpoint, the login error carries the Registry's own fetch failure, such as `dial tcp <ip>:443: i/o timeout`, `HTTP 403: failed to fetch key from ...`, or `HTTP 301: failed to fetch key from ...`. Allow plain HTTPS clients through for `/.well-known/mcp-registry-auth`, serve the path without redirects, then retry.
370+
371+
Addresses that resolve to loopback, private, link-local, or carrier-grade NAT ranges are refused by design, so the endpoint must be reachable at a public address.

‎internal/api/handlers/v0/auth/http.go‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,26 @@ func NewDefaultHTTPKeyFetcher() *DefaultHTTPKeyFetcher {
3838
transport := http.DefaultTransport.(*http.Transport).Clone()
3939
transport.DialContext = safeDialContext
4040

41-
return &DefaultHTTPKeyFetcher{
42-
client: &http.Client{
43-
Timeout: 10 * time.Second,
44-
// Disable redirects for security purposes:
45-
// Prevents people doing weird things like sending us to internal endpoints at different paths
46-
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
47-
return http.ErrUseLastResponse
48-
},
49-
Transport: transport,
41+
return &DefaultHTTPKeyFetcher{client: newHTTPKeyFetcherClient(transport)}
42+
}
43+
44+
// httpKeyFetchTimeout bounds the entire well-known fetch, including DNS resolution
45+
// and the TLS handshake. It is part of the documented verification contract: an
46+
// endpoint that cannot answer within this budget fails authentication.
47+
const httpKeyFetchTimeout = 10 * time.Second
48+
49+
// newHTTPKeyFetcherClient builds the HTTP client used to fetch a domain's
50+
// well-known verification key. The transport is a parameter so that tests can
51+
// exercise the production timeout and redirect policy against a local server.
52+
func newHTTPKeyFetcherClient(transport http.RoundTripper) *http.Client {
53+
return &http.Client{
54+
Timeout: httpKeyFetchTimeout,
55+
// Disable redirects for security purposes:
56+
// Prevents people doing weird things like sending us to internal endpoints at different paths
57+
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
58+
return http.ErrUseLastResponse
5059
},
60+
Transport: transport,
5161
}
5262
}
5363

‎internal/api/handlers/v0/auth/http_internal_test.go‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
package auth
22

33
import (
4+
"bytes"
5+
"context"
6+
"crypto/tls"
47
"net"
8+
"net/http"
9+
"net/http/httptest"
10+
"strings"
511
"testing"
12+
"time"
613
)
714

815
func TestIsBlockedIP(t *testing.T) {
@@ -78,3 +85,128 @@ func TestIsBlockedIP(t *testing.T) {
7885
})
7986
}
8087
}
88+
89+
// Mirrors the constants of the same purpose in the external test package; the
90+
// well-known path and a placeholder domain are all FetchKey needs, since the
91+
// transport dials the local test server regardless of the request hostname.
92+
const (
93+
internalWellKnownPath = "/.well-known/mcp-registry-auth"
94+
internalTestDomain = "example.com"
95+
)
96+
97+
// newLocalTLSTransport builds a transport that reaches srv regardless of the
98+
// hostname in the request URL, so the production client configuration can be
99+
// exercised against a local server.
100+
func newLocalTLSTransport(srv *httptest.Server) *http.Transport {
101+
dialAddr := srv.Listener.Addr().String()
102+
return &http.Transport{
103+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // testing only
104+
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
105+
d := &net.Dialer{}
106+
return d.DialContext(ctx, network, dialAddr)
107+
},
108+
ForceAttemptHTTP2: false,
109+
MaxIdleConns: 10,
110+
IdleConnTimeout: 30 * time.Second,
111+
TLSHandshakeTimeout: 5 * time.Second,
112+
}
113+
}
114+
115+
func newLocalTLSFetcher(srv *httptest.Server) *DefaultHTTPKeyFetcher {
116+
return &DefaultHTTPKeyFetcher{client: newHTTPKeyFetcherClient(newLocalTLSTransport(srv))}
117+
}
118+
119+
// The published HTTP verification contract (see
120+
// docs/modelcontextprotocol-io/authentication.mdx) tells users the endpoint must
121+
// answer 200 OK directly, so the client must never follow a redirect to another
122+
// host or path.
123+
func TestHTTPKeyFetcherClient_DoesNotFollowRedirects(t *testing.T) {
124+
var redirectTargetHit bool
125+
126+
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
127+
redirectTargetHit = true
128+
w.WriteHeader(http.StatusOK)
129+
_, _ = w.Write([]byte("v=MCPv1; k=ed25519; p=REDIRECTED"))
130+
}))
131+
defer target.Close()
132+
133+
redirector := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134+
if r.URL.Path != internalWellKnownPath {
135+
w.WriteHeader(http.StatusNotFound)
136+
return
137+
}
138+
http.Redirect(w, r, target.URL+internalWellKnownPath, http.StatusMovedPermanently)
139+
}))
140+
defer redirector.Close()
141+
142+
fetcher := newLocalTLSFetcher(redirector)
143+
144+
_, err := fetcher.FetchKey(context.Background(), internalTestDomain)
145+
if err == nil {
146+
t.Fatal("expected the redirect to fail verification, got nil error")
147+
}
148+
if !strings.Contains(err.Error(), "HTTP 301") {
149+
t.Fatalf("got err=%v, want it to report HTTP 301", err)
150+
}
151+
if redirectTargetHit {
152+
t.Error("the redirect target was requested; redirects must not be followed")
153+
}
154+
}
155+
156+
// Pins the timeout users are told to satisfy and the request headers the
157+
// documented fetch sends.
158+
func TestHTTPKeyFetcherClient_TimeoutAndRequestHeaders(t *testing.T) {
159+
type observed struct {
160+
accept string
161+
userAgent string
162+
}
163+
var got observed
164+
165+
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
166+
got = observed{accept: r.Header.Get("Accept"), userAgent: r.Header.Get("User-Agent")}
167+
w.WriteHeader(http.StatusOK)
168+
_, _ = w.Write([]byte(" v=MCPv1; k=ed25519; p=PUBLIC_KEY\n"))
169+
}))
170+
defer srv.Close()
171+
172+
fetcher := newLocalTLSFetcher(srv)
173+
174+
if fetcher.client.Timeout != httpKeyFetchTimeout {
175+
t.Errorf("client timeout = %v, want %v", fetcher.client.Timeout, httpKeyFetchTimeout)
176+
}
177+
178+
key, err := fetcher.FetchKey(context.Background(), internalTestDomain)
179+
if err != nil {
180+
t.Fatalf("unexpected error: %v", err)
181+
}
182+
if want := "v=MCPv1; k=ed25519; p=PUBLIC_KEY"; key != want {
183+
t.Errorf("key = %q, want %q (surrounding whitespace must be trimmed)", key, want)
184+
}
185+
if want := "text/plain"; got.accept != want {
186+
t.Errorf("Accept = %q, want %q", got.accept, want)
187+
}
188+
if want := "mcp-registry/1.0"; got.userAgent != want {
189+
t.Errorf("User-Agent = %q, want %q", got.userAgent, want)
190+
}
191+
}
192+
193+
// A body above the documented 4096 byte limit must fail even when the server
194+
// answers 200 OK.
195+
func TestHTTPKeyFetcher_RejectsResponseAboveDocumentedLimit(t *testing.T) {
196+
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
197+
if r.URL.Path != internalWellKnownPath {
198+
w.WriteHeader(http.StatusNotFound)
199+
return
200+
}
201+
w.WriteHeader(http.StatusOK)
202+
_, _ = w.Write(bytes.Repeat([]byte("A"), MaxKeyResponseSize+1))
203+
}))
204+
defer srv.Close()
205+
206+
fetcher := newLocalTLSFetcher(srv)
207+
208+
_, err := fetcher.FetchKey(context.Background(), internalTestDomain)
209+
if err == nil || !strings.Contains(err.Error(), "too large") {
210+
t.Fatalf("got err=%v, want a response-too-large error", err)
211+
}
212+
}

0 commit comments

Comments
 (0)