feat(mcp): serve sse and streamable from a single port

This commit is contained in:
Arda Güçlü
2025-07-02 15:04:18 +03:00
committed by GitHub
parent 186f445ca2
commit e6b19034aa
7 changed files with 46 additions and 36 deletions

View File

@@ -8,6 +8,6 @@ RUN make build
FROM registry.access.redhat.com/ubi9/ubi-minimal:latest
WORKDIR /app
COPY --from=builder /app/kubernetes-mcp-server /app/kubernetes-mcp-server
ENTRYPOINT ["/app/kubernetes-mcp-server", "--sse-port", "8080"]
ENTRYPOINT ["/app/kubernetes-mcp-server", "--port", "8080"]
EXPOSE 8080

View File

@@ -158,8 +158,8 @@ uvx kubernetes-mcp-server@latest --help
| Option | Description |
|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--http-port` | Starts the MCP server in Streamable HTTP mode and listens on the specified port (path /mcp). |
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port (path /sse). |
| `--http-port` | Starts the MCP server in Streamable HTTP mode and listens on the specified port (path /mcp). Deprecated: Please use --port. |
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port (path /sse). Deprecated: Please use --port. |
| `--log-level` | Sets the logging level (values [from 0-9](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md)). Similar to [kubectl logging levels](https://kubernetes.io/docs/reference/kubectl/quick-reference/#kubectl-output-verbosity-and-debugging). |
| `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). |
| `--list-output` | Output format for resource list operations (one of: yaml, table) (default "table") |

View File

