From 5bea3b96e5cd23ed351ce69c88602a521d54704b Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Wed, 24 Jun 2026 15:37:53 -0300 Subject: [PATCH] feat(mcp): add compact issue list results --- docs/tools.md | 14 +++-- internal/mcpserver/server_test.go | 86 ++++++++++++++++++++++++++++++ internal/mcpserver/tools_issues.go | 70 ++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 5 deletions(-) diff --git a/docs/tools.md b/docs/tools.md index 0601aee..549eaf3 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,8 +1,9 @@ # mcp-plane tool reference -Every tool returns a single text content block whose body is a compact JSON -document. Errors map to MCP tool-error results (`isError: true`) — the -calling agent decides how to recover. +Every tool returns a single text content block whose body is a minified JSON +document. Read tools document whether they return the Plane API payload or a +compact projection. Errors map to MCP tool-error results (`isError: true`) — +the calling agent decides how to recover. All endpoints are relative to `https:///api/v1/workspaces//`. @@ -43,13 +44,16 @@ Optional: `per_page` (number), `cursor` (string), `archived` (boolean), `state_group` (one of `backlog`, `unstarted`, `started`, `completed`, `cancelled`), `assignees` (array of plane user ids), `labels` (array of label ids), `priority`, `created_at`, `target_date`, `completed_at`, `name`, -`order_by`, `expand`, `parent` (strings). +`order_by`, `expand`, `parent` (strings), `compact` (boolean). Date filters use plane's `;` syntax — e.g. `"2026-04-01;after,2026-04-30;before"`. Comma-separated strings are also accepted for `assignees` and `labels`. -Returns `{ "results": [...], "count": n, "next_cursor": "..." }`. +Returns the Plane issue list payload, including pagination metadata such as +`count` and `next_cursor`. With `compact: true`, each issue result is projected +to identifiers, name, state, priority, labels, and assignees while pagination +metadata remains available. ### `plane_issue_get` diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index b0ad75e..e914263 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -130,6 +130,9 @@ func TestIssueListFilterSchemaAcceptsArraysAndStrings(t *testing.T) { if tool.Name == "plane_issue_list" { requireArrayOrStringFilterSchema(t, tool.InputSchema.Properties, "assignees") requireArrayOrStringFilterSchema(t, tool.InputSchema.Properties, "labels") + compact, ok := tool.InputSchema.Properties["compact"].(map[string]any) + require.True(t, ok) + require.Equal(t, "boolean", compact["type"]) return } } @@ -172,6 +175,89 @@ func TestIssueListAcceptsArrayAndStringFilters(t *testing.T) { } } +func TestIssueListCompactKeepsPaginationAndSummarizesIssues(t *testing.T) { + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/workspaces/ws/projects/P/issues/", r.URL.Path) + _, _ = w.Write([]byte(`{ + "count": 3, + "next_cursor": "cursor-2", + "results": [ + { + "id": "i1", + "identifier": "TOOLS-1", + "sequence_id": 1, + "name": "Compact me", + "description_html": "

large

", + "priority": "high", + "state": {"id": "s1", "name": "In Progress", "group": "started", "color": "#00f", "description": "hidden"}, + "labels": [{"id": "l1", "name": "Bug", "color": "#f00", "created_at": "hidden"}], + "assignees": [{"id": "u1", "display_name": "Ada", "email": "ada@example.com"}] + } + ] + }`)) + }) + + payload := callToolPayload(t, srv, "plane_issue_list", map[string]any{ + "project_id": "P", + "compact": true, + }) + + require.Equal(t, float64(3), payload["count"]) + require.Equal(t, "cursor-2", payload["next_cursor"]) + results, ok := payload["results"].([]any) + require.True(t, ok) + require.Len(t, results, 1) + issue, ok := results[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "i1", issue["id"]) + require.Equal(t, "TOOLS-1", issue["identifier"]) + require.Equal(t, float64(1), issue["sequence_id"]) + require.Equal(t, "Compact me", issue["name"]) + require.Equal(t, "high", issue["priority"]) + require.NotContains(t, issue, "description_html") + + state, ok := issue["state"].(map[string]any) + require.True(t, ok) + require.Equal(t, "s1", state["id"]) + require.Equal(t, "In Progress", state["name"]) + require.Equal(t, "started", state["group"]) + require.Equal(t, "#00f", state["color"]) + require.NotContains(t, state, "description") + + labels, ok := issue["labels"].([]any) + require.True(t, ok) + require.Len(t, labels, 1) + label, ok := labels[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "l1", label["id"]) + require.Equal(t, "Bug", label["name"]) + require.NotContains(t, label, "created_at") + + assignees, ok := issue["assignees"].([]any) + require.True(t, ok) + require.Len(t, assignees, 1) + assignee, ok := assignees[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "u1", assignee["id"]) + require.Equal(t, "Ada", assignee["display_name"]) + require.NotContains(t, assignee, "email") +} + +func TestIssueListFullResponseRemainsDefault(t *testing.T) { + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/workspaces/ws/projects/P/issues/", r.URL.Path) + _, _ = w.Write([]byte(`{"results":[{"id":"i1","description_html":"

