Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ Ordered as registered; the earlier one wraps the later:
| `CoerceArguments` | repairs arguments a client encoded as strings, before validation (`coercion.py`) |
| `ValidateActionArguments` | refuses arguments the chosen action does not accept, from the `ACTIONS` declaration |

A dispatch tool's name is not its operation, so `PlaneLoggingMiddleware` adds `resource` and `action` to every `tools/call` record — start, success and error alike, where previously only success and error carried anything. For a retired name both are resolved through the alias table, since it carries no `action` of its own.

| field | means |
|---|---|
| `tool` | the name the caller used — **unchanged**, so existing dashboards keep counting the same thing |
| `resource` | the resource tool that ran (`workitem`); equals `tool` for a current call |
| `action` | the operation (`count`), absent only for a tool that has no actions |

`resource` + `action` names one operation however it was reached, and `tool != resource` is exactly the set of calls still arriving on a retired name. `legacy.py` also keeps its per-resolution log line, which predates these fields.

Coercion runs before validation so an argument is judged by the value it repairs to. `ValidateActionArguments` closes a gap a per-tool schema cannot: every action's parameters share one schema, so an argument meant for another action validated cleanly and was then dropped, and the call answered a different question than the one asked. Only arguments carrying a value are judged, and retired names are exempt — they arrive with no `action` and under their own parameter spelling.

### Client Context (`client.py`)
Expand All @@ -70,7 +80,7 @@ Coercion runs before validation so an argument is judged by the value it repairs

### Tools (`tools/`)

One action-dispatch tool per Plane resource: **28 tools, 183 actions, ~57k chars advertised**. `tools/__init__.py` re-exports `register_tools`, so `server.py` and `__main__.py` see a single entry point.
One action-dispatch tool per Plane resource: **29 tools, 190 actions, ~59k chars advertised**. `tools/__init__.py` re-exports `register_tools`, so `server.py` and `__main__.py` see a single entry point.

One module per resource, each exporting `NAME`, `ACTIONS`, `LEGACY` and `register(mcp)`. `ACTIONS` is the single source of truth: the tool description and its `ToolAnnotations` are generated from it, and the conformance suite asserts they agree with the function signature. See `tools/README.md` for the full convention.

Expand Down Expand Up @@ -122,3 +132,4 @@ Integration tests in `tests/test_integration.py` use `FastMCP.Client` with `Stre
| `PLANE_OAUTH_PROVIDER_*` | http/sse OAuth | OAuth client credentials and base URL |
| `PLANE_OAUTH_ALLOWED_REDIRECT_URIS` | http/sse OAuth (optional) | Comma-separated redirect URI patterns appended to the built-in allowlist (onboard clients without a release) |
| `LOG_USER_INFO` | all (optional, default: false) | When `true`, include user info (PII such as display name) in logs alongside the opaque user id |
| `LOG_PAYLOADS` | all (optional, default: true) | Log request payloads.|
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ work items, cycles, modules, releases, customers and more.
Built on [FastMCP](https://github.com/jlowin/fastmcp) and the official
[`plane-sdk`](https://pypi.org/project/plane-sdk/).

- **28 tools**, one per Plane resource, covering 183 operations
- **29 tools**, one per Plane resource, covering 190 operations
- **Local or remote** — stdio, streamable HTTP, SSE
- **OAuth or API key** authentication

Expand Down Expand Up @@ -98,7 +98,7 @@ HTTP transport instead.

## Tools

The server advertises 28 tools, one per resource. Each takes an `action`
The server advertises 29 tools, one per resource. Each takes an `action`
parameter that selects the operation:

```python
Expand Down Expand Up @@ -179,7 +179,8 @@ Structured JSON. Each tool call logs its name, duration, status and — when
available — an opaque user id and the workspace slug.

```bash
export LOG_USER_INFO=true # also log the display name (PII); default false
export LOG_USER_INFO=true # also log the display name (PII);
export LOG_PAYLOADS=true # also log request paylo
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
```

Only the OAuth and PAT transports carry a display name; stdio is unaffected.
Expand Down
48 changes: 39 additions & 9 deletions plane_mcp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
from __future__ import annotations

from collections.abc import Collection
from typing import Any

from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.server.middleware.logging import StructuredLoggingMiddleware
from fastmcp.tools.tool import ToolResult
from fastmcp.utilities.logging import get_logger

from plane_mcp.coercion import coerce_arguments
from plane_mcp.tools.registry import action_arguments
from plane_mcp.tools.registry import action_arguments, alias_table

logger = get_logger(__name__)

Expand Down Expand Up @@ -91,15 +92,44 @@ async def _schema(context: MiddlewareContext) -> dict | None:


class PlaneLoggingMiddleware(StructuredLoggingMiddleware):
"""StructuredLoggingMiddleware that also records the tool name."""

