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"
"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"
@@ -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 {

View File

@@ -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"
@@ -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")
@@ -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 {

View File

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

View File

@@ -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"
@@ -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"
@@ -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.
@@ -103,7 +106,7 @@ func TestMain(m *testing.M) {
}
type mcpContext struct {
toolset Toolset
toolset api.Toolset
listOutput output.Output
logLevel int
@@ -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
@@ -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)) {
@@ -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"

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

View File

@@ -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) {

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

View File

@@ -1,19 +1,18 @@
package mcp
package full
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/api"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func (s *Server) initConfiguration() []ServerTool {
tools := []ServerTool{
{Tool: Tool{
func initConfiguration() []api.ServerTool {
tools := []api.ServerTool{
{Tool: api.Tool{
Name: "configuration_view",
Description: "Get the current Kubernetes configuration content as a kubeconfig YAML",
InputSchema: &jsonschema.Schema{
@@ -28,31 +27,31 @@ func (s *Server) initConfiguration() []ServerTool {
},
},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Configuration: View",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.configurationView},
}, Handler: configurationView},
}
return tools
}
func (s *Server) configurationView(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
func configurationView(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
minify := true
minified := ctr.GetArguments()["minified"]
minified := params.GetArguments()["minified"]
if _, ok := minified.(bool); ok {
minify = minified.(bool)
}
ret, err := s.k.ConfigurationView(minify)
ret, err := params.ConfigurationView(minify)
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)
if err != nil {
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 (
"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/api"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func (s *Server) initEvents() []ServerTool {
return []ServerTool{
{Tool: Tool{
func initEvents() []api.ServerTool {
return []api.ServerTool{
{Tool: api.Tool{
Name: "events_list",
Description: "List all the Kubernetes events in the current cluster from all namespaces",
InputSchema: &jsonschema.Schema{
@@ -25,36 +24,32 @@ func (s *Server) initEvents() []ServerTool {
},
},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Events: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.eventsList},
}, Handler: eventsList},
}
}
func (s *Server) eventsList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
namespace := ctr.GetArguments()["namespace"]
func eventsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := params.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
derived, err := s.k.Derived(ctx)
eventMap, err := params.EventsList(params, namespace.(string))
if err != nil {
return nil, err
}
eventMap, err := derived.EventsList(ctx, namespace.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil
return api.NewToolCallResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil
}
if len(eventMap) == 0 {
return NewTextResult("No events found", nil), nil
return api.NewToolCallResult("No events found", nil), nil
}
yamlEvents, err := output.MarshalYaml(eventMap)
if err != nil {
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 (
"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/api"
)
func (s *Server) initHelm() []ServerTool {
return []ServerTool{
{Tool: Tool{
func initHelm() []api.ServerTool {
return []api.ServerTool{
{Tool: api.Tool{
Name: "helm_install",
Description: "Install a Helm chart in the current or provided namespace",
InputSchema: &jsonschema.Schema{
@@ -37,15 +37,15 @@ func (s *Server) initHelm() []ServerTool {
},
Required: []string{"chart"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Helm: Install",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false), // TODO: consider replacing implementation with equivalent to: helm upgrade --install
OpenWorldHint: ptr.To(true),
},
}, Handler: s.helmInstall},
{Tool: Tool{
}, Handler: helmInstall},
{Tool: api.Tool{
Name: "helm_list",
Description: "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)",
InputSchema: &jsonschema.Schema{
@@ -61,15 +61,15 @@ func (s *Server) initHelm() []ServerTool {
},
},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Helm: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.helmList},
{Tool: Tool{
}, Handler: helmList},
{Tool: api.Tool{
Name: "helm_uninstall",
Description: "Uninstall a Helm release in the current or provided namespace",
InputSchema: &jsonschema.Schema{
@@ -86,83 +86,71 @@ func (s *Server) initHelm() []ServerTool {
},
Required: []string{"name"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Helm: Uninstall",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true),
IdempotentHint: 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
ok := false
if chart, ok = ctr.GetArguments()["chart"].(string); !ok {
return NewTextResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil
if chart, ok = params.GetArguments()["chart"].(string); !ok {
return api.NewToolCallResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil
}
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
}
name := ""
if v, ok := ctr.GetArguments()["name"].(string); ok {
if v, ok := params.GetArguments()["name"].(string); ok {
name = v
}
namespace := ""
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
if v, ok := params.GetArguments()["namespace"].(string); ok {
namespace = v
}
derived, err := s.k.Derived(ctx)
ret, err := params.NewHelm().Install(params, chart, values, name, namespace)
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)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil
}
return NewTextResult(ret, err), nil
return api.NewToolCallResult(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
if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok {
if v, ok := params.GetArguments()["all_namespaces"].(bool); ok {
allNamespaces = v
}
namespace := ""
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
if v, ok := params.GetArguments()["namespace"].(string); ok {
namespace = v
}
derived, err := s.k.Derived(ctx)
ret, err := params.NewHelm().List(namespace, allNamespaces)
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)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil
}
return NewTextResult(ret, err), nil
return api.NewToolCallResult(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
ok := false
if name, ok = ctr.GetArguments()["name"].(string); !ok {
return NewTextResult("", fmt.Errorf("failed to uninstall helm chart, missing argument name")), nil
if name, ok = params.GetArguments()["name"].(string); !ok {
return api.NewToolCallResult("", fmt.Errorf("failed to uninstall helm chart, missing argument name")), nil
}
namespace := ""
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
if v, ok := params.GetArguments()["namespace"].(string); ok {
namespace = v
}
derived, err := s.k.Derived(ctx)
ret, err := params.NewHelm().Uninstall(name, namespace)
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)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to uninstall helm chart '%s': %w", name, err)), nil
}
return NewTextResult(ret, err), nil
return api.NewToolCallResult(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 (
"bytes"
"context"
"errors"
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/kubectl/pkg/metricsutil"
"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/output"
)
func (s *Server) initPods() []ServerTool {
return []ServerTool{
{Tool: Tool{
func initPods() []api.ServerTool {
return []api.ServerTool{
{Tool: api.Tool{
Name: "pods_list",
Description: "List all the Kubernetes pods in the current cluster from all namespaces",
InputSchema: &jsonschema.Schema{
@@ -30,15 +29,15 @@ func (s *Server) initPods() []ServerTool {
},
},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Pods: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.podsListInAllNamespaces},
{Tool: Tool{
}, Handler: podsListInAllNamespaces},
{Tool: api.Tool{
Name: "pods_list_in_namespace",
Description: "List all the Kubernetes pods in the specified namespace in the current cluster",
InputSchema: &jsonschema.Schema{
@@ -56,15 +55,15 @@ func (s *Server) initPods() []ServerTool {
},
Required: []string{"namespace"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Pods: List in Namespace",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.podsListInNamespace},
{Tool: Tool{
}, Handler: podsListInNamespace},
{Tool: api.Tool{
Name: "pods_get",
Description: "Get a Kubernetes Pod in the current or provided namespace with the provided name",
InputSchema: &jsonschema.Schema{
@@ -81,15 +80,15 @@ func (s *Server) initPods() []ServerTool {
},
Required: []string{"name"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Pods: Get",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.podsGet},
{Tool: Tool{
}, Handler: podsGet},
{Tool: api.Tool{
Name: "pods_delete",
Description: "Delete a Kubernetes Pod in the current or provided namespace with the provided name",
InputSchema: &jsonschema.Schema{
@@ -106,15 +105,15 @@ func (s *Server) initPods() []ServerTool {
},
Required: []string{"name"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Pods: Delete",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true),
IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.podsDelete},
{Tool: Tool{
}, Handler: podsDelete},
{Tool: api.Tool{
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",
InputSchema: &jsonschema.Schema{
@@ -123,7 +122,7 @@ func (s *Server) initPods() []ServerTool {
"all_namespaces": {
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",
Default: ToRawMessage(true),
Default: api.ToRawMessage(true),
},
"namespace": {
Type: "string",
@@ -140,15 +139,15 @@ func (s *Server) initPods() []ServerTool {
},
},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Pods: Top",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.podsTop},
{Tool: Tool{
}, Handler: podsTop},
{Tool: api.Tool{
Name: "pods_exec",
Description: "Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command",
InputSchema: &jsonschema.Schema{
@@ -176,15 +175,15 @@ func (s *Server) initPods() []ServerTool {
},
Required: []string{"name", "command"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Pods: Exec",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true), // Depending on the Pod's entrypoint, executing certain commands may kill the Pod
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.podsExec},
{Tool: Tool{
}, Handler: podsExec},
{Tool: api.Tool{
Name: "pods_log",
Description: "Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name",
InputSchema: &jsonschema.Schema{
@@ -209,15 +208,15 @@ func (s *Server) initPods() []ServerTool {
},
Required: []string{"name"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Pods: Log",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.podsLog},
{Tool: Tool{
}, Handler: podsLog},
{Tool: api.Tool{
Name: "pods_run",
Description: "Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name",
InputSchema: &jsonschema.Schema{
@@ -242,144 +241,124 @@ func (s *Server) initPods() []ServerTool {
},
Required: []string{"image"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Pods: Run",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.podsRun},
}, Handler: podsRun},
}
}
func (s *Server) podsListInAllNamespaces(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
labelSelector := ctr.GetArguments()["labelSelector"]
func podsListInAllNamespaces(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
labelSelector := params.GetArguments()["labelSelector"]
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(),
AsTable: params.ListOutput.AsTable(),
}
if labelSelector != nil {
resourceListOptions.LabelSelector = labelSelector.(string)
}
derived, err := s.k.Derived(ctx)
ret, err := params.PodsListInAllNamespaces(params, resourceListOptions)
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)
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
return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
}
func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.GetArguments()["namespace"]
func podsListInNamespace(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
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{
AsTable: s.configuration.ListOutput.AsTable(),
AsTable: params.ListOutput.AsTable(),
}
labelSelector := ctr.GetArguments()["labelSelector"]
labelSelector := params.GetArguments()["labelSelector"]
if labelSelector != nil {
resourceListOptions.LabelSelector = labelSelector.(string)
}
derived, err := s.k.Derived(ctx)
ret, err := params.PodsListInNamespace(params, ns.(string), resourceListOptions)
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)
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
return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
}
func (s *Server) podsGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.GetArguments()["namespace"]
func podsGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := ctr.GetArguments()["name"]
name := params.GetArguments()["name"]
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 {
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))
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
return api.NewToolCallResult(output.MarshalYaml(ret)), nil
}
func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.GetArguments()["namespace"]
func podsDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := ctr.GetArguments()["name"]
name := params.GetArguments()["name"]
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 {
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))
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
return api.NewToolCallResult(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}
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
if v, ok := params.GetArguments()["namespace"].(string); ok {
podsTopOptions.Namespace = v
}
if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok {
if v, ok := params.GetArguments()["all_namespaces"].(bool); ok {
podsTopOptions.AllNamespaces = v
}
if v, ok := ctr.GetArguments()["name"].(string); ok {
if v, ok := params.GetArguments()["name"].(string); ok {
podsTopOptions.Name = v
}
if v, ok := ctr.GetArguments()["label_selector"].(string); ok {
if v, ok := params.GetArguments()["label_selector"].(string); ok {
podsTopOptions.LabelSelector = v
}
derived, err := s.k.Derived(ctx)
ret, err := params.PodsTop(params, podsTopOptions)
if err != nil {
return nil, err
}
ret, err := derived.PodsTop(ctx, podsTopOptions)
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
}
buf := new(bytes.Buffer)
printer := metricsutil.NewTopCmdPrinter(buf)
err = printer.PrintPodMetrics(ret.Items, true, true, false, "", true)
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) {
ns := ctr.GetArguments()["namespace"]
func podsExec(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := ctr.GetArguments()["name"]
name := params.GetArguments()["name"]
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 {
container = ""
}
commandArg := ctr.GetArguments()["command"]
commandArg := params.GetArguments()["command"]
command := make([]string, 0)
if _, ok := commandArg.([]interface{}); ok {
for _, cmd := range commandArg.([]interface{}) {
@@ -388,80 +367,68 @@ func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.Ca
}
}
} 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 {
return nil, err
}
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
return api.NewToolCallResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil
} else if ret == "" {
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) {
ns := ctr.GetArguments()["namespace"]
func podsLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := ctr.GetArguments()["name"]
name := params.GetArguments()["name"]
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 {
container = ""
}
previous := ctr.GetArguments()["previous"]
previous := params.GetArguments()["previous"]
var previousBool bool
if previous != nil {
previousBool = previous.(bool)
}
derived, err := s.k.Derived(ctx)
ret, err := params.PodsLog(params, ns.(string), name.(string), container.(string), previousBool)
if err != nil {
return nil, err
}
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
return api.NewToolCallResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil
} else if ret == "" {
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) {
ns := ctr.GetArguments()["namespace"]
func podsRun(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
ns := params.GetArguments()["namespace"]
if ns == nil {
ns = ""
}
name := ctr.GetArguments()["name"]
name := params.GetArguments()["name"]
if name == nil {
name = ""
}
image := ctr.GetArguments()["image"]
image := params.GetArguments()["image"]
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 {
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 {
return nil, err
}
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
return api.NewToolCallResult("", fmt.Errorf("failed to run pod %s in namespace %s: %v", name, ns, err)), nil
}
marshalledYaml, err := output.MarshalYaml(resources)
if err != nil {
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 (
"context"
@@ -6,22 +6,23 @@ import (
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/apimachinery/pkg/runtime/schema"
"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"
"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"
if s.k.IsOpenShift(context.Background()) {
if k.IsOpenShift(context.Background()) {
commonApiVersion += ", route.openshift.io/v1 Route"
}
commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion)
return []ServerTool{
{Tool: Tool{
return []api.ServerTool{
{Tool: api.Tool{
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,
InputSchema: &jsonschema.Schema{
@@ -47,15 +48,15 @@ func (s *Server) initResources() []ServerTool {
},
Required: []string{"apiVersion", "kind"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Resources: List",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.resourcesList},
{Tool: Tool{
}, Handler: resourcesList},
{Tool: api.Tool{
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,
InputSchema: &jsonschema.Schema{
@@ -80,15 +81,15 @@ func (s *Server) initResources() []ServerTool {
},
Required: []string{"apiVersion", "kind", "name"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Resources: Get",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
IdempotentHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.resourcesGet},
{Tool: Tool{
}, Handler: resourcesGet},
{Tool: api.Tool{
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,
InputSchema: &jsonschema.Schema{
@@ -101,15 +102,15 @@ func (s *Server) initResources() []ServerTool {
},
Required: []string{"resource"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Resources: Create or Update",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true),
IdempotentHint: ptr.To(true),
OpenWorldHint: ptr.To(true),
},
}, Handler: s.resourcesCreateOrUpdate},
{Tool: Tool{
}, Handler: resourcesCreateOrUpdate},
{Tool: api.Tool{
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,
InputSchema: &jsonschema.Schema{
@@ -134,149 +135,133 @@ func (s *Server) initResources() []ServerTool {
},
Required: []string{"apiVersion", "kind", "name"},
},
Annotations: ToolAnnotations{
Annotations: api.ToolAnnotations{
Title: "Resources: Delete",
ReadOnlyHint: ptr.To(false),
DestructiveHint: ptr.To(true),
IdempotentHint: 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) {
namespace := ctr.GetArguments()["namespace"]
func resourcesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := params.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
labelSelector := ctr.GetArguments()["labelSelector"]
labelSelector := params.GetArguments()["labelSelector"]
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(),
AsTable: params.ListOutput.AsTable(),
}
if labelSelector != nil {
l, ok := labelSelector.(string)
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
}
gvk, err := parseGroupVersionKind(ctr.GetArguments())
gvk, err := parseGroupVersionKind(params.GetArguments())
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)
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 {
return nil, err
return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %v", err)), nil
}
ret, err := derived.ResourcesList(ctx, gvk, ns, resourceListOptions)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list resources: %v", err)), nil
}
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
}
func (s *Server) resourcesGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
namespace := ctr.GetArguments()["namespace"]
func resourcesGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := params.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
gvk, err := parseGroupVersionKind(ctr.GetArguments())
gvk, err := parseGroupVersionKind(params.GetArguments())
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 {
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)
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)
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 {
return nil, err
return api.NewToolCallResult("", fmt.Errorf("failed to get resource: %v", err)), nil
}
ret, err := derived.ResourcesGet(ctx, gvk, ns, n)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get resource: %v", err)), nil
}
return NewTextResult(output.MarshalYaml(ret)), nil
return api.NewToolCallResult(output.MarshalYaml(ret)), nil
}
func (s *Server) resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
resource := ctr.GetArguments()["resource"]
func resourcesCreateOrUpdate(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
resource := params.GetArguments()["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)
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 {
return nil, err
}
resources, err := derived.ResourcesCreateOrUpdate(ctx, r)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
return api.NewToolCallResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
}
marshalledYaml, err := output.MarshalYaml(resources)
if err != nil {
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) {
namespace := ctr.GetArguments()["namespace"]
func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := params.GetArguments()["namespace"]
if namespace == nil {
namespace = ""
}
gvk, err := parseGroupVersionKind(ctr.GetArguments())
gvk, err := parseGroupVersionKind(params.GetArguments())
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 {
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)
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)
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 {
return nil, err
return api.NewToolCallResult("", fmt.Errorf("failed to delete resource: %v", err)), nil
}
err = derived.ResourcesDelete(ctx, gvk, ns, n)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to delete resource: %v", err)), nil
}
return NewTextResult("Resource deleted successfully", err), nil
return api.NewToolCallResult("Resource deleted successfully", err), nil
}
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))
}