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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 20 additions & 5 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ calling agent decides how to recover.

All endpoints are relative to `https://<PLANE_BASE_URL>/api/v1/workspaces/<PLANE_WORKSPACE>/`.

## 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`
Expand Down Expand Up @@ -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 `<uuid>`"
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`

Expand All @@ -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`.
2 changes: 1 addition & 1 deletion docs/verifying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions internal/mcpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func (s *Server) Serve(ctx context.Context) error {
}

func (s *Server) registerAll() {
s.registerProjectTools()
s.registerIssueTools()
s.registerStateTools()
s.registerLabelTools()
Expand Down
21 changes: 21 additions & 0 deletions internal/mcpserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions internal/mcpserver/tools_projects.go
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions internal/plane/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]}`))
Expand Down
22 changes: 22 additions & 0 deletions internal/plane/projects.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions internal/plane/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading