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
99 changes: 99 additions & 0 deletions pkg/api/toolsets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package api

import (
"context"
"encoding/json"

internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/google/jsonschema-go/jsonschema"
)

type ServerTool struct {
Tool Tool
Handler ToolHandlerFunc
}

type Toolset interface {
// GetName returns the name of the toolset.
// Used to identify the toolset in configuration, logs, and command-line arguments.
// Examples: "core", "metrics", "helm"
GetName() string
GetDescription() string
GetTools(k *internalk8s.Manager) []ServerTool
}

type ToolCallRequest interface {
GetArguments() map[string]any
}

type ToolCallResult struct {
// Raw content returned by the tool.
Content string
// Error (non-protocol) to send back to the LLM.
Error error
}

func NewToolCallResult(content string, err error) *ToolCallResult {
return &ToolCallResult{
Content: content,
Error: err,
}
}

type ToolHandlerParams struct {
context.Context
*internalk8s.Kubernetes
ToolCallRequest
ListOutput output.Output
}

type ToolHandlerFunc func(params ToolHandlerParams) (*ToolCallResult, error)

type Tool struct {
// The name of the tool.
// Intended for programmatic or logical use, but used as a display name in past
// specs or fallback (if title isn't present).
Name string `json:"name"`
// A human-readable description of the tool.
//
// This can be used by clients to improve the LLM's understanding of available
// tools. It can be thought of like a "hint" to the model.
Description string `json:"description,omitempty"`
// Additional tool information.
Annotations ToolAnnotations `json:"annotations"`
// A JSON Schema object defining the expected parameters for the tool.
InputSchema *jsonschema.Schema
}

type ToolAnnotations struct {
// Human-readable title for the tool
Title string `json:"title,omitempty"`
// If true, the tool does not modify its environment.
ReadOnlyHint *bool `json:"readOnlyHint,omitempty"`
// If true, the tool may perform destructive updates to its environment. If
// false, the tool performs only additive updates.
//
// (This property is meaningful only when ReadOnlyHint == false.)
DestructiveHint *bool `json:"destructiveHint,omitempty"`
// If true, calling the tool repeatedly with the same arguments will have no
// additional effect on its environment.
//
// (This property is meaningful only when ReadOnlyHint == false.)
IdempotentHint *bool `json:"idempotentHint,omitempty"`
// If true, this tool may interact with an "open world" of external entities. If
// false, the tool's domain of interaction is closed. For example, the world of
// a web search tool is open, whereas that of a memory tool is not.
OpenWorldHint *bool `json:"openWorldHint,omitempty"`
}

func ToRawMessage(v any) json.RawMessage {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
return b
}
3 changes: 2 additions & 1 deletion pkg/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"time"

"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest"
"golang.org/x/sync/errgroup"
Expand Down Expand Up @@ -87,7 +88,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
}
c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port)
mcpServer, err := mcp.NewServer(mcp.Configuration{
Toolset: mcp.Toolsets()[0],
Toolset: toolsets.Toolsets()[0],
StaticConfig: c.StaticConfig,
})
if err != nil {
Expand Down
7 changes: 4 additions & 3 deletions pkg/kubernetes-mcp-server/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strconv"
"strings"

"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/spf13/cobra"

Expand Down Expand Up @@ -115,7 +116,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
cmd.Flags().StringVar(&o.Port, "port", o.Port, "Start a streamable HTTP and SSE HTTP server on the specified port (e.g. 8080)")
cmd.Flags().StringVar(&o.SSEBaseUrl, "sse-base-url", o.SSEBaseUrl, "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)")
cmd.Flags().StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, "Path to the kubeconfig file to use for authentication")
cmd.Flags().StringVar(&o.Toolset, "toolset", o.Toolset, "MCP toolset to use (one of: "+strings.Join(mcp.ToolsetNames(), ", ")+")")
cmd.Flags().StringVar(&o.Toolset, "toolset", o.Toolset, "MCP toolset to use (one of: "+strings.Join(toolsets.ToolsetNames(), ", ")+")")
cmd.Flags().StringVar(&o.ListOutput, "list-output", o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+"). Defaults to table.")
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")
Expand Down Expand Up @@ -237,9 +238,9 @@ func (m *MCPServerOptions) Validate() error {
}

