diff --git a/CLAUDE.md b/CLAUDE.md index b165763..50a92b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) @@ -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. @@ -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.| diff --git a/README.md b/README.md index 1b069f6..d07ff43 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. diff --git a/plane_mcp/middleware.py b/plane_mcp/middleware.py index 7cece18..35605a3 100644 --- a/plane_mcp/middleware.py +++ b/plane_mcp/middleware.py @@ -3,6 +3,7 @@ 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 @@ -10,7 +11,7 @@ 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__) @@ -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) diff --git a/plane_mcp/server.py b/plane_mcp/server.py index 91cbd49..2ccc2cc 100644 --- a/plane_mcp/server.py +++ b/plane_mcp/server.py @@ -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) diff --git a/plane_mcp/tools/README.md b/plane_mcp/tools/README.md index eb57f06..27e08e4 100644 --- a/plane_mcp/tools/README.md +++ b/plane_mcp/tools/README.md @@ -1,7 +1,7 @@ # 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") @@ -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 — 30 tools, ~67k characters — loads fully in every MCP client and leaves the context budget to the conversation. ## The shape of a resource module @@ -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` | @@ -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` | diff --git a/plane_mcp/tools/collection.py b/plane_mcp/tools/collection.py new file mode 100644 index 0000000..1c71b7d --- /dev/null +++ b/plane_mcp/tools/collection.py @@ -0,0 +1,243 @@ +"""Collections: the workspace-level folders that group pages.""" + +from __future__ import annotations + +from typing import Any, Literal + +from fastmcp import FastMCP +from plane.models.collections import ( + AddCollectionPages, + Collection, + CollectionMember, + CollectionPage, + CollectionPageSearchResult, + CreateCollection, + CreateCollectionMember, + UpdateCollection, + UpdateCollectionMember, +) +from plane.models.query_params import CollectionPageQueryParams + +from plane_mcp.client import get_plane_client_context +from plane_mcp.toolkit import ( + Action, + as_params, + build_annotations, + build_description, + coerce_list, + envelope, + missing, + needs, + one_of, + opt, +) + +NAME = "collection" +TITLE = "Page collections" + +# 0 is a real level in both, so neither can use the 0 sentinel, and they are +# different scales -- reading one as the other silently grants or denies access. +ACCESS = {"public": 0, "private": 1} +MEMBER_ACCESS = {"view": 0, "comment": 1, "edit": 2} + +ACTIONS = ( + Action("list", (), read=True), + Action("retrieve", ("collection_id",), read=True), + Action("create", ("name",), ("access",), note="access is fixed at creation and cannot be changed afterwards"), + Action("update", ("collection_id",), ("name", "sort_order"), note="only the fields you pass are changed"), + Action( + "delete", + ("collection_id",), + ("archive_pages",), + note="the pages survive; archive_pages defaults to true, pass false to leave them unfiled instead", + destructive=True, + ), + Action("list_pages", ("collection_id",), ("search", "parent_id", "cursor", "per_page"), read=True), + Action( + "search_pages", + ("collection_id",), + ("search",), + note="pages not yet in this collection, to pick ids for add_pages", + read=True, + ), + Action("add_pages", ("collection_id", "page_ids"), note="files existing pages; use page create to make new ones"), + Action( + "remove_page", + ("collection_id", "page_collection_id"), + note="page_collection_id is the membership id from list_pages, not the page id; the page itself is kept", + ), + Action("list_members", ("collection_id",), read=True), + Action("add_member", ("collection_id", "user_id", "member_access")), + Action("update_member", ("collection_id", "collection_member_id", "member_access")), + Action( + "remove_member", + ("collection_id", "collection_member_id"), + note="collection_member_id is the membership id from list_members, not the user id", + ), +) + +FOOTER = ( + f"access is one of: {', '.join(ACCESS)} -- a private collection is visible only to its members, " + "and the level cannot be changed once the collection exists. " + f"member_access is one of: {', '.join(MEMBER_ACCESS)} and is a separate scale from access. " + "Collections group workspace pages only; a project's pages cannot be filed in one. " + "To file or move a page, use `page set_collection` -- it works out which collection holds the " + "page for you, so it needs no membership id." +) + +# A new resource: nothing was ever advertised under another name. +LEGACY: dict[str, str] = {} + + +def register(mcp: FastMCP) -> None: + @mcp.tool( + name=NAME, + description=build_description("Collections grouping workspace pages.", ACTIONS, FOOTER), + annotations=build_annotations(TITLE, ACTIONS), + ) + def collection( + action: Literal[ + "list", + "retrieve", + "create", + "update", + "delete", + "list_pages", + "search_pages", + "add_pages", + "remove_page", + "list_members", + "add_member", + "update_member", + "remove_member", + ], + collection_id: str = "", + name: str = "", + access: str = "", + member_access: str = "", + user_id: str = "", + collection_member_id: str = "", + page_ids: str = "", + page_collection_id: str = "", + parent_id: str = "", + search: str = "", + # Tri-state: the server's own default is true, which is not the same as unset. + archive_pages: bool | None = None, + # 0 is a real sort position, so it cannot use the 0 sentinel. + sort_order: float | None = None, + cursor: str = "", + per_page: int = 0, + ) -> ( + Collection + | list[Collection] + | CollectionMember + | list[CollectionMember] + | CollectionPage + | list[CollectionPage] + | list[CollectionPageSearchResult] + | dict[str, Any] + | str + | None + ): + client, workspace_slug = get_plane_client_context() + collections = client.collections + + if error := one_of("access", access, tuple(ACCESS)): + return error + if error := one_of("member_access", member_access, tuple(MEMBER_ACCESS)): + return error + + if action == "list": + return collections.list(workspace_slug=workspace_slug) + + if action == "create": + if not name: + return missing(action, "name") + return collections.create( + workspace_slug=workspace_slug, + data=CreateCollection(name=name, access=ACCESS.get(access)), + ) + + if not collection_id: + return missing(action, "collection_id") + + if action == "retrieve": + return collections.retrieve(workspace_slug=workspace_slug, collection_id=collection_id) + + if action == "update": + return collections.update( + workspace_slug=workspace_slug, + collection_id=collection_id, + data=UpdateCollection(name=opt(name), sort_order=sort_order), + ) + + if action == "delete": + collections.delete(workspace_slug=workspace_slug, collection_id=collection_id, archive_pages=archive_pages) + return None + + if action == "list_pages": + response = collections.pages.list( + workspace_slug=workspace_slug, + collection_id=collection_id, + params=as_params( + CollectionPageQueryParams, + search=search, + parent_id=parent_id, + cursor=cursor, + per_page=per_page, + ), + ) + return envelope(response) + + if action == "search_pages": + return collections.pages.search( + workspace_slug=workspace_slug, collection_id=collection_id, search=opt(search) + ) + + if action == "add_pages": + ids = coerce_list(page_ids) + if not ids: + return missing(action, "page_ids") + return collections.pages.add( + workspace_slug=workspace_slug, collection_id=collection_id, data=AddCollectionPages(page_ids=ids) + ) + + if action == "remove_page": + if not page_collection_id: + return missing(action, "page_collection_id") + collections.pages.remove( + workspace_slug=workspace_slug, + collection_id=collection_id, + page_collection_id=page_collection_id, + ) + return None + + if action == "list_members": + return collections.members.list(workspace_slug=workspace_slug, collection_id=collection_id) + + if action == "add_member": + if error := needs(action, user_id=user_id, member_access=member_access): + return error + return collections.members.add( + workspace_slug=workspace_slug, + collection_id=collection_id, + data=CreateCollectionMember(member=user_id, access=MEMBER_ACCESS[member_access]), + ) + + if not collection_member_id: + return missing(action, "collection_member_id") + + if action == "update_member": + if not member_access: + return missing(action, "member_access") + return collections.members.update( + workspace_slug=workspace_slug, + collection_id=collection_id, + member_id=collection_member_id, + data=UpdateCollectionMember(access=MEMBER_ACCESS[member_access]), + ) + + collections.members.remove( + workspace_slug=workspace_slug, collection_id=collection_id, member_id=collection_member_id + ) + return None diff --git a/plane_mcp/tools/legacy.py b/plane_mcp/tools/legacy.py index 1524c44..f9e750c 100644 --- a/plane_mcp/tools/legacy.py +++ b/plane_mcp/tools/legacy.py @@ -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. """ @@ -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: diff --git a/plane_mcp/tools/page.py b/plane_mcp/tools/page.py index 2eb09d0..e4f3581 100644 --- a/plane_mcp/tools/page.py +++ b/plane_mcp/tools/page.py @@ -1,4 +1,4 @@ -"""Pages, at workspace or project scope, and their links to work items. +"""Pages, at workspace or project scope, their hierarchy, and their links to work items. Every page action is scoped by whether project_id is supplied: with it the page is a project page, without it a workspace page. The SDK has a separate endpoint @@ -10,7 +10,8 @@ from typing import Any, Literal from fastmcp import FastMCP -from plane.models.pages import CreatePage, Page +from plane.models.collections import AddCollectionPages, UpdateCollectionPage +from plane.models.pages import CreatePage, Page, UpdatePage from plane.models.query_params import PaginatedQueryParams from plane.models.work_item_pages import CreateWorkItemPage, WorkItemPage @@ -28,7 +29,44 @@ Action( "create", ("name", "description_html"), - ("project_id", "access", "color", "is_locked", "external_source", "external_id"), + ( + "project_id", + "parent_id", + "collection_id", + "access", + "color", + "is_locked", + "external_source", + "external_id", + ), + note="parent_id nests the new page under an existing one; collection_id files it. " + "Pass one or the other, never both", + ), + Action( + "update", + ("page_id",), + ("project_id", "name", "description_html"), + note="pass name, description_html, or both; description_html replaces the whole body, " + "so retrieve the page first when editing part of it; 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( + "set_collection", + ("page_id", "collection_id"), + note="files a page into a collection, or moves it out of the one it is in; workspace pages only, " + "and collection_id comes from the collection tool", ), Action("list_workitem_pages", ("project_id", "workitem_id"), read=True), Action("attach_to_workitem", ("project_id", "workitem_id", "page_id")), @@ -42,7 +80,13 @@ FOOTER = ( "description_html is the page body as HTML. access is the page access level. " - "Omit project_id to work with workspace-level pages." + "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. " + "A page's parent is fixed at creation -- pass parent_id to create to build a hierarchy, since " + "nothing can reparent it afterwards. list and retrieve both report a page's parent_id and the " + "collection_id it is filed in, so neither needs looking up. " + "Collections themselves live in the collection tool; here, create files a new page into one and " + "set_collection files or moves an existing page." ) LEGACY = { @@ -66,12 +110,18 @@ def page( "list", "retrieve", "create", + "update", + "archive", + "delete", + "set_collection", "list_workitem_pages", "attach_to_workitem", "detach_from_workitem", ], project_id: str = "", page_id: str = "", + parent_id: str = "", + collection_id: str = "", workitem_id: str = "", workitem_page_id: str = "", name: str = "", @@ -80,6 +130,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 = "", @@ -106,15 +157,51 @@ 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 + if parent_id and collection_id: + return "Error: pass parent_id or collection_id, not both. A nested page takes its parent's collection." + if collection_id and project_id: + return "Error: collections hold workspace pages only. Omit project_id, or omit collection_id." data = CreatePage( name=name, description_html=description_html, access=access, color=opt(color), is_locked=is_locked, + parent_id=opt(parent_id), + collection_id=opt(collection_id), external_id=opt(external_id), external_source=opt(external_source), ) @@ -122,6 +209,37 @@ def page( return client.pages.create_project_page(workspace_slug=workspace_slug, project_id=project_id, data=data) return client.pages.create_workspace_page(workspace_slug=workspace_slug, data=data) + if action == "set_collection": + if error := needs(action, page_id=page_id, collection_id=collection_id): + return error + + filed = client.pages.retrieve_workspace_page(workspace_slug=workspace_slug, page_id=page_id) + + if not filed.collection_id: + added = client.collections.pages.add( + workspace_slug=workspace_slug, + collection_id=collection_id, + data=AddCollectionPages(page_ids=[page_id]), + ) + if not added: + return None + membership_id = added[0].id + elif str(filed.collection_id) == collection_id: + membership_id = filed.page_collection_id + else: + membership_id = client.collections.pages.update( + workspace_slug=workspace_slug, + collection_id=str(filed.collection_id), + page_collection_id=str(filed.page_collection_id), + data=UpdateCollectionPage(collection=collection_id), + ).id + + return { + "page_id": page_id, + "collection_id": collection_id, + "page_collection_id": str(membership_id), + } + if error := needs(action, project_id=project_id, workitem_id=workitem_id): return error diff --git a/plane_mcp/tools/registry.py b/plane_mcp/tools/registry.py index e320247..aa96c62 100644 --- a/plane_mcp/tools/registry.py +++ b/plane_mcp/tools/registry.py @@ -17,6 +17,7 @@ from types import ModuleType from plane_mcp.tools import ( + collection, customer, customer_property, customer_request, @@ -35,6 +36,7 @@ release_label, release_tag, state, + template, work_log, workitem, workitem_activity, @@ -47,11 +49,6 @@ workspace, ) -# Order is load-bearing: tool definitions sit at the front of a client's prompt -# cache, so reordering them invalidates the whole conversation. Append new -# resources; do not re-sort. `test_resource_order_is_pinned` holds this to a -# literal list, so any change shows up as a diff rather than as a silent -# cache-buster. RESOURCES: tuple[ModuleType, ...] = ( customer, customer_property, @@ -81,6 +78,8 @@ workitem_relation, workitem_type, workspace, + template, + collection, ) diff --git a/plane_mcp/tools/template.py b/plane_mcp/tools/template.py new file mode 100644 index 0000000..5bbdeae --- /dev/null +++ b/plane_mcp/tools/template.py @@ -0,0 +1,207 @@ +"""Templates: the reusable shape of a work item, a page, or a project. + +`kind` says what is templated, project_id where it lives -- with it a project +template, without it a workspace one. A project kind exists only at workspace scope, +since a project template is what creates projects. `template_data` carries the body +being templated; everything else on the tool is the template's own metadata. + +Listing without project_id is the wider view, not the other half: for a workitem or +page kind it returns a project's templates too, marked by a non-null `project`. + +Governance refuses project-scoped writes where the workspace owns its templates. +Reads are never refused, so a project still lists what it already has. +""" + +from __future__ import annotations + +import json +from typing import Any, Literal + +from fastmcp import FastMCP +from plane.errors.errors import HttpError +from plane.models import project_templates as project_models +from plane.models import workspace_templates as workspace_models + +from plane_mcp.client import get_plane_client_context +from plane_mcp.toolkit import ( + Action, + build_annotations, + build_description, + missing, + needs, + one_of, + opt, + workspace_owns, +) + +NAME = "template" +TITLE = "Templates" + +KINDS = ("workitem", "page", "project") + +ACTIONS = ( + Action("list", ("kind",), ("project_id",), read=True), + Action("create", ("kind", "name", "template_data"), ("project_id", "description")), + Action( + "update", + ("kind", "template_id"), + ("project_id", "name", "description", "template_data"), + note="only the fields you pass are changed", + ), + Action("delete", ("kind", "template_id"), ("project_id",), destructive=True), +) + +FOOTER = ( + f"kind is one of: {', '.join(KINDS)}. project_id chooses the scope a write lands in; omit it " + "for the workspace, which is also the only scope a project kind can live at. " + "For a workitem or page kind, listing without project_id is the wider view: it returns a " + "project's templates as well as the workspace's own, told apart by a non-null project. " + "Pass project_id to list one project's alone. " + "template_data is a JSON object holding what gets templated -- for a work item template, " + 'the fields a work item created from it starts with, such as {"name": "Spec", ' + '"description_html": "

