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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Coercion runs before validation so an argument is judged by the value it repairs

### Tools (`tools/`)

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 action-dispatch tool per Plane resource: **30 tools, 207 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 All @@ -103,6 +103,7 @@ Shared building blocks for the tool surface, split by *when* they act:
|---|---|---|
| `spec.py` | declaration | `Action`, `build_description`, `build_annotations` |
| `runtime.py` | call | `missing`, `needs`, `require`, `one_of`, `opt`, `coerce_list`, `page_params`, `as_params`, `ids_of` |
| `mentions.py` | call | `render_mentions`, `tokenize_mentions`, `mention_user_ids`, `project_mention_error` |
| `paging.py` | response | `envelope`, `dump_results`, `pql_failure`, `workitem_page` |
| `governance.py` | policy | `workspace_owns_resource`, `GOVERNED_BY`, `workspace_owns`, `migration_in_progress`, `plan_gated` |
| `transforms.py` | listing | `StripOutputSchemas` |
Expand Down
2 changes: 1 addition & 1 deletion 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/).

- **30 tools**, one per Plane resource, covering 204 operations
- **30 tools**, one per Plane resource, covering 207 operations
- **Local or remote** — stdio, streamable HTTP, SSE
- **OAuth or API key** authentication

Expand Down
33 changes: 29 additions & 4 deletions plane_mcp/client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Plane client initialization for MCP server."""

import os
from typing import NamedTuple
from typing import Any, NamedTuple

from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.dependencies import get_access_token
Expand All @@ -18,6 +18,32 @@ class PlaneClientContext(NamedTuple):
workspace_slug: str


def _workspace_slug(token: AccessToken | None) -> str:
"""The slug a connection is bound to: its token's claim, else the environment."""
if token:
return token.claims.get("workspace_slug", "")
return os.getenv("PLANE_WORKSPACE_SLUG", "")


def _connected_via(token: AccessToken | None) -> str:
"""How a connection authenticated: its token's claim, else the environment."""
if token:
return token.claims.get("auth_method", "oauth")
return "environment"


def current_workspace() -> dict[str, Any]:
"""The workspace this connection is bound to, as far as its credentials say."""
token = get_access_token()
detail = (token.claims.get("workspace") if token else None) or {}
return {
"slug": _workspace_slug(token),
"id": detail.get("id"),
"name": detail.get("name"),
"connected_via": _connected_via(token),
}


def get_plane_client_context() -> PlaneClientContext:
"""
Initialize and return a PlaneClient instance with workspace context.
Expand All @@ -38,18 +64,17 @@ def get_plane_client_context() -> PlaneClientContext:
ConfigurationError: If access token is not available or workspace slug is missing
"""
base_url = os.getenv("PLANE_INTERNAL_BASE_URL") or os.getenv("PLANE_BASE_URL", "https://api.plane.so")
workspace_slug = os.getenv("PLANE_WORKSPACE_SLUG", "")

api_key = os.getenv("PLANE_API_KEY", "")
access_token = None

# Get access token from the OAuth provider (which handles all auth methods)
stored_access_token: AccessToken | None = get_access_token()
workspace_slug = _workspace_slug(stored_access_token)
if stored_access_token:
# Determine authentication method to use appropriate PlaneClient constructor
auth_method = stored_access_token.claims.get("auth_method", "oauth")
auth_method = _connected_via(stored_access_token)
token = stored_access_token.token
workspace_slug = stored_access_token.claims.get("workspace_slug", "")

# For API key auth methods, use api_key parameter; for OAuth, use access_token
if auth_method in ("api_key_env", "api_key_header"):
Expand Down
3 changes: 3 additions & 0 deletions plane_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
# ChatGPT connectors — per-connector callback + legacy redirect
"https://chatgpt.com/connector/oauth/*",
"https://chatgpt.com/connector_platform_oauth_redirect",
# Gemini custom apps (Spark) — the path carries a per-user, per-connector id
"https://oauth-redirect.googleusercontent.com/r/*",
"https://grok.com/connectors-oauth-exchange-code/",
]


Expand Down
1 change: 1 addition & 0 deletions plane_mcp/toolkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Shared building blocks for the tool surface, split by *when* they act. All re-ex
|---|---|---|
| `spec.py` | declaration | `Action`, `build_description`, `build_annotations` |
| `runtime.py` | call | `missing`, `needs`, `require`, `one_of`, `opt`, `coerce_list`, `page_params`, `as_params`, `ids_of` |
| `mentions.py` | call | `render_mentions`, `tokenize_mentions`, `mention_user_ids`, `project_mention_error` |
| `paging.py` | response | `envelope`, `dump_results`, `pql_failure`, `workitem_page` |
| `governance.py` | policy | `workspace_owns_resource`, `workspace_owns`, `scoped`, `plan_gated` |
| `transforms.py` | listing | `StripOutputSchemas` |
Expand Down
17 changes: 16 additions & 1 deletion plane_mcp/toolkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
Nothing here knows which catalogue is calling it, so a resource package can be
renamed without these having to move with it.