func (m *MCPServerOptions) Run() error {
toolset := mcp.ToolsetFromString(m.Toolset)
toolset := toolsets.ToolsetFromString(m.Toolset)
if toolset == nil {
return fmt.Errorf("invalid toolset name: %s, valid names are: %s", m.Toolset, strings.Join(mcp.ToolsetNames(), ", "))
return fmt.Errorf("invalid toolset name: %s, valid names are: %s", m.Toolset, strings.Join(toolsets.ToolsetNames(), ", "))
}
listOutput := output.FromString(m.StaticConfig.ListOutput)
if listOutput == nil {
Expand Down
12 changes: 6 additions & 6 deletions pkg/kubernetes/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,24 +81,24 @@ func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return m.clientCmdConfig
}

func (m *Manager) ConfigurationView(minify bool) (runtime.Object, error) {
func (k *Kubernetes) ConfigurationView(minify bool) (runtime.Object, error) {
var cfg clientcmdapi.Config
var err error
if m.IsInCluster() {
if k.manager.IsInCluster() {
cfg = *clientcmdapi.NewConfig()
cfg.Clusters["cluster"] = &clientcmdapi.Cluster{
Server: m.cfg.Host,
InsecureSkipTLSVerify: m.cfg.Insecure,
Server: k.manager.cfg.Host,
InsecureSkipTLSVerify: k.manager.cfg.Insecure,
}
cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{
Token: m.cfg.BearerToken,
Token: k.manager.cfg.BearerToken,
}
cfg.Contexts["context"] = &clientcmdapi.Context{
Cluster: "cluster",
AuthInfo: "user",
}
cfg.CurrentContext = "context"
} else if cfg, err = m.clientCmdConfig.RawConfig(); err != nil {
} else if cfg, err = k.manager.clientCmdConfig.RawConfig(); err != nil {
return nil, err
}
if minify {
Expand Down
31 changes: 17 additions & 14 deletions pkg/mcp/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ import (
"testing"
"time"

"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
Expand All @@ -32,7 +30,7 @@ import (
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
toolswatch "k8s.io/client-go/tools/watch"
"k8s.io/klog/v2"
"k8s.io/klog/v2/textlogger"
Expand All @@ -43,6 +41,11 @@ import (
"sigs.k8s.io/controller-runtime/tools/setup-envtest/store"
"sigs.k8s.io/controller-runtime/tools/setup-envtest/versions"
"sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows"

"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
)

// envTest has an expensive setup, so we only want to do it once per entire test run.
Expand Down Expand Up @@ -103,7 +106,7 @@ func TestMain(m *testing.M) {
}

type mcpContext struct {
toolset Toolset
toolset api.Toolset
listOutput output.Output
logLevel int

Expand All @@ -127,7 +130,7 @@ func (c *mcpContext) beforeEach(t *testing.T) {
c.tempDir = t.TempDir()
c.withKubeConfig(nil)
if c.toolset == nil {
c.toolset = &Full{}
c.toolset = &full.Full{}
}
if c.listOutput == nil {
c.listOutput = output.Yaml
Expand Down Expand Up @@ -188,7 +191,7 @@ func (c *mcpContext) afterEach() {
}

func testCase(t *testing.T, test func(c *mcpContext)) {
testCaseWithContext(t, &mcpContext{toolset: &Full{}}, test)
testCaseWithContext(t, &mcpContext{toolset: &full.Full{}}, test)
}

func testCaseWithContext(t *testing.T, mcpCtx *mcpContext, test func(c *mcpContext)) {
Expand All @@ -198,23 +201,23 @@ func testCaseWithContext(t *testing.T, mcpCtx *mcpContext, test func(c *mcpConte
}

// withKubeConfig sets up a fake kubeconfig in the temp directory based on the provided rest.Config
func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {
fakeConfig := api.NewConfig()
fakeConfig.Clusters["fake"] = api.NewCluster()
func (c *mcpContext) withKubeConfig(rc *rest.Config) *clientcmdapi.Config {
fakeConfig := clientcmdapi.NewConfig()
fakeConfig.Clusters["fake"] = clientcmdapi.NewCluster()
fakeConfig.Clusters["fake"].Server = "https://127.0.0.1:6443"
fakeConfig.Clusters["additional-cluster"] = api.NewCluster()
fakeConfig.AuthInfos["fake"] = api.NewAuthInfo()
fakeConfig.AuthInfos["additional-auth"] = api.NewAuthInfo()
fakeConfig.Clusters["additional-cluster"] = clientcmdapi.NewCluster()
fakeConfig.AuthInfos["fake"] = clientcmdapi.NewAuthInfo()
fakeConfig.AuthInfos["additional-auth"] = clientcmdapi.NewAuthInfo()
if rc != nil {
fakeConfig.Clusters["fake"].Server = rc.Host
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"] = clientcmdapi.NewContext()
fakeConfig.Contexts["fake-context"].Cluster = "fake"
fakeConfig.Contexts["fake-context"].AuthInfo = "fake"
fakeConfig.Contexts["additional-context"] = api.NewContext()
fakeConfig.Contexts["additional-context"] = clientcmdapi.NewContext()
fakeConfig.Contexts["additional-context"].Cluster = "additional-cluster"
fakeConfig.Contexts["additional-context"].AuthInfo = "additional-auth"
fakeConfig.CurrentContext = "fake-context"
Expand Down
54 changes: 54 additions & 0 deletions pkg/mcp/m3labs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package mcp

import (
"context"
"encoding/json"
"fmt"

"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"

"github.com/containers/kubernetes-mcp-server/pkg/api"
)

func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.ServerTool, error) {
m3labTools := make([]server.ServerTool, 0)
for _, tool := range tools {
m3labTool := mcp.Tool{
Name: tool.Tool.Name,
Description: tool.Tool.Description,
Annotations: mcp.ToolAnnotation{
Title: tool.Tool.Annotations.Title,
ReadOnlyHint: tool.Tool.Annotations.ReadOnlyHint,
DestructiveHint: tool.Tool.Annotations.DestructiveHint,
IdempotentHint: tool.Tool.Annotations.IdempotentHint,
OpenWorldHint: tool.Tool.Annotations.OpenWorldHint,
},
}
if tool.Tool.InputSchema != nil {
schema, err := json.Marshal(tool.Tool.InputSchema)
if err != nil {
return nil, fmt.Errorf("failed to marshal tool input schema for tool %s: %v", tool.Tool.Name, err)
}
m3labTool.RawInputSchema = schema
}
m3labHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
k, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
result, err := tool.Handler(api.ToolHandlerParams{
Context: ctx,
Kubernetes: k,
ToolCallRequest: request,
ListOutput: s.configuration.ListOutput,
})
if err != nil {
return nil, err
}
return NewTextResult(result.Content, result.Error), nil
}
m3labTools = append(m3labTools, server.ServerTool{Tool: m3labTool, Handler: m3labHandler})
}
return m3labTools, nil
}
11 changes: 6 additions & 5 deletions pkg/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"slices"

"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
authenticationapiv1 "k8s.io/api/authentication/v1"
Expand All @@ -24,13 +25,13 @@ type ContextKey string
const TokenScopesContextKey = ContextKey("TokenScopesContextKey")

type Configuration struct {
Toolset Toolset
Toolset api.Toolset
ListOutput output.Output

StaticConfig *config.StaticConfig
}

func (c *Configuration) isToolApplicable(tool ServerTool) bool {
func (c *Configuration) isToolApplicable(tool api.ServerTool) bool {
if c.StaticConfig.ReadOnly && !ptr.Deref(tool.Tool.Annotations.ReadOnlyHint, false) {
return false
}
Expand Down Expand Up @@ -88,15 +89,15 @@ func (s *Server) reloadKubernetesClient() error {
return err
}
s.k = k
applicableTools := make([]ServerTool, 0)
for _, tool := range s.configuration.Toolset.GetTools(s) {
applicableTools := make([]api.ServerTool, 0)
for _, tool := range s.configuration.Toolset.GetTools(s.k) {
if !s.configuration.isToolApplicable(tool) {
continue
}
applicableTools = append(applicableTools, tool)
s.enabledTools = append(s.enabledTools, tool.Tool.Name)
}
m3labsServerTools, err := ServerToolToM3LabsServerTool(applicableTools)
m3labsServerTools, err := ServerToolToM3LabsServerTool(s, applicableTools)
if err != nil {
return fmt.Errorf("failed to convert tools: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/mcp/mcp_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func TestToolCallLogging(t *testing.T) {
}
})
sensitiveHeaders := []string{
"Authorization",
"Authorization:",
// TODO: Add more sensitive headers as needed
}
t.Run("Does not log sensitive headers", func(t *testing.T) {
Expand Down
3 changes: 3 additions & 0 deletions pkg/mcp/modules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package mcp

import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
Loading
Loading