feat(mcp): toolset definitions completely agnostic from underlying MCP impl (#322)

Signed-off-by: Marc Nuri <marc@marcnuri.com>
This commit is contained in:
Marc Nuri
2025-09-12 11:56:22 +02:00
committed by GitHub
parent 2b6c886d95
commit 209e8434d5
21 changed files with 612 additions and 537 deletions

99
pkg/api/toolsets.go Normal file
View File

@@ -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
}

View File

@@ -21,6 +21,7 @@ import (
"time" "time"
"github.com/containers/kubernetes-mcp-server/internal/test" "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"
"github.com/coreos/go-oidc/v3/oidc/oidctest" "github.com/coreos/go-oidc/v3/oidc/oidctest"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
@@ -87,7 +88,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
} }
c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port) c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port)
mcpServer, err := mcp.NewServer(mcp.Configuration{ mcpServer, err := mcp.NewServer(mcp.Configuration{
Toolset: mcp.Toolsets()[0], Toolset: toolsets.Toolsets()[0],
StaticConfig: c.StaticConfig, StaticConfig: c.StaticConfig,
}) })
if err != nil { if err != nil {

View File

@@ -13,6 +13,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -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.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.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.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().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.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.DisableDestructive, "disable-destructive", o.DisableDestructive, "If true, tools annotated with destructiveHint=true are disabled")
@@ -237,9 +238,9 @@ func (m *MCPServerOptions) Validate() error {
} }
func (m *MCPServerOptions) Run() error { func (m *MCPServerOptions) Run() error {
toolset := mcp.ToolsetFromString(m.Toolset) toolset := toolsets.ToolsetFromString(m.Toolset)
if toolset == nil { 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) listOutput := output.FromString(m.StaticConfig.ListOutput)
if listOutput == nil { if listOutput == nil {

View File

@@ -81,24 +81,24 @@ func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return m.clientCmdConfig 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 cfg clientcmdapi.Config
var err error var err error
if m.IsInCluster() { if k.manager.IsInCluster() {
cfg = *clientcmdapi.NewConfig() cfg = *clientcmdapi.NewConfig()
cfg.Clusters["cluster"] = &clientcmdapi.Cluster{ cfg.Clusters["cluster"] = &clientcmdapi.Cluster{
Server: m.cfg.Host, Server: k.manager.cfg.Host,
InsecureSkipTLSVerify: m.cfg.Insecure, InsecureSkipTLSVerify: k.manager.cfg.Insecure,
} }
cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{ cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{
Token: m.cfg.BearerToken, Token: k.manager.cfg.BearerToken,
} }
cfg.Contexts["context"] = &clientcmdapi.Context{ cfg.Contexts["context"] = &clientcmdapi.Context{
Cluster: "cluster", Cluster: "cluster",
AuthInfo: "user", AuthInfo: "user",
} }
cfg.CurrentContext = "context" 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 return nil, err
} }
if minify { if minify {

View File

@@ -14,8 +14,6 @@ import (
"testing" "testing"
"time" "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"
"github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
@@ -32,7 +30,7 @@ import (
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd" "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" toolswatch "k8s.io/client-go/tools/watch"
"k8s.io/klog/v2" "k8s.io/klog/v2"
"k8s.io/klog/v2/textlogger" "k8s.io/klog/v2/textlogger"
@@ -43,6 +41,11 @@ import (
"sigs.k8s.io/controller-runtime/tools/setup-envtest/store" "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/versions"
"sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows" "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. // envTest has an expensive setup, so we only want to do it once per entire test run.
@@ -103,7 +106,7 @@ func TestMain(m *testing.M) {
} }
type mcpContext struct { type mcpContext struct {
toolset Toolset toolset api.Toolset
listOutput output.Output listOutput output.Output
logLevel int logLevel int
@@ -127,7 +130,7 @@ func (c *mcpContext) beforeEach(t *testing.T) {
c.tempDir = t.TempDir() c.tempDir = t.TempDir()
c.withKubeConfig(nil) c.withKubeConfig(nil)
if c.toolset == nil { if c.toolset == nil {
c.toolset = &Full{} c.toolset = &full.Full{}
} }
if c.listOutput == nil { if c.listOutput == nil {
c.listOutput = output.Yaml c.listOutput = output.Yaml
@@ -188,7 +191,7 @@ func (c *mcpContext) afterEach() {
} }
func testCase(t *testing.T, test func(c *mcpContext)) { 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)) { func testCaseWithContext(t *testing.T, mcpCtx *mcpContext, test func(c *mcpContext)) {
@@ -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 // 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 { func (c *mcpContext) withKubeConfig(rc *rest.Config) *clientcmdapi.Config {
fakeConfig := api.NewConfig() fakeConfig := clientcmdapi.NewConfig()
fakeConfig.Clusters["fake"] = api.NewCluster() fakeConfig.Clusters["fake"] = clientcmdapi.NewCluster()
fakeConfig.Clusters["fake"].Server = "https://127.0.0.1:6443" fakeConfig.Clusters["fake"].Server = "https://127.0.0.1:6443"
fakeConfig.Clusters["additional-cluster"] = api.NewCluster() fakeConfig.Clusters["additional-cluster"] = clientcmdapi.NewCluster()
fakeConfig.AuthInfos["fake"] = api.NewAuthInfo() fakeConfig.AuthInfos["fake"] = clientcmdapi.NewAuthInfo()
fakeConfig.AuthInfos["additional-auth"] = api.NewAuthInfo() fakeConfig.AuthInfos["additional-auth"] = clientcmdapi.NewAuthInfo()
if rc != nil { if rc != nil {
fakeConfig.Clusters["fake"].Server = rc.Host fakeConfig.Clusters["fake"].Server = rc.Host
fakeConfig.Clusters["fake"].CertificateAuthorityData = rc.CAData fakeConfig.Clusters["fake"].CertificateAuthorityData = rc.CAData
fakeConfig.AuthInfos["fake"].ClientKeyData = rc.KeyData fakeConfig.AuthInfos["fake"].ClientKeyData = rc.KeyData
fakeConfig.AuthInfos["fake"].ClientCertificateData = rc.CertData 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"].Cluster = "fake"
fakeConfig.Contexts["fake-context"].AuthInfo = "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"].Cluster = "additional-cluster"
fakeConfig.Contexts["additional-context"].AuthInfo = "additional-auth" fakeConfig.Contexts["additional-context"].AuthInfo = "additional-auth"
fakeConfig.CurrentContext = "fake-context" fakeConfig.CurrentContext = "fake-context"

54
pkg/mcp/m3labs.go Normal file
View File

@@ -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
}

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"slices" "slices"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
authenticationapiv1 "k8s.io/api/authentication/v1" authenticationapiv1 "k8s.io/api/authentication/v1"
@@ -24,13 +25,13 @@ type ContextKey string
const TokenScopesContextKey = ContextKey("TokenScopesContextKey") const TokenScopesContextKey = ContextKey("TokenScopesContextKey")
type Configuration struct { type Configuration struct {
Toolset Toolset Toolset api.Toolset
ListOutput output.Output ListOutput output.Output
StaticConfig *config.StaticConfig 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) { if c.StaticConfig.ReadOnly && !ptr.Deref(tool.Tool.Annotations.ReadOnlyHint, false) {
return false return false
} }
@@ -88,15 +89,15 @@ func (s *Server) reloadKubernetesClient() error {
return err return err
} }
s.k = k s.k = k
applicableTools := make([]ServerTool, 0) applicableTools := make([]api.ServerTool, 0)
for _, tool := range s.configuration.Toolset.GetTools(s) { for _, tool := range s.configuration.Toolset.GetTools(s.k) {
if !s.configuration.isToolApplicable(tool) { if !s.configuration.isToolApplicable(tool) {
continue continue
} }
applicableTools = append(applicableTools, tool) applicableTools = append(applicableTools, tool)
s.enabledTools = append(s.enabledTools, tool.Tool.Name) s.enabledTools = append(s.enabledTools, tool.Tool.Name)
} }
m3labsServerTools, err := ServerToolToM3LabsServerTool(applicableTools) m3labsServerTools, err := ServerToolToM3LabsServerTool(s, applicableTools)
if err != nil { if err != nil {
return fmt.Errorf("failed to convert tools: %v", err) return fmt.Errorf("failed to convert tools: %v", err)
} }

View File

@@ -161,7 +161,7 @@ func TestToolCallLogging(t *testing.T) {
} }
}) })
sensitiveHeaders := []string{ sensitiveHeaders := []string{
"Authorization", "Authorization:",
// TODO: Add more sensitive headers as needed // TODO: Add more sensitive headers as needed
} }
t.Run("Does not log sensitive headers", func(t *testing.T) { t.Run("Does not log sensitive headers", func(t *testing.T) {

3
pkg/mcp/modules.go Normal file
View File

@@ -0,0 +1,3 @@
package mcp
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"

View File

@@ -1,75 +0,0 @@
package mcp
import (
"context"
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
)
func (s *Server) initNamespaces() []ServerTool {
ret := make([]ServerTool, 0)
ret = append(ret, ServerTool{
Tool: Tool{
Name: "namespaces_list",
Description: "List all the Kubernetes namespaces in the current cluster",
InputSchema: &jsonschema.Schema{
Type: "object",
},
Annotations: ToolAnnotations{
Title: "Namespaces: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.namespacesList,
})
if s.k.IsOpenShift(context.Background()) {
ret = append(ret, ServerTool{
Tool: Tool{
Name: "projects_list",
Description: "List all the OpenShift projects in the current cluster",
InputSchema: &jsonschema.Schema{
Type: "object",
},
Annotations: ToolAnnotations{
Title: "Projects: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.projectsList,
})
}
return ret
}
func (s *Server) namespacesList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.NamespacesList(ctx, kubernetes.ResourceListOptions{AsTable: s.configuration.ListOutput.AsTable()})
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}
func (s *Server) projectsList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
derived, err := s.k.Derived(ctx)
if err != nil {
return nil, err
}
ret, err := derived.ProjectsList(ctx, kubernetes.ResourceListOptions{AsTable: s.configuration.ListOutput.AsTable()})
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list projects: %v", err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}

View File

@@ -1,151 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"slices"
"github.com/google/jsonschema-go/jsonschema"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
type Toolset interface {
GetName() string
GetDescription() string
GetTools(s *Server) []ServerTool
}
type ServerTool struct {
Tool Tool
Handler ToolHandlerFunc
}
type ToolHandlerFunc func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, 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"`
}
var toolsets []Toolset
func Register(toolset Toolset) {
toolsets = append(toolsets, toolset)
}
func Toolsets() []Toolset {
return toolsets
}
func ToolsetNames() []string {
names := make([]string, 0)
for _, toolset := range Toolsets() {
names = append(names, toolset.GetName())
}
return names
}
func ToolsetFromString(name string) Toolset {
for _, toolset := range Toolsets() {
if toolset.GetName() == name {
return toolset
}
}
return nil
}
func ToRawMessage(v any) json.RawMessage {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
return b
}
func ServerToolToM3LabsServerTool(tools []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
}
m3labTools = append(m3labTools, server.ServerTool{Tool: m3labTool, Handler: server.ToolHandlerFunc(tool.Handler)})
}
return m3labTools, nil
}
type Full struct{}
var _ Toolset = (*Full)(nil)
func (p *Full) GetName() string {
return "full"
}
func (p *Full) GetDescription() string {
return "Complete toolset with all tools and extended outputs"
}
func (p *Full) GetTools(s *Server) []ServerTool {
return slices.Concat(
s.initConfiguration(),
s.initEvents(),
s.initNamespaces(),
s.initPods(),
s.initResources(),
s.initHelm(),
)
}
func init() {
Register(&Full{})
}

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -34,7 +35,7 @@ func TestFullToolsetTools(t *testing.T) {
"resources_create_or_update", "resources_create_or_update",
"resources_delete", "resources_delete",
} }
mcpCtx := &mcpContext{toolset: &Full{}} mcpCtx := &mcpContext{toolset: &full.Full{}}
testCaseWithContext(t, mcpCtx, func(c *mcpContext) { testCaseWithContext(t, mcpCtx, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{}) tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) { t.Run("ListTools returns tools", func(t *testing.T) {
@@ -73,7 +74,7 @@ func TestFullToolsetTools(t *testing.T) {
func TestFullToolsetToolsInOpenShift(t *testing.T) { func TestFullToolsetToolsInOpenShift(t *testing.T) {
mcpCtx := &mcpContext{ mcpCtx := &mcpContext{
toolset: &Full{}, toolset: &full.Full{},
before: inOpenShift, before: inOpenShift,
after: inOpenShiftClear, after: inOpenShiftClear,
} }

View File

@@ -1,19 +1,18 @@
package mcp package full
import ( import (
"context"
"fmt" "fmt"
"github.com/google/jsonschema-go/jsonschema" "github.com/google/jsonschema-go/jsonschema"
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/utils/ptr" "k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/containers/kubernetes-mcp-server/pkg/output"
) )
func (s *Server) initConfiguration() []ServerTool { func initConfiguration() []api.ServerTool {
tools := []ServerTool{ tools := []api.ServerTool{
{Tool: Tool{ {Tool: api.Tool{
Name: "configuration_view", Name: "configuration_view",
Description: "Get the current Kubernetes configuration content as a kubeconfig YAML", Description: "Get the current Kubernetes configuration content as a kubeconfig YAML",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -28,31 +27,31 @@ func (s *Server) initConfiguration() []ServerTool {
}, },
}, },
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Configuration: View", Title: "Configuration: View",
ReadOnlyHint: ptr.To(true), ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.configurationView}, }, Handler: configurationView},
} }
return tools return tools
} }
func (s *Server) configurationView(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func configurationView(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
minify := true minify := true
minified := ctr.GetArguments()["minified"] minified := params.GetArguments()["minified"]
if _, ok := minified.(bool); ok { if _, ok := minified.(bool); ok {
minify = minified.(bool) minify = minified.(bool)
} }
ret, err := s.k.ConfigurationView(minify) ret, err := params.ConfigurationView(minify)
if err != nil { if err != nil {
return NewTextResult("", fmt.Errorf("failed to get configuration: %v", err)), nil return api.NewToolCallResult("", fmt.Errorf("failed to get configuration: %v", err)), nil
} }
configurationYaml, err := output.MarshalYaml(ret) configurationYaml, err := output.MarshalYaml(ret)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get configuration: %v", err) err = fmt.Errorf("failed to get configuration: %v", err)
} }
return NewTextResult(configurationYaml, err), nil return api.NewToolCallResult(configurationYaml, err), nil
} }

View File

@@ -1,19 +1,18 @@
package mcp package full
import ( import (
"context"
"fmt" "fmt"
"github.com/google/jsonschema-go/jsonschema" "github.com/google/jsonschema-go/jsonschema"
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/utils/ptr" "k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/containers/kubernetes-mcp-server/pkg/output"
) )
func (s *Server) initEvents() []ServerTool { func initEvents() []api.ServerTool {
return []ServerTool{ return []api.ServerTool{
{Tool: Tool{ {Tool: api.Tool{
Name: "events_list", Name: "events_list",
Description: "List all the Kubernetes events in the current cluster from all namespaces", Description: "List all the Kubernetes events in the current cluster from all namespaces",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -25,36 +24,32 @@ func (s *Server) initEvents() []ServerTool {
}, },
}, },
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Events: List", Title: "Events: List",
ReadOnlyHint: ptr.To(true), ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.eventsList}, }, Handler: eventsList},
} }
} }
func (s *Server) eventsList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func eventsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := ctr.GetArguments()["namespace"] namespace := params.GetArguments()["namespace"]
if namespace == nil { if namespace == nil {
namespace = "" namespace = ""
} }
derived, err := s.k.Derived(ctx) eventMap, err := params.EventsList(params, namespace.(string))
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil
}
eventMap, err := derived.EventsList(ctx, namespace.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil
} }
if len(eventMap) == 0 { if len(eventMap) == 0 {
return NewTextResult("No events found", nil), nil return api.NewToolCallResult("No events found", nil), nil
} }
yamlEvents, err := output.MarshalYaml(eventMap) yamlEvents, err := output.MarshalYaml(eventMap)
if err != nil { if err != nil {
err = fmt.Errorf("failed to list events in all namespaces: %v", err) err = fmt.Errorf("failed to list events in all namespaces: %v", err)
} }
return NewTextResult(fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), err), nil return api.NewToolCallResult(fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), err), nil
} }

View File

@@ -1,17 +1,17 @@
package mcp package full
import ( import (
"context"
"fmt" "fmt"
"github.com/google/jsonschema-go/jsonschema" "github.com/google/jsonschema-go/jsonschema"
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/utils/ptr" "k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
) )
func (s *Server) initHelm() []ServerTool { func initHelm() []api.ServerTool {
return []ServerTool{ return []api.ServerTool{
{Tool: Tool{ {Tool: api.Tool{
Name: "helm_install", Name: "helm_install",
Description: "Install a Helm chart in the current or provided namespace", Description: "Install a Helm chart in the current or provided namespace",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -37,15 +37,15 @@ func (s *Server) initHelm() []ServerTool {
}, },
Required: []string{"chart"}, Required: []string{"chart"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Helm: Install", Title: "Helm: Install",
ReadOnlyHint: ptr.To(false), ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), // TODO: consider replacing implementation with equivalent to: helm upgrade --install IdempotentHint: ptr.To(false), // TODO: consider replacing implementation with equivalent to: helm upgrade --install
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.helmInstall}, }, Handler: helmInstall},
{Tool: Tool{ {Tool: api.Tool{
Name: "helm_list", Name: "helm_list",
Description: "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)", Description: "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -61,15 +61,15 @@ func (s *Server) initHelm() []ServerTool {
}, },
}, },
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Helm: List", Title: "Helm: List",
ReadOnlyHint: ptr.To(true), ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.helmList}, }, Handler: helmList},
{Tool: Tool{ {Tool: api.Tool{
Name: "helm_uninstall", Name: "helm_uninstall",
Description: "Uninstall a Helm release in the current or provided namespace", Description: "Uninstall a Helm release in the current or provided namespace",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -86,83 +86,71 @@ func (s *Server) initHelm() []ServerTool {
}, },
Required: []string{"name"}, Required: []string{"name"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Helm: Uninstall", Title: "Helm: Uninstall",
ReadOnlyHint: ptr.To(false), ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true), DestructiveHint: ptr.To(true),
IdempotentHint: ptr.To(true), IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.helmUninstall}, }, Handler: helmUninstall},
} }
} }
func (s *Server) helmInstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func helmInstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
var chart string var chart string
ok := false ok := false
if chart, ok = ctr.GetArguments()["chart"].(string); !ok { if chart, ok = params.GetArguments()["chart"].(string); !ok {
return NewTextResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil return api.NewToolCallResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil
} }
values := map[string]interface{}{} values := map[string]interface{}{}
if v, ok := ctr.GetArguments()["values"].(map[string]interface{}); ok { if v, ok := params.GetArguments()["values"].(map[string]interface{}); ok {
values = v values = v
} }
name := "" name := ""
if v, ok := ctr.GetArguments()["name"].(string); ok { if v, ok := params.GetArguments()["name"].(string); ok {
name = v name = v
} }
namespace := "" namespace := ""
if v, ok := ctr.GetArguments()["namespace"].(string); ok { if v, ok := params.GetArguments()["namespace"].(string); ok {
namespace = v namespace = v
} }
derived, err := s.k.Derived(ctx) ret, err := params.NewHelm().Install(params, chart, values, name, namespace)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil
} }
ret, err := derived.NewHelm().Install(ctx, chart, values, name, namespace) return api.NewToolCallResult(ret, err), nil
if err != nil {
return NewTextResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil
}
return NewTextResult(ret, err), nil
} }
func (s *Server) helmList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func helmList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
allNamespaces := false allNamespaces := false
if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok { if v, ok := params.GetArguments()["all_namespaces"].(bool); ok {
allNamespaces = v allNamespaces = v
} }
namespace := "" namespace := ""
if v, ok := ctr.GetArguments()["namespace"].(string); ok { if v, ok := params.GetArguments()["namespace"].(string); ok {
namespace = v namespace = v
} }
derived, err := s.k.Derived(ctx) ret, err := params.NewHelm().List(namespace, allNamespaces)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil
} }
ret, err := derived.NewHelm().List(namespace, allNamespaces) return api.NewToolCallResult(ret, err), nil
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil
}
return NewTextResult(ret, err), nil
} }
func (s *Server) helmUninstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func helmUninstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
var name string var name string
ok := false ok := false
if name, ok = ctr.GetArguments()["name"].(string); !ok { if name, ok = params.GetArguments()["name"].(string); !ok {
return NewTextResult("", fmt.Errorf("failed to uninstall helm chart, missing argument name")), nil return api.NewToolCallResult("", fmt.Errorf("failed to uninstall helm chart, missing argument name")), nil
} }
namespace := "" namespace := ""
if v, ok := ctr.GetArguments()["namespace"].(string); ok { if v, ok := params.GetArguments()["namespace"].(string); ok {
namespace = v namespace = v
} }
derived, err := s.k.Derived(ctx) ret, err := params.NewHelm().Uninstall(name, namespace)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to uninstall helm chart '%s': %w", name, err)), nil
} }
ret, err := derived.NewHelm().Uninstall(name, namespace) return api.NewToolCallResult(ret, err), nil
if err != nil {
return NewTextResult("", fmt.Errorf("failed to uninstall helm chart '%s': %w", name, err)), nil
}
return NewTextResult(ret, err), nil
} }

