From ce9b067590e7369d0cfda09d2ac53825733aebd3 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Sat, 15 Aug 2026 23:43:21 +0530 Subject: [PATCH 1/9] feat: enhance logging middleware and add template management tools --- CLAUDE.md | 13 ++- README.md | 5 +- plane_mcp/middleware.py | 48 +++++++-- plane_mcp/server.py | 5 +- plane_mcp/tools/README.md | 7 +- plane_mcp/tools/legacy.py | 7 +- plane_mcp/tools/page.py | 56 +++++++++- plane_mcp/tools/registry.py | 2 + plane_mcp/tools/template.py | 183 ++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/tools/test_conformance.py | 1 + tests/tools/test_dispatch.py | 5 + tests/tools/test_governance.py | 1 + uv.lock | 17 +-- 14 files changed, 325 insertions(+), 27 deletions(-) create mode 100644 plane_mcp/tools/template.py diff --git a/CLAUDE.md b/CLAUDE.md index b165763..ac746c8 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: **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. @@ -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: false) | When `true`, log request payloads. These carry customer content — work item descriptions, comment bodies, page text — so it is off unless asked for | diff --git a/README.md b/README.md index 1b069f6..bcf0f39 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 +- **29 tools**, one per Plane resource, covering 190 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 29 tools, one per resource. Each takes an `action` parameter that selects the operation: ```python @@ -180,6 +180,7 @@ 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_PAYLOADS=true # also log request payloads ``` 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..703011b 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", "false").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..c6d0834 100644 --- a/plane_mcp/tools/README.md +++ b/plane_mcp/tools/README.md @@ -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 @@ -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 @@ -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` | 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..fae246a 100644 --- a/plane_mcp/tools/page.py +++ b/plane_mcp/tools/page.py @@ -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 @@ -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( @@ -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." ) @@ -66,6 +86,9 @@ def page( "list", "retrieve", "create", + "update", + "archive", + "delete", "list_workitem_pages", "attach_to_workitem", "detach_from_workitem", @@ -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 = "", @@ -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 diff --git a/plane_mcp/tools/registry.py b/plane_mcp/tools/registry.py index e320247..f5b900d 100644 --- a/plane_mcp/tools/registry.py +++ b/plane_mcp/tools/registry.py @@ -35,6 +35,7 @@ release_label, release_tag, state, + template, work_log, workitem, workitem_activity, @@ -71,6 +72,7 @@ release_label, release_tag, state, + template, work_log, workitem, workitem_activity, diff --git a/plane_mcp/tools/template.py b/plane_mcp/tools/template.py new file mode 100644 index 0000000..a694f36 --- /dev/null +++ b/plane_mcp/tools/template.py @@ -0,0 +1,183 @@ +"""Templates: the reusable shape of a work item, a page, or a project. + +Two things pick the endpoint. `kind` says what is being templated, and project_id +says 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 (the work item's own fields, for +instance); everything else on the tool is the template's own metadata. +""" + +from __future__ import annotations + +import json +from typing import Any, Literal + +from fastmcp import FastMCP +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, +) + +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)}. Omit project_id for the workspace's own templates, " + "which is also the only scope a project template can live at. " + "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"}. ' + "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] = {} + +_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}." + + 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 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 diff --git a/pyproject.toml b/pyproject.toml index e5c59f0..61e12dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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..930a694 100644 --- a/tests/tools/test_conformance.py +++ b/tests/tools/test_conformance.py @@ -52,6 +52,7 @@ def _module_ids(mods): "release_label", "release_tag", "state", + "template", "work_log", "workitem", "workitem_activity", diff --git a/tests/tools/test_dispatch.py b/tests/tools/test_dispatch.py index 008c4b8..30c0a2e 100644 --- a/tests/tools/test_dispatch.py +++ b/tests/tools/test_dispatch.py @@ -30,11 +30,16 @@ "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..08fb48d 100644 --- a/uv.lock +++ b/uv.lock @@ -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" From 608ca392247b08b4953964e184d1f8eef9b14d29 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Sun, 16 Aug 2026 00:09:07 +0530 Subject: [PATCH 2/9] chore: update logging configuration and bump version to 0.3.1 --- CLAUDE.md | 2 +- README.md | 4 ++-- plane_mcp/server.py | 2 +- pyproject.toml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac746c8..f15acde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,4 +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: false) | When `true`, log request payloads. These carry customer content — work item descriptions, comment bodies, page text — so it is off unless asked for | +| `LOG_PAYLOADS` | all (optional, default: true) | Log request payloads.| diff --git a/README.md b/README.md index bcf0f39..8fe49d4 100644 --- a/README.md +++ b/README.md @@ -179,8 +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_PAYLOADS=true # also log request payloads +export LOG_USER_INFO=true # also log the display name (PII); +export LOG_PAYLOADS=true # also log request paylo ``` Only the OAuth and PAT transports carry a display name; stdio is unaffected. diff --git a/plane_mcp/server.py b/plane_mcp/server.py index 703011b..2ccc2cc 100644 --- a/plane_mcp/server.py +++ b/plane_mcp/server.py @@ -48,7 +48,7 @@ def get_allowed_client_redirect_uris() -> list[str]: return allowed -LOG_PAYLOADS = os.getenv("LOG_PAYLOADS", "false").lower() == "true" +LOG_PAYLOADS = os.getenv("LOG_PAYLOADS", "true").lower() == "true" def _configured(mcp: FastMCP) -> FastMCP: diff --git a/pyproject.toml b/pyproject.toml index 61e12dd..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" From 1c3cc3593c070baae40989dee06612fa95379a90 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Sun, 16 Aug 2026 00:20:32 +0530 Subject: [PATCH 3/9] fix: update logging configuration in README and add validation for empty template data in template.py --- README.md | 2 +- plane_mcp/tools/template.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8fe49d4..2fc632c 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ available — an opaque user id and the workspace slug. ```bash export LOG_USER_INFO=true # also log the display name (PII); -export LOG_PAYLOADS=true # also log request paylo +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/tools/template.py b/plane_mcp/tools/template.py index a694f36..b5436ea 100644 --- a/plane_mcp/tools/template.py +++ b/plane_mcp/tools/template.py @@ -170,6 +170,8 @@ def template( 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( From 1d8955e0339fb557cbfe183ff0d09d3d85a3bcac Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Sun, 16 Aug 2026 00:36:12 +0530 Subject: [PATCH 4/9] chore: bump version to 0.3.1 and update note for page actions in page.py --- plane_mcp/tools/page.py | 3 ++- uv.lock | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/plane_mcp/tools/page.py b/plane_mcp/tools/page.py index fae246a..6c5ba21 100644 --- a/plane_mcp/tools/page.py +++ b/plane_mcp/tools/page.py @@ -34,7 +34,8 @@ "update", ("page_id",), ("project_id", "name", "description_html"), - note="pass name, description_html, or both; a locked or archived page is refused", + 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", diff --git a/uv.lock b/uv.lock index 08fb48d..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" }, From 7fbcd0b5dc1a714304026ac134885120b6376644 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Sun, 16 Aug 2026 12:38:39 +0530 Subject: [PATCH 5/9] feat: add collection tool for managing workspace-level folders and update tool counts in documentation --- CLAUDE.md | 2 +- README.md | 4 +- plane_mcp/tools/README.md | 9 +- plane_mcp/tools/collection.py | 243 ++++++++++++++++++++++++++++++++ plane_mcp/tools/page.py | 86 ++++++++++- plane_mcp/tools/registry.py | 2 + tests/tools/test_conformance.py | 1 + tests/tools/test_dispatch.py | 4 +- 8 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 plane_mcp/tools/collection.py diff --git a/CLAUDE.md b/CLAUDE.md index f15acde..50a92b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,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: **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 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. diff --git a/README.md b/README.md index 2fc632c..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/). -- **29 tools**, one per Plane resource, covering 190 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 29 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 diff --git a/plane_mcp/tools/README.md b/plane_mcp/tools/README.md index c6d0834..27e08e4 100644 --- a/plane_mcp/tools/README.md +++ b/plane_mcp/tools/README.md @@ -1,7 +1,7 @@ # The tool surface -**29 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 — 29 tools, ~59k 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,7 +206,7 @@ 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` · `update` · `archive` · `delete` · `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` | 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/page.py b/plane_mcp/tools/page.py index 6c5ba21..83c5840 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,8 +10,9 @@ from typing import Any, Literal from fastmcp import FastMCP +from plane.models.collections import AddCollectionPages, Collection, UpdateCollectionPage from plane.models.pages import CreatePage, Page, UpdatePage -from plane.models.query_params import PaginatedQueryParams +from plane.models.query_params import CollectionPageQueryParams, PaginatedQueryParams from plane.models.work_item_pages import CreateWorkItemPage, WorkItemPage from plane_mcp.client import get_plane_client_context @@ -28,7 +29,18 @@ 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", @@ -50,6 +62,12 @@ 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")), Action( @@ -63,7 +81,11 @@ 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." + "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 reports each page's parent_id and collection_id. " + "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 = { @@ -90,12 +112,15 @@ def page( "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 = "", @@ -109,7 +134,7 @@ def page( external_id: str = "", cursor: str = "", per_page: int = 0, - ) -> Page | WorkItemPage | list[WorkItemPage] | dict[str, Any] | str | None: + ) -> Page | WorkItemPage | list[WorkItemPage] | list[Collection] | dict[str, Any] | str | None: client, workspace_slug = get_plane_client_context() if action == "list": @@ -164,12 +189,18 @@ def page( 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), ) @@ -177,6 +208,51 @@ 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 + + row = None + for collection in client.collections.list(workspace_slug=workspace_slug): + filed_cursor = "" + while True: + rows = client.collections.pages.list( + workspace_slug=workspace_slug, + collection_id=str(collection.id), + params=as_params(CollectionPageQueryParams, cursor=filed_cursor), + ) + row = next((r for r in rows.results if str((r.page or {}).get("id")) == page_id), None) + if row is not None or not rows.next_page_results: + break + filed_cursor = rows.next_cursor + if row is not None: + break + + if row is None: + 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(row.collection_id) == collection_id: + membership_id = row.page_collection_id + else: + membership_id = client.collections.pages.update( + workspace_slug=workspace_slug, + collection_id=str(row.collection_id), + page_collection_id=str(row.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 f5b900d..3f22cfd 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, @@ -54,6 +55,7 @@ # literal list, so any change shows up as a diff rather than as a silent # cache-buster. RESOURCES: tuple[ModuleType, ...] = ( + collection, customer, customer_property, customer_request, diff --git a/tests/tools/test_conformance.py b/tests/tools/test_conformance.py index 930a694..528e19d 100644 --- a/tests/tools/test_conformance.py +++ b/tests/tools/test_conformance.py @@ -34,6 +34,7 @@ def _module_ids(mods): # conversation. Editing this list is the deliberate act that makes that happen; # appending to it is not. CATALOGUE = [ + "collection", "customer", "customer_property", "customer_request", diff --git a/tests/tools/test_dispatch.py b/tests/tools/test_dispatch.py index 30c0a2e..f68c3ca 100644 --- a/tests/tools/test_dispatch.py +++ b/tests/tools/test_dispatch.py @@ -26,7 +26,9 @@ "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", From 69ed073fdfdf1fc8d640925c6b857a972c950b38 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Sun, 16 Aug 2026 13:20:27 +0530 Subject: [PATCH 6/9] feat: enhance page retrieval by adding workspace page name search in collection queries --- plane_mcp/tools/page.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plane_mcp/tools/page.py b/plane_mcp/tools/page.py index 83c5840..f21fb9d 100644 --- a/plane_mcp/tools/page.py +++ b/plane_mcp/tools/page.py @@ -212,6 +212,7 @@ def page( if error := needs(action, page_id=page_id, collection_id=collection_id): return error + named = client.pages.retrieve_workspace_page(workspace_slug=workspace_slug, page_id=page_id) row = None for collection in client.collections.list(workspace_slug=workspace_slug): filed_cursor = "" @@ -219,7 +220,7 @@ def page( rows = client.collections.pages.list( workspace_slug=workspace_slug, collection_id=str(collection.id), - params=as_params(CollectionPageQueryParams, cursor=filed_cursor), + params=as_params(CollectionPageQueryParams, search=opt(named.name), cursor=filed_cursor), ) row = next((r for r in rows.results if str((r.page or {}).get("id")) == page_id), None) if row is not None or not rows.next_page_results: From faf1017192b1d714d1c09921fa3f05dc23bedf9e Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Sun, 16 Aug 2026 13:59:09 +0530 Subject: [PATCH 7/9] refactor: reorder resources in registry and catalogue to maintain consistency and improve cache handling --- plane_mcp/tools/registry.py | 9 ++------- tests/tools/test_conformance.py | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/plane_mcp/tools/registry.py b/plane_mcp/tools/registry.py index 3f22cfd..aa96c62 100644 --- a/plane_mcp/tools/registry.py +++ b/plane_mcp/tools/registry.py @@ -49,13 +49,7 @@ 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, ...] = ( - collection, customer, customer_property, customer_request, @@ -74,7 +68,6 @@ release_label, release_tag, state, - template, work_log, workitem, workitem_activity, @@ -85,6 +78,8 @@ workitem_relation, workitem_type, workspace, + template, + collection, ) diff --git a/tests/tools/test_conformance.py b/tests/tools/test_conformance.py index 528e19d..9f0bd45 100644 --- a/tests/tools/test_conformance.py +++ b/tests/tools/test_conformance.py @@ -34,7 +34,6 @@ def _module_ids(mods): # conversation. Editing this list is the deliberate act that makes that happen; # appending to it is not. CATALOGUE = [ - "collection", "customer", "customer_property", "customer_request", @@ -53,7 +52,6 @@ def _module_ids(mods): "release_label", "release_tag", "state", - "template", "work_log", "workitem", "workitem_activity", @@ -64,6 +62,9 @@ def _module_ids(mods): "workitem_relation", "workitem_type", "workspace", + # Appended, not sorted in: see registry.py. + "template", + "collection", ] @@ -195,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): From dd99cc149a65ffe5fcc995ec1be4ca557c7d3984 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 17 Aug 2026 12:22:17 +0530 Subject: [PATCH 8/9] docs: update template.py for clarity on template scoping and error handling --- plane_mcp/tools/template.py | 90 +++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/plane_mcp/tools/template.py b/plane_mcp/tools/template.py index b5436ea..5bbdeae 100644 --- a/plane_mcp/tools/template.py +++ b/plane_mcp/tools/template.py @@ -1,12 +1,15 @@ """Templates: the reusable shape of a work item, a page, or a project. -Two things pick the endpoint. `kind` says what is being templated, and project_id -says 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. +`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. -`template_data` carries the body being templated (the work item's own fields, for -instance); 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 @@ -15,6 +18,7 @@ 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 @@ -27,6 +31,7 @@ needs, one_of, opt, + workspace_owns, ) NAME = "template" @@ -47,17 +52,27 @@ ) FOOTER = ( - f"kind is one of: {', '.join(KINDS)}. Omit project_id for the workspace's own templates, " - "which is also the only scope a project template can live at. " + 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), @@ -157,29 +172,36 @@ def template( except ValueError as exc: return f"Error: {exc}." - 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 + 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 From 334d360723773065ed8fee53e6e82da6c0c18acd Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 17 Aug 2026 12:52:58 +0530 Subject: [PATCH 9/9] refactor: simplify page retrieval logic and remove unused collection query parameters --- plane_mcp/tools/page.py | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/plane_mcp/tools/page.py b/plane_mcp/tools/page.py index f21fb9d..e4f3581 100644 --- a/plane_mcp/tools/page.py +++ b/plane_mcp/tools/page.py @@ -10,9 +10,9 @@ from typing import Any, Literal from fastmcp import FastMCP -from plane.models.collections import AddCollectionPages, Collection, UpdateCollectionPage +from plane.models.collections import AddCollectionPages, UpdateCollectionPage from plane.models.pages import CreatePage, Page, UpdatePage -from plane.models.query_params import CollectionPageQueryParams, PaginatedQueryParams +from plane.models.query_params import PaginatedQueryParams from plane.models.work_item_pages import CreateWorkItemPage, WorkItemPage from plane_mcp.client import get_plane_client_context @@ -83,7 +83,8 @@ "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 reports each page's parent_id and collection_id. " + "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." ) @@ -134,7 +135,7 @@ def page( external_id: str = "", cursor: str = "", per_page: int = 0, - ) -> Page | WorkItemPage | list[WorkItemPage] | list[Collection] | dict[str, Any] | str | None: + ) -> Page | WorkItemPage | list[WorkItemPage] | dict[str, Any] | str | None: client, workspace_slug = get_plane_client_context() if action == "list": @@ -212,24 +213,9 @@ def page( if error := needs(action, page_id=page_id, collection_id=collection_id): return error - named = client.pages.retrieve_workspace_page(workspace_slug=workspace_slug, page_id=page_id) - row = None - for collection in client.collections.list(workspace_slug=workspace_slug): - filed_cursor = "" - while True: - rows = client.collections.pages.list( - workspace_slug=workspace_slug, - collection_id=str(collection.id), - params=as_params(CollectionPageQueryParams, search=opt(named.name), cursor=filed_cursor), - ) - row = next((r for r in rows.results if str((r.page or {}).get("id")) == page_id), None) - if row is not None or not rows.next_page_results: - break - filed_cursor = rows.next_cursor - if row is not None: - break + filed = client.pages.retrieve_workspace_page(workspace_slug=workspace_slug, page_id=page_id) - if row is None: + if not filed.collection_id: added = client.collections.pages.add( workspace_slug=workspace_slug, collection_id=collection_id, @@ -238,13 +224,13 @@ def page( if not added: return None membership_id = added[0].id - elif str(row.collection_id) == collection_id: - membership_id = row.page_collection_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(row.collection_id), - page_collection_id=str(row.page_collection_id), + collection_id=str(filed.collection_id), + page_collection_id=str(filed.page_collection_id), data=UpdateCollectionPage(collection=collection_id), ).id