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
14 changes: 9 additions & 5 deletions docs/tools.md
Original file line number Diff line number Diff line change
@@ -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://<PLANE_BASE_URL>/api/v1/workspaces/<PLANE_WORKSPACE>/`.

Expand Down Expand Up @@ -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 `<value>;<op>` 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`

Expand Down
86 changes: 86 additions & 0 deletions internal/mcpserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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": "<p>large</p>",
"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":"<p>large</p>"}],"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, "<p>large</p>", issue["description_html"])
}

func TestNotFoundMapsToToolError(t *testing.T) {
srv := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"detail":"missing"}`, http.StatusNotFound)
Expand Down
70 changes: 70 additions & 0 deletions internal/mcpserver/tools_issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
}
Loading