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
14 changes: 14 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ LD_FLAGS = -s -w \
-X '$(PACKAGE)/pkg/version.BinaryName=$(BINARY_NAME)'
COMMON_BUILD_ARGS = -ldflags "$(LD_FLAGS)"

GOLANGCI_LINT = $(shell pwd)/_output/tools/bin/golangci-lint
GOLANGCI_LINT_VERSION ?= v2.2.2

# NPM version should not append the -dirty flag
NPM_VERSION ?= $(shell echo $(shell git describe --tags --always) | sed 's/^v//')
OSES = darwin linux windows
Expand Down Expand Up @@ -97,3 +100,14 @@ format: ## Format the code
.PHONY: tidy
tidy: ## Tidy up the go modules
go mod tidy

.PHONY: golangci-lint
golangci-lint: ## Download and install golangci-lint if not already installed
@[ -f $(GOLANGCI_LINT) ] || { \
set -e ;\
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(shell dirname $(GOLANGCI_LINT)) $(GOLANGCI_LINT_VERSION) ;\
}

.PHONY: lint
lint: golangci-lint ## Lint the code
$(GOLANGCI_LINT) run --verbose --print-resources-usage
5 changes: 4 additions & 1 deletion pkg/helm/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,14 @@ func (h *Helm) Install(ctx context.Context, chart string, values map[string]inte
install.Timeout = 5 * time.Minute
install.DryRun = false

chartRequested, err := install.ChartPathOptions.LocateChart(chart, cli.New())
chartRequested, err := install.LocateChart(chart, cli.New())
if err != nil {
return "", err
}
chartLoaded, err := loader.Load(chartRequested)
if err != nil {
return "", err
}

installedRelease, err := install.RunWithContext(ctx, chartLoaded, values)
if err != nil {
Expand Down
12 changes: 6 additions & 6 deletions pkg/kubernetes-mcp-server/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,11 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
cmd.Flags().BoolVar(&o.ReadOnly, "read-only", o.ReadOnly, "If true, only tools annotated with readOnlyHint=true are exposed")
cmd.Flags().BoolVar(&o.DisableDestructive, "disable-destructive", o.DisableDestructive, "If true, tools annotated with destructiveHint=true are disabled")
cmd.Flags().BoolVar(&o.RequireOAuth, "require-oauth", o.RequireOAuth, "If true, requires OAuth authorization as defined in the Model Context Protocol (MCP) specification. This flag is ignored if transport type is stdio")
cmd.Flags().MarkHidden("require-oauth")
_ = cmd.Flags().MarkHidden("require-oauth")
cmd.Flags().StringVar(&o.AuthorizationURL, "authorization-url", o.AuthorizationURL, "OAuth authorization server URL for protected resource endpoint. If not provided, the Kubernetes API server host will be used. Only valid if require-oauth is enabled.")
cmd.Flags().MarkHidden("authorization-url")
_ = cmd.Flags().MarkHidden("authorization-url")
cmd.Flags().StringVar(&o.ServerURL, "server-url", o.ServerURL, "Server URL of this application. Optional. If set, this url will be served in protected resource metadata endpoint and tokens will be validated with this audience. If not set, expected audience is kubernetes-mcp-server. Only valid if require-oauth is enabled.")
cmd.Flags().MarkHidden("server-url")
_ = cmd.Flags().MarkHidden("server-url")
return cmd
}

Expand Down Expand Up @@ -228,11 +228,11 @@ func (m *MCPServerOptions) Validate() error {
func (m *MCPServerOptions) Run() error {
profile := mcp.ProfileFromString(m.Profile)
if profile == nil {
return fmt.Errorf("Invalid profile name: %s, valid names are: %s\n", m.Profile, strings.Join(mcp.ProfileNames, ", "))
return fmt.Errorf("invalid profile name: %s, valid names are: %s", m.Profile, strings.Join(mcp.ProfileNames, ", "))
}
listOutput := output.FromString(m.StaticConfig.ListOutput)
if listOutput == nil {
return fmt.Errorf("Invalid output name: %s, valid names are: %s\n", m.StaticConfig.ListOutput, strings.Join(output.Names, ", "))
return fmt.Errorf("invalid output name: %s, valid names are: %s", m.StaticConfig.ListOutput, strings.Join(output.Names, ", "))
}
klog.V(1).Info("Starting kubernetes-mcp-server")
klog.V(1).Infof(" - Config: %s", m.ConfigPath)
Expand Down Expand Up @@ -261,7 +261,7 @@ func (m *MCPServerOptions) Run() error {
StaticConfig: m.StaticConfig,
})
if err != nil {
return fmt.Errorf("Failed to initialize MCP server: %w\n", err)
return fmt.Errorf("failed to initialize MCP server: %w", err)
}
defer mcpServer.Close()

Expand Down
1 change: 1 addition & 0 deletions pkg/kubernetes/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ func (m *Manager) ConfigurationView(minify bool) (runtime.Object, error) {
return nil, err
}
}
//nolint:staticcheck
if err = clientcmdapi.FlattenConfig(&cfg); err != nil {
// ignore error
//return "", err
Expand Down
2 changes: 2 additions & 0 deletions pkg/kubernetes/impersonate_roundtripper.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package kubernetes

import "net/http"

// nolint:unused
type impersonateRoundTripper struct {
delegate http.RoundTripper
}

// nolint:unused
func (irt *impersonateRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// TODO: Solution won't work with discoveryclient which uses context.TODO() instead of the passed-in context
if v, ok := req.Context().Value(OAuthAuthorizationHeader).(string); ok {
Expand Down
14 changes: 8 additions & 6 deletions pkg/kubernetes/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ import (
_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
)

type HeaderKey string

const (
CustomAuthorizationHeader = "kubernetes-authorization"
OAuthAuthorizationHeader = "Authorization"
CustomAuthorizationHeader = HeaderKey("kubernetes-authorization")
Copy link
Member

Choose a reason for hiding this comment

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

Not sure why linter forces us to do this

Copy link
Member Author

Choose a reason for hiding this comment

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

The problem is not at the header level, but at the usage within context.WithValue:

https://github.com/manusa/kubernetes-mcp-server/blob/b4d73d54c5fe711da26d5f53a0b2280505dcc182/pkg/mcp/mcp.go#L166

This was the first thing I thought of to solve it.

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense

OAuthAuthorizationHeader = HeaderKey("Authorization")

CustomUserAgent = "kubernetes-mcp-server/bearer-token-auth"
)
Expand Down Expand Up @@ -155,10 +157,10 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) {
APIPath: m.cfg.APIPath,
// Copy only server verification TLS settings (CA bundle and server name)
TLSClientConfig: rest.TLSClientConfig{
Insecure: m.cfg.TLSClientConfig.Insecure,
ServerName: m.cfg.TLSClientConfig.ServerName,
CAFile: m.cfg.TLSClientConfig.CAFile,
CAData: m.cfg.TLSClientConfig.CAData,
Insecure: m.cfg.Insecure,
ServerName: m.cfg.ServerName,
CAFile: m.cfg.CAFile,
CAData: m.cfg.CAData,
},
BearerToken: strings.TrimPrefix(authorization, "Bearer "),
// pass custom UserAgent to identify the client
Expand Down
32 changes: 16 additions & 16 deletions pkg/kubernetes/kubernetes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,17 @@ users:
t.Errorf("expected Timeout %v, got %v", originalCfg.Timeout, derivedCfg.Timeout)
}

if derivedCfg.TLSClientConfig.Insecure != originalCfg.TLSClientConfig.Insecure {
t.Errorf("expected TLS Insecure %v, got %v", originalCfg.TLSClientConfig.Insecure, derivedCfg.TLSClientConfig.Insecure)
if derivedCfg.Insecure != originalCfg.Insecure {
t.Errorf("expected TLS Insecure %v, got %v", originalCfg.Insecure, derivedCfg.Insecure)
}
if derivedCfg.TLSClientConfig.ServerName != originalCfg.TLSClientConfig.ServerName {
t.Errorf("expected TLS ServerName %s, got %s", originalCfg.TLSClientConfig.ServerName, derivedCfg.TLSClientConfig.ServerName)
if derivedCfg.ServerName != originalCfg.ServerName {
t.Errorf("expected TLS ServerName %s, got %s", originalCfg.ServerName, derivedCfg.ServerName)
}
if derivedCfg.TLSClientConfig.CAFile != originalCfg.TLSClientConfig.CAFile {
t.Errorf("expected TLS CAFile %s, got %s", originalCfg.TLSClientConfig.CAFile, derivedCfg.TLSClientConfig.CAFile)
if derivedCfg.CAFile != originalCfg.CAFile {
t.Errorf("expected TLS CAFile %s, got %s", originalCfg.CAFile, derivedCfg.CAFile)
}
if string(derivedCfg.TLSClientConfig.CAData) != string(originalCfg.TLSClientConfig.CAData) {
t.Errorf("expected TLS CAData %s, got %s", string(originalCfg.TLSClientConfig.CAData), string(derivedCfg.TLSClientConfig.CAData))
if string(derivedCfg.CAData) != string(originalCfg.CAData) {
t.Errorf("expected TLS CAData %s, got %s", string(originalCfg.CAData), string(derivedCfg.CAData))
}

if derivedCfg.BearerToken != testBearerToken {
Expand All @@ -160,17 +160,17 @@ users:
// Verify that sensitive fields are NOT copied to prevent credential leakage
// The derived config should only use the bearer token from the Authorization header
// and not inherit any authentication credentials from the original kubeconfig
if derivedCfg.TLSClientConfig.CertFile != "" {
t.Errorf("expected TLS CertFile to be empty, got %s", derivedCfg.TLSClientConfig.CertFile)
if derivedCfg.CertFile != "" {
t.Errorf("expected TLS CertFile to be empty, got %s", derivedCfg.CertFile)
}
if derivedCfg.TLSClientConfig.KeyFile != "" {
t.Errorf("expected TLS KeyFile to be empty, got %s", derivedCfg.TLSClientConfig.KeyFile)
if derivedCfg.KeyFile != "" {
t.Errorf("expected TLS KeyFile to be empty, got %s", derivedCfg.KeyFile)
}
if len(derivedCfg.TLSClientConfig.CertData) != 0 {
t.Errorf("expected TLS CertData to be empty, got %v", derivedCfg.TLSClientConfig.CertData)
if len(derivedCfg.CertData) != 0 {
t.Errorf("expected TLS CertData to be empty, got %v", derivedCfg.CertData)
}
if len(derivedCfg.TLSClientConfig.KeyData) != 0 {
t.Errorf("expected TLS KeyData to be empty, got %v", derivedCfg.TLSClientConfig.KeyData)
if len(derivedCfg.KeyData) != 0 {
t.Errorf("expected TLS KeyData to be empty, got %v", derivedCfg.KeyData)
}

if derivedCfg.Username != "" {
Expand Down
26 changes: 7 additions & 19 deletions pkg/mcp/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,9 @@ import (
apiextensionsv1spec "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/scale"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
toolswatch "k8s.io/client-go/tools/watch"
Expand Down Expand Up @@ -71,7 +68,7 @@ func TestMain(m *testing.M) {
}
envTestEnv.CheckCoherence()
workflows.Use{}.Do(envTestEnv)
versionDir := envTestEnv.Platform.Platform.BaseName(*envTestEnv.Version.AsConcrete())
versionDir := envTestEnv.Platform.BaseName(*envTestEnv.Version.AsConcrete())
envTest = &envtest.Environment{
BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir),
}
Expand Down Expand Up @@ -190,9 +187,9 @@ func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {
fakeConfig.AuthInfos["additional-auth"] = api.NewAuthInfo()
if rc != nil {
fakeConfig.Clusters["fake"].Server = rc.Host
fakeConfig.Clusters["fake"].CertificateAuthorityData = rc.TLSClientConfig.CAData
fakeConfig.AuthInfos["fake"].ClientKeyData = rc.TLSClientConfig.KeyData
fakeConfig.AuthInfos["fake"].ClientCertificateData = rc.TLSClientConfig.CertData
fakeConfig.Clusters["fake"].CertificateAuthorityData = rc.CAData
fakeConfig.AuthInfos["fake"].ClientKeyData = rc.KeyData
fakeConfig.AuthInfos["fake"].ClientCertificateData = rc.CertData
}
fakeConfig.Contexts["fake-context"] = api.NewContext()
fakeConfig.Contexts["fake-context"].Cluster = "fake"
Expand Down Expand Up @@ -264,18 +261,6 @@ func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
return kubernetes.NewForConfigOrDie(envTestRestConfig)
}

func (c *mcpContext) newRestClient(groupVersion *schema.GroupVersion) *rest.RESTClient {
config := *envTestRestConfig
config.GroupVersion = groupVersion
config.APIPath = "/api"
config.NegotiatedSerializer = serializer.NewCodecFactory(scale.NewScaleConverter().Scheme()).WithoutConversion()
rc, err := rest.RESTClientFor(&config)
if err != nil {
panic(err)
}
return rc
}

// newApiExtensionsClient creates a new ApiExtensions client with the envTest kubeconfig
func (c *mcpContext) newApiExtensionsClient() *apiextensionsv1.ApiextensionsV1Client {
return apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
Expand All @@ -286,6 +271,9 @@ func (c *mcpContext) crdApply(resource string) error {
apiExtensionsV1Client := c.newApiExtensionsClient()
var crd = &apiextensionsv1spec.CustomResourceDefinition{}
err := json.Unmarshal([]byte(resource), crd)
if err != nil {
return fmt.Errorf("failed to create CRD %v", err)
}
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Create(c.ctx, crd, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("failed to create CRD %v", err)
Expand Down
4 changes: 2 additions & 2 deletions pkg/mcp/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

func (s *Server) initConfiguration() []server.ServerTool {
tools := []server.ServerTool{
{mcp.NewTool("configuration_view",
{Tool: mcp.NewTool("configuration_view",
mcp.WithDescription("Get the current Kubernetes configuration content as a kubeconfig YAML"),
mcp.WithBoolean("minified", mcp.Description("Return a minified version of the configuration. "+
"If set to true, keeps only the current-context and the relevant pieces of the configuration for that context. "+
Expand All @@ -23,7 +23,7 @@ func (s *Server) initConfiguration() []server.ServerTool {
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), s.configurationView},
), Handler: s.configurationView},
}
return tools
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/mcp/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

func (s *Server) initEvents() []server.ServerTool {
return []server.ServerTool{
{mcp.NewTool("events_list",
{Tool: mcp.NewTool("events_list",
mcp.WithDescription("List all the Kubernetes events in the current cluster from all namespaces"),
mcp.WithString("namespace",
mcp.Description("Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces")),
Expand All @@ -21,7 +21,7 @@ func (s *Server) initEvents() []server.ServerTool {
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), s.eventsList},
), Handler: s.eventsList},
}
}

Expand Down
12 changes: 6 additions & 6 deletions pkg/mcp/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (

func (s *Server) initHelm() []server.ServerTool {
return []server.ServerTool{
{mcp.NewTool("helm_install",
{Tool: mcp.NewTool("helm_install",
mcp.WithDescription("Install a Helm chart in the current or provided namespace"),
mcp.WithString("chart", mcp.Description("Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)"), mcp.Required()),
mcp.WithObject("values", mcp.Description("Values to pass to the Helm chart (Optional)")),
Expand All @@ -22,8 +22,8 @@ func (s *Server) initHelm() []server.ServerTool {
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(false), // TODO: consider replacing implementation with equivalent to: helm upgrade --install
mcp.WithOpenWorldHintAnnotation(true),
), s.helmInstall},
{mcp.NewTool("helm_list",
), Handler: s.helmInstall},
{Tool: mcp.NewTool("helm_list",
mcp.WithDescription("List all the Helm releases in the current or provided namespace (or in all namespaces if specified)"),
mcp.WithString("namespace", mcp.Description("Namespace to list Helm releases from (Optional, all namespaces if not provided)")),
mcp.WithBoolean("all_namespaces", mcp.Description("If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)")),
Expand All @@ -32,8 +32,8 @@ func (s *Server) initHelm() []server.ServerTool {
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), s.helmList},
{mcp.NewTool("helm_uninstall",
), Handler: s.helmList},
{Tool: mcp.NewTool("helm_uninstall",
mcp.WithDescription("Uninstall a Helm release in the current or provided namespace"),
mcp.WithString("name", mcp.Description("Name of the Helm release to uninstall"), mcp.Required()),
mcp.WithString("namespace", mcp.Description("Namespace to uninstall the Helm release from (Optional, current namespace if not provided)")),
Expand All @@ -43,7 +43,7 @@ func (s *Server) initHelm() []server.ServerTool {
mcp.WithDestructiveHintAnnotation(true),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(true),
), s.helmUninstall},
), Handler: s.helmUninstall},
}
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,13 @@ func NewTextResult(content string, err error) *mcp.CallToolResult {

func contextFunc(ctx context.Context, r *http.Request) context.Context {
// Get the standard Authorization header (OAuth compliant)
authHeader := r.Header.Get(internalk8s.OAuthAuthorizationHeader)
authHeader := r.Header.Get(string(internalk8s.OAuthAuthorizationHeader))
if authHeader != "" {
return context.WithValue(ctx, internalk8s.OAuthAuthorizationHeader, authHeader)
}

// Fallback to custom header for backward compatibility
customAuthHeader := r.Header.Get(internalk8s.CustomAuthorizationHeader)
customAuthHeader := r.Header.Get(string(internalk8s.CustomAuthorizationHeader))
if customAuthHeader != "" {
return context.WithValue(ctx, internalk8s.OAuthAuthorizationHeader, customAuthHeader)
}
Expand Down
10 changes: 3 additions & 7 deletions pkg/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,9 @@ func TestWatchKubeConfig(t *testing.T) {
// When
f, _ := os.OpenFile(filepath.Join(c.tempDir, "config"), os.O_APPEND|os.O_WRONLY, 0644)
_, _ = f.WriteString("\n")
for {
if notification != nil {
break
}
for notification == nil {
select {
case <-withTimeout.Done():
break
default:
time.Sleep(100 * time.Millisecond)
}
Expand Down Expand Up @@ -94,7 +90,7 @@ func TestSseHeaders(t *testing.T) {
w.WriteHeader(404)
}))
testCaseWithContext(t, &mcpContext{before: before}, func(c *mcpContext) {
c.callTool("pods_list", map[string]interface{}{})
_, _ = c.callTool("pods_list", map[string]interface{}{})
t.Run("DiscoveryClient propagates headers to Kube API", func(t *testing.T) {
if len(pathHeaders) == 0 {
t.Fatalf("No requests were made to Kube API")
Expand All @@ -117,7 +113,7 @@ func TestSseHeaders(t *testing.T) {
t.Fatalf("Overridden header Authorization not found in request to /api/v1/namespaces/default/pods")
}
})
c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-to-delete"})
_, _ = c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-to-delete"})
t.Run("kubernetes.Interface propagates headers to Kube API", func(t *testing.T) {
if len(pathHeaders) == 0 {
t.Fatalf("No requests were made to Kube API")
Expand Down
4 changes: 2 additions & 2 deletions pkg/mcp/pods.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func (s *Server) podsListInAllNamespaces(ctx context.Context, ctr mcp.CallToolRe
AsTable: s.configuration.ListOutput.AsTable(),
}
if labelSelector != nil {
resourceListOptions.ListOptions.LabelSelector = labelSelector.(string)
resourceListOptions.LabelSelector = labelSelector.(string)
}
derived, err := s.k.Derived(ctx)
if err != nil {
Expand All @@ -150,7 +150,7 @@ func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolReques
}
labelSelector := ctr.GetArguments()["labelSelector"]
if labelSelector != nil {
resourceListOptions.ListOptions.LabelSelector = labelSelector.(string)
resourceListOptions.LabelSelector = labelSelector.(string)
}
derived, err := s.k.Derived(ctx)
if err != nil {
Expand Down
Loading
Loading