Skip to content

Commit be6654a

Browse files
authored
🤖 fix: prevent MCP schema panic in control plane status tool (#58)
## Summary Fix MCP startup panic by replacing `metav1.Condition` in `get_control_plane_status` tool output with a JSON-schema-safe summary type, add a regression test to ensure `NewServer` does not panic during tool registration, and align local lint tooling with CI by using vendored golangci-lint v2 via `go tool`. ## Background `coder-k8s` could panic on startup when MCP registered `get_control_plane_status` because the output schema exposed `metav1.Condition` (which includes `metav1.Time` embedding `time.Time`). The schema inference path rejects that embedded custom schema shape, crashing MCP registration and preventing app startup. ## Implementation - Added `controlPlaneConditionSummary` and changed `getControlPlaneStatusOutput.Conditions` to `[]controlPlaneConditionSummary`. - Added `summarizeMetav1Conditions(...)` helper to convert condition fields and stringify `LastTransitionTime` as RFC3339Nano. - Updated `get_control_plane_status` handler to return summarized conditions. - Added `internal/app/mcpapp/server_test.go` with `TestNewServerDoesNotPanic` using fake clients. - Updated `Makefile` lint target to use vendored golangci-lint v2: - `go tool golangci-lint run ./...` - `go tool golangci-lint fmt --diff` ## Validation - `make verify-vendor` - `make test` - `make build` - `make lint` ## Risks Low to medium. The API shape for a single MCP tool response changed from raw Kubernetes condition objects to summarized condition objects, but this is intentional to prevent startup panics and is scoped to MCP output formatting. --- <details> <summary>📋 Implementation Plan</summary> # Fix MCP tool schema panic (`get_control_plane_status`) ## Context / Why Running `coder-k8s` in `--app=all` (the default in `deploy/deployment.yaml`) currently crash-loops with a panic during MCP tool registration: ``` panic: AddTool: tool "get_control_plane_status": output schema: ForType(mcpapp.getControlPlaneStatusOutput): computing element schema: custom schema for embedded struct must have type "object", got "string" ``` This prevents the operator stack from starting at all (controller + aggregated API server + MCP HTTP run in the same pod). ## Evidence - `internal/app/mcpapp/tools.go:getControlPlaneStatusOutput` currently returns `[]metav1.Condition` in the tool output. - `metav1.Condition` contains `metav1.Time` fields, and **`metav1.Time` embeds `time.Time`**. - The vendored schema generator used by MCP (`vendor/github.com/google/jsonschema-go/jsonschema`) has a hard-coded custom schema for `time.Time` with `Type: "string"` and rejects *embedded* structs whose custom schema isn’t `Type: "object"`. - The failure occurs in `vendor/github.com/google/jsonschema-go/jsonschema/infer.go` around the embedded-field check: ```go if field.Anonymous { override := schemas[field.Type] if override != nil { if override.Type != "object" { return nil, fmt.Errorf( `custom schema for embedded struct must have type "object", got %q`, override.Type, ) } } } ``` - Scan of `internal/app/mcpapp/**/*.go` confirms `getControlPlaneStatusOutput` is the **only** MCP tool output type that exposes `metav1.Condition` directly; all other tools already use “summary” structs with stringified timestamps. ## Implementation plan ### 1) Replace `metav1.Condition` in MCP output with a summary type **File:** `internal/app/mcpapp/tools.go` 1. Introduce a safe condition summary struct that does **not** include any Kubernetes time types: ```go // NOTE: We cannot return metav1.Condition directly because metav1.Time embeds time.Time, // which causes jsonschema-go schema inference to fail for MCP tool schemas. type controlPlaneConditionSummary struct { Type string `json:"type"` Status string `json:"status"` Reason string `json:"reason,omitempty"` Message string `json:"message,omitempty"` ObservedGeneration int64 `json:"observedGeneration,omitempty"` LastTransitionTime string `json:"lastTransitionTime,omitempty"` } ``` 2. Update `getControlPlaneStatusOutput` to use this summary type: ```go type getControlPlaneStatusOutput struct { ObservedGeneration int64 `json:"observedGeneration"` ReadyReplicas int32 `json:"readyReplicas"` URL string `json:"url"` Phase string `json:"phase"` Conditions []controlPlaneConditionSummary `json:"conditions"` } ``` ### 2) Convert conditions when handling the tool call **File:** `internal/app/mcpapp/tools.go` Add a small helper to keep tool handlers clean and ensure consistent formatting: ```go func summarizeMetav1Conditions(in []metav1.Condition) []controlPlaneConditionSummary { out := make([]controlPlaneConditionSummary, 0, len(in)) for _, cond := range in { summary := controlPlaneConditionSummary{ Type: cond.Type, Status: string(cond.Status), Reason: cond.Reason, Message: cond.Message, ObservedGeneration: cond.ObservedGeneration, } if !cond.LastTransitionTime.IsZero() { summary.LastTransitionTime = cond.LastTransitionTime.Time.UTC().Format(time.RFC3339Nano) } out = append(out, summary) } return out } ``` Then update the `get_control_plane_status` tool handler to return: ```go Conditions: summarizeMetav1Conditions(controlPlane.Status.Conditions), ``` ### 3) Add a regression test to prevent future panics **File:** `internal/app/mcpapp/server_test.go` (new) Goal: ensure that tool registration (schema inference) does not panic. Test shape: - Build a fake controller-runtime client with `newScheme()`. - Use `k8s.io/client-go/kubernetes/fake.NewSimpleClientset()`. - Call `NewServer(k8sClient, clientset)` inside a `defer recover()` block and fail the test if it panics. This catches: - reintroducing `metav1.Condition` / `metav1.Time` / other embedded-time types into MCP tool IO structs - any future schema inference regressions ### 4) Validation / smoke checks Run locally: - `make test` - `make lint` (or at minimum `GOFLAGS=-mod=vendor golangci-lint run` if that’s the repo norm) Optional runtime smoke: - `GOFLAGS=-mod=vendor go run . --app=mcp-http` (should start without panic) - Rebuild and load the image into kind and confirm the operator pod becomes `Ready` when running `--app=all`. ## Acceptance criteria - `coder-k8s` no longer panics on startup when running `--app=all`. - MCP HTTP mode starts successfully, and the `get_control_plane_status` tool returns conditions (in summarized form). - `go test ./...` (via `make test`) passes. </details> --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh`_ _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$1.13`_ <!-- mux-attribution: model=openai:gpt-5.3-codex thinking=xhigh costs=1.13 -->
1 parent f9a37bc commit be6654a

3 files changed

Lines changed: 72 additions & 9 deletions

File tree

Makefile

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ build: $(VENDOR_STAMP)
2727
GOFLAGS=$(GOFLAGS) go build ./...
2828

2929
lint: $(VENDOR_STAMP)
30-
@command -v golangci-lint >/dev/null || (echo "golangci-lint not found; use nix develop" && exit 1)
31-
GOFLAGS=$(GOFLAGS) golangci-lint run ./...
32-
GOFLAGS=$(GOFLAGS) golangci-lint fmt --diff
30+
GOFLAGS=$(GOFLAGS) go tool golangci-lint run ./...
31+
GOFLAGS=$(GOFLAGS) go tool golangci-lint fmt --diff
3332

3433
vuln: $(VENDOR_STAMP)
3534
@command -v govulncheck >/dev/null || (echo "govulncheck not found; use nix develop" && exit 1)

internal/app/mcpapp/server_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package mcpapp
2+
3+
import (
4+
"testing"
5+
6+
k8sfake "k8s.io/client-go/kubernetes/fake"
7+
)
8+
9+
func TestNewServerDoesNotPanic(t *testing.T) {
10+
t.Helper()
11+
12+
k8sClient := mustNewFakeClient(t)
13+
clientset := k8sfake.NewClientset()
14+
if clientset == nil {
15+
t.Fatal("expected non-nil clientset")
16+
}
17+
18+
defer func() {
19+
recovered := recover()
20+
if recovered != nil {
21+
t.Fatalf("expected NewServer not to panic, got %v", recovered)
22+
}
23+
}()
24+
25+
server := NewServer(k8sClient, clientset)
26+
if server == nil {
27+
t.Fatal("expected non-nil MCP server")
28+
}
29+
}

internal/app/mcpapp/tools.go

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,24 @@ type getControlPlaneStatusInput struct {
5050
Name string `json:"name"`
5151
}
5252

53+
// NOTE: We cannot return metav1.Condition directly because metav1.Time embeds
54+
// time.Time, which causes jsonschema-go schema inference to fail for MCP tool
55+
// schemas.
56+
type controlPlaneConditionSummary struct {
57+
Type string `json:"type"`
58+
Status string `json:"status"`
59+
Reason string `json:"reason,omitempty"`
60+
Message string `json:"message,omitempty"`
61+
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
62+
LastTransitionTime string `json:"lastTransitionTime,omitempty"`
63+
}
64+
5365
type getControlPlaneStatusOutput struct {
54-
ObservedGeneration int64 `json:"observedGeneration"`
55-
ReadyReplicas int32 `json:"readyReplicas"`
56-
URL string `json:"url"`
57-
Phase string `json:"phase"`
58-
Conditions []metav1.Condition `json:"conditions"`
66+
ObservedGeneration int64 `json:"observedGeneration"`
67+
ReadyReplicas int32 `json:"readyReplicas"`
68+
URL string `json:"url"`
69+
Phase string `json:"phase"`
70+
Conditions []controlPlaneConditionSummary `json:"conditions"`
5971
}
6072

6173
type listControlPlanePodsInput struct {
@@ -293,7 +305,7 @@ func registerTools(server *mcp.Server, k8sClient client.Client, clientset kubern
293305
ReadyReplicas: controlPlane.Status.ReadyReplicas,
294306
URL: controlPlane.Status.URL,
295307
Phase: controlPlane.Status.Phase,
296-
Conditions: controlPlane.Status.Conditions,
308+
Conditions: summarizeMetav1Conditions(controlPlane.Status.Conditions),
297309
}, nil
298310
})
299311

@@ -873,6 +885,29 @@ func desiredReplicas(replicas *int32) int32 {
873885
return *replicas
874886
}
875887

888+
func summarizeMetav1Conditions(in []metav1.Condition) []controlPlaneConditionSummary {
889+
if len(in) == 0 {
890+
return nil
891+
}
892+
893+
out := make([]controlPlaneConditionSummary, 0, len(in))
894+
for _, cond := range in {
895+
summary := controlPlaneConditionSummary{
896+
Type: cond.Type,
897+
Status: string(cond.Status),
898+
Reason: cond.Reason,
899+
Message: cond.Message,
900+
ObservedGeneration: cond.ObservedGeneration,
901+
}
902+
if !cond.LastTransitionTime.IsZero() {
903+
summary.LastTransitionTime = cond.LastTransitionTime.Time.UTC().Format(time.RFC3339Nano)
904+
}
905+
out = append(out, summary)
906+
}
907+
908+
return out
909+
}
910+
876911
func sanitizeControlPlanePodListLimit(limit int64) int64 {
877912
if limit <= 0 {
878913
return defaultControlPlanePodListLimit

0 commit comments

Comments
 (0)