Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions app_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,36 @@ import (

"github.com/coder/coder-k8s/internal/app/apiserverapp"
"github.com/coder/coder-k8s/internal/app/controllerapp"
"github.com/coder/coder-k8s/internal/app/mcpapp"
)

const supportedAppModes = "controller, aggregated-apiserver, mcp-http"

var (
runControllerApp = controllerapp.Run
runAggregatedAPIServerApp = apiserverapp.Run
runMCPHTTPApp = mcpapp.RunHTTP
setupSignalHandler = ctrl.SetupSignalHandler
)

func run(args []string) error {
fs := flag.NewFlagSet("coder-k8s", flag.ContinueOnError)
var appMode string
fs.StringVar(&appMode, "app", "", "Application mode (controller, aggregated-apiserver)")
fs.StringVar(&appMode, "app", "", "Application mode (controller, aggregated-apiserver, mcp-http)")
if err := fs.Parse(args); err != nil {
return err
}

switch appMode {
case "controller":
return runControllerApp(ctrl.SetupSignalHandler())
return runControllerApp(setupSignalHandler())
case "aggregated-apiserver":
return runAggregatedAPIServerApp(ctrl.SetupSignalHandler())
return runAggregatedAPIServerApp(setupSignalHandler())
case "mcp-http":
return runMCPHTTPApp(setupSignalHandler())
case "":
return fmt.Errorf("assertion failed: --app flag is required; must be one of: controller, aggregated-apiserver")
return fmt.Errorf("assertion failed: --app flag is required; must be one of: %s", supportedAppModes)
default:
return fmt.Errorf("assertion failed: unsupported --app value %q; must be one of: controller, aggregated-apiserver", appMode)
return fmt.Errorf("assertion failed: unsupported --app value %q; must be one of: %s", appMode, supportedAppModes)
}
}
31 changes: 31 additions & 0 deletions deploy/mcp-deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: coder-k8s-mcp
namespace: coder-system
spec:
replicas: 1
selector:
matchLabels:
app: coder-k8s-mcp
template:
metadata:
labels:
app: coder-k8s-mcp
spec:
serviceAccountName: coder-k8s-mcp
containers:
- name: mcp
image: ghcr.io/coder/coder-k8s:latest
args: ["--app=mcp-http"]
ports:
- containerPort: 8090
name: mcp
livenessProbe:
httpGet:
path: /healthz
port: mcp
readinessProbe:
httpGet:
path: /readyz
port: mcp
13 changes: 13 additions & 0 deletions deploy/mcp-service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: coder-k8s
namespace: coder-system
spec:
selector:
app: coder-k8s-mcp
ports:
- name: mcp
port: 8090
protocol: TCP
targetPort: 8090
35 changes: 35 additions & 0 deletions deploy/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,38 @@ subjects:
- kind: ServiceAccount
name: coder-k8s-apiserver
namespace: coder-system
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: coder-k8s-mcp
namespace: coder-system
---
# ClusterRole for the MCP server to read operator resources.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: coder-k8s-mcp
rules:
- apiGroups: ["coder.com"]
resources: ["codercontrolplanes", "codercontrolplanes/status"]
verbs: ["get", "list", "watch"]
- apiGroups: ["aggregation.coder.com"]
resources: ["coderworkspaces", "codertemplates"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods", "pods/log", "events", "namespaces"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: coder-k8s-mcp
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: coder-k8s-mcp
subjects:
- kind: ServiceAccount
name: coder-k8s-mcp
namespace: coder-system
62 changes: 62 additions & 0 deletions docs/how-to/mcp-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Run the MCP server

This guide shows how to run the `coder-k8s` **MCP server** for local development and in-cluster access.

The MCP server runs in HTTP mode (`--app=mcp-http`).

## 1. Overview

The MCP server provides tools for inspecting Kubernetes resources managed by `coder-k8s`, including:

- `CoderControlPlane` resources
- `CoderWorkspace` resources
- `CoderTemplate` resources
- Namespace events
- Pod logs

## 2. HTTP mode (port-forward / remote clients)

Apply RBAC, deployment, and service manifests:

```bash
kubectl apply -f deploy/rbac.yaml
kubectl apply -f deploy/mcp-deployment.yaml
kubectl apply -f deploy/mcp-service.yaml
```

Port-forward the MCP service:

```bash
kubectl port-forward svc/coder-k8s -n coder-system 8090:8090
```

Connect MCP clients to:

```text
http://127.0.0.1:8090/mcp
```

## 3. Available tools

The server exposes MCP tools for:

- Reading `CoderControlPlane` resources and status
- Listing `CoderWorkspace` and `CoderTemplate` resources
- Listing namespace events for troubleshooting
- Reading pod logs for debugging

## 4. Health checks

<!-- cspell:ignore healthz readyz -->

The HTTP server exposes standard health endpoints:

- `/healthz`
- `/readyz`

Example checks:

```bash
curl -fsS http://127.0.0.1:8090/healthz
curl -fsS http://127.0.0.1:8090/readyz
```
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.25.7
require (
github.com/coder/coder/v2 v2.30.0
github.com/google/uuid v1.6.0
github.com/modelcontextprotocol/go-sdk v1.3.0
github.com/stretchr/testify v1.11.1
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
k8s.io/api v0.35.0
Expand Down Expand Up @@ -180,6 +181,7 @@ require (
github.com/google/cel-go v0.26.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
Expand Down Expand Up @@ -332,6 +334,7 @@ require (
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zclconf/go-cty v1.17.0 // indirect
github.com/zeebo/errs v1.4.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
Expand Down Expand Up @@ -573,6 +575,8 @@ github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand Down Expand Up @@ -816,6 +820,8 @@ github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5Jsjqto
github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4=
github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw=
github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
Expand Down
90 changes: 90 additions & 0 deletions internal/app/mcpapp/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package mcpapp

import (
"context"
"errors"
"fmt"
"net/http"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
ctrl "sigs.k8s.io/controller-runtime"
)

const (
// DefaultHTTPAddr is the default listen address used by MCP HTTP mode.
DefaultHTTPAddr = ":8090"
// streamableHTTPSessionTimeout ensures abandoned MCP streamable HTTP sessions are reclaimed.
streamableHTTPSessionTimeout = 15 * time.Minute
)

var setupLog = ctrl.Log.WithName("setup")

// RunHTTP starts the MCP server using streamable HTTP transport.
func RunHTTP(ctx context.Context) error {
if ctx == nil {
return fmt.Errorf("assertion failed: context must not be nil")
}

k8sClient, clientset, err := newClients()
if err != nil {
return err
}

server := NewServer(k8sClient, clientset)
if server == nil {
return fmt.Errorf("assertion failed: MCP server is nil after successful construction")
}

mcpHandler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, &mcp.StreamableHTTPOptions{
SessionTimeout: streamableHTTPSessionTimeout,
})
if mcpHandler == nil {
return fmt.Errorf("assertion failed: MCP HTTP handler is nil after successful construction")
}

mux := http.NewServeMux()
mux.Handle("/mcp", mcpHandler)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})

httpServer := &http.Server{
Addr: DefaultHTTPAddr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}

listenErr := make(chan error, 1)
go func() {
listenErr <- httpServer.ListenAndServe()
}()

setupLog.Info("MCP HTTP server listening on " + DefaultHTTPAddr)

select {
case err := <-listenErr:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("run MCP HTTP server: %w", err)
}
return nil
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown MCP HTTP server: %w", err)
}
err := <-listenErr
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("run MCP HTTP server: %w", err)
}
return nil
}
}
Loading