Skip to content

Commit 209e843

Browse files
authored
feat(mcp): toolset definitions completely agnostic from underlying MCP impl (#322)
Signed-off-by: Marc Nuri <[email protected]>
1 parent 2b6c886 commit 209e843

File tree

21 files changed

+612
-537
lines changed

21 files changed

+612
-537
lines changed

pkg/api/toolsets.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
7+
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
8+
"github.com/containers/kubernetes-mcp-server/pkg/output"
9+
"github.com/google/jsonschema-go/jsonschema"
10+
)
11+
12+
type ServerTool struct {
13+
Tool Tool
14+
Handler ToolHandlerFunc
15+
}
16+
17+
type Toolset interface {
18+
// GetName returns the name of the toolset.
19+
// Used to identify the toolset in configuration, logs, and command-line arguments.
20+
// Examples: "core", "metrics", "helm"
21+
GetName() string
22+
GetDescription() string
23+
GetTools(k *internalk8s.Manager) []ServerTool
24+
}
25+
26+
type ToolCallRequest interface {
27+
GetArguments() map[string]any
28+
}
29+
30+
type ToolCallResult struct {
31+
// Raw content returned by the tool.
32+
Content string
33+
// Error (non-protocol) to send back to the LLM.
34+
Error error
35+
}
36+
37+
func NewToolCallResult(content string, err error) *ToolCallResult {
38+
return &ToolCallResult{
39+
Content: content,
40+
Error: err,
41+
}
42+
}
43+
44+
type ToolHandlerParams struct {
45+
context.Context
46+
*internalk8s.Kubernetes
47+
ToolCallRequest
48+
ListOutput output.Output
49+
}
50+
51+
type ToolHandlerFunc func(params ToolHandlerParams) (*ToolCallResult, error)
52+
53+
type Tool struct {
54+
// The name of the tool.
55+
// Intended for programmatic or logical use, but used as a display name in past
56+
// specs or fallback (if title isn't present).
57+
Name string `json:"name"`
58+
// A human-readable description of the tool.
59+
//
60+
// This can be used by clients to improve the LLM's understanding of available
61+
// tools. It can be thought of like a "hint" to the model.
62+
Description string `json:"description,omitempty"`
63+
// Additional tool information.
64+
Annotations ToolAnnotations `json:"annotations"`
65+
// A JSON Schema object defining the expected parameters for the tool.
66+
InputSchema *jsonschema.Schema
67+
}
68+
69+
type ToolAnnotations struct {
70+
// Human-readable title for the tool
71+
Title string `json:"title,omitempty"`
72+
// If true, the tool does not modify its environment.
73+
ReadOnlyHint *bool `json:"readOnlyHint,omitempty"`
74+
// If true, the tool may perform destructive updates to its environment. If
75+
// false, the tool performs only additive updates.
76+
//
77+
// (This property is meaningful only when ReadOnlyHint == false.)
78+
DestructiveHint *bool `json:"destructiveHint,omitempty"`
79+
// If true, calling the tool repeatedly with the same arguments will have no
80+
// additional effect on its environment.
81+
//
82+
// (This property is meaningful only when ReadOnlyHint == false.)
83+
IdempotentHint *bool `json:"idempotentHint,omitempty"`
84+
// If true, this tool may interact with an "open world" of external entities. If
85+
// false, the tool's domain of interaction is closed. For example, the world of
86+
// a web search tool is open, whereas that of a memory tool is not.
87+
OpenWorldHint *bool `json:"openWorldHint,omitempty"`
88+
}
89+
90+
func ToRawMessage(v any) json.RawMessage {
91+
if v == nil {
92+
return nil
93+
}
94+
b, err := json.Marshal(v)
95+
if err != nil {
96+
return nil
97+
}
98+
return b
99+
}

pkg/http/http_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"time"
2222

