From 35fc0436c3f4ce8808d37068163ee10011bffe38 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Thu, 9 Apr 2026 14:40:48 -0500 Subject: [PATCH] feat: add dapr mcpservers cli cmd Signed-off-by: Samantha Coyle --- cmd/mcpservers.go | 79 ++++++++++ pkg/kubernetes/mcpservers.go | 153 ++++++++++++++++++ pkg/kubernetes/mcpservers_test.go | 254 ++++++++++++++++++++++++++++++ 3 files changed, 486 insertions(+) create mode 100644 cmd/mcpservers.go create mode 100644 pkg/kubernetes/mcpservers.go create mode 100644 pkg/kubernetes/mcpservers_test.go diff --git a/cmd/mcpservers.go b/cmd/mcpservers.go new file mode 100644 index 000000000..c6f4c8df9 --- /dev/null +++ b/cmd/mcpservers.go @@ -0,0 +1,79 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/dapr/cli/pkg/kubernetes" + "github.com/dapr/cli/pkg/print" + + meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + mcpserversName string + mcpserversOutputFormat string +) + +var McpserversCmd = &cobra.Command{ + Use: "mcpservers", + Short: "List all Dapr MCPServer resources. Supported platforms: Kubernetes", + Run: func(cmd *cobra.Command, args []string) { + if kubernetesMode { + if allNamespaces { + resourceNamespace = meta_v1.NamespaceAll + } else if resourceNamespace == "" { + resourceNamespace = meta_v1.NamespaceAll + } + err := kubernetes.PrintMCPServers(mcpserversName, resourceNamespace, mcpserversOutputFormat) + if err != nil { + print.FailureStatusEvent(os.Stderr, err.Error()) + os.Exit(1) + } + } + }, + PostRun: func(cmd *cobra.Command, args []string) { + kubernetes.CheckForCertExpiry() + }, + Example: ` +# List all Dapr MCPServer resources in Kubernetes mode +dapr mcpservers -k + +# List MCPServer resources in a specific namespace +dapr mcpservers -k --namespace default + +# Print a specific MCPServer resource +dapr mcpservers -k -n my-mcp-server + +# List MCPServer resources across all namespaces +dapr mcpservers -k --all-namespaces + +# Output as JSON +dapr mcpservers -k -o json +`, +} + +func init() { + McpserversCmd.Flags().BoolVarP(&allNamespaces, "all-namespaces", "A", false, "If true, list all Dapr MCPServer resources in all namespaces") + McpserversCmd.Flags().StringVarP(&mcpserversName, "name", "n", "", "The MCPServer name to be printed (optional)") + McpserversCmd.Flags().StringVarP(&resourceNamespace, "namespace", "", "", "List MCPServer resources in a specific Kubernetes namespace") + McpserversCmd.Flags().StringVarP(&mcpserversOutputFormat, "output", "o", "list", "Output format (options: json or yaml or list)") + McpserversCmd.Flags().BoolVarP(&kubernetesMode, "kubernetes", "k", false, "List all Dapr MCPServer resources in a Kubernetes cluster") + McpserversCmd.Flags().BoolP("help", "h", false, "Print this help message") + McpserversCmd.MarkFlagRequired("kubernetes") + RootCmd.AddCommand(McpserversCmd) +} diff --git a/pkg/kubernetes/mcpservers.go b/pkg/kubernetes/mcpservers.go new file mode 100644 index 000000000..8c849ee5d --- /dev/null +++ b/pkg/kubernetes/mcpservers.go @@ -0,0 +1,153 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kubernetes + +import ( + "io" + "os" + "sort" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/dapr/cli/pkg/age" + "github.com/dapr/cli/utils" + v1alpha1 "github.com/dapr/dapr/pkg/apis/mcpserver/v1alpha1" + "github.com/dapr/dapr/pkg/client/clientset/versioned" +) + +// MCPServerOutput represents an MCPServer resource for table output. +type MCPServerOutput struct { + Namespace string `csv:"Namespace"` + Name string `csv:"Name"` + Transport string `csv:"TRANSPORT"` + URL string `csv:"URL"` + Scopes string `csv:"SCOPES"` + Created string `csv:"CREATED"` + Age string `csv:"AGE"` +} + +// mcpServerDetailedOutput is used for JSON/YAML output. +type mcpServerDetailedOutput struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Spec v1alpha1.MCPServerSpec `json:"spec"` +} + +// PrintMCPServers prints all Dapr MCPServer resources. +func PrintMCPServers(name, namespace, outputFormat string) error { + return writeMCPServers(os.Stdout, func() (*v1alpha1.MCPServerList, error) { + client, err := DaprClient() + if err != nil { + return nil, err + } + + return ListMCPServers(client, namespace) + }, name, outputFormat) +} + +// ListMCPServers lists MCPServer resources from Kubernetes. +func ListMCPServers(client versioned.Interface, namespace string) (*v1alpha1.MCPServerList, error) { + list, err := client.MCPServerV1alpha1().MCPServers(namespace).List(meta_v1.ListOptions{}) + // This means that the Dapr MCPServer CRD is not installed and + // therefore no MCPServer items exist. + if apierrors.IsNotFound(err) { + list = &v1alpha1.MCPServerList{ + Items: []v1alpha1.MCPServer{}, + } + } else if err != nil { + return nil, err + } + + return list, nil +} + +func writeMCPServers(writer io.Writer, getFunc func() (*v1alpha1.MCPServerList, error), name, outputFormat string) error { + servers, err := getFunc() + if err != nil { + return err + } + + filtered := []v1alpha1.MCPServer{} + filteredSpecs := []mcpServerDetailedOutput{} + for _, s := range servers.Items { + serverName := s.GetName() + if name == "" || strings.EqualFold(serverName, name) { + filtered = append(filtered, s) + filteredSpecs = append(filteredSpecs, mcpServerDetailedOutput{ + Name: serverName, + Namespace: s.GetNamespace(), + Spec: s.Spec, + }) + } + } + + if outputFormat == "" || outputFormat == "list" { + return printMCPServerList(writer, filtered) + } + + sort.Slice(filteredSpecs, func(i, j int) bool { + return filteredSpecs[i].Namespace > filteredSpecs[j].Namespace + }) + return utils.PrintDetail(writer, outputFormat, filteredSpecs) +} + +func printMCPServerList(writer io.Writer, list []v1alpha1.MCPServer) error { + out := []MCPServerOutput{} + for _, s := range list { + out = append(out, MCPServerOutput{ + Name: s.GetName(), + Namespace: s.GetNamespace(), + Transport: mcpTransport(&s), + URL: mcpURL(&s), + Created: s.CreationTimestamp.Format("2006-01-02 15:04.05"), + Age: age.GetAge(s.CreationTimestamp.Time), + Scopes: strings.Join(s.Scopes, ","), + }) + } + + sort.Slice(out, func(i, j int) bool { + return out[i].Namespace > out[j].Namespace + }) + return utils.MarshalAndWriteTable(writer, out) +} + +// mcpTransport returns the transport type string for the MCPServer. +func mcpTransport(s *v1alpha1.MCPServer) string { + switch { + case s.Spec.Endpoint.StreamableHTTP != nil: + return "streamable_http" + case s.Spec.Endpoint.SSE != nil: + return "sse" + case s.Spec.Endpoint.Stdio != nil: + return "stdio" + default: + return "" + } +} + +// mcpURL returns the URL or command for the MCPServer. +func mcpURL(s *v1alpha1.MCPServer) string { + switch { + case s.Spec.Endpoint.StreamableHTTP != nil: + return s.Spec.Endpoint.StreamableHTTP.URL + case s.Spec.Endpoint.SSE != nil: + return s.Spec.Endpoint.SSE.URL + case s.Spec.Endpoint.Stdio != nil: + return s.Spec.Endpoint.Stdio.Command + default: + return "" + } +} diff --git a/pkg/kubernetes/mcpservers_test.go b/pkg/kubernetes/mcpservers_test.go new file mode 100644 index 000000000..511b5dbb4 --- /dev/null +++ b/pkg/kubernetes/mcpservers_test.go @@ -0,0 +1,254 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kubernetes + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1alpha1 "github.com/dapr/dapr/pkg/apis/mcpserver/v1alpha1" +) + +func TestMCPServers(t *testing.T) { + now := meta_v1.Now() + + testCases := []struct { + name string + serverName string + outputFormat string + errorExpected bool + errString string + mcpServers []v1alpha1.MCPServer + expectedOutput string + }{ + { + name: "no MCPServers", + outputFormat: "list", + mcpServers: []v1alpha1.MCPServer{}, + }, + { + name: "list MCPServers", + outputFormat: "list", + mcpServers: []v1alpha1.MCPServer{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "payments-mcp", + Namespace: "default", + CreationTimestamp: now, + }, + Spec: v1alpha1.MCPServerSpec{ + Endpoint: v1alpha1.MCPEndpoint{ + StreamableHTTP: &v1alpha1.MCPStreamableHTTP{ + URL: "https://payments.internal/mcp", + }, + }, + }, + }, + }, + }, + { + name: "filter by name", + serverName: "payments-mcp", + outputFormat: "list", + mcpServers: []v1alpha1.MCPServer{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "payments-mcp", + Namespace: "default", + CreationTimestamp: now, + }, + Spec: v1alpha1.MCPServerSpec{ + Endpoint: v1alpha1.MCPEndpoint{ + StreamableHTTP: &v1alpha1.MCPStreamableHTTP{ + URL: "https://payments.internal/mcp", + }, + }, + }, + }, + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "other-mcp", + Namespace: "default", + CreationTimestamp: now, + }, + Spec: v1alpha1.MCPServerSpec{ + Endpoint: v1alpha1.MCPEndpoint{ + SSE: &v1alpha1.MCPSSE{ + URL: "https://other.internal/sse", + }, + }, + }, + }, + }, + }, + { + name: "stdio transport", + outputFormat: "list", + mcpServers: []v1alpha1.MCPServer{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "local-tools", + Namespace: "default", + CreationTimestamp: now, + }, + Spec: v1alpha1.MCPServerSpec{ + Endpoint: v1alpha1.MCPEndpoint{ + Stdio: &v1alpha1.MCPStdio{ + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-filesystem"}, + }, + }, + }, + }, + }, + }, + { + name: "error from API", + outputFormat: "list", + errorExpected: true, + errString: "connection refused", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var buff bytes.Buffer + err := writeMCPServers(&buff, + func() (*v1alpha1.MCPServerList, error) { + if len(tc.errString) > 0 { + return nil, assert.AnError + } + return &v1alpha1.MCPServerList{Items: tc.mcpServers}, nil + }, tc.serverName, tc.outputFormat) + + if tc.errorExpected { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + if tc.expectedOutput != "" { + assert.Equal(t, tc.expectedOutput, buff.String()) + } + + // For list output with servers, verify it contains the server names. + if !tc.errorExpected && tc.outputFormat == "list" && len(tc.mcpServers) > 0 { + output := buff.String() + if tc.serverName != "" { + assert.Contains(t, output, tc.serverName) + } else { + for _, s := range tc.mcpServers { + assert.Contains(t, output, s.Name) + } + } + } + }) + } +} + +func TestMCPTransport(t *testing.T) { + tests := []struct { + name string + server v1alpha1.MCPServer + want string + }{ + { + name: "streamable_http", + server: v1alpha1.MCPServer{ + Spec: v1alpha1.MCPServerSpec{ + Endpoint: v1alpha1.MCPEndpoint{ + StreamableHTTP: &v1alpha1.MCPStreamableHTTP{URL: "http://example.com"}, + }, + }, + }, + want: "streamable_http", + }, + { + name: "sse", + server: v1alpha1.MCPServer{ + Spec: v1alpha1.MCPServerSpec{ + Endpoint: v1alpha1.MCPEndpoint{ + SSE: &v1alpha1.MCPSSE{URL: "http://example.com"}, + }, + }, + }, + want: "sse", + }, + { + name: "stdio", + server: v1alpha1.MCPServer{ + Spec: v1alpha1.MCPServerSpec{ + Endpoint: v1alpha1.MCPEndpoint{ + Stdio: &v1alpha1.MCPStdio{Command: "npx"}, + }, + }, + }, + want: "stdio", + }, + { + name: "empty", + server: v1alpha1.MCPServer{}, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := mcpTransport(&tc.server) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestMCPURL(t *testing.T) { + tests := []struct { + name string + server v1alpha1.MCPServer + want string + }{ + { + name: "streamable_http url", + server: v1alpha1.MCPServer{ + Spec: v1alpha1.MCPServerSpec{ + Endpoint: v1alpha1.MCPEndpoint{ + StreamableHTTP: &v1alpha1.MCPStreamableHTTP{URL: "https://example.com/mcp"}, + }, + }, + }, + want: "https://example.com/mcp", + }, + { + name: "stdio command", + server: v1alpha1.MCPServer{ + Spec: v1alpha1.MCPServerSpec{ + Endpoint: v1alpha1.MCPEndpoint{ + Stdio: &v1alpha1.MCPStdio{Command: "npx"}, + }, + }, + }, + want: "npx", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := mcpURL(&tc.server) + assert.Equal(t, tc.want, got) + }) + } +}