From 10099e29fd2e8d1da939a6620183b42204e62b0b Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Wed, 24 Jun 2026 15:24:03 -0300 Subject: [PATCH] feat(projects): add project list tool --- README.md | 10 ++++---- docs/architecture.md | 3 ++- docs/tools.md | 25 +++++++++++++++---- docs/verifying.md | 2 +- internal/mcpserver/server.go | 1 + internal/mcpserver/server_test.go | 21 ++++++++++++++++ internal/mcpserver/tools_projects.go | 36 ++++++++++++++++++++++++++++ internal/plane/client_test.go | 11 +++++++++ internal/plane/projects.go | 22 +++++++++++++++++ internal/plane/types.go | 3 +++ 10 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 internal/mcpserver/tools_projects.go create mode 100644 internal/plane/projects.go diff --git a/README.md b/README.md index 54a148b..4ed9602 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,12 @@ Or pointing at a locally-built binary: ## Tools -22 tools registered. See [`docs/tools.md`](docs/tools.md) for the full table +23 tools registered. See [`docs/tools.md`](docs/tools.md) for the full table with input schemas; summary below. | Group | Tools | |---|---| +| Projects | `plane_project_list` | | Issues | `plane_issue_create`, `plane_issue_list`, `plane_issue_get`, `plane_issue_get_by_identifier`, `plane_issue_update`, `plane_issue_delete` | | States / Labels | `plane_state_list`, `plane_label_list` | | Comments | `plane_comment_list`, `plane_comment_add`, `plane_comment_update`, `plane_comment_delete` | @@ -94,9 +95,10 @@ with input schemas; summary below. | Higher-level | `plane_issue_transfer`, `plane_issue_workpad_upsert` | | Sanity | `plane_workspace_info`, `plane_health` | -All inputs accept plane-native ids (uuid for project/issue/state/label, or -the workspace identifier like `PROJ-7` for the by-identifier lookups). No -user-directory mapping is performed; the caller passes plane ids directly. +Inputs accept plane-native ids (uuid for project/issue/state/label, or the +workspace identifier like `PROJ-7` for the by-identifier lookups). +`plane_project_list` returns project ids and identifiers for project-scoped +tools. ## Verifying diff --git a/docs/architecture.md b/docs/architecture.md index 24b6d60..df2f3c5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,8 +18,9 @@ internal/config PLANE_* env loading with placeholder rejectio internal/plane (.Client) net/http client; auth header; 30 s timeout │ ──► download client (60 s) for attachments ▼ -internal/mcpserver 22 tools registered; arg-coercion helpers +internal/mcpserver 23 tools registered; arg-coercion helpers │ and error mapping to mcp.CallToolResult + ├── tools_projects.go 1 tool ├── tools_issues.go 6 tools ├── tools_states.go 1 tool ├── tools_labels.go 1 tool diff --git a/docs/tools.md b/docs/tools.md index 182aa7a..b8b30a3 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -6,6 +6,21 @@ calling agent decides how to recover. All endpoints are relative to `https:///api/v1/workspaces//`. +## Projects + +### `plane_project_list` + +GET `/projects/`. + +Returns compact project records: + +```json +{ "results": [{ "id": "...", "identifier": "TOOLS", "name": "Squad Web/Tools" }] } +``` + +Use `id` as `project_id` in project-scoped tools. Use `identifier` as the +project code in cross-project transfer arguments and issue identifiers. + ## Issues ### `plane_issue_create` @@ -186,9 +201,8 @@ Returns: } ``` -Project codes are required because there is no project-directory lookup; -without them the cross-link comments would say "Transferred from ``" -instead of the readable identifier. +Project codes are required so cross-link comments use readable identifiers. +Use `plane_project_list` to discover project ids and identifiers. ### `plane_issue_workpad_upsert` @@ -212,5 +226,6 @@ Returns `{ base_url, workspace }`. Never echoes the api token. ### `plane_health` -Required: `project_id`. Runs `plane_state_list` against it to verify -credentials end-to-end. Returns `{ ok: true }` on success. +Required: `project_id`. Probes the states and issues endpoints for that +project and returns `{ ok, checks, warnings }` diagnostics. Empty states or +endpoint failures produce `ok: false`. diff --git a/docs/verifying.md b/docs/verifying.md index 3ab07dc..561d518 100644 --- a/docs/verifying.md +++ b/docs/verifying.md @@ -35,7 +35,7 @@ printf '%s\n%s\n' \ | jq . ``` -Expect 22 tools in the second response's `result.tools`. +Expect 23 tools in the second response's `result.tools`. ### Call a tool diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 36bbb4e..d845da3 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -44,6 +44,7 @@ func (s *Server) Serve(ctx context.Context) error { } func (s *Server) registerAll() { + s.registerProjectTools() s.registerIssueTools() s.registerStateTools() s.registerLabelTools() diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index d74b0f6..10e0941 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -69,6 +69,7 @@ func TestToolsListIncludesEveryRegisteredTool(t *testing.T) { names[tool.Name] = true } for _, want := range []string{ + "plane_project_list", "plane_issue_create", "plane_issue_list", "plane_issue_get", "plane_issue_get_by_identifier", "plane_issue_update", "plane_issue_delete", "plane_state_list", "plane_label_list", @@ -155,6 +156,26 @@ func TestWorkspaceInfoDoesNotEchoToken(t *testing.T) { require.Contains(t, text.Text, "ws") } +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) + _, _ = w.Write([]byte(`{"results":[{"id":"p1","identifier":"TOOLS","name":"Tools","description":"hidden"},{"id":"p2","identifier":"STAFF","name":"Staff","extra":true}]}`)) + }) + + payload := callToolPayload(t, srv, "plane_project_list", nil) + results, ok := payload["results"].([]any) + require.True(t, ok) + require.Len(t, results, 2) + first, ok := results[0].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{ + "id": "p1", + "identifier": "TOOLS", + "name": "Tools", + }, first) + require.NotContains(t, first, "description") +} + func TestHealthReportsEndpointDiagnostics(t *testing.T) { srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { diff --git a/internal/mcpserver/tools_projects.go b/internal/mcpserver/tools_projects.go new file mode 100644 index 0000000..9b72bee --- /dev/null +++ b/internal/mcpserver/tools_projects.go @@ -0,0 +1,36 @@ +package mcpserver + +import ( + "context" + + "github.com/c3-oss/mcp-plane/internal/plane" + "github.com/mark3labs/mcp-go/mcp" +) + +func (s *Server) registerProjectTools() { + s.mcp.AddTool(mcp.NewTool("plane_project_list", + mcp.WithDescription("List visible Plane projects with compact id, identifier, and name fields."), + ), s.handleProjectList) +} + +func (s *Server) handleProjectList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + projects, err := s.client.ListProjects(ctx) + if err != nil { + return toolError(err), nil + } + out := make([]map[string]any, 0, len(projects)) + for _, project := range projects { + out = append(out, compactProject(project)) + } + return asTextResult(map[string]any{"results": out}) +} + +func compactProject(project plane.Project) map[string]any { + out := map[string]any{} + for _, key := range []string{"id", "identifier", "name"} { + if value, ok := project[key].(string); ok && value != "" { + out[key] = value + } + } + return out +} diff --git a/internal/plane/client_test.go b/internal/plane/client_test.go index 9eef08d..a270787 100644 --- a/internal/plane/client_test.go +++ b/internal/plane/client_test.go @@ -183,6 +183,17 @@ func TestListIssuesPassesDictThrough(t *testing.T) { require.Equal(t, "x", out["next_cursor"]) } +func TestListProjectsUnwrapsResults(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/workspaces/ws/projects/", r.URL.Path) + _, _ = w.Write([]byte(`{"results":[{"id":"p1","identifier":"TOOLS","name":"Tools"}]}`)) + }) + projects, err := c.ListProjects(context.Background()) + require.NoError(t, err) + require.Len(t, projects, 1) + require.Equal(t, "TOOLS", projects[0]["identifier"]) +} + func TestListStatesUnwrapsResults(t *testing.T) { c, _ := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"results":[{"id":"s","group":"completed"}]}`)) diff --git a/internal/plane/projects.go b/internal/plane/projects.go new file mode 100644 index 0000000..1bbdb7a --- /dev/null +++ b/internal/plane/projects.go @@ -0,0 +1,22 @@ +package plane + +import ( + "context" + "net/http" +) + +// ListProjects returns the projects visible in the configured workspace. +func (c *Client) ListProjects(ctx context.Context) ([]Project, error) { + raw, err := c.doRaw(ctx, http.MethodGet, c.workspacePath("projects"), nil, nil) + if err != nil { + return nil, err + } + items := extractResults(raw) + out := make([]Project, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out, nil +} diff --git a/internal/plane/types.go b/internal/plane/types.go index 3148ec1..9b01846 100644 --- a/internal/plane/types.go +++ b/internal/plane/types.go @@ -23,6 +23,9 @@ type Activity = map[string]any // Attachment represents an issue attachment metadata record. type Attachment = map[string]any +// Project represents a Plane project metadata record. +type Project = map[string]any + // IssueOptions are optional fields accepted by CreateIssue / UpdateIssue. // Pointer fields preserve the distinction between "absent" and "empty": // a nil pointer means "do not send"; a non-nil pointer means "send this value