Skip to content

Commit 5fd5ba9

Browse files
authored
🤖 feat: add MCP server support (#35)
## Summary Add an in-cluster MCP (Model Context Protocol) server mode to `coder-k8s` so remote clients can inspect operator-managed resources over HTTP (ideal for `kubectl port-forward`). New app mode: - `--app=mcp-http`: MCP over Streamable HTTP on `:8090` ## Background The operator is expected to run in-cluster, and clients will connect remotely over HTTP (for example via `kubectl port-forward svc/coder-k8s -n coder-system 8090:8090`). ## Implementation - New package `internal/app/mcpapp` using `github.com/modelcontextprotocol/go-sdk/mcp` - Streamable MCP endpoint: `/mcp` - Probes: `/healthz`, `/readyz` - Idle Streamable HTTP sessions expire after 15 minutes to reclaim abandoned sessions. - Kubernetes clients: - controller-runtime client (for Coder CRDs + aggregated API types) - client-go clientset (for events + pod logs) - Tool safety bounds: - `get_events` requires a namespace and is paginated (default limit 200, max 1000, returns a `continue` token). - `get_pod_logs` applies safe defaults/bounds and marks output with `(truncated)` when the byte cap is hit. - `app_dispatch.go` supports `mcp-http` and is covered by dispatch tests. - Deployment + docs: - `deploy/mcp-deployment.yaml` (Deployment: `coder-k8s-mcp`) - `deploy/mcp-service.yaml` (Service: `coder-k8s`, port 8090) - `deploy/rbac.yaml` (adds `coder-k8s-mcp` SA + read-only ClusterRole) - `docs/how-to/mcp-server.md` ### MCP tools (MVP) - `list_control_planes` - `get_control_plane_status` - `list_workspaces` - `list_templates` - `get_events` - `get_pod_logs` - `check_health` ## Validation - `make verify-vendor` - `make build` - `make test` - `make lint` ## Risks - HTTP mode does not add auth on its own (intended for in-cluster + port-forward usage). - RBAC grants read access to pods/log, events, namespaces, and Coder CRDs for the MCP service account. --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh` • Cost: `$3.92`_ <!-- mux-attribution: model=openai:gpt-5.2 thinking=xhigh costs=3.92 -->
1 parent bbe8c8b commit 5fd5ba9

76 files changed

Lines changed: 19133 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app_dispatch.go‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,36 @@ import (
88

99
"github.com/coder/coder-k8s/internal/app/apiserverapp"
1010
"github.com/coder/coder-k8s/internal/app/controllerapp"
11+
"github.com/coder/coder-k8s/internal/app/mcpapp"
1112
)
1213

14+
const supportedAppModes = "controller, aggregated-apiserver, mcp-http"
15+
1316
var (
1417
runControllerApp = controllerapp.Run
1518
runAggregatedAPIServerApp = apiserverapp.Run
19+
runMCPHTTPApp = mcpapp.RunHTTP
20+
setupSignalHandler = ctrl.SetupSignalHandler
1621
)
1722

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

2631
switch appMode {
2732
case "controller":
28-
return runControllerApp(ctrl.SetupSignalHandler())
33+
return runControllerApp(setupSignalHandler())
2934
case "aggregated-apiserver":
30-
return runAggregatedAPIServerApp(ctrl.SetupSignalHandler())
35+
return runAggregatedAPIServerApp(setupSignalHandler())
36+
case "mcp-http":
37+
return runMCPHTTPApp(setupSignalHandler())
3138
case "":
32-
return fmt.Errorf("assertion failed: --app flag is required; must be one of: controller, aggregated-apiserver")
39+
return fmt.Errorf("assertion failed: --app flag is required; must be one of: %s", supportedAppModes)
3340
default:
34-
return fmt.Errorf("assertion failed: unsupported --app value %q; must be one of: controller, aggregated-apiserver", appMode)
41+
return fmt.Errorf("assertion failed: unsupported --app value %q; must be one of: %s", appMode, supportedAppModes)
3542
}
3643
}

‎deploy/mcp-deployment.yaml‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
apiVersion: apps/v1
2+
kind: Deployment
3+
metadata:
4+
name: coder-k8s-mcp
5+
namespace: coder-system
6+
spec:
7+
replicas: 1
8+
selector:
9+
matchLabels:
10+
app: coder-k8s-mcp
11+
template:
12+
metadata:
13+
labels:
14+
app: coder-k8s-mcp
15+
spec:
16+
serviceAccountName: coder-k8s-mcp
17+
containers:
18+
- name: mcp
19+
image: ghcr.io/coder/coder-k8s:latest
20+
args: ["--app=mcp-http"]
21+
ports:
22+
- containerPort: 8090
23+
name: mcp
24+
livenessProbe:
25+
httpGet:
26+
path: /healthz
27+
port: mcp
28+
readinessProbe:
29+
httpGet:
30+
path: /readyz
31+
port: mcp

