diff --git a/examples/server/conformance/main.go b/examples/server/conformance/main.go index 70bd4bcd..f43340c0 100644 --- a/examples/server/conformance/main.go +++ b/examples/server/conformance/main.go @@ -5,6 +5,9 @@ // The conformance server implements features required for MCP conformance testing. // It mirrors the functionality of the TypeScript conformance server at // https://github.com/modelcontextprotocol/conformance/blob/main/examples/servers/typescript/everything-server.ts + +//go:build mcp_go_client_oauth + package main import ( @@ -16,19 +19,28 @@ import ( "fmt" "log" "net/http" + "net/url" "os" + "strings" "time" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" "github.com/yosida95/uritemplate/v3" ) var ( - httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout") + httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout") + enableAuth = flag.Bool("enable_auth", false, "if set, enable OAuth authorization") ) -const watchedResourceURI = "test://watched-resource" +const ( + watchedResourceURI = "test://watched-resource" + + adminScope = "admin" +) func main() { flag.Parse() @@ -56,11 +68,29 @@ func main() { // Serve over stdio, or streamable HTTP if -http is set. if *httpAddr != "" { - handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + mux := http.NewServeMux() + var mcpHandler http.Handler = mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil) - log.Printf("Conformance server listening at %s", *httpAddr) - log.Fatal(http.ListenAndServe(*httpAddr, handler)) + + if *enableAuth { + authServerURL := os.Getenv("MCP_CONFORMANCE_AUTH_SERVER_URL") + if authServerURL == "" { + log.Fatal("MCP_CONFORMANCE_AUTH_SERVER_URL environment variable must be set when --enable-auth is true") + } + + handlePRM(mux, authServerURL) + + var err error + mcpHandler, err = addAuthMiddleware(mcpHandler, authServerURL) + if err != nil { + log.Fatalf("auth middleware: %v", err) + } + } + + mux.Handle("/mcp", mcpHandler) + log.Printf("Conformance server listening at http://%s/mcp", *httpAddr) + log.Fatal(http.ListenAndServe(*httpAddr, mux)) } else { t := &mcp.StdioTransport{} if err := server.Run(ctx, t); err != nil { @@ -722,6 +752,96 @@ func promptWithImageHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mc }, nil } +// ============================================================================= +// Middleware +// ============================================================================= + +func handlePRM(mux *http.ServeMux, authServerURL string) { + // Host the resource metadata document. + resourceMetadata := &oauthex.ProtectedResourceMetadata{ + Resource: "http://" + *httpAddr, + AuthorizationServers: []string{authServerURL}, + ScopesSupported: []string{adminScope}, + } + mux.Handle("/.well-known/oauth-protected-resource", auth.ProtectedResourceMetadataHandler(resourceMetadata)) +} + +func addAuthMiddleware(handler http.Handler, authServerURL string) (http.Handler, error) { + + log.Printf("Fetching authorization server metadata from %s...", authServerURL) + metadata, err := oauthex.GetAuthServerMeta(context.Background(), authServerURL, http.DefaultClient) + if err != nil { + return nil, fmt.Errorf("fetch auth server metadata: %v", err) + } + if metadata.IntrospectionEndpoint == "" { + return nil, fmt.Errorf("auth server metadata does not contain introspection_endpoint") + } + log.Printf("Using introspection endpoint: %s", metadata.IntrospectionEndpoint) + + tokenVerifier := createIntrospectionVerifier(metadata.IntrospectionEndpoint) + verifyAuth := auth.RequireBearerToken(tokenVerifier, &auth.RequireBearerTokenOptions{ + ResourceMetadataURL: fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", *httpAddr), + }) + + return verifyAuth(handler), nil +} + +func createIntrospectionVerifier(introspectionEndpoint string) auth.TokenVerifier { + return func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { + data := url.Values{} + data.Set("token", token) + + req, err := http.NewRequestWithContext(ctx, "POST", introspectionEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("create introspection request: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("introspection request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("introspection returned status %d", resp.StatusCode) + } + + var result struct { + Active bool `json:"active"` + Scope string `json:"scope"` + Expiration int64 `json:"exp"` + ClientID string `json:"client_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode introspection response: %v", err) + } + + if !result.Active { + return nil, auth.ErrInvalidToken + } + + expiration := time.Time{} + if result.Expiration != 0 { + expiration = time.Unix(result.Expiration, 0) + } + + var scopes []string + if result.Scope != "" { + scopes = strings.Split(result.Scope, " ") + } + + return &auth.TokenInfo{ + Scopes: scopes, + Expiration: expiration, + Extra: map[string]any{ + "client_id": result.ClientID, + }, + }, nil + } +} + // ============================================================================= // Server handlers // ============================================================================= diff --git a/scripts/conformance.sh b/scripts/conformance.sh index 9e7d3007..ef63fec9 100755 --- a/scripts/conformance.sh +++ b/scripts/conformance.sh @@ -68,11 +68,11 @@ else fi # Build the conformance server. -go build -o "$WORKDIR/conformance-server" ./examples/server/conformance +go build -tags mcp_go_client_oauth -o "$WORKDIR/conformance-server" ./examples/server/conformance # Start the server in the background echo "Starting conformance server on port $PORT..." -"$WORKDIR/conformance-server" -http=":$PORT" & +"$WORKDIR/conformance-server" -http="localhost:$PORT" & SERVER_PID=$! echo "Server pid is $SERVER_PID" @@ -92,15 +92,31 @@ for i in {1..30}; do done # Run conformance tests from the work directory to avoid writing results to the repo. -echo "Running conformance tests..." +echo "Running 'active' conformance tests..." if [ -n "$CONFORMANCE_REPO" ]; then # Run from local checkout using npm run start. (cd "$WORKDIR" && \ npm --prefix "$CONFORMANCE_REPO" run start -- \ - server --url "http://localhost:$PORT") + server --url "http://localhost:$PORT/mcp") || true else (cd "$WORKDIR" && \ - npx @modelcontextprotocol/conformance@latest server --url "http://localhost:$PORT") + npx @modelcontextprotocol/conformance@latest server --url "http://localhost:$PORT/mcp") || true +fi + +echo "" +if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true +fi +echo "Running 'auth' conformance tests..." +if [ -n "$CONFORMANCE_REPO" ]; then + # Run from local checkout using npm run start. + (cd "$WORKDIR" && \ + npm --prefix "$CONFORMANCE_REPO" run start -- \ + server --suite auth --command "$WORKDIR/conformance-server --http=\"localhost:$PORT\" --enable_auth") || true +else + (cd "$WORKDIR" && \ + npx @modelcontextprotocol/conformance@latest server --suite auth \ + --command "$WORKDIR/conformance-server --http=\"localhost:$PORT\" --enable_auth") || true fi echo ""