From 86dc90a17851e6bf4ab2eacf92c60b8285b7213a Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Fri, 30 Oct 2026 12:38:44 +0100 Subject: [PATCH 1/4] feat(http): pluggable SetAuthFunc and resource_metadata on 401 challenge --- gomcp/httpauth_test.go | 135 +++++++++++++++++++++++++++++++++++++++++ gomcp/httpserver.go | 21 +++++-- gomcp/server.go | 25 ++++++++ 3 files changed, 176 insertions(+), 5 deletions(-) diff --git a/gomcp/httpauth_test.go b/gomcp/httpauth_test.go index 8c0db7a..92a1b90 100644 --- a/gomcp/httpauth_test.go +++ b/gomcp/httpauth_test.go @@ -1,6 +1,8 @@ package gomcp import ( + "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -112,3 +114,136 @@ func newAuthTestServer(t *testing.T, srv *Server) *httptest.Server { t.Cleanup(ts.Close) return ts } + +// authPost sends a JSON-RPC ping to url with optional headers and returns +// the response (body closed) and its body text. +func authPost(t *testing.T, url string, hdr map[string]string) (*http.Response, string) { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(testPing)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + for k, v := range hdr { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp, string(b) +} + +func wantChallenge(t *testing.T, resp *http.Response, want string) { + t.Helper() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } + if got := resp.Header.Get("WWW-Authenticate"); got != want { + t.Fatalf("WWW-Authenticate = %q, want %q", got, want) + } +} + +func TestAuthFuncDenyAll(t *testing.T) { + srv := NewServer("af", "1.0.0") + srv.SetAuthFunc(func(r *http.Request) bool { return false }) + ts := newAuthTestServer(t, srv) + resp, _ := authPost(t, ts.URL, nil) + wantChallenge(t, resp, `Bearer realm="mcp"`) +} + +func TestAuthFuncReadsRequestHeader(t *testing.T) { + srv := NewServer("af", "1.0.0") + srv.SetAuthFunc(func(r *http.Request) bool { return r.Header.Get("X-Key") == "k" }) + ts := newAuthTestServer(t, srv) + + resp, body := authPost(t, ts.URL, map[string]string{"X-Key": "k"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("with key: status = %d, want 200", resp.StatusCode) + } + var rpc struct { + Result map[string]any `json:"result"` + } + if err := json.Unmarshal([]byte(body), &rpc); err != nil || rpc.Result == nil { + t.Fatalf("with key: body %q is not a JSON-RPC result (err %v)", body, err) + } + + resp, _ = authPost(t, ts.URL, nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("without key: status = %d, want 401", resp.StatusCode) + } +} + +func TestAuthFuncOverridesStaticToken(t *testing.T) { + srv := NewServer("af", "1.0.0") + srv.SetAuthToken("tok") + srv.SetAuthFunc(func(r *http.Request) bool { return true }) + ts := newAuthTestServer(t, srv) + resp, _ := authPost(t, ts.URL, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("allow-all func, no token: status = %d, want 200", resp.StatusCode) + } + + srv.SetAuthFunc(func(r *http.Request) bool { return false }) + resp, _ = authPost(t, ts.URL, map[string]string{"Authorization": "Bearer tok"}) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("deny-all func, valid token: status = %d, want 401", resp.StatusCode) + } + + srv.SetAuthFunc(nil) + resp, _ = authPost(t, ts.URL, nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("func cleared, no token: status = %d, want 401", resp.StatusCode) + } + resp, _ = authPost(t, ts.URL, map[string]string{"Authorization": "Bearer tok"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("func cleared, valid token: status = %d, want 200", resp.StatusCode) + } +} + +func TestResourceMetadataURLChallenge(t *testing.T) { + const meta = "https://x/.well-known/oauth-protected-resource" + want := `Bearer realm="mcp", resource_metadata="` + meta + `"` + + // Static token path: missing and wrong token. + srv := NewServer("rm", "1.0.0") + srv.SetAuthToken("tok") + srv.SetResourceMetadataURL(meta) + ts := newAuthTestServer(t, srv) + resp, _ := authPost(t, ts.URL, nil) + wantChallenge(t, resp, want) + resp, _ = authPost(t, ts.URL, map[string]string{"Authorization": "Bearer wrong"}) + wantChallenge(t, resp, want) + + // Auth func path. + srv2 := NewServer("rm2", "1.0.0") + srv2.SetAuthFunc(func(r *http.Request) bool { return false }) + srv2.SetResourceMetadataURL(meta) + ts2 := newAuthTestServer(t, srv2) + resp, _ = authPost(t, ts2.URL, nil) + wantChallenge(t, resp, want) + + // Empty restores the plain challenge. + srv2.SetResourceMetadataURL("") + resp, _ = authPost(t, ts2.URL, nil) + wantChallenge(t, resp, `Bearer realm="mcp"`) +} + +func TestResourceMetadataURLEscaping(t *testing.T) { + srv := NewServer("rm", "1.0.0") + srv.SetAuthToken("tok") + srv.SetResourceMetadataURL(`https://x/a"b\c`) + ts := newAuthTestServer(t, srv) + resp, _ := authPost(t, ts.URL, nil) + wantChallenge(t, resp, `Bearer realm="mcp", resource_metadata="https://x/a\"b\\c"`) +} + +func TestResourceMetadataURLUnsetKeepsPlainChallenge(t *testing.T) { + srv := NewServer("rm", "1.0.0") + srv.SetAuthToken("tok") + ts := newAuthTestServer(t, srv) + resp, _ := authPost(t, ts.URL, nil) + wantChallenge(t, resp, `Bearer realm="mcp"`) +} diff --git a/gomcp/httpserver.go b/gomcp/httpserver.go index 02cd9e4..36cb6b6 100644 --- a/gomcp/httpserver.go +++ b/gomcp/httpserver.go @@ -70,6 +70,8 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { s.mu.RLock() authToken := s.authToken allowedOrigins := s.allowedOrigins + authFunc := s.authFunc + metaURL := s.resourceMetadataURL s.mu.RUnlock() // Origin check first: a cross-site request never reaches dispatch. @@ -78,14 +80,23 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { return } - if authToken != "" { + authorized := true + if authFunc != nil { + authorized = authFunc(r) + } else if authToken != "" { const prefix = "Bearer " authz := r.Header.Get("Authorization") - if !strings.HasPrefix(authz, prefix) || !constantTimeEqual(strings.TrimPrefix(authz, prefix), authToken) { - w.Header().Set("WWW-Authenticate", `Bearer realm="mcp"`) - http.Error(w, "unauthorized", http.StatusUnauthorized) - return + authorized = strings.HasPrefix(authz, prefix) && constantTimeEqual(strings.TrimPrefix(authz, prefix), authToken) + } + if !authorized { + challenge := `Bearer realm="mcp"` + if metaURL != "" { + esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(metaURL) + challenge += `, resource_metadata="` + esc + `"` } + w.Header().Set("WWW-Authenticate", challenge) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return } if r.Method != http.MethodPost { diff --git a/gomcp/server.go b/gomcp/server.go index 1064b73..b8dc894 100644 --- a/gomcp/server.go +++ b/gomcp/server.go @@ -16,6 +16,7 @@ import ( "errors" "fmt" "io" + "net/http" "os" "sort" "sync" @@ -89,6 +90,12 @@ type Server struct { // allowedOrigins, when non-empty, is the Origin allowlist enforced // by the HTTP transport. See SetAllowedOrigins. allowedOrigins []string + + // authFunc, when set, replaces the static token check. See SetAuthFunc. + authFunc func(r *http.Request) bool + + // resourceMetadataURL is advertised in 401 challenges. See SetResourceMetadataURL. + resourceMetadataURL string } // NewServer creates a new MCP server with the given name and version. @@ -133,6 +140,24 @@ func (s *Server) SetAuthToken(token string) { s.authToken = token } +// SetAuthFunc installs a custom HTTP authenticator checked on every request. +// When set it takes precedence over SetAuthToken (the token is not +// consulted); returning false yields 401 with a Bearer challenge. nil +// restores the default (static token, or none). +func (s *Server) SetAuthFunc(fn func(r *http.Request) bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.authFunc = fn +} + +// SetResourceMetadataURL adds a resource_metadata parameter (RFC 9728) to +// every 401 WWW-Authenticate challenge. Empty restores the plain challenge. +func (s *Server) SetResourceMetadataURL(url string) { + s.mu.Lock() + defer s.mu.Unlock() + s.resourceMetadataURL = url +} + // SetAllowedOrigins restricts which Origins browser-based HTTP clients may // come from (DNS-rebinding and CSRF defense, per the 2025-11-25 spec // guidance). Requests with an Origin not on the list get 403. Requests From 0664b19b93d821645591e9f218f2bef448d2633a Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Fri, 30 Oct 2026 12:41:35 +0100 Subject: [PATCH 2/4] feat(http): serve /.well-known/oauth-protected-resource metadata --- gomcp/httpauth_test.go | 95 ++++++++++++++++++++++++++++++++++++++++++ gomcp/httpserver.go | 8 ++++ gomcp/server.go | 18 ++++++++ 3 files changed, 121 insertions(+) diff --git a/gomcp/httpauth_test.go b/gomcp/httpauth_test.go index 92a1b90..5db3844 100644 --- a/gomcp/httpauth_test.go +++ b/gomcp/httpauth_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" ) @@ -247,3 +248,97 @@ func TestResourceMetadataURLUnsetKeepsPlainChallenge(t *testing.T) { resp, _ := authPost(t, ts.URL, nil) wantChallenge(t, resp, `Bearer realm="mcp"`) } + +const wellKnownPath = "/.well-known/oauth-protected-resource" + +func newMetaServer(meta map[string]any) *Server { + srv := NewServer("meta-test", "1.0.0") + srv.SetAuthToken("tok") + srv.SetAllowedOrigins([]string{"https://ok"}) + srv.SetProtectedResourceMetadata(meta) + return srv +} + +func TestProtectedResourceMetadataServedUnauthenticated(t *testing.T) { + meta := map[string]any{ + "resource": "https://x", + "authorization_servers": []any{"https://as"}, + } + ts := newAuthTestServer(t, newMetaServer(meta)) + + req, err := http.NewRequest(http.MethodGet, ts.URL+wellKnownPath, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Origin", "https://evil") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, body) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("body not JSON: %v (%s)", err, body) + } + if !reflect.DeepEqual(got, meta) { + t.Fatalf("body = %v, want %v", got, meta) + } +} + +func TestProtectedResourceMetadataCopiedAtSetTime(t *testing.T) { + meta := map[string]any{"resource": "https://x"} + ts := newAuthTestServer(t, newMetaServer(meta)) + meta["resource"] = "https://mutated" + + resp, err := http.Get(ts.URL + wellKnownPath) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v (status %d)", err, resp.StatusCode) + } + if got["resource"] != "https://x" { + t.Fatalf("resource = %v, want https://x (map must be copied at set time)", got["resource"]) + } +} + +func TestProtectedResourceMetadataMCPEndpointUnaffected(t *testing.T) { + ts := newAuthTestServer(t, newMetaServer(map[string]any{"resource": "https://x"})) + resp, body := authPost(t, ts.URL+"/", map[string]string{"Authorization": "Bearer tok"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, body) + } + if !strings.Contains(body, `"result"`) { + t.Fatalf("body = %s, want JSON-RPC result", body) + } +} + +func TestProtectedResourceMetadataUnsetGetIs405(t *testing.T) { + srv := NewServer("meta-unset", "1.0.0") + ts := newAuthTestServer(t, srv) + resp, err := http.Get(ts.URL + wellKnownPath) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", resp.StatusCode) + } +} + +func TestProtectedResourceMetadataPostNotIntercepted(t *testing.T) { + ts := newAuthTestServer(t, newMetaServer(map[string]any{"resource": "https://x"})) + resp, body := authPost(t, ts.URL+wellKnownPath, nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 (body: %s)", resp.StatusCode, body) + } +} diff --git a/gomcp/httpserver.go b/gomcp/httpserver.go index 36cb6b6..c65ea25 100644 --- a/gomcp/httpserver.go +++ b/gomcp/httpserver.go @@ -72,8 +72,16 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { allowedOrigins := s.allowedOrigins authFunc := s.authFunc metaURL := s.resourceMetadataURL + prm := s.protectedResourceMeta s.mu.RUnlock() + // RFC 9728 metadata is public: served before the origin and auth checks. + if prm != nil && r.Method == http.MethodGet && r.URL.Path == "/.well-known/oauth-protected-resource" { + w.Header().Set("Content-Type", "application/json") + w.Write(prm) + return + } + // Origin check first: a cross-site request never reaches dispatch. if len(allowedOrigins) > 0 && r.Header.Get("Origin") != "" && !containsString(allowedOrigins, r.Header.Get("Origin")) { http.Error(w, "origin not allowed", http.StatusForbidden) diff --git a/gomcp/server.go b/gomcp/server.go index b8dc894..10ad65f 100644 --- a/gomcp/server.go +++ b/gomcp/server.go @@ -96,6 +96,10 @@ type Server struct { // resourceMetadataURL is advertised in 401 challenges. See SetResourceMetadataURL. resourceMetadataURL string + + // protectedResourceMeta is the pre-encoded RFC 9728 document served at + // the well-known path. See SetProtectedResourceMetadata. + protectedResourceMeta []byte } // NewServer creates a new MCP server with the given name and version. @@ -158,6 +162,20 @@ func (s *Server) SetResourceMetadataURL(url string) { s.resourceMetadataURL = url } +// SetProtectedResourceMetadata makes the HTTP handler serve meta as JSON on +// GET /.well-known/oauth-protected-resource (RFC 9728), without +// authentication. Nil (default) disables the endpoint. +func (s *Server) SetProtectedResourceMetadata(meta map[string]any) { + // Encoding at set time snapshots the document, nested values included. + var enc []byte + if meta != nil { + enc, _ = json.Marshal(meta) // unencodable metadata leaves the endpoint disabled + } + s.mu.Lock() + defer s.mu.Unlock() + s.protectedResourceMeta = enc +} + // SetAllowedOrigins restricts which Origins browser-based HTTP clients may // come from (DNS-rebinding and CSRF defense, per the 2025-11-25 spec // guidance). Requests with an Origin not on the list get 403. Requests From eaaee14b7e12050621676689f9956db582ea5672 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Fri, 30 Oct 2026 12:44:45 +0100 Subject: [PATCH 3/4] feat(client): auth provider and typed AuthError with resource_metadata on 401 --- gomcp/client.go | 104 ++++++++++++++++++++ gomcp/client_test.go | 210 +++++++++++++++++++++++++++++++++++++++++ gomcp/httpauth_test.go | 74 +++++++++++++++ 3 files changed, 388 insertions(+) diff --git a/gomcp/client.go b/gomcp/client.go index f35b6ab..595fce5 100644 --- a/gomcp/client.go +++ b/gomcp/client.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -214,6 +215,8 @@ type HTTPClient struct { core clientCore nextID int64 mu sync.Mutex + + authProvider func() string } // NewHTTPClient returns a Client posting JSON-RPC messages to url. @@ -230,7 +233,102 @@ func NewHTTPClientWithToken(url, token string) *HTTPClient { return hc } +// ErrUnauthorized is matched by errors.Is for any *AuthError. +var ErrUnauthorized = errors.New("unauthorized") + +// AuthError is returned when the server answers HTTP 401. +type AuthError struct { + // ResourceMetadata is the resource_metadata URL from the + // WWW-Authenticate challenge (empty if absent). + ResourceMetadata string + // Body is the response body text. + Body string +} + +// Error implements error. +func (e *AuthError) Error() string { + msg := "http 401: unauthorized" + if e.Body != "" { + msg = "http 401: " + e.Body + } + if e.ResourceMetadata != "" { + msg += " (resource_metadata=" + e.ResourceMetadata + ")" + } + return msg +} + +// Is reports whether target is ErrUnauthorized. +func (e *AuthError) Is(target error) bool { return target == ErrUnauthorized } + +// newAuthError builds an AuthError from a 401 response. +func newAuthError(h http.Header, body []byte) *AuthError { + ae := &AuthError{Body: strings.TrimSpace(string(body))} + for _, ch := range h.Values("WWW-Authenticate") { + if v, ok := challengeParam(ch, "resource_metadata"); ok { + ae.ResourceMetadata = v + break + } + } + return ae +} + +// challengeParam extracts a quoted auth-param value, honouring backslash +// escapes inside the quoted string. +func challengeParam(challenge, name string) (string, bool) { + rest := challenge + for { + i := strings.Index(rest, name+"=\"") + if i < 0 { + return "", false + } + if i > 0 && rest[i-1] != ' ' && rest[i-1] != ',' { + rest = rest[i+len(name):] + continue + } + var sb strings.Builder + q := rest[i+len(name)+2:] + for j := 0; j < len(q); j++ { + switch q[j] { + case '\\': + if j+1 < len(q) { + j++ + sb.WriteByte(q[j]) + } + case '"': + return sb.String(), true + default: + sb.WriteByte(q[j]) + } + } + return "", false + } +} + +// SetAuthProvider sets a per-request Authorization header provider. Its +// value is used verbatim and wins over the static token; an empty value +// falls back to the static token; nil clears the provider. +func (c *HTTPClient) SetAuthProvider(fn func() string) { + c.mu.Lock() + c.authProvider = fn + c.mu.Unlock() +} + +// WithAuthHeader is a chainable SetAuthProvider. +func (c *HTTPClient) WithAuthHeader(fn func() string) *HTTPClient { + c.SetAuthProvider(fn) + return c +} + func (c *HTTPClient) setAuth(h http.Header) { + c.mu.Lock() + p := c.authProvider + c.mu.Unlock() + if p != nil { + if v := p(); v != "" { + h.Set("Authorization", v) + return + } + } if c.token != "" { h.Set("Authorization", "Bearer "+c.token) } @@ -267,6 +365,9 @@ func (c *HTTPClient) call(ctx context.Context, method string, params any, out an if resp.StatusCode == http.StatusAccepted { return fmt.Errorf("unexpected 202 for request %q", method) } + if resp.StatusCode == http.StatusUnauthorized { + return newAuthError(resp.Header, body) + } if resp.StatusCode != http.StatusOK { return fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } @@ -296,6 +397,9 @@ func (c *HTTPClient) notify(ctx context.Context, method string, params any) erro defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode == http.StatusUnauthorized { + return newAuthError(resp.Header, b) + } return fmt.Errorf("notify %q: http %d: %s", method, resp.StatusCode, strings.TrimSpace(string(b))) } _, _ = io.Copy(io.Discard, resp.Body) diff --git a/gomcp/client_test.go b/gomcp/client_test.go index 5eef9da..f3e0feb 100644 --- a/gomcp/client_test.go +++ b/gomcp/client_test.go @@ -2,10 +2,16 @@ package gomcp import ( "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" "strings" + "sync" "testing" "time" ) @@ -115,3 +121,207 @@ func main() { t.Fatalf("output = %q", out) } } + +// authRecorder is a stub server recording the Authorization header of every +// request and answering with a canned status. +func authRecorder(t *testing.T, status int, challenge string) (*httptest.Server, *[]string) { + t.Helper() + var mu sync.Mutex + seen := []string{} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seen = append(seen, r.Header.Get("Authorization")) + mu.Unlock() + if challenge != "" { + w.Header().Set("WWW-Authenticate", challenge) + } + if status != http.StatusOK { + w.WriteHeader(status) + _, _ = w.Write([]byte("denied")) + return + } + var req struct { + ID json.RawMessage `json:"id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if len(req.ID) == 0 { + w.WriteHeader(http.StatusAccepted) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":{"tools":[]}}`, req.ID) + })) + t.Cleanup(ts.Close) + return ts, &seen +} + +func TestHTTPClientAuthProviderSetsHeader(t *testing.T) { + ts, seen := authRecorder(t, http.StatusOK, "") + c := NewHTTPClient(ts.URL) + c.SetAuthProvider(func() string { return "Bearer xyz" }) + if _, err := c.ListTools(context.Background()); err != nil { + t.Fatal(err) + } + if len(*seen) != 1 || (*seen)[0] != "Bearer xyz" { + t.Fatalf("server saw %q, want [Bearer xyz]", *seen) + } +} + +func TestHTTPClientAuthProviderOverridesStaticToken(t *testing.T) { + ts, seen := authRecorder(t, http.StatusOK, "") + c := NewHTTPClientWithToken(ts.URL, "static") + c.SetAuthProvider(func() string { return "Bearer dyn" }) + if _, err := c.ListTools(context.Background()); err != nil { + t.Fatal(err) + } + if len(*seen) != 1 || (*seen)[0] != "Bearer dyn" { + t.Fatalf("server saw %q, want [Bearer dyn]", *seen) + } +} + +func TestHTTPClientAuthProviderCalledPerRequest(t *testing.T) { + ts, seen := authRecorder(t, http.StatusOK, "") + c := NewHTTPClient(ts.URL) + n := 0 + c.SetAuthProvider(func() string { n++; return fmt.Sprintf("Bearer t%d", n) }) + for i := 0; i < 2; i++ { + if _, err := c.ListTools(context.Background()); err != nil { + t.Fatal(err) + } + } + if len(*seen) != 2 || (*seen)[0] != "Bearer t1" || (*seen)[1] != "Bearer t2" { + t.Fatalf("server saw %q, want [Bearer t1 Bearer t2]", *seen) + } +} + +func TestHTTPClientAuthProviderOnNotify(t *testing.T) { + ts, seen := authRecorder(t, http.StatusOK, "") + c := NewHTTPClient(ts.URL) + c.SetAuthProvider(func() string { return "Bearer note" }) + if err := c.NotifyInitialized(context.Background()); err != nil { + t.Fatal(err) + } + if len(*seen) != 1 || (*seen)[0] != "Bearer note" { + t.Fatalf("server saw %q, want [Bearer note]", *seen) + } +} + +func TestHTTPClientWithAuthHeaderChains(t *testing.T) { + ts, seen := authRecorder(t, http.StatusOK, "") + c := NewHTTPClient(ts.URL) + got := c.WithAuthHeader(func() string { return "Bearer chained" }) + if got != c { + t.Fatal("WithAuthHeader must return the same client") + } + if _, err := c.ListTools(context.Background()); err != nil { + t.Fatal(err) + } + if len(*seen) != 1 || (*seen)[0] != "Bearer chained" { + t.Fatalf("server saw %q, want [Bearer chained]", *seen) + } +} + +func TestHTTPClientAuthProviderEmptyFallsBackToStatic(t *testing.T) { + ts, seen := authRecorder(t, http.StatusOK, "") + c := NewHTTPClientWithToken(ts.URL, "static") + c.SetAuthProvider(func() string { return "" }) + if _, err := c.ListTools(context.Background()); err != nil { + t.Fatal(err) + } + if len(*seen) != 1 || (*seen)[0] != "Bearer static" { + t.Fatalf("server saw %q, want [Bearer static]", *seen) + } +} + +func TestHTTPClientAuthProviderEmptyNoTokenSendsNoHeader(t *testing.T) { + ts, seen := authRecorder(t, http.StatusOK, "") + c := NewHTTPClient(ts.URL) + c.SetAuthProvider(func() string { return "" }) + if _, err := c.ListTools(context.Background()); err != nil { + t.Fatal(err) + } + if len(*seen) != 1 || (*seen)[0] != "" { + t.Fatalf("server saw %q, want [\"\"]", *seen) + } +} + +func TestHTTPClientSetAuthProviderNilClears(t *testing.T) { + ts, seen := authRecorder(t, http.StatusOK, "") + c := NewHTTPClientWithToken(ts.URL, "static") + c.SetAuthProvider(func() string { return "Bearer dyn" }) + c.SetAuthProvider(nil) + if _, err := c.ListTools(context.Background()); err != nil { + t.Fatal(err) + } + if len(*seen) != 1 || (*seen)[0] != "Bearer static" { + t.Fatalf("server saw %q, want [Bearer static] after nil clear", *seen) + } +} + +func TestHTTPClient401IsAuthError(t *testing.T) { + ts, _ := authRecorder(t, http.StatusUnauthorized, + `Bearer realm="mcp", resource_metadata="https://x/meta"`) + c := NewHTTPClient(ts.URL) + _, err := c.ListTools(context.Background()) + var ae *AuthError + if !errors.As(err, &ae) { + t.Fatalf("err = %v (%T), want *AuthError", err, err) + } + if ae.ResourceMetadata != "https://x/meta" { + t.Fatalf("ResourceMetadata = %q", ae.ResourceMetadata) + } + if ae.Body != "denied" { + t.Fatalf("Body = %q, want denied", ae.Body) + } + if !errors.Is(err, ErrUnauthorized) { + t.Fatal("errors.Is(err, ErrUnauthorized) = false") + } + if !strings.Contains(err.Error(), "401") { + t.Fatalf("Error() = %q, want it to contain 401", err.Error()) + } +} + +func TestHTTPClient401WithoutResourceMetadata(t *testing.T) { + for _, ch := range []string{`Bearer realm="mcp"`, ""} { + ts, _ := authRecorder(t, http.StatusUnauthorized, ch) + c := NewHTTPClient(ts.URL) + _, err := c.ListTools(context.Background()) + var ae *AuthError + if !errors.As(err, &ae) { + t.Fatalf("challenge %q: err = %v, want *AuthError", ch, err) + } + if ae.ResourceMetadata != "" { + t.Fatalf("challenge %q: ResourceMetadata = %q, want empty", ch, ae.ResourceMetadata) + } + } +} + +func TestHTTPClient401OnNotify(t *testing.T) { + ts, _ := authRecorder(t, http.StatusUnauthorized, + `Bearer realm="mcp", resource_metadata="https://x/meta"`) + c := NewHTTPClient(ts.URL) + err := c.NotifyInitialized(context.Background()) + var ae *AuthError + if !errors.As(err, &ae) { + t.Fatalf("err = %v (%T), want *AuthError", err, err) + } + if ae.ResourceMetadata != "https://x/meta" || !errors.Is(err, ErrUnauthorized) { + t.Fatalf("ae = %+v, Is = %v", ae, errors.Is(err, ErrUnauthorized)) + } +} + +func TestHTTPClient500IsNotAuthError(t *testing.T) { + ts, _ := authRecorder(t, http.StatusInternalServerError, "") + c := NewHTTPClient(ts.URL) + _, err := c.ListTools(context.Background()) + if err == nil { + t.Fatal("want error") + } + var ae *AuthError + if errors.As(err, &ae) || errors.Is(err, ErrUnauthorized) { + t.Fatalf("500 must not be an AuthError: %v", err) + } + if !strings.Contains(err.Error(), "http 500") { + t.Fatalf("Error() = %q, want it to contain \"http 500\"", err.Error()) + } +} diff --git a/gomcp/httpauth_test.go b/gomcp/httpauth_test.go index 5db3844..3d93da4 100644 --- a/gomcp/httpauth_test.go +++ b/gomcp/httpauth_test.go @@ -1,12 +1,15 @@ package gomcp import ( + "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" "reflect" "strings" + "sync" "testing" ) @@ -342,3 +345,74 @@ func TestProtectedResourceMetadataPostNotIntercepted(t *testing.T) { t.Fatalf("status = %d, want 401 (body: %s)", resp.StatusCode, body) } } + +func TestHTTPClientOAuthDiscoveryEndToEnd(t *testing.T) { + var base string + srv := NewServer("oauth-e2e", "1.0.0") + var mu sync.Mutex + valid := "tok-1" + srv.SetAuthFunc(func(r *http.Request) bool { + mu.Lock() + defer mu.Unlock() + return r.Header.Get("Authorization") == "Bearer "+valid + }) + ts := httptest.NewUnstartedServer(srv.Handler()) + ts.Start() + t.Cleanup(ts.Close) + base = ts.URL + meta := map[string]any{ + "resource": base, + "authorization_servers": []any{"https://as.example"}, + } + srv.SetResourceMetadataURL(base + "/.well-known/oauth-protected-resource") + srv.SetProtectedResourceMetadata(meta) + + // Unauthenticated client is challenged with a typed error. + anon := NewHTTPClient(base) + _, err := anon.ListTools(context.Background()) + var ae *AuthError + if !errors.As(err, &ae) || !errors.Is(err, ErrUnauthorized) { + t.Fatalf("err = %v (%T), want *AuthError", err, err) + } + if ae.ResourceMetadata != base+"/.well-known/oauth-protected-resource" { + t.Fatalf("ResourceMetadata = %q", ae.ResourceMetadata) + } + + // Discovery: fetch the advertised metadata document. + resp, err := http.Get(ae.ResourceMetadata) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("metadata status = %d", resp.StatusCode) + } + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, meta) { + t.Fatalf("metadata = %v, want %v", got, meta) + } + + // A client with a rotating provider succeeds across rotations. + c := NewHTTPClient(base) + token := "tok-1" + c.SetAuthProvider(func() string { return "Bearer " + token }) + if _, err := c.ListTools(context.Background()); err != nil { + t.Fatalf("first call: %v", err) + } + mu.Lock() + valid = "tok-2" + mu.Unlock() + if _, err := c.ListTools(context.Background()); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("stale token: err = %v, want ErrUnauthorized", err) + } + token = "tok-2" + if _, err := c.ListTools(context.Background()); err != nil { + t.Fatalf("after rotation: %v", err) + } + if err := c.NotifyInitialized(context.Background()); err != nil { + t.Fatalf("notify after rotation: %v", err) + } +} From 9a4b41983b9fa213ea81c26982bdabd10a46bc82 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Fri, 30 Oct 2026 12:47:17 +0100 Subject: [PATCH 4/4] docs: document pluggable auth, OAuth discovery and client AuthError --- AGENTS.md | 6 ++++-- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ gomcp/docs_test.go | 31 +++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 gomcp/docs_test.go diff --git a/AGENTS.md b/AGENTS.md index dbc863b..59b3f93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,8 +40,8 @@ Server.run() ← dispatch loop | `gomcp/protocol.go` | Protocol versions, `_meta` negotiation, pagination | | `gomcp/path.go` | `SafeJoin` — path-traversal-safe filesystem helper | | `gomcp/server.go` | Server struct, Run(), all JSON-RPC method handlers | -| `gomcp/httpserver.go` | MCP Streamable HTTP transport (`Handler`, `ListenAndServe`, `Serve`) | -| `gomcp/client.go` | MCP client (`Client` interface, `NewHTTPClient`, `NewStdioClient`) | +| `gomcp/httpserver.go` | MCP Streamable HTTP transport (`Handler`, `ListenAndServe`, `Serve`); pluggable auth and the public well-known endpoint | +| `gomcp/client.go` | MCP client (`Client` interface, `NewHTTPClient`, `NewStdioClient`); `SetAuthProvider`, typed `AuthError` | | `gomcp/server_test.go` | Unit + integration tests (pipe-based) | | `gomcp/protocol_test.go` | 2026-07-28 + security tests | | `gomcp/httpserver_test.go`, `gomcp/client_test.go`, `gomcp/http_e2e_test.go` | HTTP transport, client, and loopback E2E tests | @@ -58,6 +58,8 @@ Server.run() ← dispatch loop - **Inbound messages are size-capped.** One line may carry at most `Server.MaxRequestBytes` bytes (default `DefaultMaxRequestBytes`, 10 MiB; negative disables — not recommended). An oversized line is answered with `-32600` (id null), its remainder discarded, and the loop keeps serving — a single line can never exhaust memory. - **Go naming.** Exported types are PascalCase. Unexported internals are camelCase. Test functions are `TestXxx`. - **Protocol versions.** Default is `2026-07-28`. Legacy `initialize` still works and echoes `2024-11-05`, `2025-03-26`, or `2025-11-25` when the client asks for them. 2026-only fields (`resultType`, `ttlMs`, `cacheScope`, result `_meta`) are emitted only when the request declares `2026-07-28`. +- **Pluggable HTTP auth.** `SetAuthFunc` replaces (wins over) the `SetAuthToken` static check; a rejected request gets `401` with `WWW-Authenticate: Bearer realm="mcp"`, plus `resource_metadata=""` when `SetResourceMetadataURL` is set. `SetProtectedResourceMetadata` serves `GET /.well-known/oauth-protected-resource` publicly (before the origin and auth checks). The library validates no tokens itself. +- **Client auth errors are typed.** A `401` becomes `*gomcp.AuthError` (`ResourceMetadata`, `Body`); use `errors.As`, or `errors.Is(err, gomcp.ErrUnauthorized)`. `HTTPClient.SetAuthProvider`/`WithAuthHeader` supply the Authorization header per request (wins over the static token; empty falls back; nil clears). - **Handlers must not kill the loop.** A panicking or timed-out handler is answered in-band; registration maps are mutex-protected. ## Testing diff --git a/README.md b/README.md index 5bac9db..ab9f7c8 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,49 @@ srv.SetAuthToken(os.Getenv("MCP_TOKEN")) srv.SetAllowedOrigins([]string{"https://claude.ai"}) ``` +#### OAuth resource server (RFC 9728) + +For OAuth-protected deployments, plug in your own token validation and advertise where clients find the authorization server. go-mcp stays dependency-free: it validates nothing itself, you bring the verifier. + +- `srv.SetAuthFunc(fn func(r *http.Request) bool)` — custom authenticator run on every HTTP request. When set it **wins over** `SetAuthToken` (the static token is not consulted); returning `false` yields `401`. `nil` restores the default. +- `srv.SetResourceMetadataURL(url string)` — adds `resource_metadata=""` to the `WWW-Authenticate: Bearer realm="mcp"` header of every `401`, so clients can discover the authorization server. Empty restores the plain challenge. +- `srv.SetProtectedResourceMetadata(meta map[string]any)` — serves `meta` as JSON on `GET /.well-known/oauth-protected-resource`. The endpoint is public: it skips both the auth and the Origin checks. `nil` (default) disables it. + +```go +srv.SetAuthFunc(func(r *http.Request) bool { + return verifyJWT(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) // your validator +}) +srv.SetResourceMetadataURL("https://mcp.example.com/.well-known/oauth-protected-resource") +srv.SetProtectedResourceMetadata(map[string]any{ + "resource": "https://mcp.example.com", + "authorization_servers": []string{"https://auth.example.com"}, +}) +``` + +Flow: the client calls without a token, gets `401` with `resource_metadata`, fetches the well-known document, talks to the listed authorization server to obtain a token, and retries. + +#### Client authentication and `AuthError` + +`HTTPClient` (from `NewHTTPClient` / `NewHTTPClientWithToken`) supports per-request credentials: + +- `c.SetAuthProvider(fn func() string)` / chainable `c.WithAuthHeader(fn)` — `fn` returns the full `Authorization` header value (e.g. `"Bearer abc"`), used verbatim on every request. It wins over the static token from `NewHTTPClientWithToken`; an empty return falls back to the static token; `nil` clears the provider. +- A `401` response is returned as a typed `*gomcp.AuthError` with `ResourceMetadata` (from the challenge, empty if absent) and `Body`. `errors.Is(err, gomcp.ErrUnauthorized)` matches any `*AuthError`. + +```go +c := gomcp.NewHTTPClient("https://mcp.example.com"). + WithAuthHeader(func() string { return "Bearer " + tokenStore.Current() }) + +_, err := c.ListTools(ctx) +var ae *gomcp.AuthError +if errors.As(err, &ae) { + // ae.ResourceMetadata points at the protected-resource document: + // fetch it, run your OAuth flow, update tokenStore, then retry. + log.Printf("auth required, metadata at %s", ae.ResourceMetadata) +} else if errors.Is(err, gomcp.ErrUnauthorized) { + log.Print("unauthorized") +} +``` + ### `srv.SetInstructions(text string)` Optional natural-language guidance returned by `initialize` and `server/discover`. diff --git a/gomcp/docs_test.go b/gomcp/docs_test.go new file mode 100644 index 0000000..fd4949f --- /dev/null +++ b/gomcp/docs_test.go @@ -0,0 +1,31 @@ +package gomcp + +import ( + "os" + "strings" + "testing" +) + +// TestDocsMentionAuthAPI pins AC-9: the README and AGENTS.md document the +// pluggable auth API and the typed client AuthError. +func TestDocsMentionAuthAPI(t *testing.T) { + cases := []struct { + file string + symbols []string + }{ + {"../README.md", []string{"SetAuthFunc", "SetResourceMetadataURL", "SetProtectedResourceMetadata", "SetAuthProvider", "AuthError"}}, + {"../AGENTS.md", []string{"SetAuthFunc", "AuthError"}}, + } + for _, c := range cases { + b, err := os.ReadFile(c.file) + if err != nil { + t.Fatalf("read %s: %v", c.file, err) + } + text := string(b) + for _, s := range c.symbols { + if !strings.Contains(text, s) { + t.Errorf("%s does not mention %s", c.file, s) + } + } + } +}