‎deploy/mcp-service.yaml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
apiVersion: v1
2+
kind: Service
3+
metadata:
4+
name: coder-k8s
5+
namespace: coder-system
6+
spec:
7+
selector:
8+
app: coder-k8s-mcp
9+
ports:
10+
- name: mcp
11+
port: 8090
12+
protocol: TCP
13+
targetPort: 8090

‎deploy/rbac.yaml‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,38 @@ subjects:
6666
- kind: ServiceAccount
6767
name: coder-k8s-apiserver
6868
namespace: coder-system
69+
---
70+
apiVersion: v1
71+
kind: ServiceAccount
72+
metadata:
73+
name: coder-k8s-mcp
74+
namespace: coder-system
75+
---
76+
# ClusterRole for the MCP server to read operator resources.
77+
apiVersion: rbac.authorization.k8s.io/v1
78+
kind: ClusterRole
79+
metadata:
80+
name: coder-k8s-mcp
81+
rules:
82+
- apiGroups: ["coder.com"]
83+
resources: ["codercontrolplanes", "codercontrolplanes/status"]
84+
verbs: ["get", "list", "watch"]
85+
- apiGroups: ["aggregation.coder.com"]
86+
resources: ["coderworkspaces", "codertemplates"]
87+
verbs: ["get", "list", "watch"]
88+
- apiGroups: [""]
89+
resources: ["pods", "pods/log", "events", "namespaces"]
90+
verbs: ["get", "list", "watch"]
91+
---
92+
apiVersion: rbac.authorization.k8s.io/v1
93+
kind: ClusterRoleBinding
94+
metadata:
95+
name: coder-k8s-mcp
96+
roleRef:
97+
apiGroup: rbac.authorization.k8s.io
98+
kind: ClusterRole
99+
name: coder-k8s-mcp
100+
subjects:
101+
- kind: ServiceAccount
102+
name: coder-k8s-mcp
103+
namespace: coder-system

‎docs/how-to/mcp-server.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Run the MCP server
2+
3+
This guide shows how to run the `coder-k8s` **MCP server** for local development and in-cluster access.
4+
5+
The MCP server runs in HTTP mode (`--app=mcp-http`).
6+
7+
## 1. Overview
8+
9+
The MCP server provides tools for inspecting Kubernetes resources managed by `coder-k8s`, including:
10+
11+
- `CoderControlPlane` resources
12+
- `CoderWorkspace` resources
13+
- `CoderTemplate` resources
14+
- Namespace events
15+
- Pod logs
16+
17+
## 2. HTTP mode (port-forward / remote clients)
18+
19+
Apply RBAC, deployment, and service manifests:
20+
21+
```bash
22+
kubectl apply -f deploy/rbac.yaml
23+
kubectl apply -f deploy/mcp-deployment.yaml
24+
kubectl apply -f deploy/mcp-service.yaml
25+
```
26+
27+
Port-forward the MCP service:
28+
29+
```bash
30+
kubectl port-forward svc/coder-k8s -n coder-system 8090:8090
31+
```
32+
33+
Connect MCP clients to:
34+
35+
```text
36+
http://127.0.0.1:8090/mcp
37+
```
38+
39+
## 3. Available tools
40+
41+
The server exposes MCP tools for:
42+
43+
- Reading `CoderControlPlane` resources and status
44+
- Listing `CoderWorkspace` and `CoderTemplate` resources
45+
- Listing namespace events for troubleshooting
46+
- Reading pod logs for debugging
47+
48+
## 4. Health checks
49+
50+
<!-- cspell:ignore healthz readyz -->
51+
52+
The HTTP server exposes standard health endpoints:
53+
54+
- `/healthz`
55+
- `/readyz`
56+
57+
Example checks:
58+
59+
```bash
60+
curl -fsS http://127.0.0.1:8090/healthz
61+
curl -fsS http://127.0.0.1:8090/readyz
62+
```

