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
90 changes: 90 additions & 0 deletions internal/mcpserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`{}`))
Expand Down Expand Up @@ -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"))
Expand Down
49 changes: 45 additions & 4 deletions internal/mcpserver/tools_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mcpserver
import (
"context"

"github.com/c3-oss/mcp-plane/internal/plane"
"github.com/mark3labs/mcp-go/mcp"
)

Expand All @@ -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)
}
Expand All @@ -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)
}
Loading