View File

@@ -0,0 +1,68 @@
package full
import (
"context"
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
)
func initNamespaces(k *internalk8s.Manager) []api.ServerTool {
ret := make([]api.ServerTool, 0)
ret = append(ret, api.ServerTool{
Tool: api.Tool{
Name: "namespaces_list",
Description: "List all the Kubernetes namespaces in the current cluster",
InputSchema: &jsonschema.Schema{
Type: "object",
},
Annotations: api.ToolAnnotations{
Title: "Namespaces: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: namespacesList,
})
if k.IsOpenShift(context.Background()) {
ret = append(ret, api.ServerTool{
Tool: api.Tool{
Name: "projects_list",
Description: "List all the OpenShift projects in the current cluster",
InputSchema: &jsonschema.Schema{
Type: "object",
},
Annotations: api.ToolAnnotations{
Title: "Projects: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: projectsList,
})
}
return ret
}
func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ret, err := params.NamespacesList(params, kubernetes.ResourceListOptions{AsTable: params.ListOutput.AsTable()})
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil
}
return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
}
func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ret, err := params.ProjectsList(params, kubernetes.ResourceListOptions{AsTable: params.ListOutput.AsTable()})
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list projects: %v", err)), nil
}
return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
}

View File

@@ -1,23 +1,22 @@
package mcp package full
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"github.com/google/jsonschema-go/jsonschema" "github.com/google/jsonschema-go/jsonschema"
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/kubectl/pkg/metricsutil" "k8s.io/kubectl/pkg/metricsutil"
"k8s.io/utils/ptr" "k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/containers/kubernetes-mcp-server/pkg/output"
) )
func (s *Server) initPods() []ServerTool { func initPods() []api.ServerTool {
return []ServerTool{ return []api.ServerTool{
{Tool: Tool{ {Tool: api.Tool{
Name: "pods_list", Name: "pods_list",
Description: "List all the Kubernetes pods in the current cluster from all namespaces", Description: "List all the Kubernetes pods in the current cluster from all namespaces",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -30,15 +29,15 @@ func (s *Server) initPods() []ServerTool {
}, },
}, },
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Pods: List", Title: "Pods: List",
ReadOnlyHint: ptr.To(true), ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.podsListInAllNamespaces}, }, Handler: podsListInAllNamespaces},
{Tool: Tool{ {Tool: api.Tool{
Name: "pods_list_in_namespace", Name: "pods_list_in_namespace",
Description: "List all the Kubernetes pods in the specified namespace in the current cluster", Description: "List all the Kubernetes pods in the specified namespace in the current cluster",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -56,15 +55,15 @@ func (s *Server) initPods() []ServerTool {
}, },
Required: []string{"namespace"}, Required: []string{"namespace"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Pods: List in Namespace", Title: "Pods: List in Namespace",
ReadOnlyHint: ptr.To(true), ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.podsListInNamespace}, }, Handler: podsListInNamespace},
{Tool: Tool{ {Tool: api.Tool{
Name: "pods_get", Name: "pods_get",
Description: "Get a Kubernetes Pod in the current or provided namespace with the provided name", Description: "Get a Kubernetes Pod in the current or provided namespace with the provided name",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -81,15 +80,15 @@ func (s *Server) initPods() []ServerTool {
}, },
Required: []string{"name"}, Required: []string{"name"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Pods: Get", Title: "Pods: Get",
ReadOnlyHint: ptr.To(true), ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.podsGet}, }, Handler: podsGet},
{Tool: Tool{ {Tool: api.Tool{
Name: "pods_delete", Name: "pods_delete",
Description: "Delete a Kubernetes Pod in the current or provided namespace with the provided name", Description: "Delete a Kubernetes Pod in the current or provided namespace with the provided name",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -106,15 +105,15 @@ func (s *Server) initPods() []ServerTool {
}, },
Required: []string{"name"}, Required: []string{"name"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Pods: Delete", Title: "Pods: Delete",
ReadOnlyHint: ptr.To(false), ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true), DestructiveHint: ptr.To(true),
IdempotentHint: ptr.To(true), IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.podsDelete}, }, Handler: podsDelete},
{Tool: Tool{ {Tool: api.Tool{
Name: "pods_top", Name: "pods_top",
Description: "List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace", Description: "List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -123,7 +122,7 @@ func (s *Server) initPods() []ServerTool {
"all_namespaces": { "all_namespaces": {
Type: "boolean", Type: "boolean",
Description: "If true, list the resource consumption for all Pods in all namespaces. If false, list the resource consumption for Pods in the provided namespace or the current namespace", Description: "If true, list the resource consumption for all Pods in all namespaces. If false, list the resource consumption for Pods in the provided namespace or the current namespace",
Default: ToRawMessage(true), Default: api.ToRawMessage(true),
}, },
"namespace": { "namespace": {
Type: "string", Type: "string",
@@ -140,15 +139,15 @@ func (s *Server) initPods() []ServerTool {
}, },
}, },
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Pods: Top", Title: "Pods: Top",
ReadOnlyHint: ptr.To(true), ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(true), IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.podsTop}, }, Handler: podsTop},
{Tool: Tool{ {Tool: api.Tool{
Name: "pods_exec", Name: "pods_exec",
Description: "Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command", Description: "Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -176,15 +175,15 @@ func (s *Server) initPods() []ServerTool {
}, },
Required: []string{"name", "command"}, Required: []string{"name", "command"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Pods: Exec", Title: "Pods: Exec",
ReadOnlyHint: ptr.To(false), ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true), // Depending on the Pod's entrypoint, executing certain commands may kill the Pod DestructiveHint: ptr.To(true), // Depending on the Pod's entrypoint, executing certain commands may kill the Pod
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.podsExec}, }, Handler: podsExec},
{Tool: Tool{ {Tool: api.Tool{
Name: "pods_log", Name: "pods_log",
Description: "Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name", Description: "Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -209,15 +208,15 @@ func (s *Server) initPods() []ServerTool {
}, },
Required: []string{"name"}, Required: []string{"name"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Pods: Log", Title: "Pods: Log",
ReadOnlyHint: ptr.To(true), ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.podsLog}, }, Handler: podsLog},
{Tool: Tool{ {Tool: api.Tool{
Name: "pods_run", Name: "pods_run",
Description: "Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name", Description: "Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name",
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -242,144 +241,124 @@ func (s *Server) initPods() []ServerTool {
}, },
Required: []string{"image"}, Required: []string{"image"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Pods: Run", Title: "Pods: Run",
ReadOnlyHint: ptr.To(false), ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.podsRun}, }, Handler: podsRun},
} }
} }
func (s *Server) podsListInAllNamespaces(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func podsListInAllNamespaces(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
labelSelector := ctr.GetArguments()["labelSelector"] labelSelector := params.GetArguments()["labelSelector"]
resourceListOptions := kubernetes.ResourceListOptions{ resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(), AsTable: params.ListOutput.AsTable(),
} }
if labelSelector != nil { if labelSelector != nil {
resourceListOptions.LabelSelector = labelSelector.(string) resourceListOptions.LabelSelector = labelSelector.(string)
} }
derived, err := s.k.Derived(ctx) ret, err := params.PodsListInAllNamespaces(params, resourceListOptions)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to list pods in all namespaces: %v", err)), nil
} }
ret, err := derived.PodsListInAllNamespaces(ctx, resourceListOptions) return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list pods in all namespaces: %v", err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
} }
func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func podsListInNamespace(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := ctr.GetArguments()["namespace"] ns := params.GetArguments()["namespace"]
if ns == nil { if ns == nil {
return NewTextResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil return api.NewToolCallResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil
} }
resourceListOptions := kubernetes.ResourceListOptions{ resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(), AsTable: params.ListOutput.AsTable(),
} }
labelSelector := ctr.GetArguments()["labelSelector"] labelSelector := params.GetArguments()["labelSelector"]
if labelSelector != nil { if labelSelector != nil {
resourceListOptions.LabelSelector = labelSelector.(string) resourceListOptions.LabelSelector = labelSelector.(string)
} }
derived, err := s.k.Derived(ctx) ret, err := params.PodsListInNamespace(params, ns.(string), resourceListOptions)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to list pods in namespace %s: %v", ns, err)), nil
} }
ret, err := derived.PodsListInNamespace(ctx, ns.(string), resourceListOptions) return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list pods in namespace %s: %v", ns, err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
} }
func (s *Server) podsGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func podsGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := ctr.GetArguments()["namespace"] ns := params.GetArguments()["namespace"]
if ns == nil { if ns == nil {
ns = "" ns = ""
} }
name := ctr.GetArguments()["name"] name := params.GetArguments()["name"]
if name == nil { if name == nil {
return NewTextResult("", errors.New("failed to get pod, missing argument name")), nil return api.NewToolCallResult("", errors.New("failed to get pod, missing argument name")), nil
} }
derived, err := s.k.Derived(ctx) ret, err := params.PodsGet(params, ns.(string), name.(string))
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil
} }
ret, err := derived.PodsGet(ctx, ns.(string), name.(string)) return api.NewToolCallResult(output.MarshalYaml(ret)), nil
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil
}
return NewTextResult(output.MarshalYaml(ret)), nil
} }
func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func podsDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := ctr.GetArguments()["namespace"] ns := params.GetArguments()["namespace"]
if ns == nil { if ns == nil {
ns = "" ns = ""
} }
name := ctr.GetArguments()["name"] name := params.GetArguments()["name"]
if name == nil { if name == nil {
return NewTextResult("", errors.New("failed to delete pod, missing argument name")), nil return api.NewToolCallResult("", errors.New("failed to delete pod, missing argument name")), nil
} }
derived, err := s.k.Derived(ctx) ret, err := params.PodsDelete(params, ns.(string), name.(string))
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to delete pod %s in namespace %s: %v", name, ns, err)), nil
} }
ret, err := derived.PodsDelete(ctx, ns.(string), name.(string)) return api.NewToolCallResult(ret, err), nil
if err != nil {
return NewTextResult("", fmt.Errorf("failed to delete pod %s in namespace %s: %v", name, ns, err)), nil
}
return NewTextResult(ret, err), nil
} }
func (s *Server) podsTop(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func podsTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
podsTopOptions := kubernetes.PodsTopOptions{AllNamespaces: true} podsTopOptions := kubernetes.PodsTopOptions{AllNamespaces: true}
if v, ok := ctr.GetArguments()["namespace"].(string); ok { if v, ok := params.GetArguments()["namespace"].(string); ok {
podsTopOptions.Namespace = v podsTopOptions.Namespace = v
} }
if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok { if v, ok := params.GetArguments()["all_namespaces"].(bool); ok {
podsTopOptions.AllNamespaces = v podsTopOptions.AllNamespaces = v
} }
if v, ok := ctr.GetArguments()["name"].(string); ok { if v, ok := params.GetArguments()["name"].(string); ok {
podsTopOptions.Name = v podsTopOptions.Name = v
} }
if v, ok := ctr.GetArguments()["label_selector"].(string); ok { if v, ok := params.GetArguments()["label_selector"].(string); ok {
podsTopOptions.LabelSelector = v podsTopOptions.LabelSelector = v
} }
derived, err := s.k.Derived(ctx) ret, err := params.PodsTop(params, podsTopOptions)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
}
ret, err := derived.PodsTop(ctx, podsTopOptions)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
} }
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
printer := metricsutil.NewTopCmdPrinter(buf) printer := metricsutil.NewTopCmdPrinter(buf)
err = printer.PrintPodMetrics(ret.Items, true, true, false, "", true) err = printer.PrintPodMetrics(ret.Items, true, true, false, "", true)
if err != nil { if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil return api.NewToolCallResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
} }
return NewTextResult(buf.String(), nil), nil return api.NewToolCallResult(buf.String(), nil), nil
} }
func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func podsExec(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := ctr.GetArguments()["namespace"] ns := params.GetArguments()["namespace"]
if ns == nil { if ns == nil {
ns = "" ns = ""
} }
name := ctr.GetArguments()["name"] name := params.GetArguments()["name"]
if name == nil { if name == nil {
return NewTextResult("", errors.New("failed to exec in pod, missing argument name")), nil return api.NewToolCallResult("", errors.New("failed to exec in pod, missing argument name")), nil
} }
container := ctr.GetArguments()["container"] container := params.GetArguments()["container"]
if container == nil { if container == nil {
container = "" container = ""
} }
commandArg := ctr.GetArguments()["command"] commandArg := params.GetArguments()["command"]
command := make([]string, 0) command := make([]string, 0)
if _, ok := commandArg.([]interface{}); ok { if _, ok := commandArg.([]interface{}); ok {
for _, cmd := range commandArg.([]interface{}) { for _, cmd := range commandArg.([]interface{}) {
@@ -388,80 +367,68 @@ func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.Ca
} }
} }
} else { } else {
return NewTextResult("", errors.New("failed to exec in pod, invalid command argument")), nil return api.NewToolCallResult("", errors.New("failed to exec in pod, invalid command argument")), nil
} }
derived, err := s.k.Derived(ctx) ret, err := params.PodsExec(params, ns.(string), name.(string), container.(string), command)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil
}
ret, err := derived.PodsExec(ctx, ns.(string), name.(string), container.(string), command)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil
} else if ret == "" { } else if ret == "" {
ret = fmt.Sprintf("The executed command in pod %s in namespace %s has not produced any output", name, ns) ret = fmt.Sprintf("The executed command in pod %s in namespace %s has not produced any output", name, ns)
} }
return NewTextResult(ret, err), nil return api.NewToolCallResult(ret, err), nil
} }
func (s *Server) podsLog(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func podsLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := ctr.GetArguments()["namespace"] ns := params.GetArguments()["namespace"]
if ns == nil { if ns == nil {
ns = "" ns = ""
} }
name := ctr.GetArguments()["name"] name := params.GetArguments()["name"]
if name == nil { if name == nil {
return NewTextResult("", errors.New("failed to get pod log, missing argument name")), nil return api.NewToolCallResult("", errors.New("failed to get pod log, missing argument name")), nil
} }
container := ctr.GetArguments()["container"] container := params.GetArguments()["container"]
if container == nil { if container == nil {
container = "" container = ""
} }
previous := ctr.GetArguments()["previous"] previous := params.GetArguments()["previous"]
var previousBool bool var previousBool bool
if previous != nil { if previous != nil {
previousBool = previous.(bool) previousBool = previous.(bool)
} }
derived, err := s.k.Derived(ctx) ret, err := params.PodsLog(params, ns.(string), name.(string), container.(string), previousBool)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil
}
ret, err := derived.PodsLog(ctx, ns.(string), name.(string), container.(string), previousBool)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil
} else if ret == "" { } else if ret == "" {
ret = fmt.Sprintf("The pod %s in namespace %s has not logged any message yet", name, ns) ret = fmt.Sprintf("The pod %s in namespace %s has not logged any message yet", name, ns)
} }
return NewTextResult(ret, err), nil return api.NewToolCallResult(ret, err), nil
} }
func (s *Server) podsRun(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func podsRun(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := ctr.GetArguments()["namespace"] ns := params.GetArguments()["namespace"]
if ns == nil { if ns == nil {
ns = "" ns = ""
} }
name := ctr.GetArguments()["name"] name := params.GetArguments()["name"]
if name == nil { if name == nil {
name = "" name = ""
} }
image := ctr.GetArguments()["image"] image := params.GetArguments()["image"]
if image == nil { if image == nil {
return NewTextResult("", errors.New("failed to run pod, missing argument image")), nil return api.NewToolCallResult("", errors.New("failed to run pod, missing argument image")), nil
} }
port := ctr.GetArguments()["port"] port := params.GetArguments()["port"]
if port == nil { if port == nil {
port = float64(0) port = float64(0)
} }
derived, err := s.k.Derived(ctx) resources, err := params.PodsRun(params, ns.(string), name.(string), image.(string), int32(port.(float64)))
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to run pod %s in namespace %s: %v", name, ns, err)), nil
}
resources, err := derived.PodsRun(ctx, ns.(string), name.(string), image.(string), int32(port.(float64)))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to run pod %s in namespace %s: %v", name, ns, err)), nil
} }
marshalledYaml, err := output.MarshalYaml(resources) marshalledYaml, err := output.MarshalYaml(resources)
if err != nil { if err != nil {
err = fmt.Errorf("failed to run pod: %v", err) err = fmt.Errorf("failed to run pod: %v", err)
} }
return NewTextResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil
} }

