From 6b1ead800ca666d953a8b7e88fe0c5a593b4e828 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Wed, 24 Jun 2026 15:17:52 -0300 Subject: [PATCH] fix(health): report endpoint diagnostics --- internal/mcpserver/server_test.go | 90 +++++++++++++++++++++++++++ internal/mcpserver/tools_workspace.go | 49 +++++++++++++-- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index c41574c..d74b0f6 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -27,6 +27,29 @@ func newTestServer(t *testing.T, handler http.HandlerFunc) *Server { return New(c) } +func callToolPayload(t *testing.T, srv *Server, name string, args map[string]any) map[string]any { + t.Helper() + c, err := client.NewInProcessClient(srv.MCPServer()) + require.NoError(t, err) + require.NoError(t, c.Start(context.Background())) + t.Cleanup(func() { _ = c.Close() }) + _, err = c.Initialize(context.Background(), mcp.InitializeRequest{}) + require.NoError(t, err) + + req := mcp.CallToolRequest{} + req.Params.Name = name + req.Params.Arguments = args + res, err := c.CallTool(context.Background(), req) + require.NoError(t, err) + require.False(t, res.IsError) + require.Len(t, res.Content, 1) + text, ok := res.Content[0].(mcp.TextContent) + require.True(t, ok) + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(text.Text), &payload)) + return payload +} + func TestToolsListIncludesEveryRegisteredTool(t *testing.T) { srv := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{}`)) @@ -132,6 +155,73 @@ func TestWorkspaceInfoDoesNotEchoToken(t *testing.T) { require.Contains(t, text.Text, "ws") } +func TestHealthReportsEndpointDiagnostics(t *testing.T) { + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/workspaces/ws/projects/P/states/": + _, _ = w.Write([]byte(`{"results":[{"id":"s","name":"Todo"}]}`)) + case "/api/v1/workspaces/ws/projects/P/issues/": + require.Equal(t, "1", r.URL.Query().Get("per_page")) + _, _ = w.Write([]byte(`{"results":[{"id":"i","name":"Issue"}],"count":3}`)) + default: + http.NotFound(w, r) + } + }) + + payload := callToolPayload(t, srv, "plane_health", map[string]any{"project_id": "P"}) + require.Equal(t, true, payload["ok"]) + checks, ok := payload["checks"].(map[string]any) + require.True(t, ok) + states, ok := checks["states_endpoint"].(map[string]any) + require.True(t, ok) + require.Equal(t, true, states["ok"]) + require.Equal(t, float64(1), states["count"]) + issues, ok := checks["issues_endpoint"].(map[string]any) + require.True(t, ok) + require.Equal(t, true, issues["ok"]) + require.Equal(t, float64(1), issues["result_count"]) +} + +func TestHealthFlagsSuspiciousEmptyProject(t *testing.T) { + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/workspaces/ws/projects/P/states/": + _, _ = w.Write([]byte(`{"results":[]}`)) + case "/api/v1/workspaces/ws/projects/P/issues/": + _, _ = w.Write([]byte(`{"results":[],"count":0}`)) + default: + http.NotFound(w, r) + } + }) + + payload := callToolPayload(t, srv, "plane_health", map[string]any{"project_id": "P"}) + require.Equal(t, false, payload["ok"]) + require.Contains(t, payload["warnings"], "states endpoint returned no states") + require.Contains(t, payload["warnings"], "issues endpoint returned no issues for per_page=1 probe") +} + +func TestHealthReturnsStructuredEndpointErrors(t *testing.T) { + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/workspaces/ws/projects/P/states/": + _, _ = w.Write([]byte(`{"results":[{"id":"s","name":"Todo"}]}`)) + case "/api/v1/workspaces/ws/projects/P/issues/": + http.Error(w, `{"detail":"issues unavailable"}`, http.StatusForbidden) + default: + http.NotFound(w, r) + } + }) + + payload := callToolPayload(t, srv, "plane_health", map[string]any{"project_id": "P"}) + require.Equal(t, false, payload["ok"]) + checks, ok := payload["checks"].(map[string]any) + require.True(t, ok) + issues, ok := checks["issues_endpoint"].(map[string]any) + require.True(t, ok) + require.Equal(t, false, issues["ok"]) + require.Contains(t, issues["error"], "403") +} + func TestAttachmentMimeTypeStripsParameters(t *testing.T) { require.Equal(t, "text/plain", attachmentMimeType("notes.txt")) require.Equal(t, "application/octet-stream", attachmentMimeType("archive.unknownext")) diff --git a/internal/mcpserver/tools_workspace.go b/internal/mcpserver/tools_workspace.go index 6ea3eaa..a49eee7 100644 --- a/internal/mcpserver/tools_workspace.go +++ b/internal/mcpserver/tools_workspace.go @@ -3,6 +3,7 @@ package mcpserver import ( "context" + "github.com/c3-oss/mcp-plane/internal/plane" "github.com/mark3labs/mcp-go/mcp" ) @@ -12,7 +13,7 @@ func (s *Server) registerWorkspaceTools() { ), s.handleWorkspaceInfo) s.mcp.AddTool(mcp.NewTool("plane_health", - mcp.WithDescription("Round-trip a cheap GET against the configured workspace using the given project to verify credentials."), + mcp.WithDescription("Probe project access by reading the states and issues endpoints, returning structured diagnostics."), mcp.WithString("project_id", mcp.Required(), mcp.Description("Any project UUID the configured token can read.")), ), s.handleHealth) } @@ -29,8 +30,48 @@ func (s *Server) handleHealth(ctx context.Context, req mcp.CallToolRequest) (*mc if err != nil { return mcp.NewToolResultErrorFromErr("project_id", err), nil } - if _, err := s.client.ListStates(ctx, projectID); err != nil { - return toolError(err), nil + + states, statesErr := s.client.ListStates(ctx, projectID) + perPage := 1 + issues, issuesErr := s.client.ListIssues(ctx, projectID, plane.ListIssuesOptions{PerPage: &perPage}) + + stateCheck := endpointCheck(statesErr) + if statesErr == nil { + stateCheck["count"] = len(states) + } + issueCheck := endpointCheck(issuesErr) + if issuesErr == nil { + issueCheck["result_count"] = issueResultCount(issues) + } + + warnings := []string{} + if statesErr == nil && len(states) == 0 { + warnings = append(warnings, "states endpoint returned no states") + } + if issuesErr == nil && issueResultCount(issues) == 0 { + warnings = append(warnings, "issues endpoint returned no issues for per_page=1 probe") } - return asTextResult(map[string]any{"ok": true}) + + return asTextResult(map[string]any{ + "ok": statesErr == nil && issuesErr == nil && len(states) > 0, + "project_id": projectID, + "checks": map[string]any{ + "states_endpoint": stateCheck, + "issues_endpoint": issueCheck, + }, + "warnings": warnings, + }) +} + +func endpointCheck(err error) map[string]any { + out := map[string]any{"ok": err == nil} + if err != nil { + out["error"] = err.Error() + } + return out +} + +func issueResultCount(issues plane.IssueList) int { + results, _ := issues["results"].([]any) + return len(results) }