Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ package auth

import (
"context"
"encoding/json"
"errors"
"net/http"
"slices"
"strings"
"time"

"github.com/modelcontextprotocol/go-sdk/oauthex"
)

// TokenInfo holds information from a bearer token.
Expand Down Expand Up @@ -118,3 +121,43 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO
}
return tokenInfo, "", 0
}

// ProtectedResourceMetadataHandler returns an http.Handler that serves OAuth 2.0
// protected resource metadata (RFC 9728) with CORS support.
//
// This handler allows cross-origin requests from any origin (Access-Control-Allow-Origin: *)
// because OAuth metadata is public information intended for client discovery (RFC 9728 §3.1).
// The metadata contains only non-sensitive configuration data about authorization servers
// and supported scopes.
//
// No validation of metadata fields is performed; ensure metadata accuracy at configuration time.
//
// For more sophisticated CORS policies or to restrict origins, wrap this handler with a
// CORS middleware like github.com/rs/cors or github.com/jub0bs/cors.
func ProtectedResourceMetadataHandler(metadata *oauthex.ProtectedResourceMetadata) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set CORS headers for cross-origin client discovery.
// OAuth metadata is public information, so allowing any origin is safe.
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")

// Handle CORS preflight requests
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}

// Only GET allowed for metadata retrieval
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(metadata); err != nil {
http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
return
}
})
}
112 changes: 112 additions & 0 deletions auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ package auth

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/modelcontextprotocol/go-sdk/oauthex"
)

func TestVerify(t *testing.T) {
Expand Down Expand Up @@ -76,3 +81,110 @@ func TestVerify(t *testing.T) {
})
}
}

func TestProtectedResourceMetadataHandler(t *testing.T) {
metadata := &oauthex.ProtectedResourceMetadata{
Resource: "https://example.com/mcp",
AuthorizationServers: []string{
"https://auth.example.com/.well-known/openid-configuration",
},
ScopesSupported: []string{"read", "write"},
}

handler := ProtectedResourceMetadataHandler(metadata)

tests := []struct {
name string
method string
wantStatus int
checkJSON bool
}{
{
name: "GET returns metadata",
method: http.MethodGet,
wantStatus: http.StatusOK,
checkJSON: true,
},
{
name: "OPTIONS for CORS preflight",
method: http.MethodOptions,
wantStatus: http.StatusNoContent,
},
{
name: "POST not allowed",
method: http.MethodPost,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "PUT not allowed",
method: http.MethodPut,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "DELETE not allowed",
method: http.MethodDelete,
wantStatus: http.StatusMethodNotAllowed,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/.well-known/oauth-protected-resource", nil)
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)

if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}

// All responses should have CORS headers
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "*")
}

if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "GET, OPTIONS" {
t.Errorf("Access-Control-Allow-Methods = %q, want %q", got, "GET, OPTIONS")
}

// Validate error response body for disallowed methods
if tt.wantStatus == http.StatusMethodNotAllowed {
if !strings.Contains(rec.Body.String(), "Method not allowed") {
t.Errorf("error body = %q, want to contain %q", rec.Body.String(), "Method not allowed")
}
}

if tt.checkJSON {
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want %q", got, "application/json")
}

var got oauthex.ProtectedResourceMetadata
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}

if got.Resource != metadata.Resource {
t.Errorf("Resource = %q, want %q", got.Resource, metadata.Resource)
}

if len(got.AuthorizationServers) != len(metadata.AuthorizationServers) {
t.Errorf("AuthorizationServers length = %d, want %d",
len(got.AuthorizationServers), len(metadata.AuthorizationServers))
}

for i, server := range got.AuthorizationServers {
if server != metadata.AuthorizationServers[i] {
t.Errorf("AuthorizationServers[%d] = %q, want %q",
i, server, metadata.AuthorizationServers[i])
}
}

