mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
feat(mcp): toolset definitions completely agnostic from underlying MCP impl (#322)
Signed-off-by: Marc Nuri <marc@marcnuri.com>
This commit is contained in:
99
pkg/api/toolsets.go
Normal file
99
pkg/api/toolsets.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
54
pkg/mcp/m3labs.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
3
pkg/mcp/modules.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package mcp
|
||||
|
||||
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
68
pkg/toolsets/full/namespaces.go
Normal file
68
pkg/toolsets/full/namespaces.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
36
pkg/toolsets/full/toolset.go
Normal file
36
pkg/toolsets/full/toolset.go
Normal 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
40
pkg/toolsets/toolsets.go
Normal 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
|
||||
}
|
||||
60
pkg/toolsets/toolsets_test.go
Normal file
60
pkg/toolsets/toolsets_test.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user