def _with_tool_name(self, context: MiddlewareContext, message: dict) -> dict:
if context.method == "tools/call":
message["tool"] = getattr(context.message, "name", "unknown")
return message
"""StructuredLoggingMiddleware that records which operation ran, not just which tool.

A dispatch tool's name is not its operation -- `workitem` covers 23 of them -- so
`resource` and `action` are recorded beside it, resolved through the alias table for
a retired name, which carries no `action` of its own. `resource` + `action` then
names one operation however it was reached, and `tool != resource` is exactly the
set of calls still arriving on a retired name.

`tool` keeps its previous meaning -- the name the caller used -- so dashboards built
on it keep counting the same thing. The two additions are additive, and they are on
the start record as well, which previously carried neither.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
# Built once; per record this is a dict lookup.
self._aliases = alias_table()

def _operation(self, context: MiddlewareContext) -> dict[str, str]:
"""What the caller called, and which operation that is."""
if context.method != "tools/call":
return {}
name = getattr(context.message, "name", "unknown")
if alias := self._aliases.get(name):
resource, action = alias
else:
resource = name
action = (getattr(context.message, "arguments", None) or {}).get("action")
fields = {"tool": name, "resource": resource}
if action:
fields["action"] = action
return fields

def _create_before_message(self, context: MiddlewareContext, *args: Any, **kwargs: Any) -> dict:
return super()._create_before_message(context, *args, **kwargs) | self._operation(context)

def _create_after_message(self, context: MiddlewareContext, start_time: float) -> dict:
return self._with_tool_name(context, super()._create_after_message(context, start_time))
return super()._create_after_message(context, start_time) | self._operation(context)

def _create_error_message(self, context: MiddlewareContext, start_time: float, error: Exception) -> dict:
return self._with_tool_name(context, super()._create_error_message(context, start_time, error))
return super()._create_error_message(context, start_time, error) | self._operation(context)
5 changes: 4 additions & 1 deletion plane_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@ def get_allowed_client_redirect_uris() -> list[str]:
return allowed


LOG_PAYLOADS = os.getenv("LOG_PAYLOADS", "true").lower() == "true"


def _configured(mcp: FastMCP) -> FastMCP:
"""The middleware stack and tools every transport shares."""
mcp.add_middleware(PlaneLoggingMiddleware(include_payloads=True))
mcp.add_middleware(PlaneLoggingMiddleware(include_payloads=LOG_PAYLOADS))
mcp.add_middleware(CoerceArguments())
mcp.add_middleware(ValidateActionArguments())
register_tools(mcp)
Expand Down
7 changes: 4 additions & 3 deletions plane_mcp/tools/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# The tool surface

**28 tools**, one per Plane resource, each taking an `action` parameter that
**29 tools**, one per Plane resource, each taking an `action` parameter that
selects the operation. 183 actions in total.

```python
Expand All @@ -9,7 +9,7 @@ workitem(action="list", project_id=..., pql='state__group = "started"')
cycle(action="archive", project_id=..., cycle_id=...)
```

A compact catalogue — 28 tools, ~57k characters — loads fully in every MCP client
A compact catalogue — 29 tools, ~59k characters — loads fully in every MCP client
and leaves the context budget to the conversation.

## The shape of a resource module
Expand Down Expand Up @@ -205,13 +205,14 @@ adopt anyway if the project write is refused.
| `member` | `me` · `list_workspace` · `list_project` · `list_roles` · `retrieve_role` |
| `milestone` | `list` · `retrieve` · `create` · `update` · `delete` · `list_workitems` · `manage_workitems` |
| `module` | `list` · `retrieve` · `create` · `update` · `delete` · `list_workitems` · `manage_workitems` · `archive` · `unarchive` |
| `page` | `list` · `retrieve` · `create` · `list_workitem_pages` · `attach_to_workitem` · `detach_from_workitem` |
| `page` | `list` · `retrieve` · `create` · `update` · `archive` · `delete` · `list_workitem_pages` · `attach_to_workitem` · `detach_from_workitem` |
| `project` | `list` · `retrieve` · `create` · `update` · `delete` · `archive` · `unarchive` · `worklog_summary` · `get_features` · `update_features` |
| `project_estimate` | `retrieve` · `create` · `update` · `delete` · `link` · `list_points` · `create_points` · `update_point` · `delete_point` |
| `release` | `list` · `retrieve` · `create` · `update` · `delete` · `get_changelog` · `update_changelog` · `list_workitems` · `manage_workitems` |
| `release_label` | `list` · `create` · `update` · `delete` · `attach` · `detach` |
| `release_tag` | `list` · `retrieve` · `create` · `update` · `delete` |
| `state` | `list` · `retrieve` · `create` · `update` · `delete` |
| `template` | `list` · `create` · `update` · `delete` |
| `work_log` | `list` · `create` · `update` · `delete` |
| `workitem` | `list` · `list_archived` · `retrieve` · `retrieve_by_identifier` · `search` · `count` · `create` · `update` · `delete` · `archive` · `manage_assignee` · `manage_label` |
| `workitem_activity` | `list` · `retrieve` |
Expand Down
7 changes: 4 additions & 3 deletions plane_mcp/tools/legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
its old name has no reason to have followed that: resolving the name but
rejecting `work_item_id` would be a rename dressed up as compatibility.

Each resolution is logged, so when removing these is scheduled, "nobody still
Each resolution is logged, and every call through one records `tool` != `resource`
(see `PlaneLoggingMiddleware`), so when removing these is scheduled, "nobody still
calls them" is an observation rather than an assumption.
"""