if len(got.ScopesSupported) != len(metadata.ScopesSupported) {
t.Errorf("ScopesSupported length = %d, want %d",
len(got.ScopesSupported), len(metadata.ScopesSupported))
}
}
})
}
}
29 changes: 28 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,34 @@ from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example,
[`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).)
HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context
with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext).


#### OAuth Protected Resource Metadata

Servers implementing OAuth 2.0 authorization should expose a protected resource metadata endpoint
as specified in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). This endpoint provides
clients with information about the resource server's OAuth configuration, including which
authorization servers can be used and what scopes are supported.

The SDK provides [`ProtectedResourceMetadataHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#ProtectedResourceMetadataHandler)
to serve this metadata. The handler automatically sets CORS headers (`Access-Control-Allow-Origin: *`)
to support cross-origin client discovery, as the metadata contains only public configuration information.

Example usage:

```go
metadata := &oauthex.ProtectedResourceMetadata{
Resource: "https://example.com/mcp",
AuthorizationServers: []string{
"https://auth.example.com/.well-known/openid-configuration",
},
ScopesSupported: []string{"read", "write"},
}
http.Handle("/.well-known/oauth-protected-resource",
auth.ProtectedResourceMetadataHandler(metadata))
```

For more sophisticated CORS policies, wrap the handler with a CORS middleware like
[github.com/rs/cors](https://github.com/rs/cors) or [github.com/jub0bs/cors](https://github.com/jub0bs/cors).

The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys.

Expand Down
36 changes: 36 additions & 0 deletions examples/server/auth-middleware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ go test -cover
### Public Endpoints (No Authentication Required)

- `GET /health` - Health check
- `GET /.well-known/oauth-protected-resource` - OAuth 2.0 Protected Resource Metadata (RFC 9728)

### MCP Endpoints (Authentication Required)

Expand Down Expand Up @@ -228,6 +229,41 @@ authenticatedHandler := authMiddleware(customMiddleware(mcpHandler))
4. **Token Expiration**: Set appropriate token expiration times
5. **Principle of Least Privilege**: Grant only the minimum required scopes

## CORS Support

The OAuth metadata endpoint (`/.well-known/oauth-protected-resource`) supports CORS to enable
cross-origin client discovery. It sets `Access-Control-Allow-Origin: *` by default because
OAuth metadata is public information meant for client discovery (RFC 9728 §3.1).

### Custom CORS Policies

For more sophisticated CORS requirements (origin validation, credentials, etc.), wrap the handler
with a CORS middleware library:

**Using github.com/rs/cors:**
```go
import "github.com/rs/cors"

c := cors.New(cors.Options{
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "OPTIONS"},
})
http.Handle("/.well-known/oauth-protected-resource",
c.Handler(auth.ProtectedResourceMetadataHandler(metadata)))
```

**Using github.com/jub0bs/cors:**
```go
import "github.com/jub0bs/cors"

corsMiddleware, err := cors.NewMiddleware(cors.Config{
Origins: []string{"https://example.com"},
Methods: []string{"GET", "OPTIONS"},
})
http.Handle("/.well-known/oauth-protected-resource",
corsMiddleware.Wrap(auth.ProtectedResourceMetadataHandler(metadata)))
```

## Use Cases

**Ideal for:**
Expand Down
1 change: 1 addition & 0 deletions examples/server/auth-middleware/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
require (
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
)

replace github.com/modelcontextprotocol/go-sdk => ../../../
2 changes: 2 additions & 0 deletions examples/server/auth-middleware/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
34 changes: 25 additions & 9 deletions examples/server/auth-middleware/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/go-sdk/oauthex"
)

// This example demonstrates how to integrate auth.RequireBearerToken middleware
Expand Down Expand Up @@ -237,7 +238,8 @@ func main() {

// Create authentication middleware.
jwtAuth := auth.RequireBearerToken(verifyJWT, &auth.RequireBearerTokenOptions{
Scopes: []string{"read"}, // Require "read" permission
Scopes: []string{"read"}, // Require "read" permission
ResourceMetadataURL: "http://localhost:8080/.well-known/oauth-protected-resource",
})

apiKeyAuth := auth.RequireBearerToken(verifyAPIKey, &auth.RequireBearerTokenOptions{
Expand Down Expand Up @@ -340,22 +342,36 @@ func main() {
})
})

// OAuth protected resource metadata endpoint.
// This endpoint provides OAuth configuration information to clients.
// CORS is enabled by default to support cross-origin client discovery.
metadata := &oauthex.ProtectedResourceMetadata{
Resource: "http://localhost:8080/mcp/jwt",
AuthorizationServers: []string{
"https://auth.example.com/.well-known/openid-configuration",
},
ScopesSupported: []string{"read", "write"},
}
http.Handle("/.well-known/oauth-protected-resource",
auth.ProtectedResourceMetadataHandler(metadata))

// Start the HTTP server.
log.Println("Authenticated MCP Server")
log.Println("========================")
log.Println("Server starting on", *httpAddr)
log.Println()
log.Println("Available endpoints:")
log.Println(" GET /health - Health check (no auth)")
log.Println(" GET /generate-token - Generate JWT token")
log.Println(" POST /generate-api-key - Generate API key")
log.Println(" POST /mcp/jwt - MCP endpoint (JWT auth)")
log.Println(" POST /mcp/apikey - MCP endpoint (API key auth)")
log.Println(" GET /health - Health check (no auth)")
log.Println(" GET /.well-known/oauth-protected-resource - OAuth resource metadata")
log.Println(" GET /generate-token - Generate JWT token")
log.Println(" POST /generate-api-key - Generate API key")
log.Println(" POST /mcp/jwt - MCP endpoint (JWT auth)")
log.Println(" POST /mcp/apikey - MCP endpoint (API key auth)")
log.Println()
log.Println("Available MCP Tools:")
log.Println(" - say_hi - Simple greeting (any auth)")
log.Println(" - get_user_info - Get user info (read scope)")
log.Println(" - create_resource - Create resource (write scope)")
log.Println(" - say_hi - Simple greeting (any auth)")
log.Println(" - get_user_info - Get user info (read scope)")
log.Println(" - create_resource - Create resource (write scope)")
log.Println()
log.Println("Example usage:")
log.Println(" # Generate a token")
Expand Down
29 changes: 28 additions & 1 deletion internal/docs/protocol.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,34 @@ from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example,
[`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).)
HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context
with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext).


#### OAuth Protected Resource Metadata

Servers implementing OAuth 2.0 authorization should expose a protected resource metadata endpoint
as specified in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). This endpoint provides
clients with information about the resource server's OAuth configuration, including which
authorization servers can be used and what scopes are supported.

The SDK provides [`ProtectedResourceMetadataHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#ProtectedResourceMetadataHandler)
to serve this metadata. The handler automatically sets CORS headers (`Access-Control-Allow-Origin: *`)
to support cross-origin client discovery, as the metadata contains only public configuration information.

Example usage:

```go
metadata := &oauthex.ProtectedResourceMetadata{
Resource: "https://example.com/mcp",
AuthorizationServers: []string{
"https://auth.example.com/.well-known/openid-configuration",
},
ScopesSupported: []string{"read", "write"},
}
http.Handle("/.well-known/oauth-protected-resource",
auth.ProtectedResourceMetadataHandler(metadata))
```

For more sophisticated CORS policies, wrap the handler with a CORS middleware like
[github.com/rs/cors](https://github.com/rs/cors) or [github.com/jub0bs/cors](https://github.com/jub0bs/cors).

The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys.

Expand Down
Loading