2323
"github.com/containers/kubernetes-mcp-server/internal/test"
24+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
2425
"github.com/coreos/go-oidc/v3/oidc"
2526
"github.com/coreos/go-oidc/v3/oidc/oidctest"
2627
"golang.org/x/sync/errgroup"
@@ -87,7 +88,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
8788
}
8889
c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port)
8990
mcpServer, err := mcp.NewServer(mcp.Configuration{
90-
Toolset: mcp.Toolsets()[0],
91+
Toolset: toolsets.Toolsets()[0],
9192
StaticConfig: c.StaticConfig,
9293
})
9394
if err != nil {

pkg/kubernetes-mcp-server/cmd/root.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strconv"
1414
"strings"
1515

16+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
1617
"github.com/coreos/go-oidc/v3/oidc"
1718
"github.com/spf13/cobra"
1819

@@ -115,7 +116,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
115116
cmd.Flags().StringVar(&o.Port, "port", o.Port, "Start a streamable HTTP and SSE HTTP server on the specified port (e.g. 8080)")
116117
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)")
117118
cmd.Flags().StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, "Path to the kubeconfig file to use for authentication")
118-
cmd.Flags().StringVar(&o.Toolset, "toolset", o.Toolset, "MCP toolset to use (one of: "+strings.Join(mcp.ToolsetNames(), ", ")+")")
119+
cmd.Flags().StringVar(&o.Toolset, "toolset", o.Toolset, "MCP toolset to use (one of: "+strings.Join(toolsets.ToolsetNames(), ", ")+")")
119120
cmd.Flags().StringVar(&o.ListOutput, "list-output", o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+"). Defaults to table.")
120121
cmd.Flags().BoolVar(&o.ReadOnly, "read-only", o.ReadOnly, "If true, only tools annotated with readOnlyHint=true are exposed")
121122
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 {
237238
}
238239

