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
6 changes: 4 additions & 2 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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": "..." }`.

Expand Down
37 changes: 28 additions & 9 deletions internal/mcpserver/jsonutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcpserver

import (
"encoding/json"
"strings"

"github.com/mark3labs/mcp-go/mcp"
)
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
78 changes: 78 additions & 0 deletions internal/mcpserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 17 additions & 4 deletions internal/mcpserver/tools_issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"},
}
}
}
Loading