Expand Down Expand Up @@ -41,8 +42,8 @@ async def get_tool(self, name: str, call_next: GetToolNext, *, version=None) ->
return await call_next(name, version=version)

tool_name, action = target
# Grep-able on purpose: the names appearing here over a release are the
# callers that removing these aliases would break.
# Kept alongside the `resource`/`retired` log fields: this line predates them and
# is grep-able, so removing it would break whatever already counts these.
logger.info("Plane MCP: retired tool name %r resolved to %r %r", name, tool_name, action)
parent = await call_next(tool_name, version=version)
if parent is None:
Expand Down
56 changes: 55 additions & 1 deletion plane_mcp/tools/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from typing import Any, Literal

from fastmcp import FastMCP
from plane.models.pages import CreatePage, Page
from plane.models.pages import CreatePage, Page, UpdatePage
from plane.models.query_params import PaginatedQueryParams
from plane.models.work_item_pages import CreateWorkItemPage, WorkItemPage

Expand All @@ -30,6 +30,25 @@
("name", "description_html"),
("project_id", "access", "color", "is_locked", "external_source", "external_id"),
),
Action(
"update",
("page_id",),
("project_id", "name", "description_html"),
note="pass name, description_html, or both; a locked or archived page is refused",
),
Action(
"archive",
("page_id",),
("project_id", "archive"),
note="archive defaults to true; pass archive=false to restore",
),
Action(
"delete",
("page_id",),
("project_id",),
note="requires the page to be archived first",
destructive=True,
),
Action("list_workitem_pages", ("project_id", "workitem_id"), read=True),
Action("attach_to_workitem", ("project_id", "workitem_id", "page_id")),
Action(
Expand All @@ -42,6 +61,7 @@

FOOTER = (
"description_html is the page body as HTML. access is the page access level. "
"update changes only the fields you pass. A page must be archived before it can be deleted. "
"Omit project_id to work with workspace-level pages."
)

Expand All @@ -66,6 +86,9 @@ def page(
"list",
"retrieve",
"create",
"update",
"archive",
"delete",
"list_workitem_pages",
"attach_to_workitem",
"detach_from_workitem",
Expand All @@ -80,6 +103,7 @@ def page(
access: int | None = None,
color: str = "",
is_locked: bool | None = None,
archive: bool = True,
external_source: str = "",
external_id: str = "",
cursor: str = "",
Expand All @@ -106,6 +130,36 @@ def page(
)
return client.pages.retrieve_workspace_page(workspace_slug=workspace_slug, page_id=page_id)

if action == "archive":
if not page_id:
return missing(action, "page_id")
if project_id:
mover = client.pages.archive_project_page if archive else client.pages.unarchive_project_page
mover(workspace_slug=workspace_slug, project_id=project_id, page_id=page_id)
else:
mover = client.pages.archive_workspace_page if archive else client.pages.unarchive_workspace_page
mover(workspace_slug=workspace_slug, page_id=page_id)
# Plane answers nothing, and delete depends on this having happened.
return {"page_id": page_id, "archived": archive}

if action in ("update", "delete"):
if not page_id:
return missing(action, "page_id")
scope = {"project_id": project_id} if project_id else {}
if action == "delete":
deleter = client.pages.delete_project_page if project_id else client.pages.delete_workspace_page
deleter(workspace_slug=workspace_slug, page_id=page_id, **scope)
return None
if not (name or description_html):
return missing(action, "name or description_html")
updater = client.pages.update_project_page if project_id else client.pages.update_workspace_page
return updater(
workspace_slug=workspace_slug,
page_id=page_id,
**scope,
data=UpdatePage(name=opt(name), description_html=opt(description_html)),
)

if action == "create":
if error := needs(action, name=name, description_html=description_html):
return error
Expand Down
2 changes: 2 additions & 0 deletions plane_mcp/tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
release_label,
release_tag,
state,
template,
work_log,
workitem,
workitem_activity,
Expand Down Expand Up @@ -71,6 +72,7 @@
release_label,
release_tag,
state,
template,
work_log,
workitem,
workitem_activity,
Expand Down
Loading