Context / Acceptance criteria

", "priority": "medium"}. ' + "update merges it into what is there rather than replacing it, so pass only what changes. " + "description is the template's own summary, not the templated body." +) + +# Templates are new to this surface, so no retired name maps onto them. +LEGACY: dict[str, str] = {} + +OWNED_BY_WORKSPACE = ( + "Error: this workspace owns its templates, so a project's own are read-only -- listing them " + "still works, but they cannot be created, changed or deleted. Omit project_id to write at " + "the workspace instead." +) + +_WORKSPACE = { + "workitem": (workspace_models.CreateWorkItemTemplate, workspace_models.UpdateWorkItemTemplate), + "page": (workspace_models.CreatePageTemplate, workspace_models.UpdatePageTemplate), + "project": (workspace_models.CreateProjectTemplate, workspace_models.UpdateProjectTemplate), +} + +_PROJECT = { + "workitem": (project_models.CreateWorkItemTemplate, project_models.UpdateWorkItemTemplate), + "page": (project_models.CreatePageTemplate, project_models.UpdatePageTemplate), +} + + +def _scope_of(client: Any, kind: str, project_id: str) -> tuple[Any, dict[str, Any], tuple[Any, Any]] | None: + """The namespace, scope kwargs and payload models that kind and project_id select. + + None when the pair has no endpoint, which is only a project template asked for + inside a project. + """ + if project_id: + if kind not in _PROJECT: + return None + namespace = { + "workitem": client.project_templates.work_item_templates, + "page": client.project_templates.page_templates, + }[kind] + return namespace, {"project_id": project_id}, _PROJECT[kind] + namespace = { + "workitem": client.workspace_templates.work_items, + "page": client.workspace_templates.pages, + "project": client.workspace_templates.projects, + }[kind] + return namespace, {}, _WORKSPACE[kind] + + +def _body(raw: str) -> dict[str, Any] | None: + """Parse template_data, raising ValueError with a correctable message.""" + if not raw: + return None + try: + parsed = json.loads(raw) + except ValueError as exc: + raise ValueError(f"template_data must be a JSON object; it is not valid JSON ({exc})") from exc + if not isinstance(parsed, dict): + raise ValueError(f"template_data must be a JSON object; got {type(parsed).__name__}") + return parsed + + +def _payload(model: Any, name: str, description: str, body: dict[str, Any] | None) -> Any: + """Build a create or update payload for whichever scope's model this is. + + The two scopes name the summary field differently -- `description_html` at the + workspace, `short_description` in a project -- so it is set by whichever the + model actually declares rather than by branching on scope again. + """ + fields: dict[str, Any] = {"name": opt(name), "template_data": body} + if description: + summary = "description_html" if "description_html" in model.model_fields else "short_description" + fields[summary] = description + return model(**{key: value for key, value in fields.items() if value is not None}) + + +def register(mcp: FastMCP) -> None: + @mcp.tool( + name=NAME, + description=build_description("Reusable templates for work items, pages and projects.", ACTIONS, FOOTER), + annotations=build_annotations(TITLE, ACTIONS), + ) + def template( + action: Literal["list", "create", "update", "delete"], + kind: str = "", + project_id: str = "", + template_id: str = "", + name: str = "", + description: str = "", + template_data: str = "", + ) -> Any: + client, workspace_slug = get_plane_client_context() + + if not kind: + return missing(action, "kind") + if error := one_of("kind", kind, KINDS): + return error + + scope = _scope_of(client, kind, project_id) + if scope is None: + return ( + "Error: a project template lives at the workspace, not inside a project. " + "Omit project_id to work with it." + ) + namespace, target, (create_model, update_model) = scope + + if action == "list": + return namespace.list(workspace_slug=workspace_slug, **target) + + try: + body = _body(template_data) + except ValueError as exc: + return f"Error: {exc}." + + try: + if action == "create": + if error := needs(action, name=name, template_data=template_data): + return error + return namespace.create( + workspace_slug=workspace_slug, + **target, + data=_payload(create_model, name, description, body), + ) + + if not template_id: + return missing(action, "template_id") + + if action == "update": + if body == {}: + return "Error: template_data must contain at least one field." + if not (name or description or body): + return missing(action, "name, description or template_data") + return namespace.update( + workspace_slug=workspace_slug, + **target, + template_id=template_id, + data=_payload(update_model, name, description, body), + ) + + namespace.delete(workspace_slug=workspace_slug, **target, template_id=template_id) + return None + except HttpError as exc: + # Only a project-scoped write draws this, so the answer is the same for + # all three: the workspace owns them, write there instead. + if workspace_owns(exc): + return OWNED_BY_WORKSPACE + raise diff --git a/pyproject.toml b/pyproject.toml index e5c59f0..9fee8cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "plane-mcp-server" -version = "0.3.0" +version = "0.3.1" description = "A Model Context Protocol server for Plane integration" readme = "README.md" requires-python = ">=3.10" @@ -13,7 +13,7 @@ keywords = ["mcp", "plane", "fastmcp", "ai", "automation"] dependencies = [ "fastmcp==3.2.0", - "plane-sdk==0.2.20", + "plane-sdk==0.2.23", "py-key-value-aio[redis]>=0.4.4,<0.5.0", "mcp==1.26.0", "PyJWT>=2.12.0", diff --git a/tests/tools/test_conformance.py b/tests/tools/test_conformance.py index 5503eaf..9f0bd45 100644 --- a/tests/tools/test_conformance.py +++ b/tests/tools/test_conformance.py @@ -62,6 +62,9 @@ def _module_ids(mods): "workitem_relation", "workitem_type", "workspace", + # Appended, not sorted in: see registry.py. + "template", + "collection", ] @@ -193,9 +196,16 @@ def test_tool_names_leave_room_for_client_prefixes(listing): def test_listing_is_deterministic(registered): - """Tool definitions head a client's prompt cache; reordering invalidates it.""" - names = list(registered) - assert names == sorted(names) + """Tool definitions head a client's prompt cache; reordering invalidates it. + + Pinned to `CATALOGUE` rather than asserted `sorted()`. Alphabetical order was an + artefact of the `pkgutil` scan this package used to do over sorted filenames; kept + as an assertion afterwards, it forced every new resource into the middle of the + listing, shifting every tool below it and costing the cached prefix each time. + `test_resource_order_is_pinned` checks the same order at the module level, so a + mismatch between the two means registration dropped or reordered a resource. + """ + assert list(registered) == CATALOGUE def test_tool_count_is_within_client_caps(listing): diff --git a/tests/tools/test_dispatch.py b/tests/tools/test_dispatch.py index 008c4b8..f68c3ca 100644 --- a/tests/tools/test_dispatch.py +++ b/tests/tools/test_dispatch.py @@ -26,15 +26,22 @@ "group": "started", "relation_type": "blocked_by", "property_type": "TEXT", - "access": 1, + # page and project take the numeric level; collection names it. + "access": {"page": 1, "project": 1, "collection": "private"}, + "member_access": "edit", "network": 2, "timezone": "UTC", "workitem_identifier": "ENG-42", + "kind": "workitem", + "template_data": '{"name": "Spec"}', } # Actions that require *one of* several optional parameters -- a condition the # declaration cannot express, so the case is spelled out here. CONDITIONAL: dict[tuple[str, str], dict[str, object]] = { + # An update has to carry a field to change; page_id alone is refused. + ("page", "update"): {"name": "Renamed"}, + ("template", "update"): {"name": "Renamed"}, ("cycle", "manage_workitems"): {"add_ids": "id-1"}, ("module", "manage_workitems"): {"add_ids": "id-1"}, ("milestone", "manage_workitems"): {"add_ids": "id-1"}, diff --git a/tests/tools/test_governance.py b/tests/tools/test_governance.py index 3c59691..1e5521e 100644 --- a/tests/tools/test_governance.py +++ b/tests/tools/test_governance.py @@ -252,6 +252,7 @@ def test_the_feature_toggles_the_sdk_offers_are_all_reachable(): missing_flags = set(ProjectFeature.model_fields) - declared assert not missing_flags, f"ProjectFeature flags with no way to set them: {sorted(missing_flags)}" + PROPERTY_REFUSAL = HttpError( "Bad Request", status_code=400, response={"error": "This resource is managed at the workspace level"} ) diff --git a/uv.lock b/uv.lock index 4605575..1bec8a0 100644 --- a/uv.lock +++ b/uv.lock @@ -894,7 +894,7 @@ wheels = [ [[package]] name = "plane-mcp-server" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "authlib" }, @@ -920,7 +920,7 @@ requires-dist = [ { name = "fakeredis", extras = ["lua"], specifier = ">=2.32.1,<2.35.0" }, { name = "fastmcp", specifier = "==3.2.0" }, { name = "mcp", specifier = "==1.26.0" }, - { name = "plane-sdk", specifier = "==0.2.20" }, + { name = "plane-sdk", directory = "/Users/akhilvamshikonam/Documents/plane-python-sdk" }, { name = "py-key-value-aio", extras = ["redis"], specifier = ">=0.4.4,<0.5.0" }, { name = "pyjwt", specifier = ">=2.12.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, @@ -930,16 +930,21 @@ provides-extras = ["dev"] [[package]] name = "plane-sdk" -version = "0.2.20" -source = { registry = "https://pypi.org/simple" } +version = "0.2.23" +source = { directory = "/Users/akhilvamshikonam/Documents/plane-python-sdk" } dependencies = [ { name = "pydantic" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/d8/d9465cdf001aac5988cde173af812b298c85afb8550682fad11ba897112f/plane_sdk-0.2.20.tar.gz", hash = "sha256:d4559e00281be200e386bd322e53dbaafd9f1968bd528bb555f6eda2115518b4", size = 86662, upload-time = "2026-07-20T16:57:41.599Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/8b/c2c52c8c632947e1e12c5612be31bd6180bb6c02046bceb66991f44545ab/plane_sdk-0.2.20-py3-none-any.whl", hash = "sha256:b0a55fec3140025761e8a6c3766b2eed9bea4828ae98c45e3df335cffd1df774", size = 123900, upload-time = "2026-07-20T16:57:40.32Z" }, + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.4.0" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-dependency", marker = "extra == 'dev'" }, + { name = "requests", specifier = ">=2.31.0" }, ] +provides-extras = ["dev"] [[package]] name = "platformdirs"