From 2092ecd798236fefdebf46abdf79c35365fb3389 Mon Sep 17 00:00:00 2001 From: Vincent-Wu-Haha Date: Fri, 28 Aug 2026 15:14:07 +0800 Subject: [PATCH 1/3] feat: route Pages API to legacy /api and auth via PLANE_API_KEY (X-Api-Key) Plane CE 1.4.2 serves Pages only under the legacy /api/ route (plane.app.urls), not /api/v1/. The previous implementation assumed /api/v1 and relied on a session cookie, which a headless MCP server cannot use. Adds core Page actions (list/retrieve/create/update/archive/delete) hitting /api/workspaces//projects//pages/ with PLANE_API_KEY sent as the X-Api-Key header. Requires the server-side patch that adds APIKeyAuthentication to DRF DEFAULT_AUTHENTICATION_CLASSES and the view base classes (see Vincent-Wu-Haha/plane-ce-pages-apikey-patch). Also syncs pyproject.toml / uv.lock (adds pyinstaller dev group). --- plane_mcp/tools/page.py | 200 +++++++++++++++++++++++++++++----------- pyproject.toml | 5 + uv.lock | 102 ++++++++++++++++++-- 3 files changed, 242 insertions(+), 65 deletions(-) diff --git a/plane_mcp/tools/page.py b/plane_mcp/tools/page.py index e4f3581..bb522b7 100644 --- a/plane_mcp/tools/page.py +++ b/plane_mcp/tools/page.py @@ -1,14 +1,33 @@ """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 -pair for each, so the branch is explicit rather than a default. +NOTE ON PLANE CE 1.4.2 +---------------------- +In Plane Community Edition 1.4.2 the Pages REST API is served ONLY under the +legacy ``/api/`` prefix (``/api/workspaces//projects//pages/``). The +``plane`` SDK hard-codes ``/api/v1`` in ``plane.config.Configuration``, and the +Pages routes are not registered under ``/api/v1/`` at all, so every SDK call to +Pages 404s. + +This module talks to the legacy ``/api/`` endpoint directly so it can reuse the +SAME ``PLANE_API_KEY`` as the rest of the connector (the ``X-Api-Key`` header), +instead of a browser session cookie. + +IMPORTANT — server-side prerequisite +------------------------------------- +Out of the box, the legacy ``/api/`` routes only use ``SessionAuthentication``, +so an ``X-Api-Key`` request returns 401. To let the API key reach Pages, add +``APIKeyAuthentication`` to DRF's ``DEFAULT_AUTHENTICATION_CLASSES`` in +``apps/api/plane/settings/common.py`` on the Plane server (one-line change, then +restart the API). With that in place this module works using only +``PLANE_API_KEY`` — no session cookie required. """ from __future__ import annotations +import os from typing import Any, Literal +import requests from fastmcp import FastMCP from plane.models.collections import AddCollectionPages, UpdateCollectionPage from plane.models.pages import CreatePage, Page, UpdatePage @@ -21,6 +40,71 @@ NAME = "page" TITLE = "Pages" +# --------------------------------------------------------------------------- +# Legacy /api/ client for Pages (Plane CE 1.4.2) — authenticated with PLANE_API_KEY +# --------------------------------------------------------------------------- +_PLANE_BASE_URL = os.getenv("PLANE_BASE_URL", "").rstrip("/") +_PLANE_WORKSPACE_SLUG = os.getenv("PLANE_WORKSPACE_SLUG", "") +_PLANE_API_KEY = os.getenv("PLANE_API_KEY", "") + + +def _require_api_key() -> None: + """Fail fast with a clear message if the API key is missing.""" + from plane.errors.errors import HttpError + + if not _PLANE_API_KEY: + raise HttpError( + "Pages require PLANE_API_KEY (X-Api-Key). The legacy /api/ Pages route must " + "also accept the API key on the server side — see the module docstring.", + 401, + "missing PLANE_API_KEY", + ) + + +def _page_url(project_id: str, page_id: str = "", extra: str = "") -> str: + """Build a legacy Pages URL. Workspace scope when project_id is empty. + + A trailing slash is always appended: Django's URLs are slash-terminated and + a missing slash triggers a 301 that drops the auth header on redirect. + """ + if project_id: + url = f"{_PLANE_BASE_URL}/api/workspaces/{_PLANE_WORKSPACE_SLUG}/projects/{project_id}/pages" + else: + url = f"{_PLANE_BASE_URL}/api/workspaces/{_PLANE_WORKSPACE_SLUG}/pages" + if page_id: + url = f"{url}/{page_id}" + if extra: + url = f"{url}/{extra}" + return url + "/" + + +def _legacy_headers() -> dict[str, str]: + return { + "Content-Type": "application/json", + "X-Api-Key": _PLANE_API_KEY, + } + + +def _legacy_request(method: str, url: str, json: dict[str, Any] | None = None) -> Any: + from plane.errors.errors import HttpError + + resp = requests.request(method, url, headers=_legacy_headers(), json=json, timeout=30) + if resp.status_code == 204: + return None + if 200 <= resp.status_code < 300: + if not resp.content: + return None + try: + return resp.json() + except Exception: + return resp.text + try: + payload = resp.json() + except Exception: + payload = resp.text + raise HttpError(f"HTTP {resp.status_code}: {resp.reason}", resp.status_code, payload) + + ACTIONS = ( Action( "list", (), ("project_id", "cursor", "per_page"), note="workspace pages unless project_id is given", read=True @@ -138,77 +222,83 @@ def page( ) -> Page | WorkItemPage | list[WorkItemPage] | dict[str, Any] | str | None: client, workspace_slug = get_plane_client_context() + # ----- Core Page CRUD: legacy /api/ + PLANE_API_KEY (X-Api-Key) ----- + _require_api_key() if action == "list": - params = as_params(PaginatedQueryParams, cursor=cursor, per_page=per_page) if project_id: - response = client.pages.list_project_pages( - workspace_slug=workspace_slug, project_id=project_id, params=params - ) - else: - response = client.pages.list_workspace_pages(workspace_slug=workspace_slug, params=params) - return envelope(response) + return _legacy_request("GET", _page_url(project_id)) + return _legacy_request("GET", _page_url("")) if action == "retrieve": if not page_id: return missing(action, "page_id") if project_id: - return client.pages.retrieve_project_page( - workspace_slug=workspace_slug, project_id=project_id, page_id=page_id - ) - return client.pages.retrieve_workspace_page(workspace_slug=workspace_slug, page_id=page_id) + return _legacy_request("GET", _page_url(project_id, page_id)) + return _legacy_request("GET", _page_url("", page_id)) + + 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: dict[str, Any] = {"name": name, "description_html": description_html} + if access is not None: + data["access"] = access + if color: + data["color"] = color + if is_locked is not None: + data["is_locked"] = is_locked + if parent_id: + data["parent_id"] = parent_id + if external_id: + data["external_id"] = external_id + if external_source: + data["external_source"] = external_source + if collection_id: + data["collection_id"] = collection_id + if project_id: + return _legacy_request("POST", _page_url(project_id), json=data) + return _legacy_request("POST", _page_url(""), json=data) + + if action == "update": + if not page_id: + return missing(action, "page_id") + if not (name or description_html): + return missing(action, "name or description_html") + data = {} + if name: + data["name"] = name + if description_html: + data["description_html"] = description_html + if project_id: + return _legacy_request("PUT", _page_url(project_id, page_id), json=data) + return _legacy_request("PUT", _page_url("", page_id), json=data) 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) + url = _page_url(project_id, page_id, "archive") 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. + url = _page_url("", page_id, "archive") + if archive: + _legacy_request("POST", url) + else: + _legacy_request("DELETE", url) return {"page_id": page_id, "archived": archive} - if action in ("update", "delete"): + if action == "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), - ) if project_id: - 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) + _legacy_request("DELETE", _page_url(project_id, page_id)) + else: + _legacy_request("DELETE", _page_url("", page_id)) + return None + # ----- Set collection / work-item links: still via SDK (/api/v1/) ----- if action == "set_collection": if error := needs(action, page_id=page_id, collection_id=collection_id): return error diff --git a/pyproject.toml b/pyproject.toml index 9fee8cc..dc93620 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,3 +60,8 @@ quote-style = "double" testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] + +[dependency-groups] +dev = [ + "pyinstaller>=6.22.2", +] diff --git a/uv.lock b/uv.lock index 1bec8a0..43a3154 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, ] +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -794,6 +803,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/85/0271227eab939921a12ebba5d17aa4cd18346aa534ca7f5da09cd0b63dd4/lupa-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:86f6f668966965b15247dc32d064cfe7be67b71e584ccfacbe2f637575296878", size = 1847445, upload-time = "2026-04-15T20:08:27.031Z" }, ] +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -892,6 +913,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, ] +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + [[package]] name = "plane-mcp-server" version = "0.3.1" @@ -913,6 +943,11 @@ dev = [ { name = "ruff" }, ] +[package.dev-dependencies] +dev = [ + { name = "pyinstaller" }, +] + [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.6.9" }, @@ -920,7 +955,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", directory = "/Users/akhilvamshikonam/Documents/plane-python-sdk" }, + { name = "plane-sdk", specifier = "==0.2.23" }, { 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" }, @@ -928,23 +963,21 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [{ name = "pyinstaller", specifier = ">=6.22.2" }] + [[package]] name = "plane-sdk" version = "0.2.23" -source = { directory = "/Users/akhilvamshikonam/Documents/plane-python-sdk" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "requests" }, ] - -[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" }, +sdist = { url = "https://files.pythonhosted.org/packages/7e/89/c683207e50f641843273de4e19ac88154bdc89c522ec2cdab956201a4d1e/plane_sdk-0.2.23.tar.gz", hash = "sha256:aee2f3da583459b6a513f9200f703fac1942ea65c27dd6f6dd76872677535bd7", size = 102738, upload-time = "2026-08-20T10:35:34.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/38/6d7f7292025806b56655e38c6df3156b3a0ecac055d8dc0ef229fe30f1bb/plane_sdk-0.2.23-py3-none-any.whl", hash = "sha256:b2cdb86943f2cee43d066dce0e8af2b9fb955db757c4666b1a269e7c81d381c0", size = 147179, upload-time = "2026-08-20T10:35:33.017Z" }, ] -provides-extras = ["dev"] [[package]] name = "platformdirs" @@ -1162,6 +1195,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyinstaller" +version = "6.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/2b/836d9def811c02522e0921d8b8cdf0c16b0545a216e97e71041758057859/pyinstaller-6.22.2.tar.gz", hash = "sha256:89b65a3ad07d9dd5832253e37bc45f31872d10d7f9d5c9fd0fdd6088a83829dd", size = 4092631, upload-time = "2026-08-17T20:53:22.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/39/08cd53632276de70426e7c273820277a48253fee397b4048301ec03c3566/pyinstaller-6.22.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:ebd1b1ca932d7cf25d7366ce691aaf79a5ff9425811ed7328b5116e4471b6d6d", size = 1062734, upload-time = "2026-08-17T20:52:17.635Z" }, + { url = "https://files.pythonhosted.org/packages/74/22/2d865896782cbb41e2388c7314207c17a98acdcc1b8e5eef668873505c9f/pyinstaller-6.22.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f5ccb847451df4207bce18bf53a57b124c9bb4e7e4bad08c5ecc627bcf00b28c", size = 755697, upload-time = "2026-08-17T20:52:21.676Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/6c11e4d5a76e716ea68da1946ab706a4bdf6d8e68b0e1dea314366d046a0/pyinstaller-6.22.2-py3-none-manylinux2014_i686.whl", hash = "sha256:becb47ad78272bede87acf2ed830d7545a8f65ab11d06a68fe2b99ba1afaac6d", size = 768870, upload-time = "2026-08-17T20:52:25.511Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e2/dbfea6a58b68acf644f7193b11d570476d6ffaed57381b6d1dd9e898a971/pyinstaller-6.22.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:06d7b3827a8049db4a2d47e3ec4ae2f69a1041577d8833b8f169745cde573ab4", size = 767445, upload-time = "2026-08-17T20:52:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/f24a21af2f87ce1df4e07fdad95eec65ef9f284267a7c1e5eeea40f49aa4/pyinstaller-6.22.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:7bee432404eca5dc3ef37c36811c266561b03988f6d3e70ab0fa5352dd5de9e4", size = 762113, upload-time = "2026-08-17T20:52:34.025Z" }, + { url = "https://files.pythonhosted.org/packages/2b/07/b304ff3f5f8333778065e3658b604b0e108cd934b920f3fbab825a0dd5b7/pyinstaller-6.22.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:9622686ecc5d5fa492fe6cde29d47df9dd41138cff8177be9f901ca3260f2096", size = 762173, upload-time = "2026-08-17T20:52:38.477Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9b/0af69d93dfad2e3d590a5de5828d5bcd903747c0224bd9560ab476904dfe/pyinstaller-6.22.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:0260eaad6be3f6fbc1affffe6dc7b8e5b636dbca51b463224daba971610b6fc1", size = 761674, upload-time = "2026-08-17T20:52:42.646Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4e/28b6094dcbd1e1bbb868daf4f8752999a20c1020ab64569232be42af2be8/pyinstaller-6.22.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8c2c4b14caad38c1f3df8e9bf5276fc265e27fe8b4180cab4a37864288427bc0", size = 761053, upload-time = "2026-08-17T20:52:46.594Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2c/f91b63fd01422111ac04e3b9bf61c039da5a3292acb4fe5248905f0be688/pyinstaller-6.22.2-py3-none-win32.whl", hash = "sha256:9a078877caa3920558a3242d0a7019fe5b825cff4b65ed380c7d1e1bd200ddd9", size = 1344474, upload-time = "2026-08-17T20:52:53.108Z" }, + { url = "https://files.pythonhosted.org/packages/3f/53/8ba1d0f6159b490f700eac6161a4be5f0d4672608a6dae9fd73679f183ee/pyinstaller-6.22.2-py3-none-win_amd64.whl", hash = "sha256:9b990fa6bbe143572f06644a984ad0d7aa2e2ccc6929d4916031343a5888e9a7", size = 1405725, upload-time = "2026-08-17T20:52:59.667Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/9a3062cc34c939f694d4262c4754681f6ffe853c21c124209ad2d08b8e84/pyinstaller-6.22.2-py3-none-win_arm64.whl", hash = "sha256:afb6f9a95d19b6dcd3a7decc40d9adb6ba9c4f8802ddd6c972dfb552953f384e", size = 1353806, upload-time = "2026-08-17T20:53:06.222Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/60/d881fa1ba8c160c18d8e6f782bb16ec4640c08bc08fc50f704c368ad4f9e/pyinstaller_hooks_contrib-2026.7.tar.gz", hash = "sha256:5fbcaacb22c4f4aac869a127dce283f67a4b4cfcc37d496f2446603e6d68aefa", size = 175992, upload-time = "2026-08-24T21:26:58.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/67/350377af7b50416344ab8792756d414eef7629c618a73e9a0b13bb1552d9/pyinstaller_hooks_contrib-2026.7-py3-none-any.whl", hash = "sha256:24257a04c7a5a7a034cf28e39dcee20fbeeb9f043076729480f2e1b69904408a", size = 459445, upload-time = "2026-08-24T21:26:57.274Z" }, +] + [[package]] name = "pyjwt" version = "2.12.1" @@ -1570,6 +1643,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + [[package]] name = "six" version = "1.17.0" From d9675950fbe06751d9d65ecef8adc72184f599f2 Mon Sep 17 00:00:00 2001 From: Vincent-Wu-Haha Date: Fri, 28 Aug 2026 15:20:34 +0800 Subject: [PATCH 2/3] docs: add fork note linking upstream README and explaining Pages patch requirement --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index bfcc67d..8b817ec 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,20 @@ # Plane MCP Server +> ⚠️ **This is a fork** of [makeplane/plane-mcp-server](https://github.com/makeplane/plane-mcp-server). +> For the upstream project's general setup, configuration and tool reference, read the +> [official README](https://github.com/makeplane/plane-mcp-server#readme) — most of it applies here too. +> +> **What's different in this fork (read this before using the Pages tools):** +> - The `page` tools (Pages API) were re-routed to Plane's **legacy `/api/` route** and authenticate with your +> **`PLANE_API_KEY`** sent as the `X-Api-Key` header. The upstream SDK hard-codes `/api/v1`, which returns +> **404** for Pages on Plane CE 1.4.2 (Pages live under `plane.app.urls`, not under `/api/v1/`). +> - **Your Plane CE server needs a patch first.** API keys are rejected on the `/api/` tree unless you add +> `APIKeyAuthentication` to DRF's `DEFAULT_AUTHENTICATION_CLASSES` and to the view base classes. +> Get the patch + apply / bind-mount instructions here: +> **** +> - The MCP client env vars are the same as upstream: `PLANE_API_KEY`, `PLANE_WORKSPACE_SLUG`, and +> (for self-hosted) `PLANE_BASE_URL`. With the server patch applied, those keys now also cover the Pages API. + A [Model Context Protocol](https://modelcontextprotocol.io) server for [Plane](https://plane.so). Gives an AI agent tools to read and manage projects, work items, cycles, modules, releases, customers and more. From 1850aaef6ddba6fe95c8b6cec19bd8cb179d52c5 Mon Sep 17 00:00:00 2001 From: Vincent-Wu-Haha Date: Fri, 28 Aug 2026 15:35:40 +0800 Subject: [PATCH 3/3] chore: revert pyproject.toml/uv.lock to upstream; keep only page.py fix The pyinstaller dev-dependency entries in pyproject.toml / uv.lock came from an upstream sync and are unrelated to the Pages fix. Drop them so this fork's diff contains only plane_mcp/tools/page.py (the actual fix) plus the README fork note. --- pyproject.toml | 5 --- uv.lock | 102 +++++-------------------------------------------- 2 files changed, 10 insertions(+), 97 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dc93620..9fee8cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,8 +60,3 @@ quote-style = "double" testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] - -[dependency-groups] -dev = [ - "pyinstaller>=6.22.2", -] diff --git a/uv.lock b/uv.lock index 43a3154..1bec8a0 100644 --- a/uv.lock +++ b/uv.lock @@ -14,15 +14,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, ] -[[package]] -name = "altgraph" -version = "0.17.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -803,18 +794,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/85/0271227eab939921a12ebba5d17aa4cd18346aa534ca7f5da09cd0b63dd4/lupa-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:86f6f668966965b15247dc32d064cfe7be67b71e584ccfacbe2f637575296878", size = 1847445, upload-time = "2026-04-15T20:08:27.031Z" }, ] -[[package]] -name = "macholib" -version = "1.16.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altgraph" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" @@ -913,15 +892,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, ] -[[package]] -name = "pefile" -version = "2024.8.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, -] - [[package]] name = "plane-mcp-server" version = "0.3.1" @@ -943,11 +913,6 @@ dev = [ { name = "ruff" }, ] -[package.dev-dependencies] -dev = [ - { name = "pyinstaller" }, -] - [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.6.9" }, @@ -955,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.23" }, + { 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" }, @@ -963,21 +928,23 @@ requires-dist = [ ] provides-extras = ["dev"] -[package.metadata.requires-dev] -dev = [{ name = "pyinstaller", specifier = ">=6.22.2" }] - [[package]] name = "plane-sdk" version = "0.2.23" -source = { registry = "https://pypi.org/simple" } +source = { directory = "/Users/akhilvamshikonam/Documents/plane-python-sdk" } dependencies = [ { name = "pydantic" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/89/c683207e50f641843273de4e19ac88154bdc89c522ec2cdab956201a4d1e/plane_sdk-0.2.23.tar.gz", hash = "sha256:aee2f3da583459b6a513f9200f703fac1942ea65c27dd6f6dd76872677535bd7", size = 102738, upload-time = "2026-08-20T10:35:34.387Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/09/38/6d7f7292025806b56655e38c6df3156b3a0ecac055d8dc0ef229fe30f1bb/plane_sdk-0.2.23-py3-none-any.whl", hash = "sha256:b2cdb86943f2cee43d066dce0e8af2b9fb955db757c4666b1a269e7c81d381c0", size = 147179, upload-time = "2026-08-20T10:35:33.017Z" }, + +[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" @@ -1195,46 +1162,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pyinstaller" -version = "6.22.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altgraph" }, - { name = "macholib", marker = "sys_platform == 'darwin'" }, - { name = "packaging" }, - { name = "pefile", marker = "sys_platform == 'win32'" }, - { name = "pyinstaller-hooks-contrib" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/2b/836d9def811c02522e0921d8b8cdf0c16b0545a216e97e71041758057859/pyinstaller-6.22.2.tar.gz", hash = "sha256:89b65a3ad07d9dd5832253e37bc45f31872d10d7f9d5c9fd0fdd6088a83829dd", size = 4092631, upload-time = "2026-08-17T20:53:22.231Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/39/08cd53632276de70426e7c273820277a48253fee397b4048301ec03c3566/pyinstaller-6.22.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:ebd1b1ca932d7cf25d7366ce691aaf79a5ff9425811ed7328b5116e4471b6d6d", size = 1062734, upload-time = "2026-08-17T20:52:17.635Z" }, - { url = "https://files.pythonhosted.org/packages/74/22/2d865896782cbb41e2388c7314207c17a98acdcc1b8e5eef668873505c9f/pyinstaller-6.22.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f5ccb847451df4207bce18bf53a57b124c9bb4e7e4bad08c5ecc627bcf00b28c", size = 755697, upload-time = "2026-08-17T20:52:21.676Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/6c11e4d5a76e716ea68da1946ab706a4bdf6d8e68b0e1dea314366d046a0/pyinstaller-6.22.2-py3-none-manylinux2014_i686.whl", hash = "sha256:becb47ad78272bede87acf2ed830d7545a8f65ab11d06a68fe2b99ba1afaac6d", size = 768870, upload-time = "2026-08-17T20:52:25.511Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e2/dbfea6a58b68acf644f7193b11d570476d6ffaed57381b6d1dd9e898a971/pyinstaller-6.22.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:06d7b3827a8049db4a2d47e3ec4ae2f69a1041577d8833b8f169745cde573ab4", size = 767445, upload-time = "2026-08-17T20:52:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/5e/db/f24a21af2f87ce1df4e07fdad95eec65ef9f284267a7c1e5eeea40f49aa4/pyinstaller-6.22.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:7bee432404eca5dc3ef37c36811c266561b03988f6d3e70ab0fa5352dd5de9e4", size = 762113, upload-time = "2026-08-17T20:52:34.025Z" }, - { url = "https://files.pythonhosted.org/packages/2b/07/b304ff3f5f8333778065e3658b604b0e108cd934b920f3fbab825a0dd5b7/pyinstaller-6.22.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:9622686ecc5d5fa492fe6cde29d47df9dd41138cff8177be9f901ca3260f2096", size = 762173, upload-time = "2026-08-17T20:52:38.477Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9b/0af69d93dfad2e3d590a5de5828d5bcd903747c0224bd9560ab476904dfe/pyinstaller-6.22.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:0260eaad6be3f6fbc1affffe6dc7b8e5b636dbca51b463224daba971610b6fc1", size = 761674, upload-time = "2026-08-17T20:52:42.646Z" }, - { url = "https://files.pythonhosted.org/packages/d3/4e/28b6094dcbd1e1bbb868daf4f8752999a20c1020ab64569232be42af2be8/pyinstaller-6.22.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8c2c4b14caad38c1f3df8e9bf5276fc265e27fe8b4180cab4a37864288427bc0", size = 761053, upload-time = "2026-08-17T20:52:46.594Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2c/f91b63fd01422111ac04e3b9bf61c039da5a3292acb4fe5248905f0be688/pyinstaller-6.22.2-py3-none-win32.whl", hash = "sha256:9a078877caa3920558a3242d0a7019fe5b825cff4b65ed380c7d1e1bd200ddd9", size = 1344474, upload-time = "2026-08-17T20:52:53.108Z" }, - { url = "https://files.pythonhosted.org/packages/3f/53/8ba1d0f6159b490f700eac6161a4be5f0d4672608a6dae9fd73679f183ee/pyinstaller-6.22.2-py3-none-win_amd64.whl", hash = "sha256:9b990fa6bbe143572f06644a984ad0d7aa2e2ccc6929d4916031343a5888e9a7", size = 1405725, upload-time = "2026-08-17T20:52:59.667Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/9a3062cc34c939f694d4262c4754681f6ffe853c21c124209ad2d08b8e84/pyinstaller-6.22.2-py3-none-win_arm64.whl", hash = "sha256:afb6f9a95d19b6dcd3a7decc40d9adb6ba9c4f8802ddd6c972dfb552953f384e", size = 1353806, upload-time = "2026-08-17T20:53:06.222Z" }, -] - -[[package]] -name = "pyinstaller-hooks-contrib" -version = "2026.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/60/d881fa1ba8c160c18d8e6f782bb16ec4640c08bc08fc50f704c368ad4f9e/pyinstaller_hooks_contrib-2026.7.tar.gz", hash = "sha256:5fbcaacb22c4f4aac869a127dce283f67a4b4cfcc37d496f2446603e6d68aefa", size = 175992, upload-time = "2026-08-24T21:26:58.713Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/67/350377af7b50416344ab8792756d414eef7629c618a73e9a0b13bb1552d9/pyinstaller_hooks_contrib-2026.7-py3-none-any.whl", hash = "sha256:24257a04c7a5a7a034cf28e39dcee20fbeeb9f043076729480f2e1b69904408a", size = 459445, upload-time = "2026-08-24T21:26:57.274Z" }, -] - [[package]] name = "pyjwt" version = "2.12.1" @@ -1643,15 +1570,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] -[[package]] -name = "setuptools" -version = "84.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, -] - [[package]] name = "six" version = "1.17.0"