Skip to content
Merged
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
1 change: 1 addition & 0 deletions pkg/http/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *
// Scopes are likely to be used for authorization.
scopes := claims.GetScopes()
klog.V(2).Infof("JWT token validated - Scopes: %v", scopes)
r = r.WithContext(context.WithValue(r.Context(), mcp.TokenScopesContextKey, scopes))

// Now, there are a couple of options:
// 1. If there is no authorization url configured for this MCP Server,
Expand Down
5 changes: 3 additions & 2 deletions pkg/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import (
"context"
"encoding/json"
"errors"
"github.com/coreos/go-oidc/v3/oidc"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/coreos/go-oidc/v3/oidc"

"k8s.io/klog/v2"

"github.com/containers/kubernetes-mcp-server/pkg/config"
Expand Down Expand Up @@ -61,7 +62,7 @@ func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.Stat
response := map[string]interface{}{
"authorization_servers": authServers,
"authorization_server": authServers[0],
"scopes_supported": []string{},
"scopes_supported": mcpServer.GetEnabledTools(),
"bearer_methods_supported": []string{"header"},
}

Expand Down
12 changes: 0 additions & 12 deletions pkg/kubernetes-mcp-server/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,18 +227,6 @@ func (m *MCPServerOptions) Validate() error {
klog.Warningf("authorization-url is using http://, this is not recommended production use")
}
}
if m.StaticConfig.ServerURL != "" {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, server url field is used for arbitrary audience rather than a structured URL format. I'm removing this validation (and its test). But if we decide to force URL format in the future, we'll need to revert this change (and its test).

u, err := url.Parse(m.StaticConfig.ServerURL)
if err != nil {
return err
}
if u.Scheme != "https" && u.Scheme != "http" {
return fmt.Errorf("--server-url must be a valid URL")
}
if u.Scheme == "http" {
klog.Warningf("server-url is using http://, this is not recommended production use")
}
}
if m.StaticConfig.JwksURL != "" {
u, err := url.Parse(m.StaticConfig.JwksURL)
if err != nil {
Expand Down
25 changes: 0 additions & 25 deletions pkg/kubernetes-mcp-server/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,28 +255,3 @@ func TestAuthorizationURL(t *testing.T) {
}
})
}

func TestServerURL(t *testing.T) {
t.Run("invalid server-url without protocol", func(t *testing.T) {
ioStreams, _ := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--require-oauth", "--port=8080", "--server-url", "example.com:8080", "--authorization-url", "https://example.com/auth"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("Expected error for invalid server-url without protocol, got nil")
}
expected := "--server-url must be a valid URL"
if !strings.Contains(err.Error(), expected) {
t.Fatalf("Expected error to contain %s, got %s", expected, err.Error())
}
})
t.Run("valid server-url with https", func(t *testing.T) {
ioStreams, _ := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--require-oauth", "--port=8080", "--server-url", "https://example.com:8080", "--authorization-url", "https://example.com/auth"})
err := rootCmd.Execute()
if err != nil {
t.Fatalf("Expected no error for valid https server-url, got %s", err.Error())
}
})
}
39 changes: 34 additions & 5 deletions pkg/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
"github.com/containers/kubernetes-mcp-server/pkg/version"
)

const TokenScopesContextKey = "TokenScopesContextKey"

type Configuration struct {
Profile Profile
ListOutput output.Output
Expand All @@ -45,20 +47,29 @@ func (c *Configuration) isToolApplicable(tool server.ServerTool) bool {
type Server struct {
configuration *Configuration
server *server.MCPServer
enabledTools []string
k *internalk8s.Manager
}

func NewServer(configuration Configuration) (*Server, error) {
var serverOptions []server.ServerOption
serverOptions = append(serverOptions,
server.WithResourceCapabilities(true, true),
server.WithPromptCapabilities(true),
server.WithToolCapabilities(true),
server.WithLogging(),
server.WithToolHandlerMiddleware(toolCallLoggingMiddleware),
)
if configuration.StaticConfig.RequireOAuth {
serverOptions = append(serverOptions, server.WithToolHandlerMiddleware(toolScopedAuthorizationMiddleware))
}

s := &Server{
configuration: &configuration,
server: server.NewMCPServer(
version.BinaryName,
version.Version,
server.WithResourceCapabilities(true, true),
server.WithPromptCapabilities(true),
server.WithToolCapabilities(true),
server.WithLogging(),
server.WithToolHandlerMiddleware(toolCallLoggingMiddleware),
serverOptions...,
),
}
if err := s.reloadKubernetesClient(); err != nil {
Expand All @@ -81,6 +92,7 @@ func (s *Server) reloadKubernetesClient() error {
continue
}
applicableTools = append(applicableTools, tool)
s.enabledTools = append(s.enabledTools, tool.Tool.Name)
}
s.server.SetTools(applicableTools...)
return nil
Expand Down Expand Up @@ -125,6 +137,10 @@ func (s *Server) GetKubernetesAPIServerHost() string {
return s.k.GetAPIServerHost()
}

func (s *Server) GetEnabledTools() []string {
return s.enabledTools
}

func (s *Server) Close() {
if s.k != nil {
s.k.Close()
Expand Down Expand Up @@ -181,3 +197,16 @@ func toolCallLoggingMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFu
return next(ctx, ctr)
}
}

func toolScopedAuthorizationMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFunc {
return func(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
scopes, ok := ctx.Value(TokenScopesContextKey).([]string)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@manusa although we agreed that we'll add this scoped based validation in authorization.go rather than here, mcp clients all failed to handle the flow. I think, apart from here all the rest is a clear indication of model context protocol violation.

After adding the scope based check in tool call middleware, it simply works.

if !ok {
return NewTextResult("", fmt.Errorf("Authorization failed: Access denied: Tool '%s' requires scope 'mcp:%s' but no scope is available", ctr.Params.Name, ctr.Params.Name)), nil
}
if !slices.Contains(scopes, "mcp:"+ctr.Params.Name) && !slices.Contains(scopes, ctr.Params.Name) {
return NewTextResult("", fmt.Errorf("Authorization failed: Access denied: Tool '%s' requires scope 'mcp:%s' but only scopes %s are available", ctr.Params.Name, ctr.Params.Name, scopes)), nil
}
return next(ctx, ctr)
}
}
Loading