View File

@@ -1,4 +1,4 @@
package mcp package full
import ( import (
"context" "context"
@@ -6,22 +6,23 @@ import (
"fmt" "fmt"
"github.com/google/jsonschema-go/jsonschema" "github.com/google/jsonschema-go/jsonschema"
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/utils/ptr" "k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/containers/kubernetes-mcp-server/pkg/output"
) )
func (s *Server) initResources() []ServerTool { func initResources(k *internalk8s.Manager) []api.ServerTool {
commonApiVersion := "v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress" commonApiVersion := "v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress"
if s.k.IsOpenShift(context.Background()) { if k.IsOpenShift(context.Background()) {
commonApiVersion += ", route.openshift.io/v1 Route" commonApiVersion += ", route.openshift.io/v1 Route"
} }
commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion) commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion)
return []ServerTool{ return []api.ServerTool{
{Tool: Tool{ {Tool: api.Tool{
Name: "resources_list", Name: "resources_list",
Description: "List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n" + commonApiVersion, Description: "List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n" + commonApiVersion,
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -47,15 +48,15 @@ func (s *Server) initResources() []ServerTool {
}, },
Required: []string{"apiVersion", "kind"}, Required: []string{"apiVersion", "kind"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Resources: List", Title: "Resources: List",
ReadOnlyHint: ptr.To(true), ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.resourcesList}, }, Handler: resourcesList},
{Tool: Tool{ {Tool: api.Tool{
Name: "resources_get", Name: "resources_get",
Description: "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion, Description: "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion,
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -80,15 +81,15 @@ func (s *Server) initResources() []ServerTool {
}, },
Required: []string{"apiVersion", "kind", "name"}, Required: []string{"apiVersion", "kind", "name"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Resources: Get", Title: "Resources: Get",
ReadOnlyHint: ptr.To(true), ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false), DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.resourcesGet}, }, Handler: resourcesGet},
{Tool: Tool{ {Tool: api.Tool{
Name: "resources_create_or_update", Name: "resources_create_or_update",
Description: "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n" + commonApiVersion, Description: "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n" + commonApiVersion,
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -101,15 +102,15 @@ func (s *Server) initResources() []ServerTool {
}, },
Required: []string{"resource"}, Required: []string{"resource"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Resources: Create or Update", Title: "Resources: Create or Update",
ReadOnlyHint: ptr.To(false), ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true), DestructiveHint: ptr.To(true),
IdempotentHint: ptr.To(true), IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.resourcesCreateOrUpdate}, }, Handler: resourcesCreateOrUpdate},
{Tool: Tool{ {Tool: api.Tool{
Name: "resources_delete", Name: "resources_delete",
Description: "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion, Description: "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion,
InputSchema: &jsonschema.Schema{ InputSchema: &jsonschema.Schema{
@@ -134,149 +135,133 @@ func (s *Server) initResources() []ServerTool {
}, },
Required: []string{"apiVersion", "kind", "name"}, Required: []string{"apiVersion", "kind", "name"},
}, },
Annotations: ToolAnnotations{ Annotations: api.ToolAnnotations{
Title: "Resources: Delete", Title: "Resources: Delete",
ReadOnlyHint: ptr.To(false), ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true), DestructiveHint: ptr.To(true),
IdempotentHint: ptr.To(true), IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true), OpenWorldHint: ptr.To(true),
}, },
}, Handler: s.resourcesDelete}, }, Handler: resourcesDelete},
} }
} }
func (s *Server) resourcesList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func resourcesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := ctr.GetArguments()["namespace"] namespace := params.GetArguments()["namespace"]
if namespace == nil { if namespace == nil {
namespace = "" namespace = ""
} }
labelSelector := ctr.GetArguments()["labelSelector"] labelSelector := params.GetArguments()["labelSelector"]
resourceListOptions := kubernetes.ResourceListOptions{ resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(), AsTable: params.ListOutput.AsTable(),
} }
if labelSelector != nil { if labelSelector != nil {
l, ok := labelSelector.(string) l, ok := labelSelector.(string)
if !ok { if !ok {
return NewTextResult("", fmt.Errorf("labelSelector is not a string")), nil return api.NewToolCallResult("", fmt.Errorf("labelSelector is not a string")), nil
} }
resourceListOptions.LabelSelector = l resourceListOptions.LabelSelector = l
} }
gvk, err := parseGroupVersionKind(ctr.GetArguments()) gvk, err := parseGroupVersionKind(params.GetArguments())
if err != nil { if err != nil {
return NewTextResult("", fmt.Errorf("failed to list resources, %s", err)), nil return api.NewToolCallResult("", fmt.Errorf("failed to list resources, %s", err)), nil
} }
ns, ok := namespace.(string) ns, ok := namespace.(string)
if !ok { if !ok {
return NewTextResult("", fmt.Errorf("namespace is not a string")), nil return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
} }
derived, err := s.k.Derived(ctx) ret, err := params.ResourcesList(params, gvk, ns, resourceListOptions)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %v", err)), nil
} }
ret, err := derived.ResourcesList(ctx, gvk, ns, resourceListOptions) return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list resources: %v", err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
} }
func (s *Server) resourcesGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func resourcesGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := ctr.GetArguments()["namespace"] namespace := params.GetArguments()["namespace"]
if namespace == nil { if namespace == nil {
namespace = "" namespace = ""
} }
gvk, err := parseGroupVersionKind(ctr.GetArguments()) gvk, err := parseGroupVersionKind(params.GetArguments())
if err != nil { if err != nil {
return NewTextResult("", fmt.Errorf("failed to get resource, %s", err)), nil return api.NewToolCallResult("", fmt.Errorf("failed to get resource, %s", err)), nil
} }
name := ctr.GetArguments()["name"] name := params.GetArguments()["name"]
if name == nil { if name == nil {
return NewTextResult("", errors.New("failed to get resource, missing argument name")), nil return api.NewToolCallResult("", errors.New("failed to get resource, missing argument name")), nil
} }
ns, ok := namespace.(string) ns, ok := namespace.(string)
if !ok { if !ok {
return NewTextResult("", fmt.Errorf("namespace is not a string")), nil return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
} }
n, ok := name.(string) n, ok := name.(string)
if !ok { if !ok {
return NewTextResult("", fmt.Errorf("name is not a string")), nil return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil
} }
derived, err := s.k.Derived(ctx) ret, err := params.ResourcesGet(params, gvk, ns, n)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to get resource: %v", err)), nil
} }
ret, err := derived.ResourcesGet(ctx, gvk, ns, n) return api.NewToolCallResult(output.MarshalYaml(ret)), nil
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get resource: %v", err)), nil
}
return NewTextResult(output.MarshalYaml(ret)), nil
} }
func (s *Server) resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func resourcesCreateOrUpdate(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
resource := ctr.GetArguments()["resource"] resource := params.GetArguments()["resource"]
if resource == nil || resource == "" { if resource == nil || resource == "" {
return NewTextResult("", errors.New("failed to create or update resources, missing argument resource")), nil return api.NewToolCallResult("", errors.New("failed to create or update resources, missing argument resource")), nil
} }
r, ok := resource.(string) r, ok := resource.(string)
if !ok { if !ok {
return NewTextResult("", fmt.Errorf("resource is not a string")), nil return api.NewToolCallResult("", fmt.Errorf("resource is not a string")), nil
} }
derived, err := s.k.Derived(ctx) resources, err := params.ResourcesCreateOrUpdate(params, r)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
}
resources, err := derived.ResourcesCreateOrUpdate(ctx, r)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
} }
marshalledYaml, err := output.MarshalYaml(resources) marshalledYaml, err := output.MarshalYaml(resources)
if err != nil { if err != nil {
err = fmt.Errorf("failed to create or update resources:: %v", err) err = fmt.Errorf("failed to create or update resources:: %v", err)
} }
return NewTextResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil
} }
func (s *Server) resourcesDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := ctr.GetArguments()["namespace"] namespace := params.GetArguments()["namespace"]
if namespace == nil { if namespace == nil {
namespace = "" namespace = ""
} }
gvk, err := parseGroupVersionKind(ctr.GetArguments()) gvk, err := parseGroupVersionKind(params.GetArguments())
if err != nil { if err != nil {
return NewTextResult("", fmt.Errorf("failed to delete resource, %s", err)), nil return api.NewToolCallResult("", fmt.Errorf("failed to delete resource, %s", err)), nil
} }
name := ctr.GetArguments()["name"] name := params.GetArguments()["name"]
if name == nil { if name == nil {
return NewTextResult("", errors.New("failed to delete resource, missing argument name")), nil return api.NewToolCallResult("", errors.New("failed to delete resource, missing argument name")), nil
} }
ns, ok := namespace.(string) ns, ok := namespace.(string)
if !ok { if !ok {
return NewTextResult("", fmt.Errorf("namespace is not a string")), nil return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
} }
n, ok := name.(string) n, ok := name.(string)
if !ok { if !ok {
return NewTextResult("", fmt.Errorf("name is not a string")), nil return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil
} }
derived, err := s.k.Derived(ctx) err = params.ResourcesDelete(params, gvk, ns, n)
if err != nil { if err != nil {
return nil, err return api.NewToolCallResult("", fmt.Errorf("failed to delete resource: %v", err)), nil
} }
err = derived.ResourcesDelete(ctx, gvk, ns, n) return api.NewToolCallResult("Resource deleted successfully", err), nil
if err != nil {
return NewTextResult("", fmt.Errorf("failed to delete resource: %v", err)), nil
}
return NewTextResult("Resource deleted successfully", err), nil
} }
func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) { func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) {

View File

@@ -0,0 +1,36 @@
package full
import (
"slices"
"github.com/containers/kubernetes-mcp-server/pkg/api"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
)
type Full struct{}
var _ api.Toolset = (*Full)(nil)
func (p *Full) GetName() string {
return "full"
}
func (p *Full) GetDescription() string {
return "Complete toolset with all tools and extended outputs"
}
func (p *Full) GetTools(k *internalk8s.Manager) []api.ServerTool {
return slices.Concat(
initConfiguration(),
initEvents(),
initNamespaces(k),
initPods(),
initResources(k),
initHelm(),
)
}
func init() {
toolsets.Register(&Full{})
}

40
pkg/toolsets/toolsets.go Normal file
View File

@@ -0,0 +1,40 @@
package toolsets
import (
"slices"
"github.com/containers/kubernetes-mcp-server/pkg/api"
)
var toolsets []api.Toolset
// Clear removes all registered toolsets, TESTING PURPOSES ONLY.
func Clear() {
toolsets = []api.Toolset{}
}
func Register(toolset api.Toolset) {
toolsets = append(toolsets, toolset)
}
func Toolsets() []api.Toolset {
return toolsets
}
func ToolsetNames() []string {
names := make([]string, 0)
for _, toolset := range Toolsets() {
names = append(names, toolset.GetName())
}
slices.Sort(names)
return names
}
func ToolsetFromString(name string) api.Toolset {
for _, toolset := range Toolsets() {
if toolset.GetName() == name {
return toolset
}
}
return nil
}

View File

@@ -0,0 +1,60 @@
package toolsets
import (
"testing"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/stretchr/testify/suite"
)
type ToolsetsSuite struct {
suite.Suite
}
func (s *ToolsetsSuite) SetupTest() {
Clear()
}
type TestToolset struct {
name string
description string
}
func (t *TestToolset) GetName() string { return t.name }
func (t *TestToolset) GetDescription() string { return t.description }
func (t *TestToolset) GetTools(k *kubernetes.Manager) []api.ServerTool { return nil }
var _ api.Toolset = (*TestToolset)(nil)
func (s *ToolsetsSuite) TestToolsetNames() {
s.Run("Returns empty list if no toolsets registered", func() {
s.Empty(ToolsetNames(), "Expected empty list of toolset names")
})
Register(&TestToolset{name: "z"})
Register(&TestToolset{name: "b"})
Register(&TestToolset{name: "1"})
s.Run("Returns sorted list of registered toolset names", func() {
names := ToolsetNames()
s.Equal([]string{"1", "b", "z"}, names, "Expected sorted list of toolset names")
})
}
func (s *ToolsetsSuite) TestToolsetFromString() {
s.Run("Returns nil if toolset not found", func() {
s.Nil(ToolsetFromString("non-existent"), "Expected nil for non-existent toolset")
})
s.Run("Returns the correct toolset if found", func() {
Register(&TestToolset{name: "existent"})
res := ToolsetFromString("existent")
s.NotNil(res, "Expected to find the registered toolset")
s.Equal("existent", res.GetName(), "Expected to find the registered toolset by name")
})
}
func TestToolsets(t *testing.T) {
suite.Run(t, new(ToolsetsSuite))
}