large

"}],"count":1}`)) + }) + + payload := callToolPayload(t, srv, "plane_issue_list", map[string]any{"project_id": "P"}) + results, ok := payload["results"].([]any) + require.True(t, ok) + require.Len(t, results, 1) + issue, ok := results[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "

large

", issue["description_html"]) +} + func TestNotFoundMapsToToolError(t *testing.T) { srv := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, `{"detail":"missing"}`, http.StatusNotFound) diff --git a/internal/mcpserver/tools_issues.go b/internal/mcpserver/tools_issues.go index a7ba18f..0d818f4 100644 --- a/internal/mcpserver/tools_issues.go +++ b/internal/mcpserver/tools_issues.go @@ -37,6 +37,7 @@ func (s *Server) registerIssueTools() { mcp.WithString("order_by", mcp.Description(`e.g. "-created_at"`)), mcp.WithString("expand", mcp.Description(`Comma-separated fields to expand, e.g. "state,assignees".`)), mcp.WithString("parent"), + mcp.WithBoolean("compact", mcp.Description("Return compact issue summaries plus pagination metadata.")), ), s.handleIssueList) s.mcp.AddTool(mcp.NewTool("plane_issue_get", @@ -114,6 +115,9 @@ func (s *Server) handleIssueList(ctx context.Context, req mcp.CallToolRequest) ( if err != nil { return toolError(err), nil } + if compact := argBoolPtr(args, "compact"); compact != nil && *compact { + return asTextResult(compactIssueList(out)) + } return asTextResult(out) } @@ -202,3 +206,69 @@ func issueListIDsFilter(description string) mcp.PropertyOption { } } } + +func compactIssueList(in plane.IssueList) map[string]any { + out := make(map[string]any, len(in)) + for key, value := range in { + if key != "results" { + out[key] = value + } + } + + results, _ := in["results"].([]any) + compactResults := make([]any, 0, len(results)) + for _, item := range results { + issue, ok := item.(map[string]any) + if !ok { + compactResults = append(compactResults, item) + continue + } + compactResults = append(compactResults, compactIssue(issue)) + } + out["results"] = compactResults + return out +} + +func compactIssue(issue map[string]any) map[string]any { + out := pickFields(issue, "id", "identifier", "sequence_id", "name", "priority") + if state, ok := issue["state"]; ok { + out["state"] = compactObject(state, "id", "name", "group", "color") + } + if labels, ok := issue["labels"]; ok { + out["labels"] = compactObjects(labels, "id", "name", "color") + } + if assignees, ok := issue["assignees"]; ok { + out["assignees"] = compactObjects(assignees, "id", "name", "display_name", "first_name", "last_name") + } + return out +} + +func compactObjects(value any, keys ...string) any { + items, ok := value.([]any) + if !ok { + return compactObject(value, keys...) + } + out := make([]any, 0, len(items)) + for _, item := range items { + out = append(out, compactObject(item, keys...)) + } + return out +} + +func compactObject(value any, keys ...string) any { + obj, ok := value.(map[string]any) + if !ok { + return value + } + return pickFields(obj, keys...) +} + +func pickFields(obj map[string]any, keys ...string) map[string]any { + out := make(map[string]any, len(keys)) + for _, key := range keys { + if value, ok := obj[key]; ok { + out[key] = value + } + } + return out +}