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
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: **30 tools, 204 actions, ~67k 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
- **30 tools**, one per Plane resource, covering 204 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 30 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=false # keep request payloads out of logs; default true
```

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
10 changes: 6 additions & 4 deletions plane_mcp/tools/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# The tool surface

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

```python
workitem(action="create", project_id=..., name="Fix login")
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 — 30 tools, ~67k 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 @@ -194,6 +194,7 @@ adopt anyway if the project write is refused.

| Tool | Actions |
|---|---|
| `collection` | `list` · `retrieve` · `create` · `update` · `delete` · `list_pages` · `search_pages` · `add_pages` · `remove_page` · `list_members` · `add_member` · `update_member` · `remove_member` |
| `customer` | `list` · `retrieve` · `create` · `update` · `delete` · `list_workitems` · `manage_workitems` |
| `customer_property` | `list` · `retrieve` · `create` · `update` · `delete` · `get_values` · `set_values` |
| `customer_request` | `list` · `retrieve` · `create` · `update` · `delete` |
Expand All @@ -205,13 +206,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` · `set_collection` · `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
Loading