The four modules split by *when* they act:
The modules split by *when* they act:

spec declaration time -- describe a resource's actions
runtime call time -- validate parameters, shape SDK payloads
mentions call time -- render `@[uuid]` into Plane's mention markup, and back
paging response time -- Plane's pagination envelope and PQL failures
governance refusal time -- recognise a workspace-owned resource, read its flag
transforms listing time -- reshape the advertised catalogue
Expand All @@ -33,6 +34,14 @@
workspace_owns,
workspace_owns_resource,
)
from plane_mcp.toolkit.mentions import (
MENTION_TOKEN,
mention_user_ids,
project_mention_error,
render_mentions,
tokenize_mentions,
unmentionable,
)
from plane_mcp.toolkit.paging import dump_results, envelope, pql_failure, workitem_page
from plane_mcp.toolkit.runtime import (
as_params,
Expand All @@ -55,6 +64,7 @@
from plane_mcp.toolkit.transforms import StripOutputSchemas

__all__ = [
"MENTION_TOKEN",
"WORK_ITEM_TYPES",
"Action",
"StripOutputSchemas",
Expand All @@ -66,6 +76,7 @@
"dump_results",
"envelope",
"ids_of",
"mention_user_ids",
"migration_in_progress",
"missing",
"needs",
Expand All @@ -74,11 +85,15 @@
"rich_text",
"page_params",
"plan_gated",
"project_mention_error",
"plan_required",
"project_owns",
"pql_failure",
"render_mentions",
"require",
"scoped",
"tokenize_mentions",
"unmentionable",
"workspace_owns",
"workspace_owns_resource",
"workitem_page",
Expand Down
180 changes: 180 additions & 0 deletions plane_mcp/toolkit/mentions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""User mentions in Plane rich text.

Plane shows a mention as a chip, and tells the person they were mentioned, for one
exact element::

<mention-component id="…" entity_identifier="<user uuid>" entity_name="user_mention">
"""

from __future__ import annotations

import re
import uuid
from html.parser import HTMLParser
from typing import Any

MENTION_TOKEN = "@[<user uuid>]"

ELEMENT = "mention-component"
USER_MENTION = "user_mention"

_UUID = r"[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}"
_TOKEN = re.compile(rf"@\[\s*({_UUID})\s*\]")


def _canonical(value: Any) -> str | None:
"""`value` as a canonical lowercase UUID, or None when it is not one."""
try:
return str(uuid.UUID(str(value).strip()))
except (AttributeError, TypeError, ValueError):
return None


TEXT = object()
"""Marks a run of character data -- the only place a `@[uuid]` token can be written."""


class _Scan(HTMLParser):
"""A document as byte spans: mention elements, and the text between all markup."""

def __init__(self, html: str) -> None:
super().__init__(convert_charrefs=False)
self._html = html
self._line_starts = [0]
for line in html.splitlines(keepends=True):
self._line_starts.append(self._line_starts[-1] + len(line))
self.spans: list[tuple[int, int, Any]] = []
self.feed(html)
self.close()

def _at(self) -> int:
line, column = self.getpos()
return self._line_starts[line - 1] + column

def _record(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag != ELEMENT:
return
start = self._at()
text = self.get_starttag_text() or ""
self.spans.append((start, start + len(text), {k.lower(): (v or "") for k, v in attrs}))

handle_starttag = _record
handle_startendtag = _record

def handle_data(self, data: str) -> None:
start = self._at()
self.spans.append((start, start + len(data), TEXT))

def handle_endtag(self, tag: str) -> None:
if tag != ELEMENT:
return
element = next((i for i in reversed(range(len(self.spans))) if self.spans[i][2] is not TEXT), None)
if element is None:
return
start, end, attrs = self.spans[element]
close = self._at()
if self._html[end:close].strip():
return
self.spans[element] = (start, self._html.index(">", close) + 1, attrs)
del self.spans[element + 1 :]


def _mentioned_user(attrs: dict[str, str]) -> str | None:
"""The user this element addresses, or None if it addresses something else."""
if attrs.get("entity_name") != USER_MENTION:
return None
return _canonical(attrs.get("entity_identifier"))


def _element(user_id: str) -> str:
return (
f'<mention-component id="{uuid.uuid4()}" entity_identifier="{user_id}" '
f'entity_name="{USER_MENTION}"></mention-component>'
)


def _segments(html: str):
"""`html` as `(raw, kind)` pieces, in order, covering every byte exactly once.

`kind` is `TEXT` for character data, a dict of attributes for a mention element,
and None for markup neither of those -- which is passed through verbatim.
"""
cursor = 0
for start, end, kind in _Scan(html).spans:
if start > cursor:
yield html[cursor:start], None
yield html[start:end], kind
cursor = end
if cursor < len(html):
yield html[cursor:], None


def mention_user_ids(html: str | None) -> list[str]:
"""Every user id `html` addresses, by token or by element, first occurrence first."""
if not html:
return []
found: dict[str, None] = {}
for raw, kind in _segments(html):
if kind is TEXT:
found.update(dict.fromkeys(filter(None, (_canonical(m[1]) for m in _TOKEN.finditer(raw)))))
elif isinstance(kind, dict) and (user_id := _mentioned_user(kind)):
found.setdefault(user_id, None)
return list(found)


def unnamed_mentions(html: str | None) -> bool:
"""True if `html` holds a mention element with no `entity_name`."""
return bool(html) and any(isinstance(kind, dict) and not kind.get("entity_name") for _, kind in _segments(html))


def render_mentions(html: str) -> str:
"""Turn every `@[uuid]` token into a mention element, leaving all else alone."""
if not html:
return html
out = []
for raw, kind in _segments(html):
if kind is TEXT:
out.append(_TOKEN.sub(lambda m: _element(_canonical(m[1]) or ""), raw))
elif isinstance(kind, dict) and (user_id := _mentioned_user(kind)):
out.append(_element(user_id))
else:
out.append(raw)
return "".join(out)


def tokenize_mentions(html: str | None) -> str | None:
"""The reverse of `render_mentions`: user mention elements become `@[uuid]` tokens."""
if not html:
return html
return "".join(
f"@[{user_id}]" if isinstance(kind, dict) and (user_id := _mentioned_user(kind)) else raw
for raw, kind in _segments(html)
)


def unmentionable(html: str | None, may_be_mentioned: set[str]) -> list[str]:
"""Ids `html` addresses that are not in `may_be_mentioned`, in the order written."""
return [user_id for user_id in mention_user_ids(html) if user_id not in may_be_mentioned]


def project_mention_error(client: Any, workspace_slug: str, project_id: str, html: str | None) -> str | None:
"""Why `html` cannot be posted to this project, or None when it can."""
if unnamed_mentions(html):
return (
f"Error: a <{ELEMENT}> here has no entity_name, so Plane would show a chip and tell "
f"nobody. Nothing was written. Write {MENTION_TOKEN} instead and the element is "
"generated for you."
)
if not mention_user_ids(html):
return None
members = client.projects.get_members(workspace_slug=workspace_slug, project_id=project_id)
# A bot is a legitimate target, as it is in Plane's own mention picker.
active = {member.id for member in members if member.is_active is not False}
if unknown := unmentionable(html, active):
return (
f"Error: {', '.join(unknown)} cannot be mentioned -- not an active member of this "
"project, so Plane would show the chip and tell nobody. Nothing was written. Resolve "
f"the person with `member list_project`, then re-send with {MENTION_TOKEN} for the id "
"it gives, or drop the mention."
)
return None
6 changes: 3 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

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

```python
workitem(action="create", project_id=..., name="Fix login")
Expand Down Expand Up @@ -97,7 +97,7 @@ Where the workspace owns a resource outright, both directions of wrong-scope wri
| `customer_request` | `list` · `retrieve` · `create` · `update` · `delete` |
| `cycle` | `list` · `retrieve` · `create` · `update` · `delete` · `list_workitems` · `manage_workitems` · `transfer_workitems` · `complete` · `archive` · `unarchive` |
| `get_pql_reference` | *(no action parameter)* |
| `initiative` | `list` · `retrieve` · `create` · `update` · `delete` · `list_projects` · `add_projects` · `remove_projects` |
| `initiative` | `list` · `retrieve` · `create` · `update` · `delete` · `list_projects` · `add_projects` · `remove_projects` · `list_workitems` · `manage_workitems` |
| `intake` | `list` · `retrieve` · `create` · `update` · `delete` |
| `label` | `list` · `retrieve` · `create` · `update` · `delete` |
| `member` | `me` · `list_workspace` · `list_project` · `list_roles` · `retrieve_role` |
Expand All @@ -120,7 +120,7 @@ Where the workspace owns a resource outright, both directions of wrong-scope wri
| `workitem_property` | `list` · `retrieve` · `create` · `update` · `delete` · `manage_type_properties` · `list_options` · `retrieve_option` · `create_option` · `update_option` · `delete_option` · `get_value` · `set_value` · `delete_value` |
| `workitem_relation` | `list` · `create` · `delete` · `list_definitions` · `create_definition` · `update_definition` · `delete_definition` |
| `workitem_type` | `list` · `retrieve` · `resolve` · `create` · `update` · `delete` · `import_to_project` |
| `workspace` | `get_features` · `update_features` |
| `workspace` | `retrieve` · `get_features` · `update_features` |

Every tool's own description lists its actions with their required and optional
parameters; that description is generated from `ACTIONS` and is the authoritative
Expand Down
Loading