239240
func (m *MCPServerOptions) Run() error {
240-
toolset := mcp.ToolsetFromString(m.Toolset)
241+
toolset := toolsets.ToolsetFromString(m.Toolset)
241242
if toolset == nil {
242-
return fmt.Errorf("invalid toolset name: %s, valid names are: %s", m.Toolset, strings.Join(mcp.ToolsetNames(), ", "))
243+
return fmt.Errorf("invalid toolset name: %s, valid names are: %s", m.Toolset, strings.Join(toolsets.ToolsetNames(), ", "))
243244
}
244245
listOutput := output.FromString(m.StaticConfig.ListOutput)
245246
if listOutput == nil {

pkg/kubernetes/configuration.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,24 +81,24 @@ func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig {
8181
return m.clientCmdConfig
8282
}
8383

84-
func (m *Manager) ConfigurationView(minify bool) (runtime.Object, error) {
84+
func (k *Kubernetes) ConfigurationView(minify bool) (runtime.Object, error) {
8585
var cfg clientcmdapi.Config
8686
var err error
87-
if m.IsInCluster() {
87+
if k.manager.IsInCluster() {
8888
cfg = *clientcmdapi.NewConfig()
8989
cfg.Clusters["cluster"] = &clientcmdapi.Cluster{
90-
Server: m.cfg.Host,
91-
InsecureSkipTLSVerify: m.cfg.Insecure,
90+
Server: k.manager.cfg.Host,
91+
InsecureSkipTLSVerify: k.manager.cfg.Insecure,
9292
}
9393
cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{
94-
Token: m.cfg.BearerToken,
94+
Token: k.manager.cfg.BearerToken,
9595
}
9696
cfg.Contexts["context"] = &clientcmdapi.Context{
9797
Cluster: "cluster",
9898
AuthInfo: "user",
9999
}
100100
cfg.CurrentContext = "context"
101-
} else if cfg, err = m.clientCmdConfig.RawConfig(); err != nil {
101+
} else if cfg, err = k.manager.clientCmdConfig.RawConfig(); err != nil {
102102
return nil, err
103103
}
104104
if minify {

pkg/mcp/common_test.go

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ import (
1414
"testing"
1515
"time"
1616

17-
"github.com/containers/kubernetes-mcp-server/pkg/config"
18-
"github.com/containers/kubernetes-mcp-server/pkg/output"
1917
"github.com/mark3labs/mcp-go/client"
2018
"github.com/mark3labs/mcp-go/client/transport"
2119
"github.com/mark3labs/mcp-go/mcp"
@@ -32,7 +30,7 @@ import (
3230
"k8s.io/client-go/kubernetes"
3331
"k8s.io/client-go/rest"
3432
"k8s.io/client-go/tools/clientcmd"
35-
"k8s.io/client-go/tools/clientcmd/api"
33+
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
3634
toolswatch "k8s.io/client-go/tools/watch"
3735
"k8s.io/klog/v2"
3836
"k8s.io/klog/v2/textlogger"
@@ -43,6 +41,11 @@ import (
4341
"sigs.k8s.io/controller-runtime/tools/setup-envtest/store"
4442
"sigs.k8s.io/controller-runtime/tools/setup-envtest/versions"
4543
"sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows"
44+
45+
"github.com/containers/kubernetes-mcp-server/pkg/api"
46+
"github.com/containers/kubernetes-mcp-server/pkg/config"
47+
"github.com/containers/kubernetes-mcp-server/pkg/output"
48+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
4649
)
4750

4851
// 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) {
103106
}
104107

105108
type mcpContext struct {
106-
toolset Toolset
109+
toolset api.Toolset
107110
listOutput output.Output
108111
logLevel int
109112

@@ -127,7 +130,7 @@ func (c *mcpContext) beforeEach(t *testing.T) {
127130
c.tempDir = t.TempDir()
128131
c.withKubeConfig(nil)
129132
if c.toolset == nil {
130-
c.toolset = &Full{}
133+
c.toolset = &full.Full{}
131134
}
132135
if c.listOutput == nil {
133136
c.listOutput = output.Yaml
@@ -188,7 +191,7 @@ func (c *mcpContext) afterEach() {
188191
}
189192

190193
func testCase(t *testing.T, test func(c *mcpContext)) {
191-
testCaseWithContext(t, &mcpContext{toolset: &Full{}}, test)
194+
testCaseWithContext(t, &mcpContext{toolset: &full.Full{}}, test)
192195
}
193196

194197
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
198201
}
199202

200203
// withKubeConfig sets up a fake kubeconfig in the temp directory based on the provided rest.Config
201-
func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {
202-
fakeConfig := api.NewConfig()
203-
fakeConfig.Clusters["fake"] = api.NewCluster()
204+
func (c *mcpContext) withKubeConfig(rc *rest.Config) *clientcmdapi.Config {
205+
fakeConfig := clientcmdapi.NewConfig()
206+
fakeConfig.Clusters["fake"] = clientcmdapi.NewCluster()
204207
fakeConfig.Clusters["fake"].Server = "https://127.0.0.1:6443"
205-
fakeConfig.Clusters["additional-cluster"] = api.NewCluster()
206-
fakeConfig.AuthInfos["fake"] = api.NewAuthInfo()
207-
fakeConfig.AuthInfos["additional-auth"] = api.NewAuthInfo()
208+
fakeConfig.Clusters["additional-cluster"] = clientcmdapi.NewCluster()
209+
fakeConfig.AuthInfos["fake"] = clientcmdapi.NewAuthInfo()
210+
fakeConfig.AuthInfos["additional-auth"] = clientcmdapi.NewAuthInfo()
208211
if rc != nil {
209212
fakeConfig.Clusters["fake"].Server = rc.Host
210213
fakeConfig.Clusters["fake"].CertificateAuthorityData = rc.CAData
211214
fakeConfig.AuthInfos["fake"].ClientKeyData = rc.KeyData
212215
fakeConfig.AuthInfos["fake"].ClientCertificateData = rc.CertData
213216
}
214-
fakeConfig.Contexts["fake-context"] = api.NewContext()
217+
fakeConfig.Contexts["fake-context"] = clientcmdapi.NewContext()
215218
fakeConfig.Contexts["fake-context"].Cluster = "fake"
216219
fakeConfig.Contexts["fake-context"].AuthInfo = "fake"
217-
fakeConfig.Contexts["additional-context"] = api.NewContext()
220+
fakeConfig.Contexts["additional-context"] = clientcmdapi.NewContext()
218221
fakeConfig.Contexts["additional-context"].Cluster = "additional-cluster"
219222
fakeConfig.Contexts["additional-context"].AuthInfo = "additional-auth"
220223
fakeConfig.CurrentContext = "fake-context"

pkg/mcp/m3labs.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/mark3labs/mcp-go/mcp"
9+
"github.com/mark3labs/mcp-go/server"
10+
11+
"github.com/containers/kubernetes-mcp-server/pkg/api"
12+
)
13+
14+
func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.ServerTool, error) {
15+
m3labTools := make([]server.ServerTool, 0)
16+
for _, tool := range tools {
17+
m3labTool := mcp.Tool{
18+
Name: tool.Tool.Name,
19+
Description: tool.Tool.Description,
20+
Annotations: mcp.ToolAnnotation{
21+
Title: tool.Tool.Annotations.Title,
22+
ReadOnlyHint: tool.Tool.Annotations.ReadOnlyHint,
23+
DestructiveHint: tool.Tool.Annotations.DestructiveHint,
24+
IdempotentHint: tool.Tool.Annotations.IdempotentHint,
25+
OpenWorldHint: tool.Tool.Annotations.OpenWorldHint,
26+
},
27+
}
28+
if tool.Tool.InputSchema != nil {
29+
schema, err := json.Marshal(tool.Tool.InputSchema)
30+
if err != nil {
31+
return nil, fmt.Errorf("failed to marshal tool input schema for tool %s: %v", tool.Tool.Name, err)
32+
}
33+
m3labTool.RawInputSchema = schema
34+
}
35+
m3labHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
36+
k, err := s.k.Derived(ctx)
37+
if err != nil {
38+
return nil, err
39+
}
40+
result, err := tool.Handler(api.ToolHandlerParams{
41+
Context: ctx,
42+
Kubernetes: k,
43+
ToolCallRequest: request,
44+
ListOutput: s.configuration.ListOutput,
45+
})
46+
if err != nil {
47+
return nil, err
48+
}
49+
return NewTextResult(result.Content, result.Error), nil
50+
}
51+
m3labTools = append(m3labTools, server.ServerTool{Tool: m3labTool, Handler: m3labHandler})
52+
}
53+
return m3labTools, nil
54+
}

pkg/mcp/mcp.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"slices"
99

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

2627
type Configuration struct {
27-
Toolset Toolset
28+
Toolset api.Toolset
2829
ListOutput output.Output
2930

3031
StaticConfig *config.StaticConfig
3132
}
3233

33-
func (c *Configuration) isToolApplicable(tool ServerTool) bool {
34+
func (c *Configuration) isToolApplicable(tool api.ServerTool) bool {
3435
if c.StaticConfig.ReadOnly && !ptr.Deref(tool.Tool.Annotations.ReadOnlyHint, false) {
3536
return false
3637
}
@@ -88,15 +89,15 @@ func (s *Server) reloadKubernetesClient() error {
8889
return err
8990
}
9091
s.k = k
91-
applicableTools := make([]ServerTool, 0)
92-
for _, tool := range s.configuration.Toolset.GetTools(s) {
92+
applicableTools := make([]api.ServerTool, 0)
93+
for _, tool := range s.configuration.Toolset.GetTools(s.k) {
9394
if !s.configuration.isToolApplicable(tool) {
9495
continue
9596
}
9697
applicableTools = append(applicableTools, tool)
9798
s.enabledTools = append(s.enabledTools, tool.Tool.Name)
9899
}
99-
m3labsServerTools, err := ServerToolToM3LabsServerTool(applicableTools)
100+
m3labsServerTools, err := ServerToolToM3LabsServerTool(s, applicableTools)
100101
if err != nil {
101102
return fmt.Errorf("failed to convert tools: %v", err)
102103
}

pkg/mcp/mcp_tools_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ func TestToolCallLogging(t *testing.T) {
161161
}
162162
})
163163
sensitiveHeaders := []string{
164-
"Authorization",
164+
"Authorization:",
165165
// TODO: Add more sensitive headers as needed
166166
}
167167
t.Run("Does not log sensitive headers", func(t *testing.T) {

pkg/mcp/modules.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package mcp
2+
3+
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"

0 commit comments

Comments
 (0)