@@ -12,8 +12,7 @@ type StaticConfig struct {
DeniedResources []GroupVersionKind `toml:"denied_resources"`
LogLevel int `toml:"log_level,omitempty"`
SSEPort int `toml:"sse_port,omitempty"`
HTTPPort int `toml:"http_port,omitempty"`
Port string `toml:"port,omitempty"`
SSEBaseURL string `toml:"sse_base_url,omitempty"`
KubeConfig string `toml:"kubeconfig,omitempty"`
ListOutput string `toml:"list_output,omitempty"`

View File

@@ -51,7 +51,7 @@ kind = "Role
func TestReadConfigValid(t *testing.T) {
validConfigPath := writeConfig(t, `
log_level = 1
sse_port = 9999
port = "9999"
kubeconfig = "test"
list_output = "yaml"
read_only = true
@@ -87,15 +87,12 @@ disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"
if config.LogLevel != 1 {
t.Fatalf("Unexpected log level: %v", config.LogLevel)
}
if config.SSEPort != 9999 {
t.Fatalf("Unexpected sse_port value: %v", config.SSEPort)
if config.Port != "9999" {
t.Fatalf("Unexpected port value: %v", config.Port)
}
if config.SSEBaseURL != "" {
t.Fatalf("Unexpected sse_base_url value: %v", config.SSEBaseURL)
}
if config.HTTPPort != 0 {
t.Fatalf("Unexpected http_port value: %v", config.HTTPPort)
}
if config.KubeConfig != "test" {
t.Fatalf("Unexpected kubeconfig value: %v", config.KubeConfig)
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"flag"
"fmt"
"net/http"
"strconv"
"strings"
@@ -35,16 +36,17 @@ kubernetes-mcp-server --version
kubernetes-mcp-server
# start a SSE server on port 8080
kubernetes-mcp-server --sse-port 8080
kubernetes-mcp-server --port 8080
# start a SSE server on port 8443 with a public HTTPS host of example.com
kubernetes-mcp-server --sse-port 8443 --sse-base-url https://example.com:8443
kubernetes-mcp-server --port 8443 --sse-base-url https://example.com:8443
`))
)
type MCPServerOptions struct {
Version bool
LogLevel int
Port string
SSEPort int
HttpPort int
SSEBaseUrl string
@@ -95,7 +97,10 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
cmd.Flags().IntVar(&o.LogLevel, "log-level", o.LogLevel, "Set the log level (from 0 to 9)")
cmd.Flags().StringVar(&o.ConfigPath, "config", o.ConfigPath, "Path of the config file. Each profile has its set of defaults.")
cmd.Flags().IntVar(&o.SSEPort, "sse-port", o.SSEPort, "Start a SSE server on the specified port")
cmd.Flag("sse-port").Deprecated = "Use --port instead"
cmd.Flags().IntVar(&o.HttpPort, "http-port", o.HttpPort, "Start a streamable HTTP server on the specified port")
cmd.Flag("http-port").Deprecated = "Use --port instead"
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.Profile, "profile", o.Profile, "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+")")
@@ -126,11 +131,12 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) {
if cmd.Flag("log-level").Changed {
m.StaticConfig.LogLevel = m.LogLevel
}
if cmd.Flag("sse-port").Changed {
m.StaticConfig.SSEPort = m.SSEPort
}
if cmd.Flag("http-port").Changed {
m.StaticConfig.HTTPPort = m.HttpPort
if cmd.Flag("port").Changed {
m.StaticConfig.Port = m.Port
} else if cmd.Flag("sse-port").Changed {
m.StaticConfig.Port = strconv.Itoa(m.SSEPort)
} else if cmd.Flag("http-port").Changed {
m.StaticConfig.Port = strconv.Itoa(m.HttpPort)
}
if cmd.Flag("sse-base-url").Changed {
m.StaticConfig.SSEBaseURL = m.SSEBaseUrl
@@ -162,6 +168,9 @@ func (m *MCPServerOptions) initializeLogging() {
}
func (m *MCPServerOptions) Validate() error {
if m.Port != "" && (m.SSEPort > 0 || m.HttpPort > 0) {
return fmt.Errorf("--port is mutually exclusive with deprecated --http-port and --sse-port flags")
}
return nil
}
@@ -195,23 +204,27 @@ func (m *MCPServerOptions) Run() error {
}
defer mcpServer.Close()
ctx := context.Background()
if m.StaticConfig.SSEPort > 0 {
sseServer := mcpServer.ServeSse(m.StaticConfig.SSEBaseURL)
defer func() { _ = sseServer.Shutdown(ctx) }()
klog.V(0).Infof("SSE server starting on port %d and path /sse", m.StaticConfig.SSEPort)
if err := sseServer.Start(fmt.Sprintf(":%d", m.StaticConfig.SSEPort)); err != nil {
return fmt.Errorf("failed to start SSE server: %w\n", err)
if m.StaticConfig.Port != "" {
mux := http.NewServeMux()
httpServer := &http.Server{
Addr: ":" + m.StaticConfig.Port,
Handler: mux,
}
}
if m.StaticConfig.HTTPPort > 0 {
httpServer := mcpServer.ServeHTTP()
klog.V(0).Infof("Streaming HTTP server starting on port %d and path /mcp", m.StaticConfig.HTTPPort)
if err := httpServer.Start(fmt.Sprintf(":%d", m.StaticConfig.HTTPPort)); err != nil {
return fmt.Errorf("failed to start streaming HTTP server: %w\n", err)
sseServer := mcpServer.ServeSse(m.SSEBaseUrl, httpServer)
streamableHttpServer := mcpServer.ServeHTTP(httpServer)
mux.Handle("/sse", sseServer)
mux.Handle("/message", sseServer)
mux.Handle("/mcp", streamableHttpServer)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
klog.V(0).Infof("Streaming and SSE HTTP servers starting on port %s and paths /mcp, /sse, /message", m.StaticConfig.Port)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
if err := mcpServer.ServeStdio(); err != nil && !errors.Is(err, context.Canceled) {

View File

@@ -1,5 +1,5 @@
log_level = 1
sse_port = 9999
port = "9999"
kubeconfig = "test"
list_output = "yaml"
read_only = true

View File

@@ -85,18 +85,19 @@ func (s *Server) ServeStdio() error {
return server.ServeStdio(s.server)
}
func (s *Server) ServeSse(baseUrl string) *server.SSEServer {
func (s *Server) ServeSse(baseUrl string, httpServer *http.Server) *server.SSEServer {
options := make([]server.SSEOption, 0)
options = append(options, server.WithSSEContextFunc(contextFunc))
options = append(options, server.WithSSEContextFunc(contextFunc), server.WithHTTPServer(httpServer))
if baseUrl != "" {
options = append(options, server.WithBaseURL(baseUrl))
}
return server.NewSSEServer(s.server, options...)
}
func (s *Server) ServeHTTP() *server.StreamableHTTPServer {
func (s *Server) ServeHTTP(httpServer *http.Server) *server.StreamableHTTPServer {
options := []server.StreamableHTTPOption{
server.WithHTTPContextFunc(contextFunc),
server.WithStreamableHTTPServer(httpServer),
}
return server.NewStreamableHTTPServer(s.server, options...)
}