From 9c5abafb5a374236ee54c110bc6fbfbc43e0ab7b Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Wed, 24 Jun 2026 15:31:45 -0300 Subject: [PATCH] feat(mcp): accept array issue list filters --- docs/tools.md | 6 ++- internal/mcpserver/jsonutil.go | 37 ++++++++++---- internal/mcpserver/server_test.go | 78 ++++++++++++++++++++++++++++++ internal/mcpserver/tools_issues.go | 21 ++++++-- 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/docs/tools.md b/docs/tools.md index b8b30a3..0601aee 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -41,11 +41,13 @@ GET `/projects/{project_id}/issues/`. Required: `project_id`. Optional: `per_page` (number), `cursor` (string), `archived` (boolean), `state_group` (one of `backlog`, `unstarted`, `started`, `completed`, -`cancelled`), `assignees`, `labels`, `priority`, `created_at`, `target_date`, -`completed_at`, `name`, `order_by`, `expand`, `parent` (all strings). +`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). 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": "..." }`. diff --git a/internal/mcpserver/jsonutil.go b/internal/mcpserver/jsonutil.go index bf35494..2e32332 100644 --- a/internal/mcpserver/jsonutil.go +++ b/internal/mcpserver/jsonutil.go @@ -2,6 +2,7 @@ package mcpserver import ( "encoding/json" + "strings" "github.com/mark3labs/mcp-go/mcp" ) @@ -29,17 +30,21 @@ func argStringPtr(args map[string]any, key string) *string { // stored under key. Non-string items are skipped. Returns nil if key is // missing or not an array. func argStringSlicePtr(args map[string]any, key string) *[]string { - raw, ok := args[key].([]any) - if !ok { - return nil - } - out := make([]string, 0, len(raw)) - for _, item := range raw { - if s, ok := item.(string); ok { - out = append(out, s) + switch raw := args[key].(type) { + case []any: + out := make([]string, 0, len(raw)) + for _, item := range raw { + if s, ok := item.(string); ok { + out = append(out, s) + } } + return &out + case []string: + out := append([]string(nil), raw...) + return &out + default: + return nil } - return &out } // argStringSlice returns []string from args[key], or nil. Useful when the @@ -51,6 +56,20 @@ func argStringSlice(args map[string]any, key string) []string { return nil } +// argStringOrStringSliceCSVPtr accepts either a comma-separated string or an +// array of strings and returns the query-string form expected by Plane. +func argStringOrStringSliceCSVPtr(args map[string]any, key string) *string { + if v, ok := args[key].(string); ok { + return &v + } + values := argStringSlice(args, key) + if len(values) == 0 { + return nil + } + joined := strings.Join(values, ",") + return &joined +} + // argIntPtr coerces args[key] from float64 (JSON's number) into *int. func argIntPtr(args map[string]any, key string) *int { switch v := args[key].(type) { diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 10e0941..b0ad75e 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -112,6 +112,66 @@ func TestStateListRoundTripsThroughInProcessClient(t *testing.T) { require.Len(t, results, 1) } +func TestIssueListFilterSchemaAcceptsArraysAndStrings(t *testing.T) { + srv := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{}`)) + }) + 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) + list, err := c.ListTools(context.Background(), mcp.ListToolsRequest{}) + require.NoError(t, err) + + for _, tool := range list.Tools { + if tool.Name == "plane_issue_list" { + requireArrayOrStringFilterSchema(t, tool.InputSchema.Properties, "assignees") + requireArrayOrStringFilterSchema(t, tool.InputSchema.Properties, "labels") + return + } + } + require.Fail(t, "plane_issue_list should be registered") +} + +func TestIssueListAcceptsArrayAndStringFilters(t *testing.T) { + for _, tt := range []struct { + name string + args map[string]any + }{ + { + name: "arrays", + args: map[string]any{ + "project_id": "P", + "assignees": []any{"u1", "u2"}, + "labels": []any{"l1", "l2"}, + }, + }, + { + name: "strings", + args: map[string]any{ + "project_id": "P", + "assignees": "u1,u2", + "labels": "l1,l2", + }, + }, + } { + t.Run(tt.name, func(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) + require.Equal(t, "u1,u2", r.URL.Query().Get("assignees")) + require.Equal(t, "l1,l2", r.URL.Query().Get("labels")) + _, _ = w.Write([]byte(`{"results":[],"count":0}`)) + }) + + payload := callToolPayload(t, srv, "plane_issue_list", tt.args) + require.Equal(t, float64(0), payload["count"]) + }) + } +} + func TestNotFoundMapsToToolError(t *testing.T) { srv := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, `{"detail":"missing"}`, http.StatusNotFound) @@ -156,6 +216,24 @@ func TestWorkspaceInfoDoesNotEchoToken(t *testing.T) { require.Contains(t, text.Text, "ws") } +func requireArrayOrStringFilterSchema(t *testing.T, props map[string]any, name string) { + t.Helper() + prop, ok := props[name].(map[string]any) + require.True(t, ok) + oneOf, ok := prop["oneOf"].([]any) + require.True(t, ok) + require.Len(t, oneOf, 2) + arraySchema, ok := oneOf[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "array", arraySchema["type"]) + items, ok := arraySchema["items"].(map[string]any) + require.True(t, ok) + require.Equal(t, "string", items["type"]) + stringSchema, ok := oneOf[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "string", stringSchema["type"]) +} + func TestProjectListReturnsCompactResults(t *testing.T) { srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/api/v1/workspaces/ws/projects/", r.URL.Path) diff --git a/internal/mcpserver/tools_issues.go b/internal/mcpserver/tools_issues.go index 140f797..a7ba18f 100644 --- a/internal/mcpserver/tools_issues.go +++ b/internal/mcpserver/tools_issues.go @@ -27,8 +27,8 @@ func (s *Server) registerIssueTools() { mcp.WithString("cursor"), mcp.WithBoolean("archived"), mcp.WithString("state_group", mcp.Description(`Plane state group: "backlog", "unstarted", "started", "completed", "cancelled".`)), - mcp.WithString("assignees", mcp.Description("Comma-separated assignee UUIDs.")), - mcp.WithString("labels", mcp.Description("Comma-separated label UUIDs.")), + mcp.WithAny("assignees", issueListIDsFilter("Assignee Plane user UUIDs. Use an array; comma-separated string is accepted.")), + mcp.WithAny("labels", issueListIDsFilter("Label UUIDs. Use an array; comma-separated string is accepted.")), mcp.WithString("priority"), mcp.WithString("created_at", mcp.Description(`Range string, e.g. "2026-04-01;after,2026-04-30;before".`)), mcp.WithString("target_date"), @@ -99,8 +99,8 @@ func (s *Server) handleIssueList(ctx context.Context, req mcp.CallToolRequest) ( Cursor: argStringPtr(args, "cursor"), Archived: argBoolPtr(args, "archived"), StateGroup: argStringPtr(args, "state_group"), - Assignees: argStringPtr(args, "assignees"), - Labels: argStringPtr(args, "labels"), + Assignees: argStringOrStringSliceCSVPtr(args, "assignees"), + Labels: argStringOrStringSliceCSVPtr(args, "labels"), Priority: argStringPtr(args, "priority"), CreatedAt: argStringPtr(args, "created_at"), TargetDate: argStringPtr(args, "target_date"), @@ -189,3 +189,16 @@ func issueOptionsFromArgs(args map[string]any) plane.IssueOptions { Parent: argStringPtr(args, "parent"), } } + +func issueListIDsFilter(description string) mcp.PropertyOption { + return func(schema map[string]any) { + schema["description"] = description + schema["oneOf"] = []any{ + map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + map[string]any{"type": "string"}, + } + } +}