‎go.mod‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25.7
55
require (
66
github.com/coder/coder/v2 v2.30.0
77
github.com/google/uuid v1.6.0
8+
github.com/modelcontextprotocol/go-sdk v1.3.0
89
github.com/stretchr/testify v1.11.1
910
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
1011
k8s.io/api v0.35.0
@@ -180,6 +181,7 @@ require (
180181
github.com/google/cel-go v0.26.0 // indirect
181182
github.com/google/gnostic-models v0.7.0 // indirect
182183
github.com/google/go-cmp v0.7.0 // indirect
184+
github.com/google/jsonschema-go v0.4.2 // indirect
183185
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect
184186
github.com/gordonklaus/ineffassign v0.2.0 // indirect
185187
github.com/gorilla/css v1.0.1 // indirect
@@ -332,6 +334,7 @@ require (
332334
github.com/yagipy/maintidx v1.0.0 // indirect
333335
github.com/yeya24/promlinter v0.3.0 // indirect
334336
github.com/ykadowak/zerologlint v0.1.5 // indirect
337+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
335338
github.com/yusufpapurcu/wmi v1.2.4 // indirect
336339
github.com/zclconf/go-cty v1.17.0 // indirect
337340
github.com/zeebo/errs v1.4.0 // indirect

‎go.sum‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
378378
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
379379
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
380380
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
381+
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
382+
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
381383
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
382384
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
383385
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
@@ -573,6 +575,8 @@ github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374
573575
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
574576
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
575577
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
578+
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
579+
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
576580
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
577581
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
578582
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -816,6 +820,8 @@ github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5Jsjqto
816820
github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4=
817821
github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw=
818822
github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg=
823+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
824+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
819825
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
820826
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
821827
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package mcpapp
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"time"
9+
10+
"github.com/modelcontextprotocol/go-sdk/mcp"
11+
ctrl "sigs.k8s.io/controller-runtime"
12+
)
13+
14+
const (
15+
// DefaultHTTPAddr is the default listen address used by MCP HTTP mode.
16+
DefaultHTTPAddr = ":8090"
17+
// streamableHTTPSessionTimeout ensures abandoned MCP streamable HTTP sessions are reclaimed.
18+
streamableHTTPSessionTimeout = 15 * time.Minute
19+
)
20+
21+
var setupLog = ctrl.Log.WithName("setup")
22+
23+
// RunHTTP starts the MCP server using streamable HTTP transport.
24+
func RunHTTP(ctx context.Context) error {
25+
if ctx == nil {
26+
return fmt.Errorf("assertion failed: context must not be nil")
27+
}
28+
29+
k8sClient, clientset, err := newClients()
30+
if err != nil {
31+
return err
32+
}
33+
34+
server := NewServer(k8sClient, clientset)
35+
if server == nil {
36+
return fmt.Errorf("assertion failed: MCP server is nil after successful construction")
37+
}
38+
39+
mcpHandler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
40+
return server
41+
}, &mcp.StreamableHTTPOptions{
42+
SessionTimeout: streamableHTTPSessionTimeout,
43+
})
44+
if mcpHandler == nil {
45+
return fmt.Errorf("assertion failed: MCP HTTP handler is nil after successful construction")
46+
}
47+
48+
mux := http.NewServeMux()
49+
mux.Handle("/mcp", mcpHandler)
50+
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
51+
w.WriteHeader(http.StatusOK)
52+
_, _ = w.Write([]byte("ok"))
53+
})
54+
mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
55+
w.WriteHeader(http.StatusOK)
56+
_, _ = w.Write([]byte("ok"))
57+
})
58+
59+
httpServer := &http.Server{
60+
Addr: DefaultHTTPAddr,
61+
Handler: mux,
62+
ReadHeaderTimeout: 5 * time.Second,
63+
}
64+
65+
listenErr := make(chan error, 1)
66+
go func() {
67+
listenErr <- httpServer.ListenAndServe()
68+
}()
69+
70+
setupLog.Info("MCP HTTP server listening on " + DefaultHTTPAddr)
71+
72+
select {
73+
case err := <-listenErr:
74+
if err != nil && !errors.Is(err, http.ErrServerClosed) {
75+
return fmt.Errorf("run MCP HTTP server: %w", err)
76+
}
77+
return nil
78+
case <-ctx.Done():
79+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
80+
defer cancel()
81+
if err := httpServer.Shutdown(shutdownCtx); err != nil {
82+
return fmt.Errorf("shutdown MCP HTTP server: %w", err)
83+
}
84+
err := <-listenErr
85+
if err != nil && !errors.Is(err, http.ErrServerClosed) {
86+
return fmt.Errorf("run MCP HTTP server: %w", err)
87+
}
88+
return nil
89+
}
90+
}

0 commit comments

Comments
 (0)