From 10376c350e94029f3ed019009d8d7f56a694a909 Mon Sep 17 00:00:00 2001 From: jawad-khan Date: Wed, 29 Apr 2026 13:27:35 +0500 Subject: [PATCH 1/6] fix: working tree of plane mcp --- README.md | 158 ++++------------ plane_mcp/__main__.py | 24 +++ plane_mcp/client.py | 47 ++++- plane_mcp/cognito_http.py | 346 ++++++++++++++++++++++++++++++++++ plane_mcp/tools/workspaces.py | 98 +++++++++- tests/test_cognito_http.py | 70 +++++++ tests/test_workspaces_tool.py | 44 +++++ 7 files changed, 654 insertions(+), 133 deletions(-) create mode 100644 plane_mcp/cognito_http.py create mode 100644 tests/test_cognito_http.py create mode 100644 tests/test_workspaces_tool.py diff --git a/README.md b/README.md index f407630..f0f10e5 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,6 @@ A Model Context Protocol (MCP) server for Plane integration. This server provide The server supports three transport methods. **We recommend using `uvx`** as it doesn't require installation. -**Requirements**: -- **Python 3.10+** (for stdio transport, via `uvx`) -- **Node.js 22+** (for remote transports, via `npx`) - ### 1. Stdio Transport (for local use) **MCP Client Configuration** (using uvx - recommended): @@ -62,7 +58,7 @@ Connect to the hosted Plane MCP server using OAuth authentication. Connect to the hosted Plane MCP server using a Personal Access Token (PAT). -**URL**: `https://mcp.plane.so/http/api-key/mcp` +**URL**: `https://mcp.plane.so/api-key/mcp` **Headers**: - `Authorization: Bearer ` @@ -85,9 +81,41 @@ Connect to the hosted Plane MCP server using a Personal Access Token (PAT). } ``` -### 4. SSE Transport (Legacy) +### 4. HTTP with AWS Cognito + +When the Cognito-related environment variables below are **all** set, `python -m plane_mcp http` (or the container entrypoint in `http` mode) serves MCP OAuth via FastMCP’s `AWSCognitoProvider`. In that case the default multi-mount app (Plane OAuth at `/http`, SSE at `/`) is **not** started in the same process—use a separate deployment or clear the Cognito variables if you need those transports. + +- **MCP URL**: `{MCP_BASE_URL}/mcp` (Cognito session; the access token is sent to Plane as `Authorization: Bearer …` from the MCP session). +- **Do not use** `{MCP_BASE_URL}/http/mcp` in this mode — that path belongs to the **non-Cognito** HTTP layout; Cognito-only installs serve MCP at **`/mcp`** only. +- **Callback**: register **`{MCP_BASE_URL}/auth/callback`** in the Cognito app client. +- **PAT fallback** (unchanged): `{MCP_BASE_URL}/http/api-key/mcp` with `Authorization` + `X-Workspace-slug` headers. +- **Health**: `GET {MCP_BASE_URL}/healthz` + +Required env vars for this mode: + +| Variable | Purpose | +|----------|---------| +| `MCP_BASE_URL` | Public base URL of this MCP server (no trailing slash required) | +| `COGNITO_USER_POOL_ID` | Cognito user pool ID | +| `COGNITO_AWS_REGION` | AWS region of the pool | +| `OIDC_CLIENT_ID` | Cognito app client ID | +| `OIDC_CLIENT_SECRET` | Cognito app client secret | + +Optional: + +- `COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH` — when **`true`** (default), allow OAuth **`/authorize`** when the MCP client’s **`resource`** URL does not exactly match **`MCP_BASE_URL`** (e.g. **`http://127.0.0.1:…/mcp`** vs **`https://host…/mcp`**). FastMCP otherwise returns **`invalid_target`** before redirecting to Cognito. +- `MCP_ALLOWED_CLIENT_REDIRECT_URIS` — optional comma-separated fnmatch patterns for MCP Dynamic Client Registration redirects. **Unset (default): allow any redirect URI** so clients using **`cursor://`**, **`vscode://`**, or loopback URLs can complete OAuth. Set explicit patterns only in locked-down environments (see FastMCP `redirect_validation`). +- `REDIS_HOST` / `REDIS_PORT` — persist OAuth client registrations (recommended in production). +- `PLANE_BASE_URL` or `PLANE_INTERNAL_BASE_URL` — Plane API host for tools. +- `PLANE_WORKSPACE_SLUG` — default workspace when the IdP token has no `workspace_slug` claim (e.g. Cognito). + +**`invalid_grant` after returning to `/auth/callback` (token exchange):** Often caused by Cognito expecting a **resource server** matching the MCP `resource` URL when FastMCP forwards `resource=` to `/oauth2/authorize`. This server’s Cognito mode **drops `resource` on the upstream authorize request** so a pool that only allowlists **`…/auth/callback`** can still complete token exchange—**no Cognito change required.** If your admins *have* configured a resource server, you can still use this behavior (Cognito typically accepts authorize without `resource`). For the canonical IdP-side fix, see the [FastMCP AWS Cognito guide](https://gofastmcp.com/v2/integrations/aws-cognito) (“Configure Resource Server”). -⚠️ **Legacy Transport**: SSE (Server-Sent Events) transport is maintained for backward compatibility. New implementations should use the HTTP transport (sections 2 or 3) instead. +**`redirect_mismatch` on the Cognito error page:** Cognito only accepts the **`redirect_uri`** your MCP server sends on `/oauth2/authorize`. FastMCP uses **`{MCP_BASE_URL}/auth/callback`** unless **`MCP_COGNITO_REDIRECT_URI`** is set to the full allowlisted callback URL. See server logs on startup for the exact **`IdP redirect_uri`**. Register that URL under the app client’s **Allowed callback URLs**—`http://127.0.0.1:8211/...` and `http://localhost:8211/...` are different; register the one that matches **`MCP_BASE_URL`** / **`MCP_COGNITO_REDIRECT_URI`** (or register both). Restart the server after changing env. + +### 5. SSE Transport (Legacy) + +⚠️ **Legacy Transport**: SSE (Server-Sent Events) transport is maintained for backward compatibility. New implementations should use the HTTP transport (sections 2, 3, or 4) instead. Connect to the hosted Plane MCP server using OAuth authentication via Server-Sent Events. @@ -222,127 +250,13 @@ The server provides comprehensive tools for interacting with Plane. All tools us | `update_work_item_property` | Update a work item property with partial data | | `delete_work_item_property` | Delete a work item property by ID | -### Epics - -| Tool Name | Description | -|-----------|-------------| -| `list_epics` | List all epics in a project | -| `create_epic` | Create a new epic | -| `retrieve_epic` | Retrieve an epic by ID | -| `update_epic` | Update an epic by ID | -| `delete_epic` | Delete an epic by ID | - -### Milestones - -| Tool Name | Description | -|-----------|-------------| -| `list_milestones` | List all milestones in a project | -| `create_milestone` | Create a new milestone | -| `retrieve_milestone` | Retrieve a milestone by ID | -| `update_milestone` | Update a milestone by ID | -| `delete_milestone` | Delete a milestone by ID | -| `add_work_items_to_milestone` | Add work items to a milestone | -| `remove_work_items_from_milestone` | Remove work items from a milestone | -| `list_milestone_work_items` | List work items in a milestone | - -### Labels - -| Tool Name | Description | -|-----------|-------------| -| `list_labels` | List all labels in a project | -| `create_label` | Create a new label | -| `retrieve_label` | Retrieve a label by ID | -| `update_label` | Update a label by ID | -| `delete_label` | Delete a label by ID | - -### States - -| Tool Name | Description | -|-----------|-------------| -| `list_states` | List all states in a project | -| `create_state` | Create a new state | -| `retrieve_state` | Retrieve a state by ID | -| `update_state` | Update a state by ID | -| `delete_state` | Delete a state by ID | - -### Work Item Comments - -| Tool Name | Description | -|-----------|-------------| -| `list_work_item_comments` | List comments for a work item | -| `retrieve_work_item_comment` | Retrieve a specific comment for a work item | -| `create_work_item_comment` | Create a comment for a work item | -| `update_work_item_comment` | Update a comment for a work item | -| `delete_work_item_comment` | Delete a comment for a work item | - -### Work Item Links - -| Tool Name | Description | -|-----------|-------------| -| `list_work_item_links` | List links for a work item | -| `retrieve_work_item_link` | Retrieve a specific link for a work item | -| `create_work_item_link` | Create a link for a work item | -| `update_work_item_link` | Update a link for a work item | -| `delete_work_item_link` | Delete a link for a work item | - -### Work Item Types - -| Tool Name | Description | -|-----------|-------------| -| `list_work_item_types` | List all work item types in a project | -| `create_work_item_type` | Create a new work item type | -| `retrieve_work_item_type` | Retrieve a work item type by ID | -| `update_work_item_type` | Update a work item type by ID | -| `delete_work_item_type` | Delete a work item type by ID | - -### Work Item Relations - -| Tool Name | Description | -|-----------|-------------| -| `list_work_item_relations` | List relations for a work item | -| `create_work_item_relation` | Create relations for a work item | -| `remove_work_item_relation` | Remove a relation from a work item | - -### Work Item Activities - -| Tool Name | Description | -|-----------|-------------| -| `list_work_item_activities` | List activities for a work item | -| `retrieve_work_item_activity` | Retrieve a specific activity for a work item | - -### Work Logs - -| Tool Name | Description | -|-----------|-------------| -| `list_work_logs` | List work logs for a work item | -| `create_work_log` | Create a work log for a work item | -| `update_work_log` | Update a work log for a work item | -| `delete_work_log` | Delete a work log for a work item | - -### Pages - -| Tool Name | Description | -|-----------|-------------| -| `retrieve_workspace_page` | Retrieve a workspace page by ID | -| `retrieve_project_page` | Retrieve a project page by ID | -| `create_workspace_page` | Create a workspace page | -| `create_project_page` | Create a project page | - -### Workspaces - -| Tool Name | Description | -|-----------|-------------| -| `get_workspace_members` | Get all members of the current workspace | -| `get_workspace_features` | Get features of the current workspace | -| `update_workspace_features` | Update features of the current workspace | - ### Users | Tool Name | Description | |-----------|-------------| | `get_me` | Get current authenticated user information | -**Total Tools**: 100+ tools across 20 categories +**Total Tools**: 55+ tools across 8 categories ## Development diff --git a/plane_mcp/__main__.py b/plane_mcp/__main__.py index d943d90..52fd430 100644 --- a/plane_mcp/__main__.py +++ b/plane_mcp/__main__.py @@ -13,6 +13,7 @@ from starlette.middleware.cors import CORSMiddleware from starlette.routing import Mount +from plane_mcp.cognito_http import build_cognito_http_starlette_app, cognito_http_env_ready from plane_mcp.server import get_header_mcp, get_oauth_mcp, get_stdio_mcp @@ -87,6 +88,29 @@ def main() -> None: return if server_mode == ServerMode.HTTP: + if cognito_http_env_ready(): + app = build_cognito_http_starlette_app() + for uv_logger_name in ("uvicorn", "uvicorn.error"): + uv_logger = logging.getLogger(uv_logger_name) + for h in uv_logger.handlers[:]: + uv_logger.removeHandler(h) + uv_handler = logging.StreamHandler(sys.stderr) + uv_handler.setFormatter(JSONFormatter()) + uv_logger.addHandler(uv_handler) + + logger.info( + "Starting Cognito HTTP server: /mcp, /http/api-key/mcp, GET /healthz " + "(set Cognito env vars per README)" + ) + uvicorn.run( + app, + host="0.0.0.0", + port=8211, + log_level="info", + access_log=False, + ) + return + oauth_mcp = get_oauth_mcp("/http") oauth_app = oauth_mcp.http_app(stateless_http=True) header_app = get_header_mcp().http_app(stateless_http=True) diff --git a/plane_mcp/client.py b/plane_mcp/client.py index dd8c6b6..6391846 100644 --- a/plane_mcp/client.py +++ b/plane_mcp/client.py @@ -1,7 +1,7 @@ """Plane client initialization for MCP server.""" import os -from typing import NamedTuple +from typing import Any, NamedTuple from fastmcp.server.auth.auth import AccessToken from fastmcp.server.dependencies import get_access_token @@ -18,6 +18,31 @@ class PlaneClientContext(NamedTuple): workspace_slug: str +def _plane_bearer_for(token: str, claims: dict[str, Any] | None) -> str: + """Pick the Cognito JWT to forward to Plane: ID token if available, else access token. + + oauth2-proxy in this stack uses ``--user-id-claim=cognito:username`` (set so the web + cookie flow, which presents the **ID** token, resolves the right user). Cognito + **access** tokens have only ``username`` (no ``cognito:`` prefix) and no ``email`` + claim, so forwarding the access token unchanged through Traefik+oauth2-proxy makes + oauth2-proxy fall back to ``sub`` and Plane's ``ProxyAuthMiddleware`` resolves a + different account than the web user. + + The matching ID token is attached to ``AccessToken.claims["id_token"]`` by + :class:`plane_mcp.cognito_http._PlaneCognitoProvider.load_access_token`. It has the + same ``aud`` / signature as the access token, so oauth2-proxy validates it cleanly + and emits the right ``X-Auth-Request-User`` — same trust chain as the web flow, + no header injection, no internal-port bypass. + + Falls back to ``token`` when no ID token is attached (e.g., header API-key auth, + legacy stdio mode); local PAT setups still work in that case. + """ + id_token = (claims or {}).get("id_token") + if isinstance(id_token, str) and id_token: + return id_token + return token + + def get_plane_client_context() -> PlaneClientContext: """ Initialize and return a PlaneClient instance with workspace context. @@ -25,11 +50,14 @@ def get_plane_client_context() -> PlaneClientContext: Authentication is handled by the PlaneOAuthProvider, which supports: 1. Environment variables (PLANE_API_KEY + PLANE_WORKSPACE_SLUG) 2. HTTP headers (x-api-key + x-workspace-slug) - 3. OAuth access token + 3. OAuth access token (Cognito browser flow — swapped for the matching ID token + so oauth2-proxy can resolve the user via ``cognito:username``) Environment variables: - - PLANE_INTERNAL_BASE_URL: Internal URL for Plane API (preferred for server-to-server calls) - - PLANE_BASE_URL: Base URL for Plane API (fallback, default: https://api.plane.so) + - PLANE_INTERNAL_BASE_URL: Internal URL for Plane API (skips Traefik+oauth2-proxy; + only safe inside the same trust boundary, e.g. local dev with PAT auth). + - PLANE_BASE_URL: Public Plane URL fronted by Traefik+oauth2-proxy (default for + Cognito browser auth so all calls go through the same chain as the web UI). Returns: PlaneClientContext containing configured PlaneClient instance and workspace slug @@ -41,21 +69,20 @@ def get_plane_client_context() -> PlaneClientContext: workspace_slug = os.getenv("PLANE_WORKSPACE_SLUG", "") api_key = os.getenv("PLANE_API_KEY", "") - access_token = None + access_token: str | None = None - # Get access token from the OAuth provider (which handles all auth methods) stored_access_token: AccessToken | None = get_access_token() if stored_access_token: - # Determine authentication method to use appropriate PlaneClient constructor auth_method = stored_access_token.claims.get("auth_method", "oauth") token = stored_access_token.token - workspace_slug = stored_access_token.claims.get("workspace_slug", "") + claim_workspace = stored_access_token.claims.get("workspace_slug", "") + if claim_workspace: + workspace_slug = claim_workspace - # For API key auth methods, use api_key parameter; for OAuth, use access_token if auth_method in ("api_key_env", "api_key_header"): api_key = token else: - access_token = token + access_token = _plane_bearer_for(token, stored_access_token.claims) if access_token: client = PlaneClient( diff --git a/plane_mcp/cognito_http.py b/plane_mcp/cognito_http.py new file mode 100644 index 0000000..bf17c4e --- /dev/null +++ b/plane_mcp/cognito_http.py @@ -0,0 +1,346 @@ +"""AWS Cognito browser OAuth for streamable HTTP MCP. + +When ``cognito_http_env_ready()`` is true, ``python -m plane_mcp http`` serves MCP at +``{MCP_BASE_URL}/mcp`` with FastMCP's ``AWSCognitoProvider``. + +After login, FastMCP validates the **Cognito access token** and exposes it on the session +``AccessToken``; :func:`plane_mcp.client.get_plane_client_context` passes that string to +``PlaneClient(..., access_token=...)``, so Plane receives ``Authorization: Bearer ``. + +Set ``PLANE_BASE_URL`` / ``PLANE_INTERNAL_BASE_URL`` to your Plane API host. Cognito tokens +usually do not include ``workspace_slug``; set ``PLANE_WORKSPACE_SLUG`` (or +``PLANE_MCP_WORKSPACE_SLUG``) so tools use the correct workspace in API paths. + +Optional PAT mount: ``{MCP_BASE_URL}/http/api-key/mcp`` (same as default HTTP mode). +""" + +from __future__ import annotations + +import logging +import os +from contextlib import asynccontextmanager +from typing import Any +from urllib.parse import urlparse + +from fastmcp import FastMCP +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.providers.aws import AWSCognitoProvider, AWSCognitoTokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.middleware.logging import StructuredLoggingMiddleware +from key_value.aio.stores.memory import MemoryStore +from key_value.aio.stores.redis import RedisStore +from mcp.server.auth.provider import AuthorizationParams +from mcp.shared.auth import OAuthClientInformationFull +from mcp.types import Icon +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Mount, Route + +from plane_mcp.server import get_header_mcp +from plane_mcp.tools import register_tools + +logger = logging.getLogger(__name__) + +REQUIRED_HTTP_ENV_VARS = ( + "MCP_BASE_URL", + "COGNITO_USER_POOL_ID", + "COGNITO_AWS_REGION", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", +) + +_oauth_kv_singleton: MemoryStore | RedisStore | None = None + +_DEFAULT_CALLBACK_PATH = "/auth/callback" + + +def _cognito_callback_base_and_path() -> tuple[str, str]: + """Return ``(base_url, redirect_path)`` for Cognito ``redirect_uri`` (authorize + token).""" + raw = os.getenv("MCP_COGNITO_REDIRECT_URI", "").strip() or os.getenv("PLANE_MCP_COGNITO_REDIRECT_URI", "").strip() + if raw: + p = urlparse(raw) + if p.scheme not in ("http", "https") or not p.netloc: + raise ValueError("MCP_COGNITO_REDIRECT_URI must be an absolute http(s) URL without query or fragment") + if p.query or p.fragment: + raise ValueError("MCP_COGNITO_REDIRECT_URI must not include query or fragment") + path = p.path or _DEFAULT_CALLBACK_PATH + if not path.startswith("/"): + path = "/" + path + path = path.rstrip("/") or "/" + if path == "/": + raise ValueError("MCP_COGNITO_REDIRECT_URI must include a path (e.g. /auth/callback)") + base = f"{p.scheme}://{p.netloc}".rstrip("/") + mcp = os.environ.get("MCP_BASE_URL", "").strip().rstrip("/") + if mcp and mcp != base: + logger.warning( + "MCP_COGNITO_REDIRECT_URI host %s differs from MCP_BASE_URL %s — Cognito uses the redirect URI only", + base, + mcp, + ) + return base, path + base = os.environ["MCP_BASE_URL"].strip().rstrip("/") + return base, _DEFAULT_CALLBACK_PATH + + +def cognito_idp_redirect_uri() -> str: + """Exact IdP callback URL sent to Cognito (for logs, Cognito console, tests).""" + b, path = _cognito_callback_base_and_path() + return f"{b}{path}" + + +class _AWSCognitoTokenVerifierUsernameFallback(AWSCognitoTokenVerifier): + """Cognito access tokens often use ``cognito:username`` instead of ``username``.""" + + async def verify_token(self, token: str) -> AccessToken | None: + access_token = await JWTVerifier.verify_token(self, token) + if not access_token: + return None + raw = access_token.claims + username = raw.get("username") or raw.get("cognito:username") + cognito_claims = { + "sub": raw.get("sub"), + "username": username, + "cognito:groups": raw.get("cognito:groups", []), + } + return AccessToken( + token=access_token.token, + client_id=access_token.client_id, + scopes=access_token.scopes, + expires_at=access_token.expires_at, + claims=cognito_claims, + ) + + +class _PlaneCognitoProvider(AWSCognitoProvider): + """Cognito OAuth: drop ``resource`` on upstream authorize when pool has no resource server. + + Also attaches the upstream Cognito **ID token** to the validated MCP ``AccessToken.claims`` + so tools can forward it to Plane API instead of the access token. Plane's Traefik + chain runs ForwardAuth via oauth2-proxy with ``--user-id-claim=cognito:username``; + Cognito **access** tokens have only ``username`` (no prefix), so plane-mcp would + otherwise authenticate as ``sub@.com``. Forwarding the ID token (which has + ``cognito:username``) gives plane-mcp the same Plane user as the web cookie flow + without any oauth2-proxy / Plane / AWS changes. See ``plane_mcp/client.py``. + """ + + async def load_access_token(self, token: str) -> AccessToken | None: + validated = await super().load_access_token(token) + if validated is None: + return None + try: + payload = self.jwt_issuer.verify_token(token) + jti = payload.get("jti") + if not jti: + return validated + jti_mapping = await self._jti_mapping_store.get(key=jti) + if not jti_mapping: + return validated + ts = await self._upstream_token_store.get(key=jti_mapping.upstream_token_id) + id_token = (ts.raw_token_data or {}).get("id_token") if ts else None + if not isinstance(id_token, str) or not id_token: + return validated + new_claims = dict(validated.claims or {}) + new_claims["id_token"] = id_token + return validated.model_copy(update={"claims": new_claims}) + except Exception as exc: + logger.warning("Cognito HTTP: failed to attach upstream id_token to claims: %s", exc) + return validated + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + """Allow MCP clients whose ``resource`` URL does not match ``MCP_BASE_URL`` (e.g. localhost vs Traefik). + + FastMCP rejects mismatched ``resource`` with ``invalid_target`` before any redirect — Cursor often + sends ``http://127.0.0.1:/mcp`` while ``MCP_BASE_URL`` is ``https://foss-pm-mcp...``. + Clear ``resource`` so consent/upstream Cognito flow runs; upstream authorize drops ``resource`` anyway. + Set ``COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH=false`` to enforce strict binding. + """ + relax = os.getenv("COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH", "true").strip().lower() in ( + "1", + "true", + "yes", + ) + server_resource = getattr(self, "_resource_url", None) + client_resource = getattr(params, "resource", None) + if ( + relax + and client_resource is not None + and server_resource is not None + and str(client_resource) != str(server_resource) + ): + logger.warning( + "Cognito OAuth: client resource %s != server MCP resource %s; continuing without " + "resource binding (COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH). Prefer aligning PLANE_MCP_BASE_URL " + "/ MCP_BASE_URL with the MCP URL your client uses.", + client_resource, + server_resource, + ) + params = params.model_copy(update={"resource": None}) + return await super().authorize(client, params) + + def _build_upstream_authorize_url(self, txn_id: str, transaction: dict[str, Any]) -> str: + tx = dict(transaction) + if tx.get("resource"): + logger.debug( + "Cognito HTTP: omitting resource=%s on upstream authorize (no Cognito resource server)", + tx.get("resource"), + ) + tx["resource"] = None + return super()._build_upstream_authorize_url(txn_id, tx) + + def get_token_verifier( + self, + *, + algorithm: str | None = None, + audience: str | None = None, + required_scopes: list[str] | None = None, + timeout_seconds: int | None = None, + ): + return _AWSCognitoTokenVerifierUsernameFallback( + issuer=str(self.oidc_config.issuer), + audience=audience, + algorithm=algorithm, + jwks_uri=str(self.oidc_config.jwks_uri), + required_scopes=required_scopes, + ) + + +def cognito_http_env_ready() -> bool: + return all(os.getenv(name, "").strip() for name in REQUIRED_HTTP_ENV_VARS) + + +def validate_cognito_http_env() -> None: + missing = [name for name in REQUIRED_HTTP_ENV_VARS if not os.getenv(name, "").strip()] + if missing: + raise ValueError("http mode (Cognito) is missing required env vars: " + ", ".join(missing)) + + +def _allowed_client_redirect_uris() -> list[str] | None: + """Redirect URI patterns for MCP OAuth Dynamic Client Registration (fnmatch wildcards). + + ``None`` means allow **any** client redirect URI (FastMCP default; needed for clients that use + custom schemes such as ``cursor://``). Set ``MCP_ALLOWED_CLIENT_REDIRECT_URIS`` to a + comma-separated list to restrict patterns in production. + """ + raw = os.getenv("MCP_ALLOWED_CLIENT_REDIRECT_URIS", "").strip() + if not raw: + return None + return [uri.strip() for uri in raw.split(",") if uri.strip()] + + +def _oauth_client_storage() -> MemoryStore | RedisStore: + global _oauth_kv_singleton + if _oauth_kv_singleton is not None: + return _oauth_kv_singleton + redis_host = os.getenv("REDIS_HOST", "").strip() + redis_port = os.getenv("REDIS_PORT", "").strip() + if redis_host and redis_port: + logger.info("Cognito HTTP: using Redis for OAuth storage") + _oauth_kv_singleton = RedisStore(host=redis_host, port=int(redis_port)) + else: + logger.warning("Cognito HTTP: in-memory OAuth storage (set REDIS_HOST/REDIS_PORT for production)") + _oauth_kv_singleton = MemoryStore() + return _oauth_kv_singleton + + +def _consent_enabled() -> bool: + v = os.getenv("COGNITO_REQUIRE_CONSENT", "true").strip().lower() + return v not in ("0", "false", "no") + + +def _build_cognito_provider() -> AWSCognitoProvider: + base_url, redirect_path = _cognito_callback_base_and_path() + redirect_patterns = _allowed_client_redirect_uris() + if redirect_patterns is None: + logger.info( + "Cognito HTTP: MCP_ALLOWED_CLIENT_REDIRECT_URIS unset — any MCP client redirect URI allowed " + "(needed for Cursor/custom OAuth schemes). Set MCP_ALLOWED_CLIENT_REDIRECT_URIS to restrict." + ) + else: + logger.info("Cognito HTTP: MCP client redirect URI patterns: %s", redirect_patterns) + kwargs = dict( + user_pool_id=os.environ["COGNITO_USER_POOL_ID"], + aws_region=os.environ["COGNITO_AWS_REGION"], + client_id=os.environ["OIDC_CLIENT_ID"], + client_secret=os.environ["OIDC_CLIENT_SECRET"], + base_url=base_url, + redirect_path=redirect_path, + required_scopes=["openid"], + allowed_client_redirect_uris=redirect_patterns, + client_storage=_oauth_client_storage(), + require_authorization_consent=_consent_enabled(), + ) + provider = _PlaneCognitoProvider(**kwargs) + + forward_pkce = os.getenv("COGNITO_OIDC_FORWARD_PKCE", "false").strip().lower() in ("1", "true", "yes") + provider._forward_pkce = forward_pkce # type: ignore[attr-defined] + if forward_pkce: + logger.info("Cognito HTTP: COGNITO_OIDC_FORWARD_PKCE=true (PKCE sent to Cognito authorize/token)") + else: + logger.info("Cognito HTTP: upstream PKCE off (default). Tools use Cognito access token as Plane Bearer.") + + auth_meth = os.getenv("COGNITO_TOKEN_ENDPOINT_AUTH_METHOD", "").strip() + if auth_meth: + provider._token_endpoint_auth_method = auth_meth # type: ignore[attr-defined] + logger.info("Cognito HTTP: COGNITO_TOKEN_ENDPOINT_AUTH_METHOD=%s", auth_meth) + + return provider + + +def get_cognito_http_mcp() -> FastMCP: + if not os.getenv("PLANE_BASE_URL", "").strip() and not os.getenv("PLANE_INTERNAL_BASE_URL", "").strip(): + logger.warning("Cognito HTTP: PLANE_BASE_URL / PLANE_INTERNAL_BASE_URL unset — Plane API host may be wrong.") + + mcp = FastMCP( + "Plane MCP Server (Cognito http)", + icons=[Icon(src="https://plane.so/favicon.ico", alt="Plane MCP Server")], + website_url="https://plane.so", + auth=_build_cognito_provider(), + ) + mcp.add_middleware(StructuredLoggingMiddleware(include_payloads=True)) + register_tools(mcp) + return mcp + + +async def healthz(_request: Request) -> JSONResponse: + return JSONResponse({"status": "ok"}) + + +def build_cognito_http_starlette_app() -> Starlette: + validate_cognito_http_env() + cognito_mcp = get_cognito_http_mcp() + header_mcp = get_header_mcp() + cognito_app = cognito_mcp.http_app(stateless_http=True) + header_app = header_mcp.http_app(stateless_http=True) + + routes: list = [ + Route("/healthz", healthz, methods=["GET"]), + Mount("/http/api-key", app=header_app), + Mount("/", app=cognito_app), + ] + + @asynccontextmanager + async def lifespan(app): + async with cognito_app.lifespan(cognito_app): + async with header_app.lifespan(header_app): + yield + + app = Starlette(routes=routes, lifespan=lifespan) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + cb = cognito_idp_redirect_uri() + base = os.environ["MCP_BASE_URL"].strip().rstrip("/") + logger.info( + "Cognito HTTP: MCP %s/mcp; PAT %s/http/api-key/mcp; health %s/healthz", + base, + base, + base, + ) + logger.info("Cognito HTTP: register this exact Callback URL in Cognito: %s", cb) + return app diff --git a/plane_mcp/tools/workspaces.py b/plane_mcp/tools/workspaces.py index a24e0b9..acf84ee 100644 --- a/plane_mcp/tools/workspaces.py +++ b/plane_mcp/tools/workspaces.py @@ -1,15 +1,111 @@ """Workspace-related tools for Plane MCP Server.""" +import os + +import httpx from fastmcp import FastMCP +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.dependencies import get_access_token +from fastmcp.utilities.logging import get_logger from plane.models.users import UserLite from plane.models.workspaces import WorkspaceFeature +from pydantic import BaseModel, ConfigDict + +from plane_mcp.client import _plane_bearer_for, get_plane_client_context + +logger = get_logger(__name__) + + +class Workspace(BaseModel): + """Plane workspace summary returned from ``GET /api/v1/workspaces/``. + + Pressingly Plane (and upstream Plane) include other fields (logo, organization size, + timestamps, etc.); ``extra="allow"`` keeps them on the model so tool consumers can + still inspect everything without a schema mismatch breaking the call. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str | None = None + slug: str | None = None + owner: str | None = None + + +def _plane_root_base() -> str: + """Return ```` (no path) for non-``/api/v1`` endpoints. + + Plane's "list user workspaces" lives at ``/api/users/me/workspaces/`` (the **app** API, + not the public ``/api/v1/`` surface — see Plane backend ``UserWorkSpacesEndpoint``). + Prefers ``PLANE_INTERNAL_BASE_URL`` (server-to-server); otherwise ``PLANE_BASE_URL``. + """ + base = os.getenv("PLANE_INTERNAL_BASE_URL") or os.getenv("PLANE_BASE_URL", "https://api.plane.so") + return base.rstrip("/") + -from plane_mcp.client import get_plane_client_context +def _httpx_verify() -> bool | str: + """Match the SDK's TLS trust: honor ``REQUESTS_CA_BUNDLE`` / ``SSL_CERT_FILE``. + + httpx ignores ``REQUESTS_CA_BUNDLE`` (which the Plane SDK / requests honors), so + when calling Traefik with a dev wildcard cert we must pass ``verify=`` + explicitly. Falls through to httpx's default trust store when neither is set. + """ + bundle = os.getenv("REQUESTS_CA_BUNDLE") or os.getenv("SSL_CERT_FILE") + return bundle or True + + +def _plane_auth_headers() -> dict[str, str]: + """Build ``Authorization`` / ``X-Api-Key`` for Plane from the current MCP session. + + Cognito browser-OAuth: ``get_access_token()`` returns the validated Cognito **access** + token; we forward the matching **ID token** (looked up via + ``plane_mcp.client._plane_bearer_for``) so oauth2-proxy can resolve + ``cognito:username`` and Plane's ``ProxyAuthMiddleware`` authenticates the same + user as the web cookie flow. PAT mount: forwards ``X-Api-Key`` instead. + Stdio: falls back to ``PLANE_API_KEY``. + """ + headers: dict[str, str] = {"Content-Type": "application/json"} + stored: AccessToken | None = get_access_token() + if stored: + auth_method = (stored.claims or {}).get("auth_method", "oauth") + if auth_method in ("api_key_env", "api_key_header"): + headers["X-Api-Key"] = stored.token + else: + headers["Authorization"] = f"Bearer {_plane_bearer_for(stored.token, stored.claims)}" + return headers + if api_key := os.getenv("PLANE_API_KEY"): + headers["X-Api-Key"] = api_key + return headers def register_workspace_tools(mcp: FastMCP) -> None: """Register all workspace-related tools with the MCP server.""" + @mcp.tool() + def list_workspaces() -> list[Workspace]: + """List Plane workspaces the authenticated user belongs to. + + Calls ``GET /api/users/me/workspaces/`` (Plane app API, not ``/api/v1/``) with the + current bearer token (Cognito OAuth) or API key (PAT / stdio). Plane's public API + does not list a user's workspaces — the workspace-scoped routes all require a + slug, so this tool exists to bootstrap that slug. + + Use a returned ``slug`` to set ``PLANE_WORKSPACE_SLUG`` (or send + ``X-Workspace-slug``) before calling workspace-scoped tools. + + Returns: + List of Workspace objects (id, name, slug, owner, plus any extra fields). + """ + url = f"{_plane_root_base()}/api/users/me/workspaces/" + with httpx.Client(timeout=30.0, verify=_httpx_verify()) as client: + resp = client.get(url, headers=_plane_auth_headers()) + resp.raise_for_status() + data = resp.json() + items = data["results"] if isinstance(data, dict) and "results" in data else data + if not isinstance(items, list): + raise ValueError(f"Unexpected workspaces payload from {url}: {type(items).__name__}") + return [Workspace.model_validate(item) for item in items] + @mcp.tool() def get_workspace_members() -> list[UserLite]: """ diff --git a/tests/test_cognito_http.py b/tests/test_cognito_http.py new file mode 100644 index 0000000..20f20df --- /dev/null +++ b/tests/test_cognito_http.py @@ -0,0 +1,70 @@ +"""Cognito HTTP callback URL resolution.""" + +import pytest + +from plane_mcp.cognito_http import ( + REQUIRED_HTTP_ENV_VARS, + _allowed_client_redirect_uris, + _cognito_callback_base_and_path, + cognito_http_env_ready, + cognito_idp_redirect_uri, +) + + +@pytest.fixture +def clear_cognito_env(monkeypatch): + for name in REQUIRED_HTTP_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def test_cognito_http_env_ready(monkeypatch): + for name in REQUIRED_HTTP_ENV_VARS: + monkeypatch.setenv(name, "x") + assert cognito_http_env_ready() is True + + +def test_cognito_http_env_ready_false(clear_cognito_env, monkeypatch): + for i, _name in enumerate(REQUIRED_HTTP_ENV_VARS): + for n in REQUIRED_HTTP_ENV_VARS: + monkeypatch.delenv(n, raising=False) + for j, n in enumerate(REQUIRED_HTTP_ENV_VARS): + if j != i: + monkeypatch.setenv(n, "x") + assert cognito_http_env_ready() is False + + +def test_cognito_idp_redirect_from_mcp_base(monkeypatch): + monkeypatch.setenv("MCP_BASE_URL", "https://mcp.example/") + assert cognito_idp_redirect_uri() == "https://mcp.example/auth/callback" + + +def test_cognito_idp_redirect_explicit(monkeypatch): + monkeypatch.setenv("MCP_BASE_URL", "https://internal:8211") + monkeypatch.setenv("MCP_COGNITO_REDIRECT_URI", "https://public.example/cb/path") + assert cognito_idp_redirect_uri() == "https://public.example/cb/path" + + +def test_cognito_idp_redirect_rejects_query(monkeypatch): + monkeypatch.setenv("MCP_BASE_URL", "https://x.test") + monkeypatch.setenv("MCP_COGNITO_REDIRECT_URI", "https://x.test/cb?a=1") + with pytest.raises(ValueError, match="query"): + cognito_idp_redirect_uri() + + +def test_allowed_client_redirect_uris_none_when_unset(clear_cognito_env, monkeypatch): + monkeypatch.delenv("MCP_ALLOWED_CLIENT_REDIRECT_URIS", raising=False) + assert _allowed_client_redirect_uris() is None + + +def test_allowed_client_redirect_uris_when_set(monkeypatch): + monkeypatch.setenv("MCP_ALLOWED_CLIENT_REDIRECT_URIS", "cursor://*,http://localhost:*/*") + assert _allowed_client_redirect_uris() == ["cursor://*", "http://localhost:*/*"] + + +def test_cognito_idp_redirect_legacy_alias(monkeypatch): + monkeypatch.setenv("MCP_BASE_URL", "https://x.test") + monkeypatch.delenv("MCP_COGNITO_REDIRECT_URI", raising=False) + monkeypatch.setenv("PLANE_MCP_COGNITO_REDIRECT_URI", "https://other.example/auth/callback") + b, p = _cognito_callback_base_and_path() + assert b == "https://other.example" + assert p == "/auth/callback" diff --git a/tests/test_workspaces_tool.py b/tests/test_workspaces_tool.py new file mode 100644 index 0000000..3cce0fe --- /dev/null +++ b/tests/test_workspaces_tool.py @@ -0,0 +1,44 @@ +"""Unit tests for the ``list_workspaces`` tool helpers.""" + +from types import SimpleNamespace +from unittest.mock import patch + +from plane_mcp.tools.workspaces import _plane_auth_headers, _plane_root_base + + +def test_plane_root_base_uses_internal_first(monkeypatch): + monkeypatch.setenv("PLANE_INTERNAL_BASE_URL", "http://plane-api:8000") + monkeypatch.setenv("PLANE_BASE_URL", "https://public.example/") + assert _plane_root_base() == "http://plane-api:8000" + + +def test_plane_root_base_falls_back_to_public(monkeypatch): + monkeypatch.delenv("PLANE_INTERNAL_BASE_URL", raising=False) + monkeypatch.setenv("PLANE_BASE_URL", "https://foss-pm.local.moneta.dev/") + assert _plane_root_base() == "https://foss-pm.local.moneta.dev" + + +@patch("plane_mcp.tools.workspaces.get_access_token") +def test_plane_auth_headers_oauth_uses_bearer(mock_get_token, monkeypatch): + monkeypatch.delenv("PLANE_API_KEY", raising=False) + mock_get_token.return_value = SimpleNamespace(token="cognito-jwt", claims={"auth_method": "oauth"}) + headers = _plane_auth_headers() + assert headers["Authorization"] == "Bearer cognito-jwt" + assert "X-Api-Key" not in headers + + +@patch("plane_mcp.tools.workspaces.get_access_token") +def test_plane_auth_headers_pat_uses_x_api_key(mock_get_token, monkeypatch): + monkeypatch.delenv("PLANE_API_KEY", raising=False) + mock_get_token.return_value = SimpleNamespace(token="pat-value", claims={"auth_method": "api_key_header"}) + headers = _plane_auth_headers() + assert headers["X-Api-Key"] == "pat-value" + assert "Authorization" not in headers + + +@patch("plane_mcp.tools.workspaces.get_access_token", return_value=None) +def test_plane_auth_headers_stdio_falls_back_to_env_api_key(_mock_get_token, monkeypatch): + monkeypatch.setenv("PLANE_API_KEY", "env-key") + headers = _plane_auth_headers() + assert headers["X-Api-Key"] == "env-key" + assert "Authorization" not in headers From a8c220a698bdcdb8771dfa38b61b49c0cc6d3ee8 Mon Sep 17 00:00:00 2001 From: jawad-khan Date: Tue, 5 May 2026 14:40:27 +0500 Subject: [PATCH 2/6] fix: Added flexible workspace --- README.md | 158 ++++++++++++++++++++---- plane_mcp/__main__.py | 21 +++- plane_mcp/client.py | 63 ++++++---- plane_mcp/cognito_http.py | 66 ++++++---- plane_mcp/server.py | 2 +- plane_mcp/tools/cycles.py | 40 +++--- plane_mcp/tools/epics.py | 15 ++- plane_mcp/tools/initiatives.py | 17 +-- plane_mcp/tools/intake.py | 16 ++- plane_mcp/tools/labels.py | 17 +-- plane_mcp/tools/milestones.py | 26 ++-- plane_mcp/tools/modules.py | 37 +++--- plane_mcp/tools/pages.py | 12 +- plane_mcp/tools/projects.py | 52 ++++---- plane_mcp/tools/states.py | 17 +-- plane_mcp/tools/users.py | 7 +- plane_mcp/tools/work_item_activities.py | 6 +- plane_mcp/tools/work_item_comments.py | 15 ++- plane_mcp/tools/work_item_links.py | 15 ++- plane_mcp/tools/work_item_properties.py | 15 ++- plane_mcp/tools/work_item_relations.py | 11 +- plane_mcp/tools/work_item_types.py | 15 ++- plane_mcp/tools/work_items.py | 42 ++++--- plane_mcp/tools/work_logs.py | 12 +- plane_mcp/tools/workspaces.py | 27 ++-- tests/test_client_context.py | 34 +++++ tests/test_cognito_http.py | 43 +++++++ 27 files changed, 560 insertions(+), 241 deletions(-) create mode 100644 tests/test_client_context.py diff --git a/README.md b/README.md index f0f10e5..f49cd8a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ A Model Context Protocol (MCP) server for Plane integration. This server provide The server supports three transport methods. **We recommend using `uvx`** as it doesn't require installation. +**Requirements**: +- **Python 3.10+** (for stdio transport, via `uvx`) +- **Node.js 22+** (for remote transports, via `npx`) + ### 1. Stdio Transport (for local use) **MCP Client Configuration** (using uvx - recommended): @@ -58,7 +62,7 @@ Connect to the hosted Plane MCP server using OAuth authentication. Connect to the hosted Plane MCP server using a Personal Access Token (PAT). -**URL**: `https://mcp.plane.so/api-key/mcp` +**URL**: `https://mcp.plane.so/http/api-key/mcp` **Headers**: - `Authorization: Bearer ` @@ -81,37 +85,25 @@ Connect to the hosted Plane MCP server using a Personal Access Token (PAT). } ``` -### 4. HTTP with AWS Cognito - -When the Cognito-related environment variables below are **all** set, `python -m plane_mcp http` (or the container entrypoint in `http` mode) serves MCP OAuth via FastMCP’s `AWSCognitoProvider`. In that case the default multi-mount app (Plane OAuth at `/http`, SSE at `/`) is **not** started in the same process—use a separate deployment or clear the Cognito variables if you need those transports. +### 4. HTTP with AWS Cognito (self-hosted) -- **MCP URL**: `{MCP_BASE_URL}/mcp` (Cognito session; the access token is sent to Plane as `Authorization: Bearer …` from the MCP session). -- **Do not use** `{MCP_BASE_URL}/http/mcp` in this mode — that path belongs to the **non-Cognito** HTTP layout; Cognito-only installs serve MCP at **`/mcp`** only. -- **Callback**: register **`{MCP_BASE_URL}/auth/callback`** in the Cognito app client. -- **PAT fallback** (unchanged): `{MCP_BASE_URL}/http/api-key/mcp` with `Authorization` + `X-Workspace-slug` headers. -- **Health**: `GET {MCP_BASE_URL}/healthz` +When **all** of the following environment variables are set, `python -m plane_mcp http` runs the Cognito-backed HTTP app (`plane_mcp.cognito_http`) instead of the default Plane OAuth + SSE layout in the same process. Unset them if you need `/http/mcp` Plane OAuth and legacy SSE on `/`. -Required env vars for this mode: +| Endpoint | URL | +|----------|-----| +| Streamable HTTP (browser OAuth via Cognito) | `{MCP_BASE_URL}/mcp` | +| PAT (same as non-Cognito HTTP) | `{MCP_BASE_URL}/http/api-key/mcp` | +| Health check | `GET {MCP_BASE_URL}/healthz` | -| Variable | Purpose | -|----------|---------| -| `MCP_BASE_URL` | Public base URL of this MCP server (no trailing slash required) | -| `COGNITO_USER_POOL_ID` | Cognito user pool ID | -| `COGNITO_AWS_REGION` | AWS region of the pool | -| `OIDC_CLIENT_ID` | Cognito app client ID | -| `OIDC_CLIENT_SECRET` | Cognito app client secret | +**Cognito app client:** use a **public** client (client ID only). Register **`{MCP_BASE_URL}/auth/callback`** as an allowed callback URL (or the full URL from `MCP_COGNITO_REDIRECT_URI` if you set that). Do not point MCP clients at `{MCP_BASE_URL}/http/mcp` in this mode. -Optional: +**Required:** `MCP_BASE_URL`, `COGNITO_USER_POOL_ID`, `COGNITO_AWS_REGION`, `OIDC_CLIENT_ID`, `MCP_JWT_SIGNING_KEY` (long random secret, e.g. `openssl rand -hex 32`). -- `COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH` — when **`true`** (default), allow OAuth **`/authorize`** when the MCP client’s **`resource`** URL does not exactly match **`MCP_BASE_URL`** (e.g. **`http://127.0.0.1:…/mcp`** vs **`https://host…/mcp`**). FastMCP otherwise returns **`invalid_target`** before redirecting to Cognito. -- `MCP_ALLOWED_CLIENT_REDIRECT_URIS` — optional comma-separated fnmatch patterns for MCP Dynamic Client Registration redirects. **Unset (default): allow any redirect URI** so clients using **`cursor://`**, **`vscode://`**, or loopback URLs can complete OAuth. Set explicit patterns only in locked-down environments (see FastMCP `redirect_validation`). -- `REDIS_HOST` / `REDIS_PORT` — persist OAuth client registrations (recommended in production). -- `PLANE_BASE_URL` or `PLANE_INTERNAL_BASE_URL` — Plane API host for tools. -- `PLANE_WORKSPACE_SLUG` — default workspace when the IdP token has no `workspace_slug` claim (e.g. Cognito). +**Optional:** `MCP_COGNITO_REDIRECT_URI` (or legacy alias `PLANE_MCP_COGNITO_REDIRECT_URI`), `COGNITO_TOKEN_ENDPOINT_AUTH_METHOD` (default `none` for public clients), `COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH` (default `true`), `COGNITO_REQUIRE_CONSENT`, `COGNITO_OIDC_FORWARD_PKCE`, `MCP_ALLOWED_CLIENT_REDIRECT_URIS`, `REDIS_HOST` / `REDIS_PORT`, `PLANE_BASE_URL` / `PLANE_INTERNAL_BASE_URL`, `PLANE_WORKSPACE_SLUG`. -**`invalid_grant` after returning to `/auth/callback` (token exchange):** Often caused by Cognito expecting a **resource server** matching the MCP `resource` URL when FastMCP forwards `resource=` to `/oauth2/authorize`. This server’s Cognito mode **drops `resource` on the upstream authorize request** so a pool that only allowlists **`…/auth/callback`** can still complete token exchange—**no Cognito change required.** If your admins *have* configured a resource server, you can still use this behavior (Cognito typically accepts authorize without `resource`). For the canonical IdP-side fix, see the [FastMCP AWS Cognito guide](https://gofastmcp.com/v2/integrations/aws-cognito) (“Configure Resource Server”). +This build subclasses FastMCP’s Cognito provider to omit `resource` on the upstream authorize request when your pool has no resource server, optionally relax MCP `resource` vs `MCP_BASE_URL` mismatches, and attach the Cognito **ID token** to the MCP session when available so Plane’s gateway can resolve `cognito:username` the same way as the web UI (see `plane_mcp/client.py`). -**`redirect_mismatch` on the Cognito error page:** Cognito only accepts the **`redirect_uri`** your MCP server sends on `/oauth2/authorize`. FastMCP uses **`{MCP_BASE_URL}/auth/callback`** unless **`MCP_COGNITO_REDIRECT_URI`** is set to the full allowlisted callback URL. See server logs on startup for the exact **`IdP redirect_uri`**. Register that URL under the app client’s **Allowed callback URLs**—`http://127.0.0.1:8211/...` and `http://localhost:8211/...` are different; register the one that matches **`MCP_BASE_URL`** / **`MCP_COGNITO_REDIRECT_URI`** (or register both). Restart the server after changing env. +If `COGNITO_USER_POOL_ID` or `OIDC_CLIENT_ID` is set but any required variable is missing, HTTP mode fails with an explicit error instead of falling through to Plane OAuth. ### 5. SSE Transport (Legacy) @@ -250,13 +242,127 @@ The server provides comprehensive tools for interacting with Plane. All tools us | `update_work_item_property` | Update a work item property with partial data | | `delete_work_item_property` | Delete a work item property by ID | +### Epics + +| Tool Name | Description | +|-----------|-------------| +| `list_epics` | List all epics in a project | +| `create_epic` | Create a new epic | +| `retrieve_epic` | Retrieve an epic by ID | +| `update_epic` | Update an epic by ID | +| `delete_epic` | Delete an epic by ID | + +### Milestones + +| Tool Name | Description | +|-----------|-------------| +| `list_milestones` | List all milestones in a project | +| `create_milestone` | Create a new milestone | +| `retrieve_milestone` | Retrieve a milestone by ID | +| `update_milestone` | Update a milestone by ID | +| `delete_milestone` | Delete a milestone by ID | +| `add_work_items_to_milestone` | Add work items to a milestone | +| `remove_work_items_from_milestone` | Remove work items from a milestone | +| `list_milestone_work_items` | List work items in a milestone | + +### Labels + +| Tool Name | Description | +|-----------|-------------| +| `list_labels` | List all labels in a project | +| `create_label` | Create a new label | +| `retrieve_label` | Retrieve a label by ID | +| `update_label` | Update a label by ID | +| `delete_label` | Delete a label by ID | + +### States + +| Tool Name | Description | +|-----------|-------------| +| `list_states` | List all states in a project | +| `create_state` | Create a new state | +| `retrieve_state` | Retrieve a state by ID | +| `update_state` | Update a state by ID | +| `delete_state` | Delete a state by ID | + +### Work Item Comments + +| Tool Name | Description | +|-----------|-------------| +| `list_work_item_comments` | List comments for a work item | +| `retrieve_work_item_comment` | Retrieve a specific comment for a work item | +| `create_work_item_comment` | Create a comment for a work item | +| `update_work_item_comment` | Update a comment for a work item | +| `delete_work_item_comment` | Delete a comment for a work item | + +### Work Item Links + +| Tool Name | Description | +|-----------|-------------| +| `list_work_item_links` | List links for a work item | +| `retrieve_work_item_link` | Retrieve a specific link for a work item | +| `create_work_item_link` | Create a link for a work item | +| `update_work_item_link` | Update a link for a work item | +| `delete_work_item_link` | Delete a link for a work item | + +### Work Item Types + +| Tool Name | Description | +|-----------|-------------| +| `list_work_item_types` | List all work item types in a project | +| `create_work_item_type` | Create a new work item type | +| `retrieve_work_item_type` | Retrieve a work item type by ID | +| `update_work_item_type` | Update a work item type by ID | +| `delete_work_item_type` | Delete a work item type by ID | + +### Work Item Relations + +| Tool Name | Description | +|-----------|-------------| +| `list_work_item_relations` | List relations for a work item | +| `create_work_item_relation` | Create relations for a work item | +| `remove_work_item_relation` | Remove a relation from a work item | + +### Work Item Activities + +| Tool Name | Description | +|-----------|-------------| +| `list_work_item_activities` | List activities for a work item | +| `retrieve_work_item_activity` | Retrieve a specific activity for a work item | + +### Work Logs + +| Tool Name | Description | +|-----------|-------------| +| `list_work_logs` | List work logs for a work item | +| `create_work_log` | Create a work log for a work item | +| `update_work_log` | Update a work log for a work item | +| `delete_work_log` | Delete a work log for a work item | + +### Pages + +| Tool Name | Description | +|-----------|-------------| +| `retrieve_workspace_page` | Retrieve a workspace page by ID | +| `retrieve_project_page` | Retrieve a project page by ID | +| `create_workspace_page` | Create a workspace page | +| `create_project_page` | Create a project page | + +### Workspaces + +| Tool Name | Description | +|-----------|-------------| +| `get_workspace_members` | Get all members of the current workspace | +| `get_workspace_features` | Get features of the current workspace | +| `update_workspace_features` | Update features of the current workspace | + ### Users | Tool Name | Description | |-----------|-------------| | `get_me` | Get current authenticated user information | -**Total Tools**: 55+ tools across 8 categories +**Total Tools**: 100+ tools across 20 categories ## Development diff --git a/plane_mcp/__main__.py b/plane_mcp/__main__.py index 52fd430..2e6c3d4 100644 --- a/plane_mcp/__main__.py +++ b/plane_mcp/__main__.py @@ -13,7 +13,12 @@ from starlette.middleware.cors import CORSMiddleware from starlette.routing import Mount -from plane_mcp.cognito_http import build_cognito_http_starlette_app, cognito_http_env_ready +from plane_mcp.cognito_http import ( + build_cognito_http_starlette_app, + cognito_http_configuration_intended, + cognito_http_env_missing, + cognito_http_env_ready, +) from plane_mcp.server import get_header_mcp, get_oauth_mcp, get_stdio_mcp @@ -99,8 +104,7 @@ def main() -> None: uv_logger.addHandler(uv_handler) logger.info( - "Starting Cognito HTTP server: /mcp, /http/api-key/mcp, GET /healthz " - "(set Cognito env vars per README)" + "Starting Cognito HTTP server: /mcp, /http/api-key/mcp, GET /healthz (set Cognito env vars per README)" ) uvicorn.run( app, @@ -111,6 +115,17 @@ def main() -> None: ) return + if cognito_http_configuration_intended(): + missing = cognito_http_env_missing() + raise ValueError( + "HTTP mode: Cognito is partially configured (COGNITO_USER_POOL_ID and/or OIDC_CLIENT_ID set) " + f"but required variables are missing or empty: {', '.join(missing)}. " + "Set all of: MCP_BASE_URL, COGNITO_USER_POOL_ID, COGNITO_AWS_REGION, OIDC_CLIENT_ID, " + "MCP_JWT_SIGNING_KEY. " + "To use Plane OAuth at /http/mcp instead, unset COGNITO_USER_POOL_ID and OIDC_CLIENT_ID and set " + "PLANE_OAUTH_PROVIDER_CLIENT_ID and PLANE_OAUTH_PROVIDER_CLIENT_SECRET." + ) + oauth_mcp = get_oauth_mcp("/http") oauth_app = oauth_mcp.http_app(stateless_http=True) header_app = get_header_mcp().http_app(stateless_http=True) diff --git a/plane_mcp/client.py b/plane_mcp/client.py index 6391846..25e5976 100644 --- a/plane_mcp/client.py +++ b/plane_mcp/client.py @@ -7,6 +7,7 @@ from fastmcp.server.dependencies import get_access_token from fastmcp.utilities.logging import get_logger from plane import PlaneClient +from plane.errors import ConfigurationError logger = get_logger(__name__) @@ -19,66 +20,62 @@ class PlaneClientContext(NamedTuple): def _plane_bearer_for(token: str, claims: dict[str, Any] | None) -> str: - """Pick the Cognito JWT to forward to Plane: ID token if available, else access token. - - oauth2-proxy in this stack uses ``--user-id-claim=cognito:username`` (set so the web - cookie flow, which presents the **ID** token, resolves the right user). Cognito - **access** tokens have only ``username`` (no ``cognito:`` prefix) and no ``email`` - claim, so forwarding the access token unchanged through Traefik+oauth2-proxy makes - oauth2-proxy fall back to ``sub`` and Plane's ``ProxyAuthMiddleware`` resolves a - different account than the web user. - - The matching ID token is attached to ``AccessToken.claims["id_token"]`` by - :class:`plane_mcp.cognito_http._PlaneCognitoProvider.load_access_token`. It has the - same ``aud`` / signature as the access token, so oauth2-proxy validates it cleanly - and emits the right ``X-Auth-Request-User`` — same trust chain as the web flow, - no header injection, no internal-port bypass. - - Falls back to ``token`` when no ID token is attached (e.g., header API-key auth, - legacy stdio mode); local PAT setups still work in that case. + """Use Cognito ID token for Plane when present on the MCP session (oauth2-proxy ``cognito:username``). + + The ID token is attached to ``AccessToken.claims`` by ``plane_mcp.cognito_http`` when + available. Otherwise returns ``token`` (Plane OAuth, PAT, stdio). """ id_token = (claims or {}).get("id_token") if isinstance(id_token, str) and id_token: + logger.info("Plane bearer: forwarding upstream Cognito id_token (len=%d)", len(id_token)) return id_token + logger.warning( + "Plane bearer: no id_token in claims (keys=%s) — forwarding access token (oauth2-proxy may reject it)", + list((claims or {}).keys()), + ) return token -def get_plane_client_context() -> PlaneClientContext: +def get_plane_client_context(workspace_slug_from_client: str | None = None) -> PlaneClientContext: """ Initialize and return a PlaneClient instance with workspace context. Authentication is handled by the PlaneOAuthProvider, which supports: 1. Environment variables (PLANE_API_KEY + PLANE_WORKSPACE_SLUG) 2. HTTP headers (x-api-key + x-workspace-slug) - 3. OAuth access token (Cognito browser flow — swapped for the matching ID token - so oauth2-proxy can resolve the user via ``cognito:username``) + 3. OAuth access token + + Workspace slug: if ``workspace_slug_from_client`` is set, it wins; otherwise + token ``claims['workspace_slug']`` (e.g. PAT header), then ``PLANE_WORKSPACE_SLUG``. Environment variables: - - PLANE_INTERNAL_BASE_URL: Internal URL for Plane API (skips Traefik+oauth2-proxy; - only safe inside the same trust boundary, e.g. local dev with PAT auth). - - PLANE_BASE_URL: Public Plane URL fronted by Traefik+oauth2-proxy (default for - Cognito browser auth so all calls go through the same chain as the web UI). + - PLANE_INTERNAL_BASE_URL: Internal URL for Plane API (preferred for server-to-server calls) + - PLANE_BASE_URL: Base URL for Plane API (fallback, default: https://api.plane.so) Returns: PlaneClientContext containing configured PlaneClient instance and workspace slug Raises: - ConfigurationError: If access token is not available or workspace slug is missing + ConfigurationError: If the resolved workspace slug is empty (would produce invalid + ``/api/v1/workspaces/work-items/...`` URLs and Plane 404s). """ base_url = os.getenv("PLANE_INTERNAL_BASE_URL") or os.getenv("PLANE_BASE_URL", "https://api.plane.so") workspace_slug = os.getenv("PLANE_WORKSPACE_SLUG", "") api_key = os.getenv("PLANE_API_KEY", "") - access_token: str | None = None + access_token = None + # Get access token from the OAuth provider (which handles all auth methods) stored_access_token: AccessToken | None = get_access_token() if stored_access_token: + # Determine authentication method to use appropriate PlaneClient constructor auth_method = stored_access_token.claims.get("auth_method", "oauth") token = stored_access_token.token claim_workspace = stored_access_token.claims.get("workspace_slug", "") if claim_workspace: workspace_slug = claim_workspace + # For API key auth methods, use api_key parameter; for OAuth, use access_token if auth_method in ("api_key_env", "api_key_header"): api_key = token else: @@ -95,7 +92,19 @@ def get_plane_client_context() -> PlaneClientContext: api_key=api_key, ) + slug = (workspace_slug or "").strip() + if workspace_slug_from_client and str(workspace_slug_from_client).strip(): + slug = str(workspace_slug_from_client).strip() + + if not slug: + raise ConfigurationError( + "Workspace slug is required for Plane API calls but none was resolved. " + "Pass workspace_slug on the tool (use list_workspaces to get the slug, e.g. 'arbisofttt'), " + "set PLANE_WORKSPACE_SLUG for stdio, or authenticate with PAT and header X-Workspace-Slug. " + "Without a slug, URLs look like /api/v1/workspaces/work-items/... and Plane returns 404." + ) + return PlaneClientContext( client=client, - workspace_slug=workspace_slug, + workspace_slug=slug, ) diff --git a/plane_mcp/cognito_http.py b/plane_mcp/cognito_http.py index bf17c4e..e87d5ca 100644 --- a/plane_mcp/cognito_http.py +++ b/plane_mcp/cognito_http.py @@ -1,17 +1,13 @@ -"""AWS Cognito browser OAuth for streamable HTTP MCP. +"""AWS Cognito browser OAuth for streamable HTTP MCP (see README, "HTTP with AWS Cognito"). When ``cognito_http_env_ready()`` is true, ``python -m plane_mcp http`` serves MCP at -``{MCP_BASE_URL}/mcp`` with FastMCP's ``AWSCognitoProvider``. +``{MCP_BASE_URL}/mcp`` using a public Cognito app client and ``MCP_JWT_SIGNING_KEY``. -After login, FastMCP validates the **Cognito access token** and exposes it on the session -``AccessToken``; :func:`plane_mcp.client.get_plane_client_context` passes that string to -``PlaneClient(..., access_token=...)``, so Plane receives ``Authorization: Bearer ``. +``get_plane_client_context`` forwards the Cognito **ID token** to Plane when it is attached +to the MCP session (see ``plane_mcp.client._plane_bearer_for``). Set ``PLANE_WORKSPACE_SLUG`` +when tokens have no ``workspace_slug`` claim. -Set ``PLANE_BASE_URL`` / ``PLANE_INTERNAL_BASE_URL`` to your Plane API host. Cognito tokens -usually do not include ``workspace_slug``; set ``PLANE_WORKSPACE_SLUG`` (or -``PLANE_MCP_WORKSPACE_SLUG``) so tools use the correct workspace in API paths. - -Optional PAT mount: ``{MCP_BASE_URL}/http/api-key/mcp`` (same as default HTTP mode). +PAT mount: ``{MCP_BASE_URL}/http/api-key/mcp``. """ from __future__ import annotations @@ -41,14 +37,14 @@ from plane_mcp.server import get_header_mcp from plane_mcp.tools import register_tools -logger = logging.getLogger(__name__) +logger = logging.getLogger(f"fastmcp.{__name__}") REQUIRED_HTTP_ENV_VARS = ( "MCP_BASE_URL", "COGNITO_USER_POOL_ID", "COGNITO_AWS_REGION", "OIDC_CLIENT_ID", - "OIDC_CLIENT_SECRET", + "MCP_JWT_SIGNING_KEY", ) _oauth_kv_singleton: MemoryStore | RedisStore | None = None @@ -137,8 +133,16 @@ async def load_access_token(self, token: str) -> AccessToken | None: jti_mapping = await self._jti_mapping_store.get(key=jti) if not jti_mapping: return validated - ts = await self._upstream_token_store.get(key=jti_mapping.upstream_token_id) - id_token = (ts.raw_token_data or {}).get("id_token") if ts else None + upstream_id = getattr(jti_mapping, "upstream_token_id", None) or ( + jti_mapping.get("upstream_token_id") if isinstance(jti_mapping, dict) else None + ) + if not upstream_id: + return validated + ts = await self._upstream_token_store.get(key=upstream_id) + raw = getattr(ts, "raw_token_data", None) if ts else None + if raw is None and isinstance(ts, dict): + raw = ts.get("raw_token_data") + id_token = (raw or {}).get("id_token") if isinstance(raw, dict) else None if not isinstance(id_token, str) or not id_token: return validated new_claims = dict(validated.claims or {}) @@ -171,8 +175,8 @@ async def authorize(self, client: OAuthClientInformationFull, params: Authorizat ): logger.warning( "Cognito OAuth: client resource %s != server MCP resource %s; continuing without " - "resource binding (COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH). Prefer aligning PLANE_MCP_BASE_URL " - "/ MCP_BASE_URL with the MCP URL your client uses.", + "resource binding (COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH). Prefer aligning MCP_BASE_URL " + "with the MCP URL your client uses.", client_resource, server_resource, ) @@ -206,12 +210,22 @@ def get_token_verifier( ) +def cognito_http_env_missing() -> list[str]: + """Return required Cognito env var names that are missing or blank.""" + return [name for name in REQUIRED_HTTP_ENV_VARS if not os.getenv(name, "").strip()] + + +def cognito_http_configuration_intended() -> bool: + """True when Cognito pool/client id hints are set but env may be incomplete.""" + return bool(os.getenv("COGNITO_USER_POOL_ID", "").strip() or os.getenv("OIDC_CLIENT_ID", "").strip()) + + def cognito_http_env_ready() -> bool: - return all(os.getenv(name, "").strip() for name in REQUIRED_HTTP_ENV_VARS) + return not cognito_http_env_missing() def validate_cognito_http_env() -> None: - missing = [name for name in REQUIRED_HTTP_ENV_VARS if not os.getenv(name, "").strip()] + missing = cognito_http_env_missing() if missing: raise ValueError("http mode (Cognito) is missing required env vars: " + ", ".join(missing)) @@ -259,17 +273,22 @@ def _build_cognito_provider() -> AWSCognitoProvider: ) else: logger.info("Cognito HTTP: MCP client redirect URI patterns: %s", redirect_patterns) - kwargs = dict( + + jwt_signing_key = os.environ["MCP_JWT_SIGNING_KEY"].strip() + # FastMCP's AWSCognitoProvider requires non-empty client_secret; public Cognito apps + # use COGNITO_TOKEN_ENDPOINT_AUTH_METHOD=none so this value is not sent to Cognito. + kwargs: dict[str, Any] = dict( user_pool_id=os.environ["COGNITO_USER_POOL_ID"], aws_region=os.environ["COGNITO_AWS_REGION"], client_id=os.environ["OIDC_CLIENT_ID"], - client_secret=os.environ["OIDC_CLIENT_SECRET"], + client_secret=jwt_signing_key, base_url=base_url, redirect_path=redirect_path, required_scopes=["openid"], allowed_client_redirect_uris=redirect_patterns, client_storage=_oauth_client_storage(), require_authorization_consent=_consent_enabled(), + jwt_signing_key=jwt_signing_key, ) provider = _PlaneCognitoProvider(**kwargs) @@ -280,10 +299,9 @@ def _build_cognito_provider() -> AWSCognitoProvider: else: logger.info("Cognito HTTP: upstream PKCE off (default). Tools use Cognito access token as Plane Bearer.") - auth_meth = os.getenv("COGNITO_TOKEN_ENDPOINT_AUTH_METHOD", "").strip() - if auth_meth: - provider._token_endpoint_auth_method = auth_meth # type: ignore[attr-defined] - logger.info("Cognito HTTP: COGNITO_TOKEN_ENDPOINT_AUTH_METHOD=%s", auth_meth) + auth_meth = os.getenv("COGNITO_TOKEN_ENDPOINT_AUTH_METHOD", "none").strip() or "none" + provider._token_endpoint_auth_method = auth_meth # type: ignore[attr-defined] + logger.info("Cognito HTTP: public Cognito app client; token endpoint client authentication: %s", auth_meth) return provider diff --git a/plane_mcp/server.py b/plane_mcp/server.py index 878afe1..c39fb09 100644 --- a/plane_mcp/server.py +++ b/plane_mcp/server.py @@ -25,7 +25,7 @@ def get_oauth_mcp(base_path: str = "/"): client_storage = RedisStore(host=redis_host, port=int(redis_port)) else: logger.warning( - "Using in-memory storage - tokens will be lost on restart! " "Set REDIS_HOST and REDIS_PORT for production." + "Using in-memory storage - tokens will be lost on restart! Set REDIS_HOST and REDIS_PORT for production." ) client_storage = MemoryStore() diff --git a/plane_mcp/tools/cycles.py b/plane_mcp/tools/cycles.py index a0814c3..91a993f 100644 --- a/plane_mcp/tools/cycles.py +++ b/plane_mcp/tools/cycles.py @@ -24,6 +24,7 @@ def register_cycle_tools(mcp: FastMCP) -> None: def list_cycles( project_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[Cycle]: """ List all cycles in a project. @@ -36,7 +37,7 @@ def list_cycles( Returns: List of Cycle objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedCycleResponse = client.cycles.list( workspace_slug=workspace_slug, project_id=project_id, params=params ) @@ -53,6 +54,7 @@ def create_cycle( external_source: str | None = None, external_id: str | None = None, timezone: str | None = None, + workspace_slug: str | None = None, ) -> Cycle: """ Create a new cycle. @@ -72,7 +74,7 @@ def create_cycle( Returns: Created Cycle object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = CreateCycle( name=name, @@ -89,7 +91,7 @@ def create_cycle( return client.cycles.create(workspace_slug=workspace_slug, project_id=project_id, data=data) @mcp.tool() - def retrieve_cycle(project_id: str, cycle_id: str) -> Cycle: + def retrieve_cycle(project_id: str, cycle_id: str, workspace_slug: str | None = None) -> Cycle: """ Retrieve a cycle by ID. @@ -101,7 +103,7 @@ def retrieve_cycle(project_id: str, cycle_id: str) -> Cycle: Returns: Cycle object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.cycles.retrieve(workspace_slug=workspace_slug, project_id=project_id, cycle_id=cycle_id) @mcp.tool() @@ -116,6 +118,7 @@ def update_cycle( external_source: str | None = None, external_id: str | None = None, timezone: str | None = None, + workspace_slug: str | None = None, ) -> Cycle: """ Update a cycle by ID. @@ -136,7 +139,7 @@ def update_cycle( Returns: Updated Cycle object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = UpdateCycle( name=name, @@ -152,7 +155,7 @@ def update_cycle( return client.cycles.update(workspace_slug=workspace_slug, project_id=project_id, cycle_id=cycle_id, data=data) @mcp.tool() - def delete_cycle(project_id: str, cycle_id: str) -> None: + def delete_cycle(project_id: str, cycle_id: str, workspace_slug: str | None = None) -> None: """ Delete a cycle by ID. @@ -161,13 +164,14 @@ def delete_cycle(project_id: str, cycle_id: str) -> None: project_id: UUID of the project cycle_id: UUID of the cycle """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.cycles.delete(workspace_slug=workspace_slug, project_id=project_id, cycle_id=cycle_id) @mcp.tool() def list_archived_cycles( project_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[Cycle]: """ List archived cycles in a project. @@ -180,7 +184,7 @@ def list_archived_cycles( Returns: List of archived Cycle objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedArchivedCycleResponse = client.cycles.list_archived( workspace_slug=workspace_slug, project_id=project_id, params=params ) @@ -191,6 +195,7 @@ def add_work_items_to_cycle( project_id: str, cycle_id: str, issue_ids: list[str], + workspace_slug: str | None = None, ) -> None: """ Add work items to a cycle. @@ -201,7 +206,7 @@ def add_work_items_to_cycle( cycle_id: UUID of the cycle issue_ids: List of work item IDs to add to the cycle """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.cycles.add_work_items( workspace_slug=workspace_slug, project_id=project_id, @@ -214,6 +219,7 @@ def remove_work_item_from_cycle( project_id: str, cycle_id: str, work_item_id: str, + workspace_slug: str | None = None, ) -> None: """ Remove a work item from a cycle. @@ -224,7 +230,7 @@ def remove_work_item_from_cycle( cycle_id: UUID of the cycle work_item_id: UUID of the work item to remove """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.cycles.remove_work_item( workspace_slug=workspace_slug, project_id=project_id, @@ -237,6 +243,7 @@ def list_cycle_work_items( project_id: str, cycle_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[WorkItem]: """ List work items in a cycle. @@ -250,7 +257,7 @@ def list_cycle_work_items( Returns: List of WorkItem objects in the cycle """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedCycleWorkItemResponse = client.cycles.list_work_items( workspace_slug=workspace_slug, project_id=project_id, @@ -264,6 +271,7 @@ def transfer_cycle_work_items( project_id: str, cycle_id: str, new_cycle_id: str, + workspace_slug: str | None = None, ) -> None: """ Transfer work items from one cycle to another. @@ -274,7 +282,7 @@ def transfer_cycle_work_items( cycle_id: UUID of the source cycle new_cycle_id: UUID of the target cycle to transfer issues to """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = TransferCycleWorkItemsRequest(new_cycle_id=new_cycle_id) @@ -286,7 +294,7 @@ def transfer_cycle_work_items( ) @mcp.tool() - def archive_cycle(project_id: str, cycle_id: str) -> bool: + def archive_cycle(project_id: str, cycle_id: str, workspace_slug: str | None = None) -> bool: """ Archive a cycle. @@ -298,11 +306,11 @@ def archive_cycle(project_id: str, cycle_id: str) -> bool: Returns: True if the cycle was archived successfully """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.cycles.archive(workspace_slug=workspace_slug, project_id=project_id, cycle_id=cycle_id) @mcp.tool() - def unarchive_cycle(project_id: str, cycle_id: str) -> bool: + def unarchive_cycle(project_id: str, cycle_id: str, workspace_slug: str | None = None) -> bool: """ Unarchive a cycle. @@ -314,5 +322,5 @@ def unarchive_cycle(project_id: str, cycle_id: str) -> bool: Returns: True if the cycle was unarchived successfully """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.cycles.unarchive(workspace_slug=workspace_slug, project_id=project_id, cycle_id=cycle_id) diff --git a/plane_mcp/tools/epics.py b/plane_mcp/tools/epics.py index 9385a17..81f3f7b 100644 --- a/plane_mcp/tools/epics.py +++ b/plane_mcp/tools/epics.py @@ -37,6 +37,7 @@ def list_epics( project_id: str, cursor: str | None = None, per_page: int | None = None, + workspace_slug: str | None = None, ) -> list[Epic]: """ List all epics in a project. @@ -49,7 +50,7 @@ def list_epics( Returns: List of Epic objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) params = PaginatedQueryParams( cursor=cursor, @@ -83,6 +84,7 @@ def create_epic( parent: str | None = None, state: str | None = None, estimate_point: str | None = None, + workspace_slug: str | None = None, ) -> Epic: """ Create a new epic. @@ -111,7 +113,7 @@ def create_epic( Returns: Created WorkItem object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate priority against allowed literal values validated_priority: PriorityEnum | None = ( @@ -170,6 +172,7 @@ def update_epic( external_id: str | None = None, state: str | None = None, estimate_point: str | None = None, + workspace_slug: str | None = None, ) -> Epic: """ Update an epic by ID. @@ -196,7 +199,7 @@ def update_epic( Returns: Updated Epic object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate priority against allowed literal values valid_priorities = get_args(PriorityEnum) @@ -239,6 +242,7 @@ def update_epic( def retrieve_epic( project_id: str, epic_id: str, + workspace_slug: str | None = None, ) -> Epic: """ Retrieve an epic by ID. @@ -250,7 +254,7 @@ def retrieve_epic( Returns: Epic object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) params = RetrieveQueryParams() @@ -265,6 +269,7 @@ def retrieve_epic( def delete_epic( project_id: str, epic_id: str, + workspace_slug: str | None = None, ) -> None: """ Delete an epic by ID. @@ -276,7 +281,7 @@ def delete_epic( Returns: None """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.work_items.delete( workspace_slug=workspace_slug, diff --git a/plane_mcp/tools/initiatives.py b/plane_mcp/tools/initiatives.py index 1948340..f876349 100644 --- a/plane_mcp/tools/initiatives.py +++ b/plane_mcp/tools/initiatives.py @@ -20,6 +20,7 @@ def register_initiative_tools(mcp: FastMCP) -> None: @mcp.tool() def list_initiatives( params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[Initiative]: """ List all initiatives in a workspace. @@ -31,7 +32,7 @@ def list_initiatives( Returns: List of Initiative objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedInitiativeResponse = client.initiatives.list(workspace_slug=workspace_slug, params=params) return response.results @@ -44,6 +45,7 @@ def create_initiative( logo_props: dict | None = None, state: InitiativeState | str | None = None, lead: str | None = None, + workspace_slug: str | None = None, ) -> Initiative: """ Create a new initiative in the workspace. @@ -61,7 +63,7 @@ def create_initiative( Returns: Created Initiative object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = CreateInitiative( name=name, @@ -76,7 +78,7 @@ def create_initiative( return client.initiatives.create(workspace_slug=workspace_slug, data=data) @mcp.tool() - def retrieve_initiative(initiative_id: str) -> Initiative: + def retrieve_initiative(initiative_id: str, workspace_slug: str | None = None) -> Initiative: """ Retrieve an initiative by ID. @@ -87,7 +89,7 @@ def retrieve_initiative(initiative_id: str) -> Initiative: Returns: Initiative object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.initiatives.retrieve(workspace_slug=workspace_slug, initiative_id=initiative_id) @mcp.tool() @@ -100,6 +102,7 @@ def update_initiative( logo_props: dict | None = None, state: InitiativeState | str | None = None, lead: str | None = None, + workspace_slug: str | None = None, ) -> Initiative: """ Update an initiative by ID. @@ -118,7 +121,7 @@ def update_initiative( Returns: Updated Initiative object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = UpdateInitiative( name=name, @@ -133,7 +136,7 @@ def update_initiative( return client.initiatives.update(workspace_slug=workspace_slug, initiative_id=initiative_id, data=data) @mcp.tool() - def delete_initiative(initiative_id: str) -> None: + def delete_initiative(initiative_id: str, workspace_slug: str | None = None) -> None: """ Delete an initiative by ID. @@ -141,5 +144,5 @@ def delete_initiative(initiative_id: str) -> None: workspace_slug: The workspace slug identifier initiative_id: UUID of the initiative """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.initiatives.delete(workspace_slug=workspace_slug, initiative_id=initiative_id) diff --git a/plane_mcp/tools/intake.py b/plane_mcp/tools/intake.py index 0807f67..2ff2cc6 100644 --- a/plane_mcp/tools/intake.py +++ b/plane_mcp/tools/intake.py @@ -21,6 +21,7 @@ def register_intake_tools(mcp: FastMCP) -> None: def list_intake_work_items( project_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[IntakeWorkItem]: """ List all intake work items in a project. @@ -33,7 +34,7 @@ def list_intake_work_items( Returns: List of IntakeWorkItem objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) query_params = None if params: @@ -48,6 +49,7 @@ def list_intake_work_items( def create_intake_work_item( project_id: str, data: dict[str, Any], + workspace_slug: str | None = None, ) -> IntakeWorkItem: """ Create a new intake work item in a project. @@ -60,7 +62,7 @@ def create_intake_work_item( Returns: Created IntakeWorkItem object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) intake_data = CreateIntakeWorkItem(**data) @@ -71,6 +73,7 @@ def retrieve_intake_work_item( project_id: str, work_item_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> IntakeWorkItem: """ Retrieve an intake work item by work item ID. @@ -85,7 +88,7 @@ def retrieve_intake_work_item( Returns: IntakeWorkItem object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) query_params = None if params: @@ -103,6 +106,7 @@ def update_intake_work_item( project_id: str, work_item_id: str, data: dict[str, Any], + workspace_slug: str | None = None, ) -> IntakeWorkItem: """ Update an intake work item by work item ID. @@ -117,7 +121,7 @@ def update_intake_work_item( Returns: Updated IntakeWorkItem object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) intake_data = UpdateIntakeWorkItem(**data) @@ -129,7 +133,7 @@ def update_intake_work_item( ) @mcp.tool() - def delete_intake_work_item(project_id: str, work_item_id: str) -> None: + def delete_intake_work_item(project_id: str, work_item_id: str, workspace_slug: str | None = None) -> None: """ Delete an intake work item by work item ID. @@ -139,5 +143,5 @@ def delete_intake_work_item(project_id: str, work_item_id: str) -> None: work_item_id: UUID of the work item (use the issue field from IntakeWorkItem response, not the intake work item ID) """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.intake.delete(workspace_slug=workspace_slug, project_id=project_id, work_item_id=work_item_id) diff --git a/plane_mcp/tools/labels.py b/plane_mcp/tools/labels.py index 3392117..eb2cdf4 100644 --- a/plane_mcp/tools/labels.py +++ b/plane_mcp/tools/labels.py @@ -20,6 +20,7 @@ def register_label_tools(mcp: FastMCP) -> None: def list_labels( project_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[Label]: """ List all labels in a project. @@ -31,7 +32,7 @@ def list_labels( Returns: List of Label objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedLabelResponse = client.labels.list( workspace_slug=workspace_slug, project_id=project_id, params=params ) @@ -47,6 +48,7 @@ def create_label( sort_order: float | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> Label: """ Create a new label. @@ -64,7 +66,7 @@ def create_label( Returns: Created Label object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = CreateLabel( name=name, @@ -79,7 +81,7 @@ def create_label( return client.labels.create(workspace_slug=workspace_slug, project_id=project_id, data=data) @mcp.tool() - def retrieve_label(project_id: str, label_id: str) -> Label: + def retrieve_label(project_id: str, label_id: str, workspace_slug: str | None = None) -> Label: """ Retrieve a label by ID. @@ -90,7 +92,7 @@ def retrieve_label(project_id: str, label_id: str) -> Label: Returns: Label object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.labels.retrieve(workspace_slug=workspace_slug, project_id=project_id, label_id=label_id) @mcp.tool() @@ -104,6 +106,7 @@ def update_label( sort_order: float | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> Label: """ Update a label by ID. @@ -122,7 +125,7 @@ def update_label( Returns: Updated Label object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = UpdateLabel( name=name, @@ -142,7 +145,7 @@ def update_label( ) @mcp.tool() - def delete_label(project_id: str, label_id: str) -> None: + def delete_label(project_id: str, label_id: str, workspace_slug: str | None = None) -> None: """ Delete a label by ID. @@ -150,5 +153,5 @@ def delete_label(project_id: str, label_id: str) -> None: project_id: UUID of the project label_id: UUID of the label """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.labels.delete(workspace_slug=workspace_slug, project_id=project_id, label_id=label_id) diff --git a/plane_mcp/tools/milestones.py b/plane_mcp/tools/milestones.py index 11870e5..36ded68 100644 --- a/plane_mcp/tools/milestones.py +++ b/plane_mcp/tools/milestones.py @@ -22,6 +22,7 @@ def register_milestone_tools(mcp: FastMCP) -> None: def list_milestones( project_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[Milestone]: """ List all milestones in a project. @@ -33,7 +34,7 @@ def list_milestones( Returns: List of Milestone objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedMilestoneResponse = client.milestones.list( workspace_slug=workspace_slug, project_id=project_id, params=params ) @@ -46,6 +47,7 @@ def create_milestone( target_date: str | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> Milestone: """ Create a new milestone. @@ -60,7 +62,7 @@ def create_milestone( Returns: Created Milestone object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = CreateMilestone( title=title, @@ -72,7 +74,7 @@ def create_milestone( return client.milestones.create(workspace_slug=workspace_slug, project_id=project_id, data=data) @mcp.tool() - def retrieve_milestone(project_id: str, milestone_id: str) -> Milestone: + def retrieve_milestone(project_id: str, milestone_id: str, workspace_slug: str | None = None) -> Milestone: """ Retrieve a milestone by ID. @@ -83,7 +85,7 @@ def retrieve_milestone(project_id: str, milestone_id: str) -> Milestone: Returns: Milestone object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.milestones.retrieve( workspace_slug=workspace_slug, project_id=project_id, milestone_id=milestone_id ) @@ -96,6 +98,7 @@ def update_milestone( target_date: str | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> Milestone: """ Update a milestone by ID. @@ -111,7 +114,7 @@ def update_milestone( Returns: Updated Milestone object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = UpdateMilestone( title=title, @@ -128,7 +131,7 @@ def update_milestone( ) @mcp.tool() - def delete_milestone(project_id: str, milestone_id: str) -> None: + def delete_milestone(project_id: str, milestone_id: str, workspace_slug: str | None = None) -> None: """ Delete a milestone by ID. @@ -136,7 +139,7 @@ def delete_milestone(project_id: str, milestone_id: str) -> None: project_id: UUID of the project milestone_id: UUID of the milestone """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.milestones.delete(workspace_slug=workspace_slug, project_id=project_id, milestone_id=milestone_id) @mcp.tool() @@ -144,6 +147,7 @@ def add_work_items_to_milestone( project_id: str, milestone_id: str, issue_ids: list[str], + workspace_slug: str | None = None, ) -> None: """ Add work items to a milestone. @@ -153,7 +157,7 @@ def add_work_items_to_milestone( milestone_id: UUID of the milestone issue_ids: List of work item IDs to add to the milestone """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.milestones.add_work_items( workspace_slug=workspace_slug, project_id=project_id, @@ -166,6 +170,7 @@ def remove_work_items_from_milestone( project_id: str, milestone_id: str, issue_ids: list[str], + workspace_slug: str | None = None, ) -> None: """ Remove work items from a milestone. @@ -175,7 +180,7 @@ def remove_work_items_from_milestone( milestone_id: UUID of the milestone issue_ids: List of work item IDs to remove from the milestone """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.milestones.remove_work_items( workspace_slug=workspace_slug, project_id=project_id, @@ -188,6 +193,7 @@ def list_milestone_work_items( project_id: str, milestone_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[MilestoneWorkItem]: """ List work items in a milestone. @@ -200,7 +206,7 @@ def list_milestone_work_items( Returns: List of MilestoneWorkItem objects in the milestone """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedMilestoneWorkItemResponse = client.milestones.list_work_items( workspace_slug=workspace_slug, project_id=project_id, diff --git a/plane_mcp/tools/modules.py b/plane_mcp/tools/modules.py index 4c75bac..3f500e9 100644 --- a/plane_mcp/tools/modules.py +++ b/plane_mcp/tools/modules.py @@ -24,6 +24,7 @@ def register_module_tools(mcp: FastMCP) -> None: def list_modules( project_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[Module]: """ List all modules in a project. @@ -36,7 +37,7 @@ def list_modules( Returns: List of Module objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedModuleResponse = client.modules.list( workspace_slug=workspace_slug, project_id=project_id, params=params ) @@ -54,6 +55,7 @@ def create_module( members: list[str] | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> Module: """ Create a new module. @@ -74,7 +76,7 @@ def create_module( Returns: Created Module object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate status against allowed literal values validated_status: ModuleStatusEnum | None = ( @@ -96,7 +98,7 @@ def create_module( return client.modules.create(workspace_slug=workspace_slug, project_id=project_id, data=data) @mcp.tool() - def retrieve_module(project_id: str, module_id: str) -> Module: + def retrieve_module(project_id: str, module_id: str, workspace_slug: str | None = None) -> Module: """ Retrieve a module by ID. @@ -108,7 +110,7 @@ def retrieve_module(project_id: str, module_id: str) -> Module: Returns: Module object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.modules.retrieve(workspace_slug=workspace_slug, project_id=project_id, module_id=module_id) @mcp.tool() @@ -124,6 +126,7 @@ def update_module( members: list[str] | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> Module: """ Update a module by ID. @@ -145,7 +148,7 @@ def update_module( Returns: Updated Module object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate status against allowed literal values validated_status: ModuleStatusEnum | None = ( @@ -169,7 +172,7 @@ def update_module( ) @mcp.tool() - def delete_module(project_id: str, module_id: str) -> None: + def delete_module(project_id: str, module_id: str, workspace_slug: str | None = None) -> None: """ Delete a module by ID. @@ -178,13 +181,14 @@ def delete_module(project_id: str, module_id: str) -> None: project_id: UUID of the project module_id: UUID of the module """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.modules.delete(workspace_slug=workspace_slug, project_id=project_id, module_id=module_id) @mcp.tool() def list_archived_modules( project_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[Module]: """ List archived modules in a project. @@ -197,7 +201,7 @@ def list_archived_modules( Returns: List of archived Module objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedArchivedModuleResponse = client.modules.list_archived( workspace_slug=workspace_slug, project_id=project_id, params=params ) @@ -208,6 +212,7 @@ def add_work_items_to_module( project_id: str, module_id: str, issue_ids: list[str], + workspace_slug: str | None = None, ) -> None: """ Add work items to a module. @@ -218,7 +223,7 @@ def add_work_items_to_module( module_id: UUID of the module issue_ids: List of work item IDs to add to the module """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.modules.add_work_items( workspace_slug=workspace_slug, project_id=project_id, @@ -231,6 +236,7 @@ def remove_work_item_from_module( project_id: str, module_id: str, work_item_id: str, + workspace_slug: str | None = None, ) -> None: """ Remove a work item from a module. @@ -241,7 +247,7 @@ def remove_work_item_from_module( module_id: UUID of the module work_item_id: UUID of the work item to remove """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.modules.remove_work_item( workspace_slug=workspace_slug, project_id=project_id, @@ -254,6 +260,7 @@ def list_module_work_items( project_id: str, module_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[WorkItem]: """ List work items in a module. @@ -267,7 +274,7 @@ def list_module_work_items( Returns: List of WorkItem objects in the module """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedModuleWorkItemResponse = client.modules.list_work_items( workspace_slug=workspace_slug, project_id=project_id, @@ -277,7 +284,7 @@ def list_module_work_items( return response.results @mcp.tool() - def archive_module(project_id: str, module_id: str) -> None: + def archive_module(project_id: str, module_id: str, workspace_slug: str | None = None) -> None: """ Archive a module. @@ -286,11 +293,11 @@ def archive_module(project_id: str, module_id: str) -> None: project_id: UUID of the project module_id: UUID of the module """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.modules.archive(workspace_slug=workspace_slug, project_id=project_id, module_id=module_id) @mcp.tool() - def unarchive_module(project_id: str, module_id: str) -> None: + def unarchive_module(project_id: str, module_id: str, workspace_slug: str | None = None) -> None: """ Unarchive a module. @@ -299,5 +306,5 @@ def unarchive_module(project_id: str, module_id: str) -> None: project_id: UUID of the project module_id: UUID of the module """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.modules.unarchive(workspace_slug=workspace_slug, project_id=project_id, module_id=module_id) diff --git a/plane_mcp/tools/pages.py b/plane_mcp/tools/pages.py index 4422de7..ef8e507 100644 --- a/plane_mcp/tools/pages.py +++ b/plane_mcp/tools/pages.py @@ -14,6 +14,7 @@ def register_page_tools(mcp: FastMCP) -> None: @mcp.tool() def retrieve_workspace_page( page_id: str, + workspace_slug: str | None = None, ) -> Page: """ Retrieve a workspace page by ID. @@ -26,7 +27,7 @@ def retrieve_workspace_page( Returns: Page object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.pages.retrieve_workspace_page( workspace_slug=workspace_slug, @@ -37,6 +38,7 @@ def retrieve_workspace_page( def retrieve_project_page( project_id: str, page_id: str, + workspace_slug: str | None = None, ) -> Page: """ Retrieve a project page by ID. @@ -50,7 +52,7 @@ def retrieve_project_page( Returns: Page object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.pages.retrieve_project_page( workspace_slug=workspace_slug, @@ -70,6 +72,7 @@ def create_workspace_page( logo_props: dict[str, Any] | None = None, external_id: str | None = None, external_source: str | None = None, + workspace_slug: str | None = None, ) -> Page: """ Create a workspace page. @@ -89,7 +92,7 @@ def create_workspace_page( Returns: Created Page object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = CreatePage( name=name, @@ -122,6 +125,7 @@ def create_project_page( logo_props: dict[str, Any] | None = None, external_id: str | None = None, external_source: str | None = None, + workspace_slug: str | None = None, ) -> Page: """ Create a project page. @@ -142,7 +146,7 @@ def create_project_page( Returns: Created Page object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = CreatePage( name=name, diff --git a/plane_mcp/tools/projects.py b/plane_mcp/tools/projects.py index 1c257e7..34f3510 100644 --- a/plane_mcp/tools/projects.py +++ b/plane_mcp/tools/projects.py @@ -23,6 +23,7 @@ def register_project_tools(mcp: FastMCP) -> None: @mcp.tool() def list_projects( + workspace_slug: str | None = None, cursor: str | None = None, per_page: int | None = None, expand: str | None = None, @@ -33,7 +34,7 @@ def list_projects( List all projects in a workspace. Args: - workspace_slug: The workspace slug identifier + workspace_slug: Optional. When set, overrides ``PLANE_WORKSPACE_SLUG`` and token claims for this call. cursor: Pagination cursor for getting next set of results per_page: Number of results per page (1-100) expand: Comma-separated list of related fields to expand in response @@ -43,7 +44,7 @@ def list_projects( Returns: List of Project objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) params = PaginatedQueryParams( cursor=cursor, @@ -64,6 +65,7 @@ def list_projects( def create_project( name: str, identifier: str, + workspace_slug: str | None = None, description: str | None = None, project_lead: str | None = None, default_assignee: str | None = None, @@ -86,9 +88,9 @@ def create_project( Create a new project. Args: - workspace_slug: The workspace slug identifier name: Project name identifier: Project identifier (e.g., "MP" for "My Project") + workspace_slug: Optional; overrides default workspace for this call description: Project description project_lead: UUID of the project lead user default_assignee: UUID of the default assignee user @@ -110,7 +112,7 @@ def create_project( Returns: Created Project object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate timezone against allowed literal values validated_timezone: TimezoneEnum | None = ( @@ -142,18 +144,18 @@ def create_project( return client.projects.create(workspace_slug=workspace_slug, data=data) @mcp.tool() - def retrieve_project(project_id: str) -> Project: + def retrieve_project(project_id: str, workspace_slug: str | None = None) -> Project: """ Retrieve a project by ID. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project + workspace_slug: Optional; overrides default workspace for this call Returns: Project object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.projects.retrieve(workspace_slug=workspace_slug, project_id=project_id) @mcp.tool() @@ -181,13 +183,14 @@ def update_project( is_time_tracking_enabled: bool | None = None, default_state: str | None = None, estimate: str | None = None, + workspace_slug: str | None = None, ) -> Project: """ Update a project by ID. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project + workspace_slug: Optional; overrides default workspace for this call name: Project name description: Project description project_lead: UUID of the project lead user @@ -214,7 +217,7 @@ def update_project( Returns: Updated Project object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate timezone against allowed literal values validated_timezone: TimezoneEnum | None = ( @@ -249,61 +252,63 @@ def update_project( return client.projects.update(workspace_slug=workspace_slug, project_id=project_id, data=data) @mcp.tool() - def delete_project(project_id: str) -> None: + def delete_project(project_id: str, workspace_slug: str | None = None) -> None: """ Delete a project by ID. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project + workspace_slug: Optional; overrides default workspace for this call """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.projects.delete(workspace_slug=workspace_slug, project_id=project_id) @mcp.tool() - def get_project_worklog_summary(project_id: str) -> list[ProjectWorklogSummary]: + def get_project_worklog_summary(project_id: str, workspace_slug: str | None = None) -> list[ProjectWorklogSummary]: """ Get work log summary for a project. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project + workspace_slug: Optional; overrides default workspace for this call Returns: List of ProjectWorklogSummary objects containing work item IDs and durations """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.projects.get_worklog_summary(workspace_slug=workspace_slug, project_id=project_id) @mcp.tool() - def get_project_members(project_id: str, params: dict[str, Any] | None = None) -> list[UserLite]: + def get_project_members( + project_id: str, params: dict[str, Any] | None = None, workspace_slug: str | None = None + ) -> list[UserLite]: """ Get all members of a project. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project params: Optional query parameters as a dictionary + workspace_slug: Optional; overrides default workspace for this call Returns: List of UserLite objects representing project members """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.projects.get_members(workspace_slug=workspace_slug, project_id=project_id, params=params) @mcp.tool() - def get_project_features(project_id: str) -> ProjectFeature: + def get_project_features(project_id: str, workspace_slug: str | None = None) -> ProjectFeature: """ Get features of a project. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project + workspace_slug: Optional; overrides default workspace for this call Returns: ProjectFeature object containing enabled/disabled features """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.projects.get_features(workspace_slug=workspace_slug, project_id=project_id) @mcp.tool() @@ -316,12 +321,12 @@ def update_project_features( pages: bool | None = None, intakes: bool | None = None, work_item_types: bool | None = None, + workspace_slug: str | None = None, ) -> ProjectFeature: """ Update features of a project. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project epics: Enable/disable epics feature modules: Enable/disable modules feature @@ -330,11 +335,12 @@ def update_project_features( pages: Enable/disable pages feature intakes: Enable/disable intakes feature work_item_types: Enable/disable work item types feature + workspace_slug: Optional; overrides default workspace for this call Returns: Updated ProjectFeature object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = ProjectFeature( epics=epics, diff --git a/plane_mcp/tools/states.py b/plane_mcp/tools/states.py index 53a4875..b97c98e 100644 --- a/plane_mcp/tools/states.py +++ b/plane_mcp/tools/states.py @@ -21,6 +21,7 @@ def register_state_tools(mcp: FastMCP) -> None: def list_states( project_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[State]: """ List all states in a project. @@ -32,7 +33,7 @@ def list_states( Returns: List of State objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedStateResponse = client.states.list( workspace_slug=workspace_slug, project_id=project_id, params=params ) @@ -50,6 +51,7 @@ def create_state( default: bool | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> State: """ Create a new state. @@ -69,7 +71,7 @@ def create_state( Returns: Created State object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate group against allowed literal values validated_group: GroupEnum | None = ( @@ -91,7 +93,7 @@ def create_state( return client.states.create(workspace_slug=workspace_slug, project_id=project_id, data=data) @mcp.tool() - def retrieve_state(project_id: str, state_id: str) -> State: + def retrieve_state(project_id: str, state_id: str, workspace_slug: str | None = None) -> State: """ Retrieve a state by ID. @@ -102,7 +104,7 @@ def retrieve_state(project_id: str, state_id: str) -> State: Returns: State object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.states.retrieve(workspace_slug=workspace_slug, project_id=project_id, state_id=state_id) @mcp.tool() @@ -118,6 +120,7 @@ def update_state( default: bool | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> State: """ Update a state by ID. @@ -138,7 +141,7 @@ def update_state( Returns: Updated State object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate group against allowed literal values validated_group: GroupEnum | None = ( @@ -165,7 +168,7 @@ def update_state( ) @mcp.tool() - def delete_state(project_id: str, state_id: str) -> None: + def delete_state(project_id: str, state_id: str, workspace_slug: str | None = None) -> None: """ Delete a state by ID. @@ -173,5 +176,5 @@ def delete_state(project_id: str, state_id: str) -> None: project_id: UUID of the project state_id: UUID of the state """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.states.delete(workspace_slug=workspace_slug, project_id=project_id, state_id=state_id) diff --git a/plane_mcp/tools/users.py b/plane_mcp/tools/users.py index 243936f..18aa1b6 100644 --- a/plane_mcp/tools/users.py +++ b/plane_mcp/tools/users.py @@ -10,12 +10,15 @@ def register_user_tools(mcp: FastMCP) -> None: """Register all user-related tools with the MCP server.""" @mcp.tool() - def get_me() -> UserLite: + def get_me(workspace_slug: str | None = None) -> UserLite: """ Get current user information. + Args: + workspace_slug: Optional; overrides default workspace for this request context. + Returns: UserLite object containing current user information """ - client, workspace_slug = get_plane_client_context() + client, _workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.users.get_me() diff --git a/plane_mcp/tools/work_item_activities.py b/plane_mcp/tools/work_item_activities.py index 11a0d44..45093ce 100644 --- a/plane_mcp/tools/work_item_activities.py +++ b/plane_mcp/tools/work_item_activities.py @@ -19,6 +19,7 @@ def list_work_item_activities( project_id: str, work_item_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[WorkItemActivity]: """ List activities for a work item. @@ -31,7 +32,7 @@ def list_work_item_activities( Returns: List of WorkItemActivity objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedWorkItemActivityResponse = client.work_items.activities.list( workspace_slug=workspace_slug, project_id=project_id, @@ -45,6 +46,7 @@ def retrieve_work_item_activity( project_id: str, work_item_id: str, activity_id: str, + workspace_slug: str | None = None, ) -> WorkItemActivity: """ Retrieve a specific activity for a work item. @@ -57,7 +59,7 @@ def retrieve_work_item_activity( Returns: WorkItemActivity object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.work_items.activities.retrieve( workspace_slug=workspace_slug, project_id=project_id, diff --git a/plane_mcp/tools/work_item_comments.py b/plane_mcp/tools/work_item_comments.py index 7a8624e..c23f6aa 100644 --- a/plane_mcp/tools/work_item_comments.py +++ b/plane_mcp/tools/work_item_comments.py @@ -22,6 +22,7 @@ def list_work_item_comments( project_id: str, work_item_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[WorkItemComment]: """ List comments for a work item. @@ -34,7 +35,7 @@ def list_work_item_comments( Returns: List of WorkItemComment objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedWorkItemCommentResponse = client.work_items.comments.list( workspace_slug=workspace_slug, project_id=project_id, @@ -48,6 +49,7 @@ def retrieve_work_item_comment( project_id: str, work_item_id: str, comment_id: str, + workspace_slug: str | None = None, ) -> WorkItemComment: """ Retrieve a specific comment for a work item. @@ -60,7 +62,7 @@ def retrieve_work_item_comment( Returns: WorkItemComment object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.work_items.comments.retrieve( workspace_slug=workspace_slug, project_id=project_id, @@ -77,6 +79,7 @@ def create_work_item_comment( access: str | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemComment: """ Create a comment for a work item. @@ -93,7 +96,7 @@ def create_work_item_comment( Returns: Created WorkItemComment object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate access against allowed literal values validated_access: AccessEnum | None = ( @@ -125,6 +128,7 @@ def update_work_item_comment( access: str | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemComment: """ Update a comment for a work item. @@ -142,7 +146,7 @@ def update_work_item_comment( Returns: Updated WorkItemComment object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate access against allowed literal values validated_access: AccessEnum | None = ( @@ -170,6 +174,7 @@ def delete_work_item_comment( project_id: str, work_item_id: str, comment_id: str, + workspace_slug: str | None = None, ) -> None: """ Delete a comment for a work item. @@ -179,7 +184,7 @@ def delete_work_item_comment( work_item_id: UUID of the work item comment_id: UUID of the comment """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.work_items.comments.delete( workspace_slug=workspace_slug, project_id=project_id, diff --git a/plane_mcp/tools/work_item_links.py b/plane_mcp/tools/work_item_links.py index 24aa3c7..697b550 100644 --- a/plane_mcp/tools/work_item_links.py +++ b/plane_mcp/tools/work_item_links.py @@ -21,6 +21,7 @@ def list_work_item_links( project_id: str, work_item_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[WorkItemLink]: """ List links for a work item. @@ -33,7 +34,7 @@ def list_work_item_links( Returns: List of WorkItemLink objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) response: PaginatedWorkItemLinkResponse = client.work_items.links.list( workspace_slug=workspace_slug, project_id=project_id, @@ -47,6 +48,7 @@ def retrieve_work_item_link( project_id: str, work_item_id: str, link_id: str, + workspace_slug: str | None = None, ) -> WorkItemLink: """ Retrieve a specific link for a work item. @@ -59,7 +61,7 @@ def retrieve_work_item_link( Returns: WorkItemLink object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.work_items.links.retrieve( workspace_slug=workspace_slug, project_id=project_id, @@ -72,6 +74,7 @@ def create_work_item_link( project_id: str, work_item_id: str, url: str, + workspace_slug: str | None = None, ) -> WorkItemLink: """ Create a link for a work item. @@ -84,7 +87,7 @@ def create_work_item_link( Returns: Created WorkItemLink object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = CreateWorkItemLink(url=url) @@ -101,6 +104,7 @@ def update_work_item_link( work_item_id: str, link_id: str, url: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemLink: """ Update a link for a work item. @@ -114,7 +118,7 @@ def update_work_item_link( Returns: Updated WorkItemLink object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = UpdateWorkItemLink(url=url) @@ -131,6 +135,7 @@ def delete_work_item_link( project_id: str, work_item_id: str, link_id: str, + workspace_slug: str | None = None, ) -> None: """ Delete a link for a work item. @@ -140,7 +145,7 @@ def delete_work_item_link( work_item_id: UUID of the work item link_id: UUID of the link """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.work_items.links.delete( workspace_slug=workspace_slug, project_id=project_id, diff --git a/plane_mcp/tools/work_item_properties.py b/plane_mcp/tools/work_item_properties.py index 5ae998a..5171198 100644 --- a/plane_mcp/tools/work_item_properties.py +++ b/plane_mcp/tools/work_item_properties.py @@ -27,6 +27,7 @@ def list_work_item_properties( project_id: str, type_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[WorkItemProperty]: """ List work item properties for a work item type. @@ -40,7 +41,7 @@ def list_work_item_properties( Returns: List of WorkItemProperty objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.work_item_properties.list( workspace_slug=workspace_slug, project_id=project_id, @@ -65,6 +66,7 @@ def create_work_item_property( external_source: str | None = None, external_id: str | None = None, options: list[dict] | None = None, + workspace_slug: str | None = None, ) -> WorkItemProperty: """ Create a new work item property. @@ -94,7 +96,7 @@ def create_work_item_property( Returns: Created WorkItemProperty object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Convert string to PropertyType enum validated_property_type = PropertyType(property_type) @@ -142,6 +144,7 @@ def retrieve_work_item_property( project_id: str, type_id: str, work_item_property_id: str, + workspace_slug: str | None = None, ) -> WorkItemProperty: """ Retrieve a work item property by ID. @@ -155,7 +158,7 @@ def retrieve_work_item_property( Returns: WorkItemProperty object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.work_item_properties.retrieve( workspace_slug=workspace_slug, project_id=project_id, @@ -180,6 +183,7 @@ def update_work_item_property( validation_rules: dict | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemProperty: """ Update a work item property by ID. @@ -209,7 +213,7 @@ def update_work_item_property( Returns: Updated WorkItemProperty object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Convert string to PropertyType enum if provided validated_property_type: PropertyType | None = None @@ -257,6 +261,7 @@ def delete_work_item_property( project_id: str, type_id: str, work_item_property_id: str, + workspace_slug: str | None = None, ) -> None: """ Delete a work item property by ID. @@ -267,7 +272,7 @@ def delete_work_item_property( type_id: UUID of the work item type work_item_property_id: UUID of the property """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.work_item_properties.delete( workspace_slug=workspace_slug, project_id=project_id, diff --git a/plane_mcp/tools/work_item_relations.py b/plane_mcp/tools/work_item_relations.py index 361da90..9df364b 100644 --- a/plane_mcp/tools/work_item_relations.py +++ b/plane_mcp/tools/work_item_relations.py @@ -20,6 +20,7 @@ def register_work_item_relation_tools(mcp: FastMCP) -> None: def list_work_item_relations( project_id: str, work_item_id: str, + workspace_slug: str | None = None, ) -> WorkItemRelationResponse: """ List relations for a work item. @@ -39,7 +40,7 @@ def list_work_item_relations( - finish_after: Work items that finish after this item - finish_before: Work items that finish before this item """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.work_items.relations.list( workspace_slug=workspace_slug, project_id=project_id, @@ -52,6 +53,7 @@ def create_work_item_relation( work_item_id: str, relation_type: str, issues: list[str], + workspace_slug: str | None = None, ) -> None: """ Create relations for a work item. @@ -63,12 +65,12 @@ def create_work_item_relation( relates_to, start_before, start_after, finish_before, finish_after) issues: List of work item IDs to create relations with """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate relation_type against allowed literal values if relation_type not in get_args(WorkItemRelationTypeEnum): raise ValueError( - f"Invalid relation_type '{relation_type}'. " f"Must be one of: {get_args(WorkItemRelationTypeEnum)}" + f"Invalid relation_type '{relation_type}'. Must be one of: {get_args(WorkItemRelationTypeEnum)}" ) validated_relation_type: WorkItemRelationTypeEnum = relation_type # type: ignore[assignment] @@ -89,6 +91,7 @@ def remove_work_item_relation( project_id: str, work_item_id: str, related_issue: str, + workspace_slug: str | None = None, ) -> None: """ Remove a relation from a work item. @@ -98,7 +101,7 @@ def remove_work_item_relation( work_item_id: UUID of the work item related_issue: UUID of the related work item to remove relation with """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = RemoveWorkItemRelation(related_issue=related_issue) diff --git a/plane_mcp/tools/work_item_types.py b/plane_mcp/tools/work_item_types.py index 31e4868..f6ab429 100644 --- a/plane_mcp/tools/work_item_types.py +++ b/plane_mcp/tools/work_item_types.py @@ -19,6 +19,7 @@ def register_work_item_type_tools(mcp: FastMCP) -> None: def list_work_item_types( project_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[WorkItemType]: """ List all work item types in a project. @@ -30,7 +31,7 @@ def list_work_item_types( Returns: List of WorkItemType objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.work_item_types.list(workspace_slug=workspace_slug, project_id=project_id, params=params) @mcp.tool() @@ -43,6 +44,7 @@ def create_work_item_type( is_active: bool | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemType: """ Create a new work item type. @@ -60,7 +62,7 @@ def create_work_item_type( Returns: Created WorkItemType object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = CreateWorkItemType( name=name, @@ -78,6 +80,7 @@ def create_work_item_type( def retrieve_work_item_type( project_id: str, work_item_type_id: str, + workspace_slug: str | None = None, ) -> WorkItemType: """ Retrieve a work item type by ID. @@ -89,7 +92,7 @@ def retrieve_work_item_type( Returns: WorkItemType object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.work_item_types.retrieve( workspace_slug=workspace_slug, project_id=project_id, @@ -107,6 +110,7 @@ def update_work_item_type( is_active: bool | None = None, external_source: str | None = None, external_id: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemType: """ Update a work item type by ID. @@ -125,7 +129,7 @@ def update_work_item_type( Returns: Updated WorkItemType object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data = UpdateWorkItemType( name=name, @@ -148,6 +152,7 @@ def update_work_item_type( def delete_work_item_type( project_id: str, work_item_type_id: str, + workspace_slug: str | None = None, ) -> None: """ Delete a work item type by ID. @@ -156,7 +161,7 @@ def delete_work_item_type( project_id: UUID of the project work_item_type_id: UUID of the work item type """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.work_item_types.delete( workspace_slug=workspace_slug, project_id=project_id, diff --git a/plane_mcp/tools/work_items.py b/plane_mcp/tools/work_items.py index 6809e24..166401f 100644 --- a/plane_mcp/tools/work_items.py +++ b/plane_mcp/tools/work_items.py @@ -66,6 +66,7 @@ def register_work_item_tools(mcp: FastMCP) -> None: @mcp.tool() def list_work_items( + workspace_slug: str | None = None, project_id: str | None = None, query: str | None = None, assignee_ids: list[str] | None = None, @@ -97,6 +98,10 @@ def list_work_items( supports powerful filtering. Otherwise it uses the standard list endpoint. Args: + workspace_slug: Target workspace slug (e.g. from list_workspaces). Required when + the MCP session has no default (no ``PLANE_WORKSPACE_SLUG``, no PAT + ``X-Workspace-Slug``). Omitting it with only browser/OAuth auth yields empty + slug paths and Plane 404s. When set, overrides env and token claims. project_id: UUID of the project. Required when no filters are provided. Optional when using filters (omit for workspace-wide search). query: Free-form text search across work item name and description @@ -127,7 +132,7 @@ def list_work_items( Returns: List of WorkItem objects (unfiltered) or AdvancedSearchResult objects (filtered) """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) filters = _build_advanced_search_filters( assignee_ids=assignee_ids, @@ -197,13 +202,14 @@ def create_work_item( state: str | None = None, estimate_point: str | None = None, type: str | None = None, + workspace_slug: str | None = None, ) -> WorkItem: """ Create a new work item. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project + workspace_slug: Optional; overrides default workspace for this call name: Work item name (required) assignees: List of user IDs to assign to the work item labels: List of label IDs to attach to the work item @@ -226,7 +232,7 @@ def create_work_item( Returns: Created WorkItem object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate priority against allowed literal values validated_priority: PriorityEnum | None = ( @@ -265,13 +271,14 @@ def retrieve_work_item( external_id: str | None = None, external_source: str | None = None, order_by: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemDetail: """ Retrieve a work item by ID. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project + workspace_slug: Optional; overrides default workspace for this call work_item_id: UUID of the work item expand: Comma-separated fields to expand (e.g., "assignees,labels,state") fields: Comma-separated fields to include in response @@ -282,7 +289,7 @@ def retrieve_work_item( Returns: WorkItemDetail object with expanded relationships """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) params = RetrieveQueryParams( expand=expand, @@ -308,13 +315,14 @@ def retrieve_work_item_by_identifier( external_id: str | None = None, external_source: str | None = None, order_by: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemDetail: """ Retrieve a work item by project identifier and issue sequence number. Args: - workspace_slug: The workspace slug identifier project_identifier: Project identifier string (e.g., "MP" for "My Project") + workspace_slug: Optional; overrides default workspace for this call issue_identifier: Issue sequence number (e.g., 1, 2, 3) expand: Comma-separated fields to expand (e.g., "assignees,labels,state") fields: Comma-separated list of fields to include in response @@ -325,7 +333,7 @@ def retrieve_work_item_by_identifier( Returns: WorkItemDetail object with expanded relationships """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) params = RetrieveQueryParams( expand=expand, @@ -364,13 +372,14 @@ def update_work_item( state: str | None = None, estimate_point: str | None = None, type: str | None = None, + workspace_slug: str | None = None, ) -> WorkItem: """ Update a work item by ID. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project + workspace_slug: Optional; overrides default workspace for this call work_item_id: UUID of the work item name: Work item name assignees: List of user IDs to assign to the work item @@ -394,7 +403,7 @@ def update_work_item( Returns: Updated WorkItem object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Validate priority against allowed literal values validated_priority: PriorityEnum | None = ( @@ -430,16 +439,16 @@ def update_work_item( ) @mcp.tool() - def delete_work_item(project_id: str, work_item_id: str) -> None: + def delete_work_item(project_id: str, work_item_id: str, workspace_slug: str | None = None) -> None: """ Delete a work item by ID. Args: - workspace_slug: The workspace slug identifier project_id: UUID of the project work_item_id: UUID of the work item + workspace_slug: Optional; overrides default workspace for this call """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.work_items.delete(workspace_slug=workspace_slug, project_id=project_id, work_item_id=work_item_id) @mcp.tool() @@ -450,14 +459,15 @@ def search_work_items( external_id: str | None = None, external_source: str | None = None, order_by: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemSearch: """ Search work items across a workspace. Args: - workspace_slug: The workspace slug identifier - query: This is a free-form text search and will be used to search the work items - by name, description etc. + query: Free-form text search across work items (name, description, etc.) + workspace_slug: Target workspace slug (e.g. from list_workspaces). Required when + the MCP session has no default slug (same rules as list_work_items). expand: Comma-separated list of related fields to expand in response fields: Comma-separated list of fields to include in response external_id: External system identifier for filtering @@ -467,7 +477,7 @@ def search_work_items( Returns: WorkItemSearch object containing search results """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) params = RetrieveQueryParams( expand=expand, diff --git a/plane_mcp/tools/work_logs.py b/plane_mcp/tools/work_logs.py index c72efdb..e8a1882 100644 --- a/plane_mcp/tools/work_logs.py +++ b/plane_mcp/tools/work_logs.py @@ -16,6 +16,7 @@ def list_work_logs( project_id: str, work_item_id: str, params: dict[str, Any] | None = None, + workspace_slug: str | None = None, ) -> list[WorkItemWorkLog]: """ List work logs for a work item. @@ -28,7 +29,7 @@ def list_work_logs( Returns: List of WorkItemWorkLog objects """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.work_items.work_logs.list( workspace_slug=workspace_slug, project_id=project_id, @@ -42,6 +43,7 @@ def create_work_log( work_item_id: str, duration: int | None = None, description: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemWorkLog: """ Create a work log for a work item. @@ -55,7 +57,7 @@ def create_work_log( Returns: Created WorkItemWorkLog object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data: dict[str, Any] = {} if duration is not None: @@ -77,6 +79,7 @@ def update_work_log( work_log_id: str, duration: int | None = None, description: str | None = None, + workspace_slug: str | None = None, ) -> WorkItemWorkLog: """ Update a work log for a work item. @@ -91,7 +94,7 @@ def update_work_log( Returns: Updated WorkItemWorkLog object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) data: dict[str, Any] = {} if duration is not None: @@ -112,6 +115,7 @@ def delete_work_log( project_id: str, work_item_id: str, work_log_id: str, + workspace_slug: str | None = None, ) -> None: """ Delete a work log for a work item. @@ -121,7 +125,7 @@ def delete_work_log( work_item_id: UUID of the work item work_log_id: UUID of the work log """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) client.work_items.work_logs.delete( workspace_slug=workspace_slug, project_id=project_id, diff --git a/plane_mcp/tools/workspaces.py b/plane_mcp/tools/workspaces.py index acf84ee..5320922 100644 --- a/plane_mcp/tools/workspaces.py +++ b/plane_mcp/tools/workspaces.py @@ -17,12 +17,7 @@ class Workspace(BaseModel): - """Plane workspace summary returned from ``GET /api/v1/workspaces/``. - - Pressingly Plane (and upstream Plane) include other fields (logo, organization size, - timestamps, etc.); ``extra="allow"`` keeps them on the model so tool consumers can - still inspect everything without a schema mismatch breaking the call. - """ + """Workspace row from ``GET /api/users/me/workspaces/`` (shape varies; extra fields allowed).""" model_config = ConfigDict(extra="allow", populate_by_name=True) @@ -91,7 +86,7 @@ def list_workspaces() -> list[Workspace]: slug, so this tool exists to bootstrap that slug. Use a returned ``slug`` to set ``PLANE_WORKSPACE_SLUG`` (or send - ``X-Workspace-slug``) before calling workspace-scoped tools. + ``X-Workspace-slug`` on PAT) before calling workspace-scoped tools. Returns: List of Workspace objects (id, name, slug, owner, plus any extra fields). @@ -107,25 +102,31 @@ def list_workspaces() -> list[Workspace]: return [Workspace.model_validate(item) for item in items] @mcp.tool() - def get_workspace_members() -> list[UserLite]: + def get_workspace_members(workspace_slug: str | None = None) -> list[UserLite]: """ Get all members of the current workspace. + Args: + workspace_slug: Optional; overrides default workspace for this call + Returns: List of UserLite objects representing workspace members """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.workspaces.get_members(workspace_slug=workspace_slug) @mcp.tool() - def get_workspace_features() -> WorkspaceFeature: + def get_workspace_features(workspace_slug: str | None = None) -> WorkspaceFeature: """ Get features of the current workspace. + Args: + workspace_slug: Optional; overrides default workspace for this call + Returns: WorkspaceFeature object containing feature flags """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) return client.workspaces.get_features(workspace_slug=workspace_slug) @mcp.tool() @@ -136,6 +137,7 @@ def update_workspace_features( customers: bool | None = None, wiki: bool | None = None, pi: bool | None = None, + workspace_slug: str | None = None, ) -> WorkspaceFeature: """ Update features of the current workspace. @@ -147,11 +149,12 @@ def update_workspace_features( customers: Enable/disable customers feature wiki: Enable/disable wiki feature pi: Enable/disable PI (Program Increment) feature + workspace_slug: Optional; overrides default workspace for this call Returns: Updated WorkspaceFeature object """ - client, workspace_slug = get_plane_client_context() + client, workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) # Build data dict with only non-None values feature_data: dict[str, bool] = {} diff --git a/tests/test_client_context.py b/tests/test_client_context.py new file mode 100644 index 0000000..0b379a6 --- /dev/null +++ b/tests/test_client_context.py @@ -0,0 +1,34 @@ +"""Tests for Plane client context resolution.""" + +import pytest +from plane.errors import ConfigurationError + +import plane_mcp.client as client_mod + + +def test_get_plane_client_context_raises_when_workspace_slug_unresolved(monkeypatch): + """OAuth/browser sessions often have no claim; empty slug must not reach the SDK.""" + monkeypatch.setenv("PLANE_API_KEY", "test-api-key") + monkeypatch.delenv("PLANE_WORKSPACE_SLUG", raising=False) + monkeypatch.setattr(client_mod, "get_access_token", lambda: None) + + with pytest.raises(ConfigurationError, match="Workspace slug is required"): + client_mod.get_plane_client_context() + + +def test_get_plane_client_context_uses_env_slug(monkeypatch): + monkeypatch.setenv("PLANE_API_KEY", "test-api-key") + monkeypatch.setenv("PLANE_WORKSPACE_SLUG", "my-ws") + monkeypatch.setattr(client_mod, "get_access_token", lambda: None) + + ctx = client_mod.get_plane_client_context() + assert ctx.workspace_slug == "my-ws" + + +def test_get_plane_client_context_tool_arg_wins(monkeypatch): + monkeypatch.setenv("PLANE_API_KEY", "test-api-key") + monkeypatch.setenv("PLANE_WORKSPACE_SLUG", "env-ws") + monkeypatch.setattr(client_mod, "get_access_token", lambda: None) + + ctx = client_mod.get_plane_client_context(workspace_slug_from_client="tool-ws") + assert ctx.workspace_slug == "tool-ws" diff --git a/tests/test_cognito_http.py b/tests/test_cognito_http.py index 20f20df..c110401 100644 --- a/tests/test_cognito_http.py +++ b/tests/test_cognito_http.py @@ -1,13 +1,18 @@ """Cognito HTTP callback URL resolution.""" +import sys + import pytest from plane_mcp.cognito_http import ( REQUIRED_HTTP_ENV_VARS, _allowed_client_redirect_uris, _cognito_callback_base_and_path, + cognito_http_configuration_intended, + cognito_http_env_missing, cognito_http_env_ready, cognito_idp_redirect_uri, + validate_cognito_http_env, ) @@ -61,6 +66,44 @@ def test_allowed_client_redirect_uris_when_set(monkeypatch): assert _allowed_client_redirect_uris() == ["cursor://*", "http://localhost:*/*"] +def test_cognito_http_configuration_intended(monkeypatch): + monkeypatch.delenv("COGNITO_USER_POOL_ID", raising=False) + monkeypatch.delenv("OIDC_CLIENT_ID", raising=False) + assert cognito_http_configuration_intended() is False + monkeypatch.setenv("COGNITO_USER_POOL_ID", "pool") + assert cognito_http_configuration_intended() is True + monkeypatch.delenv("COGNITO_USER_POOL_ID", raising=False) + monkeypatch.setenv("OIDC_CLIENT_ID", "cid") + assert cognito_http_configuration_intended() is True + + +def test_cognito_http_env_missing_lists_blank_vars(monkeypatch): + for name in REQUIRED_HTTP_ENV_VARS: + monkeypatch.setenv(name, "x") + assert cognito_http_env_missing() == [] + monkeypatch.setenv("MCP_JWT_SIGNING_KEY", "") + assert "MCP_JWT_SIGNING_KEY" in cognito_http_env_missing() + + +def test_validate_cognito_http_env_succeeds_when_required_set(monkeypatch): + for name in REQUIRED_HTTP_ENV_VARS: + monkeypatch.setenv(name, "x") + validate_cognito_http_env() + + +def test_http_main_raises_clear_error_when_cognito_partial(monkeypatch): + monkeypatch.delenv("MCP_BASE_URL", raising=False) + monkeypatch.delenv("COGNITO_AWS_REGION", raising=False) + monkeypatch.delenv("OIDC_CLIENT_ID", raising=False) + monkeypatch.delenv("MCP_JWT_SIGNING_KEY", raising=False) + monkeypatch.setenv("COGNITO_USER_POOL_ID", "test-pool") + monkeypatch.setattr(sys, "argv", ["plane_mcp", "http"]) + import plane_mcp.__main__ as entry + + with pytest.raises(ValueError, match="Cognito is partially configured"): + entry.main() + + def test_cognito_idp_redirect_legacy_alias(monkeypatch): monkeypatch.setenv("MCP_BASE_URL", "https://x.test") monkeypatch.delenv("MCP_COGNITO_REDIRECT_URI", raising=False) From e2f5c0fa9a48e6d61df9223f84d035f99e87ca1a Mon Sep 17 00:00:00 2001 From: jawad-khan Date: Tue, 5 May 2026 16:58:16 +0500 Subject: [PATCH 3/6] fix: fixed copilot points --- README.md | 3 ++- plane_mcp/client.py | 10 +++++++--- plane_mcp/cognito_http.py | 6 +++++- plane_mcp/tools/workspaces.py | 3 ++- pyproject.toml | 1 + 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f49cd8a..01e6765 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,7 @@ The server provides comprehensive tools for interacting with Plane. All tools us | Tool Name | Description | |-----------|-------------| +| `list_workspaces` | List Plane workspaces the authenticated user belongs to (slug bootstrap) | | `get_workspace_members` | Get all members of the current workspace | | `get_workspace_features` | Get features of the current workspace | | `update_workspace_features` | Update features of the current workspace | @@ -362,7 +363,7 @@ The server provides comprehensive tools for interacting with Plane. All tools us |-----------|-------------| | `get_me` | Get current authenticated user information | -**Total Tools**: 100+ tools across 20 categories +**Tool coverage:** Sections above summarize the main tool surface. The authoritative list of registered modules is `plane_mcp/tools/__init__.py` (`register_tools`). ## Development diff --git a/plane_mcp/client.py b/plane_mcp/client.py index 25e5976..2eb8450 100644 --- a/plane_mcp/client.py +++ b/plane_mcp/client.py @@ -48,9 +48,13 @@ def get_plane_client_context(workspace_slug_from_client: str | None = None) -> P Workspace slug: if ``workspace_slug_from_client`` is set, it wins; otherwise token ``claims['workspace_slug']`` (e.g. PAT header), then ``PLANE_WORKSPACE_SLUG``. - Environment variables: - - PLANE_INTERNAL_BASE_URL: Internal URL for Plane API (preferred for server-to-server calls) - - PLANE_BASE_URL: Base URL for Plane API (fallback, default: https://api.plane.so) + Environment variables (Plane API host): + - PLANE_INTERNAL_BASE_URL: Optional internal origin for the Plane deployment. When set, + it is used ahead of PLANE_BASE_URL for **all** SDK calls in this process (OAuth and PAT). + For Cognito/browser flows that must match Traefik + oauth2-proxy like the web UI, leave it + unset and set only PLANE_BASE_URL to the public Plane URL. + - PLANE_BASE_URL: Plane deployment origin (fallback: https://api.plane.so). Used whenever + PLANE_INTERNAL_BASE_URL is unset. Returns: PlaneClientContext containing configured PlaneClient instance and workspace slug diff --git a/plane_mcp/cognito_http.py b/plane_mcp/cognito_http.py index e87d5ca..c9932a6 100644 --- a/plane_mcp/cognito_http.py +++ b/plane_mcp/cognito_http.py @@ -297,7 +297,11 @@ def _build_cognito_provider() -> AWSCognitoProvider: if forward_pkce: logger.info("Cognito HTTP: COGNITO_OIDC_FORWARD_PKCE=true (PKCE sent to Cognito authorize/token)") else: - logger.info("Cognito HTTP: upstream PKCE off (default). Tools use Cognito access token as Plane Bearer.") + logger.info( + "Cognito HTTP: upstream PKCE off (default). Plane tools use Cognito ID token as " + "Bearer when attached to the session (see plane_mcp.client._plane_bearer_for); " + "otherwise the access token." + ) auth_meth = os.getenv("COGNITO_TOKEN_ENDPOINT_AUTH_METHOD", "none").strip() or "none" provider._token_endpoint_auth_method = auth_meth # type: ignore[attr-defined] diff --git a/plane_mcp/tools/workspaces.py b/plane_mcp/tools/workspaces.py index 5320922..1e9795f 100644 --- a/plane_mcp/tools/workspaces.py +++ b/plane_mcp/tools/workspaces.py @@ -32,7 +32,8 @@ def _plane_root_base() -> str: Plane's "list user workspaces" lives at ``/api/users/me/workspaces/`` (the **app** API, not the public ``/api/v1/`` surface — see Plane backend ``UserWorkSpacesEndpoint``). - Prefers ``PLANE_INTERNAL_BASE_URL`` (server-to-server); otherwise ``PLANE_BASE_URL``. + Prefers ``PLANE_INTERNAL_BASE_URL`` when set; otherwise ``PLANE_BASE_URL`` (typical for + Cognito + public Plane URLs). """ base = os.getenv("PLANE_INTERNAL_BASE_URL") or os.getenv("PLANE_BASE_URL", "https://api.plane.so") return base.rstrip("/") diff --git a/pyproject.toml b/pyproject.toml index 0458af6..6dbb846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ keywords = ["mcp", "plane", "fastmcp", "ai", "automation"] dependencies = [ "fastmcp==2.14.4", "plane-sdk==0.2.10", + "httpx>=0.27", "py-key-value-aio[redis]==0.3.0", "mcp==1.26.0", "PyJWT>=2.12.0", From ddee621e376b10c99f6f8d07b0bcf0f5064ff123 Mon Sep 17 00:00:00 2001 From: jawad-khan Date: Tue, 5 May 2026 19:50:59 +0500 Subject: [PATCH 4/6] fix: fixed review points --- README.md | 4 +-- plane_mcp/__main__.py | 2 +- plane_mcp/cognito_http.py | 52 +++++++++++++++--------------- plane_mcp/tools/users.py | 7 ++++ tests/test_cognito_http.py | 65 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 01e6765..58855c7 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,9 @@ When **all** of the following environment variables are set, `python -m plane_mc **Cognito app client:** use a **public** client (client ID only). Register **`{MCP_BASE_URL}/auth/callback`** as an allowed callback URL (or the full URL from `MCP_COGNITO_REDIRECT_URI` if you set that). Do not point MCP clients at `{MCP_BASE_URL}/http/mcp` in this mode. -**Required:** `MCP_BASE_URL`, `COGNITO_USER_POOL_ID`, `COGNITO_AWS_REGION`, `OIDC_CLIENT_ID`, `MCP_JWT_SIGNING_KEY` (long random secret, e.g. `openssl rand -hex 32`). +**Required:** `MCP_BASE_URL`, `COGNITO_USER_POOL_ID`, `COGNITO_AWS_REGION`, `OIDC_CLIENT_ID`, `MCP_JWT_SIGNING_KEY` (long random secret, e.g. `openssl rand -hex 32`), `MCP_ALLOWED_CLIENT_REDIRECT_URIS` (comma-separated fnmatch patterns for MCP client redirect URIs, e.g. `cursor://*,http://127.0.0.1:*/*`). -**Optional:** `MCP_COGNITO_REDIRECT_URI` (or legacy alias `PLANE_MCP_COGNITO_REDIRECT_URI`), `COGNITO_TOKEN_ENDPOINT_AUTH_METHOD` (default `none` for public clients), `COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH` (default `true`), `COGNITO_REQUIRE_CONSENT`, `COGNITO_OIDC_FORWARD_PKCE`, `MCP_ALLOWED_CLIENT_REDIRECT_URIS`, `REDIS_HOST` / `REDIS_PORT`, `PLANE_BASE_URL` / `PLANE_INTERNAL_BASE_URL`, `PLANE_WORKSPACE_SLUG`. +**Optional:** `MCP_COGNITO_REDIRECT_URI` (or legacy alias `PLANE_MCP_COGNITO_REDIRECT_URI`), `COGNITO_TOKEN_ENDPOINT_AUTH_METHOD` (default `none` for public clients), `COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH` (default `true`), `COGNITO_REQUIRE_CONSENT`, `COGNITO_OIDC_FORWARD_PKCE`, `REDIS_HOST` / `REDIS_PORT`, `PLANE_BASE_URL` / `PLANE_INTERNAL_BASE_URL`, `PLANE_WORKSPACE_SLUG`. This build subclasses FastMCP’s Cognito provider to omit `resource` on the upstream authorize request when your pool has no resource server, optionally relax MCP `resource` vs `MCP_BASE_URL` mismatches, and attach the Cognito **ID token** to the MCP session when available so Plane’s gateway can resolve `cognito:username` the same way as the web UI (see `plane_mcp/client.py`). diff --git a/plane_mcp/__main__.py b/plane_mcp/__main__.py index 2e6c3d4..87d40d3 100644 --- a/plane_mcp/__main__.py +++ b/plane_mcp/__main__.py @@ -121,7 +121,7 @@ def main() -> None: "HTTP mode: Cognito is partially configured (COGNITO_USER_POOL_ID and/or OIDC_CLIENT_ID set) " f"but required variables are missing or empty: {', '.join(missing)}. " "Set all of: MCP_BASE_URL, COGNITO_USER_POOL_ID, COGNITO_AWS_REGION, OIDC_CLIENT_ID, " - "MCP_JWT_SIGNING_KEY. " + "MCP_JWT_SIGNING_KEY, MCP_ALLOWED_CLIENT_REDIRECT_URIS. " "To use Plane OAuth at /http/mcp instead, unset COGNITO_USER_POOL_ID and OIDC_CLIENT_ID and set " "PLANE_OAUTH_PROVIDER_CLIENT_ID and PLANE_OAUTH_PROVIDER_CLIENT_SECRET." ) diff --git a/plane_mcp/cognito_http.py b/plane_mcp/cognito_http.py index c9932a6..5de05b8 100644 --- a/plane_mcp/cognito_http.py +++ b/plane_mcp/cognito_http.py @@ -15,7 +15,7 @@ import logging import os from contextlib import asynccontextmanager -from typing import Any +from typing import Any, cast from urllib.parse import urlparse from fastmcp import FastMCP @@ -45,6 +45,7 @@ "COGNITO_AWS_REGION", "OIDC_CLIENT_ID", "MCP_JWT_SIGNING_KEY", + "MCP_ALLOWED_CLIENT_REDIRECT_URIS", ) _oauth_kv_singleton: MemoryStore | RedisStore | None = None @@ -113,12 +114,14 @@ class _PlaneCognitoProvider(AWSCognitoProvider): """Cognito OAuth: drop ``resource`` on upstream authorize when pool has no resource server. Also attaches the upstream Cognito **ID token** to the validated MCP ``AccessToken.claims`` - so tools can forward it to Plane API instead of the access token. Plane's Traefik - chain runs ForwardAuth via oauth2-proxy with ``--user-id-claim=cognito:username``; - Cognito **access** tokens have only ``username`` (no prefix), so plane-mcp would - otherwise authenticate as ``sub@.com``. Forwarding the ID token (which has - ``cognito:username``) gives plane-mcp the same Plane user as the web cookie flow - without any oauth2-proxy / Plane / AWS changes. See ``plane_mcp/client.py``. + so tools can forward it to Plane API instead of the access token. ``load_access_token`` returns + ``None`` if that ID token cannot be resolved (no silent access-token-only session). + + Plane's Traefik chain runs ForwardAuth via oauth2-proxy with ``--user-id-claim=cognito:username``; + Cognito **access** tokens have only ``username`` (no prefix), so plane-mcp would otherwise + authenticate as ``sub@.com``. Forwarding the ID token (which has ``cognito:username``) + gives plane-mcp the same Plane user as the web cookie flow without any oauth2-proxy / Plane + / AWS changes. See ``plane_mcp/client.py``. """ async def load_access_token(self, token: str) -> AccessToken | None: @@ -129,28 +132,28 @@ async def load_access_token(self, token: str) -> AccessToken | None: payload = self.jwt_issuer.verify_token(token) jti = payload.get("jti") if not jti: - return validated + return None jti_mapping = await self._jti_mapping_store.get(key=jti) if not jti_mapping: - return validated + return None upstream_id = getattr(jti_mapping, "upstream_token_id", None) or ( jti_mapping.get("upstream_token_id") if isinstance(jti_mapping, dict) else None ) if not upstream_id: - return validated + return None ts = await self._upstream_token_store.get(key=upstream_id) raw = getattr(ts, "raw_token_data", None) if ts else None if raw is None and isinstance(ts, dict): raw = ts.get("raw_token_data") id_token = (raw or {}).get("id_token") if isinstance(raw, dict) else None if not isinstance(id_token, str) or not id_token: - return validated + return None new_claims = dict(validated.claims or {}) new_claims["id_token"] = id_token return validated.model_copy(update={"claims": new_claims}) except Exception as exc: logger.warning("Cognito HTTP: failed to attach upstream id_token to claims: %s", exc) - return validated + return None async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: """Allow MCP clients whose ``resource`` URL does not match ``MCP_BASE_URL`` (e.g. localhost vs Traefik). @@ -228,19 +231,24 @@ def validate_cognito_http_env() -> None: missing = cognito_http_env_missing() if missing: raise ValueError("http mode (Cognito) is missing required env vars: " + ", ".join(missing)) + if not _allowed_client_redirect_uris(): + raise ValueError( + "http mode (Cognito) requires MCP_ALLOWED_CLIENT_REDIRECT_URIS with at least one non-empty " + "pattern (comma-separated; fnmatch wildcards allowed, e.g. cursor://*,http://127.0.0.1:*/*)" + ) def _allowed_client_redirect_uris() -> list[str] | None: """Redirect URI patterns for MCP OAuth Dynamic Client Registration (fnmatch wildcards). - ``None`` means allow **any** client redirect URI (FastMCP default; needed for clients that use - custom schemes such as ``cursor://``). Set ``MCP_ALLOWED_CLIENT_REDIRECT_URIS`` to a - comma-separated list to restrict patterns in production. + Returns ``None`` when the env var is unset/blank or contains no non-empty patterns after splitting. + Cognito HTTP startup requires at least one pattern (see ``validate_cognito_http_env``). """ raw = os.getenv("MCP_ALLOWED_CLIENT_REDIRECT_URIS", "").strip() if not raw: return None - return [uri.strip() for uri in raw.split(",") if uri.strip()] + out = [uri.strip() for uri in raw.split(",") if uri.strip()] + return out or None def _oauth_client_storage() -> MemoryStore | RedisStore: @@ -264,15 +272,10 @@ def _consent_enabled() -> bool: def _build_cognito_provider() -> AWSCognitoProvider: + validate_cognito_http_env() base_url, redirect_path = _cognito_callback_base_and_path() - redirect_patterns = _allowed_client_redirect_uris() - if redirect_patterns is None: - logger.info( - "Cognito HTTP: MCP_ALLOWED_CLIENT_REDIRECT_URIS unset — any MCP client redirect URI allowed " - "(needed for Cursor/custom OAuth schemes). Set MCP_ALLOWED_CLIENT_REDIRECT_URIS to restrict." - ) - else: - logger.info("Cognito HTTP: MCP client redirect URI patterns: %s", redirect_patterns) + redirect_patterns = cast(list[str], _allowed_client_redirect_uris()) + logger.info("Cognito HTTP: MCP client redirect URI patterns: %s", redirect_patterns) jwt_signing_key = os.environ["MCP_JWT_SIGNING_KEY"].strip() # FastMCP's AWSCognitoProvider requires non-empty client_secret; public Cognito apps @@ -330,7 +333,6 @@ async def healthz(_request: Request) -> JSONResponse: def build_cognito_http_starlette_app() -> Starlette: - validate_cognito_http_env() cognito_mcp = get_cognito_http_mcp() header_mcp = get_header_mcp() cognito_app = cognito_mcp.http_app(stateless_http=True) diff --git a/plane_mcp/tools/users.py b/plane_mcp/tools/users.py index 18aa1b6..ad245eb 100644 --- a/plane_mcp/tools/users.py +++ b/plane_mcp/tools/users.py @@ -14,6 +14,13 @@ def get_me(workspace_slug: str | None = None) -> UserLite: """ Get current user information. + The Plane ``GET /users/me`` call is not workspace-scoped, but this MCP tool still uses + ``get_plane_client_context``, which requires a **resolved workspace slug** for the session: + stdio ``PLANE_WORKSPACE_SLUG``, PAT or Cognito headers / claims, the ``workspace_slug`` argument + here, or ``PLANE_WORKSPACE_SLUG`` in the server environment. For browser OAuth against Cognito, + if the session has no workspace yet, use ``list_workspaces`` (or set ``PLANE_WORKSPACE_SLUG``) + before calling ``get_me``. + Args: workspace_slug: Optional; overrides default workspace for this request context. diff --git a/tests/test_cognito_http.py b/tests/test_cognito_http.py index c110401..a71fd18 100644 --- a/tests/test_cognito_http.py +++ b/tests/test_cognito_http.py @@ -1,13 +1,18 @@ """Cognito HTTP callback URL resolution.""" +import asyncio import sys +from unittest.mock import AsyncMock, MagicMock import pytest +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.oidc_proxy import OIDCProxy from plane_mcp.cognito_http import ( REQUIRED_HTTP_ENV_VARS, _allowed_client_redirect_uris, _cognito_callback_base_and_path, + _PlaneCognitoProvider, cognito_http_configuration_intended, cognito_http_env_missing, cognito_http_env_ready, @@ -61,6 +66,14 @@ def test_allowed_client_redirect_uris_none_when_unset(clear_cognito_env, monkeyp assert _allowed_client_redirect_uris() is None +def test_validate_cognito_http_env_fails_when_redirect_patterns_only_commas(monkeypatch): + for name in REQUIRED_HTTP_ENV_VARS: + monkeypatch.setenv(name, "x") + monkeypatch.setenv("MCP_ALLOWED_CLIENT_REDIRECT_URIS", " , , ") + with pytest.raises(ValueError, match="MCP_ALLOWED_CLIENT_REDIRECT_URIS"): + validate_cognito_http_env() + + def test_allowed_client_redirect_uris_when_set(monkeypatch): monkeypatch.setenv("MCP_ALLOWED_CLIENT_REDIRECT_URIS", "cursor://*,http://localhost:*/*") assert _allowed_client_redirect_uris() == ["cursor://*", "http://localhost:*/*"] @@ -111,3 +124,55 @@ def test_cognito_idp_redirect_legacy_alias(monkeypatch): b, p = _cognito_callback_base_and_path() assert b == "https://other.example" assert p == "/auth/callback" + + +def _access_token() -> AccessToken: + return AccessToken(token="t", client_id="c", scopes=[], expires_at=None, claims={}) + + +def _provider_load_token_setup(monkeypatch, *, jti_payload: dict, jti_mapping, upstream_store_val): + validated = _access_token() + + async def fake_super_load(self, token: str): + return validated + + monkeypatch.setattr(OIDCProxy, "load_access_token", fake_super_load) + + provider = object.__new__(_PlaneCognitoProvider) + mock_issuer = MagicMock() + mock_issuer.verify_token = MagicMock(return_value=jti_payload) + object.__setattr__(provider, "_jwt_issuer", mock_issuer) + jti_store = AsyncMock() + jti_store.get = AsyncMock(return_value=jti_mapping) + object.__setattr__(provider, "_jti_mapping_store", jti_store) + up_store = AsyncMock() + up_store.get = AsyncMock(return_value=upstream_store_val) + object.__setattr__(provider, "_upstream_token_store", up_store) + return provider + + +def test_plane_cognito_load_access_token_returns_none_without_jti(monkeypatch): + p = _provider_load_token_setup(monkeypatch, jti_payload={}, jti_mapping=None, upstream_store_val=None) + assert asyncio.run(_PlaneCognitoProvider.load_access_token(p, "raw")) is None + + +def test_plane_cognito_load_access_token_returns_none_without_id_token(monkeypatch): + p = _provider_load_token_setup( + monkeypatch, + jti_payload={"jti": "j1"}, + jti_mapping={"upstream_token_id": "up1"}, + upstream_store_val={"raw_token_data": {}}, + ) + assert asyncio.run(_PlaneCognitoProvider.load_access_token(p, "raw")) is None + + +def test_plane_cognito_load_access_token_attaches_id_token(monkeypatch): + p = _provider_load_token_setup( + monkeypatch, + jti_payload={"jti": "j1"}, + jti_mapping={"upstream_token_id": "up1"}, + upstream_store_val={"raw_token_data": {"id_token": "id.jwt"}}, + ) + out = asyncio.run(_PlaneCognitoProvider.load_access_token(p, "raw")) + assert out is not None + assert out.claims.get("id_token") == "id.jwt" From bdacaa519dd40f73051052f0075e943d10846fef Mon Sep 17 00:00:00 2001 From: jawad-khan Date: Tue, 5 May 2026 20:10:51 +0500 Subject: [PATCH 5/6] fix: fixed review points --- tests/test_client_context.py | 32 ++++++++++++++++++++++++++++++ tests/test_workspaces_tool.py | 37 ++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/tests/test_client_context.py b/tests/test_client_context.py index 0b379a6..0da7b0d 100644 --- a/tests/test_client_context.py +++ b/tests/test_client_context.py @@ -1,5 +1,7 @@ """Tests for Plane client context resolution.""" +from types import SimpleNamespace + import pytest from plane.errors import ConfigurationError @@ -32,3 +34,33 @@ def test_get_plane_client_context_tool_arg_wins(monkeypatch): ctx = client_mod.get_plane_client_context(workspace_slug_from_client="tool-ws") assert ctx.workspace_slug == "tool-ws" + + +def test_plane_bearer_for_prefers_id_token(): + assert ( + client_mod._plane_bearer_for("access-jwt", {"id_token": "id-jwt", "sub": "x"}) == "id-jwt" + ) + + +def test_plane_bearer_for_ignores_non_string_id_token(): + assert client_mod._plane_bearer_for("access-jwt", {"id_token": 123}) == "access-jwt" + + +def test_plane_bearer_for_falls_back_to_access_token(): + assert client_mod._plane_bearer_for("access-jwt", {"sub": "u"}) == "access-jwt" + + +def test_get_plane_client_context_uses_workspace_slug_from_oauth_claims(monkeypatch): + monkeypatch.delenv("PLANE_WORKSPACE_SLUG", raising=False) + monkeypatch.delenv("PLANE_API_KEY", raising=False) + monkeypatch.setattr( + client_mod, + "get_access_token", + lambda: SimpleNamespace( + token="cognito-access", + claims={"auth_method": "oauth", "workspace_slug": "claim-ws"}, + ), + ) + + ctx = client_mod.get_plane_client_context() + assert ctx.workspace_slug == "claim-ws" diff --git a/tests/test_workspaces_tool.py b/tests/test_workspaces_tool.py index 3cce0fe..7c48117 100644 --- a/tests/test_workspaces_tool.py +++ b/tests/test_workspaces_tool.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from unittest.mock import patch -from plane_mcp.tools.workspaces import _plane_auth_headers, _plane_root_base +from plane_mcp.tools.workspaces import _httpx_verify, _plane_auth_headers, _plane_root_base def test_plane_root_base_uses_internal_first(monkeypatch): @@ -27,6 +27,17 @@ def test_plane_auth_headers_oauth_uses_bearer(mock_get_token, monkeypatch): assert "X-Api-Key" not in headers +@patch("plane_mcp.tools.workspaces.get_access_token") +def test_plane_auth_headers_oauth_prefers_id_token_for_bearer(mock_get_token, monkeypatch): + monkeypatch.delenv("PLANE_API_KEY", raising=False) + mock_get_token.return_value = SimpleNamespace( + token="cognito-access", + claims={"auth_method": "oauth", "id_token": "cognito-id.jwt"}, + ) + headers = _plane_auth_headers() + assert headers["Authorization"] == "Bearer cognito-id.jwt" + + @patch("plane_mcp.tools.workspaces.get_access_token") def test_plane_auth_headers_pat_uses_x_api_key(mock_get_token, monkeypatch): monkeypatch.delenv("PLANE_API_KEY", raising=False) @@ -42,3 +53,27 @@ def test_plane_auth_headers_stdio_falls_back_to_env_api_key(_mock_get_token, mon headers = _plane_auth_headers() assert headers["X-Api-Key"] == "env-key" assert "Authorization" not in headers + + +def test_httpx_verify_prefers_requests_ca_bundle_over_ssl_cert_file(monkeypatch): + monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/ca.pem") + monkeypatch.setenv("SSL_CERT_FILE", "/ssl.pem") + assert _httpx_verify() == "/ca.pem" + + +def test_httpx_verify_prefers_requests_ca_bundle(monkeypatch): + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/path/to/ca.pem") + assert _httpx_verify() == "/path/to/ca.pem" + + +def test_httpx_verify_falls_back_to_ssl_cert_file(monkeypatch): + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.setenv("SSL_CERT_FILE", "/path/to/cert.pem") + assert _httpx_verify() == "/path/to/cert.pem" + + +def test_httpx_verify_defaults_true_when_no_bundle(monkeypatch): + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + assert _httpx_verify() is True From fe3b414c5089ce9ba5d50b71825bf1448155382f Mon Sep 17 00:00:00 2001 From: Azan Ali Date: Tue, 2 Jun 2026 15:38:00 +0500 Subject: [PATCH 6/6] refactor: simplify and debloat Cognito HTTP setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed ~170 lines of over-engineered configurability that had safe, fixed values in practice: - AUTH_TYPE=SSO explicit opt-in replaces heuristic env-var sniffing (cognito_http_env_ready / cognito_http_configuration_intended) - Dropped MCP_COGNITO_REDIRECT_URI — MCP_BASE_URL is always the public URL by definition (OAuth clients must reach it), so a separate redirect URI override is never needed - Hardcoded COGNITO_OIDC_FORWARD_PKCE=false, COGNITO_TOKEN_ENDPOINT_AUTH_METHOD=none, COGNITO_REQUIRE_CONSENT=true, COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH=true — all were already the defaults and never changed - Inlined _plane_root_base() and _httpx_verify() one-use helpers - Restored _plane_bearer_for info log (id_token forwarding); dropped the warning (we only use Cognito SSO, never Plane OAuth, so absence of id_token is never an actionable signal) - Added targeted tests for _plane_bearer_for and id_token header preference Co-Authored-By: Claude Sonnet 4.6 --- README.md | 4 +- plane_mcp/__main__.py | 27 +++++----- plane_mcp/client.py | 36 +++---------- plane_mcp/cognito_http.py | 95 +++-------------------------------- plane_mcp/tools/workspaces.py | 52 ++++--------------- tests/test_cognito_http.py | 70 ++------------------------ tests/test_workspaces_tool.py | 38 +------------- uv.lock | 12 +++-- 8 files changed, 48 insertions(+), 286 deletions(-) diff --git a/README.md b/README.md index 58855c7..b5d1c54 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,11 @@ When **all** of the following environment variables are set, `python -m plane_mc | PAT (same as non-Cognito HTTP) | `{MCP_BASE_URL}/http/api-key/mcp` | | Health check | `GET {MCP_BASE_URL}/healthz` | -**Cognito app client:** use a **public** client (client ID only). Register **`{MCP_BASE_URL}/auth/callback`** as an allowed callback URL (or the full URL from `MCP_COGNITO_REDIRECT_URI` if you set that). Do not point MCP clients at `{MCP_BASE_URL}/http/mcp` in this mode. +**Cognito app client:** use a **public** client (client ID only). Register **`{MCP_BASE_URL}/auth/callback`** as an allowed callback URL. Do not point MCP clients at `{MCP_BASE_URL}/http/mcp` in this mode. **Required:** `MCP_BASE_URL`, `COGNITO_USER_POOL_ID`, `COGNITO_AWS_REGION`, `OIDC_CLIENT_ID`, `MCP_JWT_SIGNING_KEY` (long random secret, e.g. `openssl rand -hex 32`), `MCP_ALLOWED_CLIENT_REDIRECT_URIS` (comma-separated fnmatch patterns for MCP client redirect URIs, e.g. `cursor://*,http://127.0.0.1:*/*`). -**Optional:** `MCP_COGNITO_REDIRECT_URI` (or legacy alias `PLANE_MCP_COGNITO_REDIRECT_URI`), `COGNITO_TOKEN_ENDPOINT_AUTH_METHOD` (default `none` for public clients), `COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH` (default `true`), `COGNITO_REQUIRE_CONSENT`, `COGNITO_OIDC_FORWARD_PKCE`, `REDIS_HOST` / `REDIS_PORT`, `PLANE_BASE_URL` / `PLANE_INTERNAL_BASE_URL`, `PLANE_WORKSPACE_SLUG`. +**Optional:** `REDIS_HOST` / `REDIS_PORT`, `PLANE_BASE_URL` / `PLANE_INTERNAL_BASE_URL`, `PLANE_WORKSPACE_SLUG`. This build subclasses FastMCP’s Cognito provider to omit `resource` on the upstream authorize request when your pool has no resource server, optionally relax MCP `resource` vs `MCP_BASE_URL` mismatches, and attach the Cognito **ID token** to the MCP session when available so Plane’s gateway can resolve `cognito:username` the same way as the web UI (see `plane_mcp/client.py`). diff --git a/plane_mcp/__main__.py b/plane_mcp/__main__.py index 87d40d3..7d046a9 100644 --- a/plane_mcp/__main__.py +++ b/plane_mcp/__main__.py @@ -15,9 +15,7 @@ from plane_mcp.cognito_http import ( build_cognito_http_starlette_app, - cognito_http_configuration_intended, cognito_http_env_missing, - cognito_http_env_ready, ) from plane_mcp.server import get_header_mcp, get_oauth_mcp, get_stdio_mcp @@ -93,7 +91,17 @@ def main() -> None: return if server_mode == ServerMode.HTTP: - if cognito_http_env_ready(): + auth_type = os.getenv("AUTH_TYPE", "").strip().upper() + + if auth_type == "SSO": + missing = cognito_http_env_missing() + if missing: + raise ValueError( + "AUTH_TYPE=SSO requires Cognito env vars but these are missing or empty: " + f"{', '.join(missing)}. " + "Set all of: MCP_BASE_URL, COGNITO_USER_POOL_ID, COGNITO_AWS_REGION, OIDC_CLIENT_ID, " + "MCP_JWT_SIGNING_KEY, MCP_ALLOWED_CLIENT_REDIRECT_URIS." + ) app = build_cognito_http_starlette_app() for uv_logger_name in ("uvicorn", "uvicorn.error"): uv_logger = logging.getLogger(uv_logger_name) @@ -104,7 +112,7 @@ def main() -> None: uv_logger.addHandler(uv_handler) logger.info( - "Starting Cognito HTTP server: /mcp, /http/api-key/mcp, GET /healthz (set Cognito env vars per README)" + "Starting Cognito HTTP server (AUTH_TYPE=SSO): /mcp, /http/api-key/mcp, GET /healthz" ) uvicorn.run( app, @@ -115,17 +123,6 @@ def main() -> None: ) return - if cognito_http_configuration_intended(): - missing = cognito_http_env_missing() - raise ValueError( - "HTTP mode: Cognito is partially configured (COGNITO_USER_POOL_ID and/or OIDC_CLIENT_ID set) " - f"but required variables are missing or empty: {', '.join(missing)}. " - "Set all of: MCP_BASE_URL, COGNITO_USER_POOL_ID, COGNITO_AWS_REGION, OIDC_CLIENT_ID, " - "MCP_JWT_SIGNING_KEY, MCP_ALLOWED_CLIENT_REDIRECT_URIS. " - "To use Plane OAuth at /http/mcp instead, unset COGNITO_USER_POOL_ID and OIDC_CLIENT_ID and set " - "PLANE_OAUTH_PROVIDER_CLIENT_ID and PLANE_OAUTH_PROVIDER_CLIENT_SECRET." - ) - oauth_mcp = get_oauth_mcp("/http") oauth_app = oauth_mcp.http_app(stateless_http=True) header_app = get_header_mcp().http_app(stateless_http=True) diff --git a/plane_mcp/client.py b/plane_mcp/client.py index 2eb8450..273d592 100644 --- a/plane_mcp/client.py +++ b/plane_mcp/client.py @@ -20,19 +20,15 @@ class PlaneClientContext(NamedTuple): def _plane_bearer_for(token: str, claims: dict[str, Any] | None) -> str: - """Use Cognito ID token for Plane when present on the MCP session (oauth2-proxy ``cognito:username``). + """Return the Cognito ID token from claims when present, otherwise the access token. - The ID token is attached to ``AccessToken.claims`` by ``plane_mcp.cognito_http`` when - available. Otherwise returns ``token`` (Plane OAuth, PAT, stdio). + Cognito ID tokens carry ``cognito:username`` which oauth2-proxy needs to match the + web cookie flow. Falls back to ``token`` for Plane OAuth / PAT / stdio paths. """ id_token = (claims or {}).get("id_token") if isinstance(id_token, str) and id_token: logger.info("Plane bearer: forwarding upstream Cognito id_token (len=%d)", len(id_token)) return id_token - logger.warning( - "Plane bearer: no id_token in claims (keys=%s) — forwarding access token (oauth2-proxy may reject it)", - list((claims or {}).keys()), - ) return token @@ -40,28 +36,10 @@ def get_plane_client_context(workspace_slug_from_client: str | None = None) -> P """ Initialize and return a PlaneClient instance with workspace context. - Authentication is handled by the PlaneOAuthProvider, which supports: - 1. Environment variables (PLANE_API_KEY + PLANE_WORKSPACE_SLUG) - 2. HTTP headers (x-api-key + x-workspace-slug) - 3. OAuth access token - - Workspace slug: if ``workspace_slug_from_client`` is set, it wins; otherwise - token ``claims['workspace_slug']`` (e.g. PAT header), then ``PLANE_WORKSPACE_SLUG``. - - Environment variables (Plane API host): - - PLANE_INTERNAL_BASE_URL: Optional internal origin for the Plane deployment. When set, - it is used ahead of PLANE_BASE_URL for **all** SDK calls in this process (OAuth and PAT). - For Cognito/browser flows that must match Traefik + oauth2-proxy like the web UI, leave it - unset and set only PLANE_BASE_URL to the public Plane URL. - - PLANE_BASE_URL: Plane deployment origin (fallback: https://api.plane.so). Used whenever - PLANE_INTERNAL_BASE_URL is unset. - - Returns: - PlaneClientContext containing configured PlaneClient instance and workspace slug + Workspace slug precedence: ``workspace_slug_from_client`` > token claim > ``PLANE_WORKSPACE_SLUG``. Raises: - ConfigurationError: If the resolved workspace slug is empty (would produce invalid - ``/api/v1/workspaces/work-items/...`` URLs and Plane 404s). + ConfigurationError: If no workspace slug can be resolved. """ base_url = os.getenv("PLANE_INTERNAL_BASE_URL") or os.getenv("PLANE_BASE_URL", "https://api.plane.so") workspace_slug = os.getenv("PLANE_WORKSPACE_SLUG", "") @@ -96,9 +74,7 @@ def get_plane_client_context(workspace_slug_from_client: str | None = None) -> P api_key=api_key, ) - slug = (workspace_slug or "").strip() - if workspace_slug_from_client and str(workspace_slug_from_client).strip(): - slug = str(workspace_slug_from_client).strip() + slug = (workspace_slug_from_client or workspace_slug or "").strip() if not slug: raise ConfigurationError( diff --git a/plane_mcp/cognito_http.py b/plane_mcp/cognito_http.py index 5de05b8..2055a6c 100644 --- a/plane_mcp/cognito_http.py +++ b/plane_mcp/cognito_http.py @@ -1,6 +1,6 @@ """AWS Cognito browser OAuth for streamable HTTP MCP (see README, "HTTP with AWS Cognito"). -When ``cognito_http_env_ready()`` is true, ``python -m plane_mcp http`` serves MCP at +When ``AUTH_TYPE=SSO``, ``python -m plane_mcp http`` serves MCP at ``{MCP_BASE_URL}/mcp`` using a public Cognito app client and ``MCP_JWT_SIGNING_KEY``. ``get_plane_client_context`` forwards the Cognito **ID token** to Plane when it is attached @@ -16,7 +16,6 @@ import os from contextlib import asynccontextmanager from typing import Any, cast -from urllib.parse import urlparse from fastmcp import FastMCP from fastmcp.server.auth.auth import AccessToken @@ -55,38 +54,10 @@ def _cognito_callback_base_and_path() -> tuple[str, str]: """Return ``(base_url, redirect_path)`` for Cognito ``redirect_uri`` (authorize + token).""" - raw = os.getenv("MCP_COGNITO_REDIRECT_URI", "").strip() or os.getenv("PLANE_MCP_COGNITO_REDIRECT_URI", "").strip() - if raw: - p = urlparse(raw) - if p.scheme not in ("http", "https") or not p.netloc: - raise ValueError("MCP_COGNITO_REDIRECT_URI must be an absolute http(s) URL without query or fragment") - if p.query or p.fragment: - raise ValueError("MCP_COGNITO_REDIRECT_URI must not include query or fragment") - path = p.path or _DEFAULT_CALLBACK_PATH - if not path.startswith("/"): - path = "/" + path - path = path.rstrip("/") or "/" - if path == "/": - raise ValueError("MCP_COGNITO_REDIRECT_URI must include a path (e.g. /auth/callback)") - base = f"{p.scheme}://{p.netloc}".rstrip("/") - mcp = os.environ.get("MCP_BASE_URL", "").strip().rstrip("/") - if mcp and mcp != base: - logger.warning( - "MCP_COGNITO_REDIRECT_URI host %s differs from MCP_BASE_URL %s — Cognito uses the redirect URI only", - base, - mcp, - ) - return base, path base = os.environ["MCP_BASE_URL"].strip().rstrip("/") return base, _DEFAULT_CALLBACK_PATH -def cognito_idp_redirect_uri() -> str: - """Exact IdP callback URL sent to Cognito (for logs, Cognito console, tests).""" - b, path = _cognito_callback_base_and_path() - return f"{b}{path}" - - class _AWSCognitoTokenVerifierUsernameFallback(AWSCognitoTokenVerifier): """Cognito access tokens often use ``cognito:username`` instead of ``username``.""" @@ -156,33 +127,10 @@ async def load_access_token(self, token: str) -> AccessToken | None: return None async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: - """Allow MCP clients whose ``resource`` URL does not match ``MCP_BASE_URL`` (e.g. localhost vs Traefik). - - FastMCP rejects mismatched ``resource`` with ``invalid_target`` before any redirect — Cursor often - sends ``http://127.0.0.1:/mcp`` while ``MCP_BASE_URL`` is ``https://foss-pm-mcp...``. - Clear ``resource`` so consent/upstream Cognito flow runs; upstream authorize drops ``resource`` anyway. - Set ``COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH=false`` to enforce strict binding. - """ - relax = os.getenv("COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH", "true").strip().lower() in ( - "1", - "true", - "yes", - ) + """Clear mismatched ``resource`` so Cursor (http://127.0.0.1:) works against a remote MCP_BASE_URL.""" server_resource = getattr(self, "_resource_url", None) client_resource = getattr(params, "resource", None) - if ( - relax - and client_resource is not None - and server_resource is not None - and str(client_resource) != str(server_resource) - ): - logger.warning( - "Cognito OAuth: client resource %s != server MCP resource %s; continuing without " - "resource binding (COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH). Prefer aligning MCP_BASE_URL " - "with the MCP URL your client uses.", - client_resource, - server_resource, - ) + if client_resource is not None and server_resource is not None and str(client_resource) != str(server_resource): params = params.model_copy(update={"resource": None}) return await super().authorize(client, params) @@ -218,15 +166,6 @@ def cognito_http_env_missing() -> list[str]: return [name for name in REQUIRED_HTTP_ENV_VARS if not os.getenv(name, "").strip()] -def cognito_http_configuration_intended() -> bool: - """True when Cognito pool/client id hints are set but env may be incomplete.""" - return bool(os.getenv("COGNITO_USER_POOL_ID", "").strip() or os.getenv("OIDC_CLIENT_ID", "").strip()) - - -def cognito_http_env_ready() -> bool: - return not cognito_http_env_missing() - - def validate_cognito_http_env() -> None: missing = cognito_http_env_missing() if missing: @@ -266,11 +205,6 @@ def _oauth_client_storage() -> MemoryStore | RedisStore: return _oauth_kv_singleton -def _consent_enabled() -> bool: - v = os.getenv("COGNITO_REQUIRE_CONSENT", "true").strip().lower() - return v not in ("0", "false", "no") - - def _build_cognito_provider() -> AWSCognitoProvider: validate_cognito_http_env() base_url, redirect_path = _cognito_callback_base_and_path() @@ -290,26 +224,12 @@ def _build_cognito_provider() -> AWSCognitoProvider: required_scopes=["openid"], allowed_client_redirect_uris=redirect_patterns, client_storage=_oauth_client_storage(), - require_authorization_consent=_consent_enabled(), + require_authorization_consent=True, jwt_signing_key=jwt_signing_key, ) provider = _PlaneCognitoProvider(**kwargs) - - forward_pkce = os.getenv("COGNITO_OIDC_FORWARD_PKCE", "false").strip().lower() in ("1", "true", "yes") - provider._forward_pkce = forward_pkce # type: ignore[attr-defined] - if forward_pkce: - logger.info("Cognito HTTP: COGNITO_OIDC_FORWARD_PKCE=true (PKCE sent to Cognito authorize/token)") - else: - logger.info( - "Cognito HTTP: upstream PKCE off (default). Plane tools use Cognito ID token as " - "Bearer when attached to the session (see plane_mcp.client._plane_bearer_for); " - "otherwise the access token." - ) - - auth_meth = os.getenv("COGNITO_TOKEN_ENDPOINT_AUTH_METHOD", "none").strip() or "none" - provider._token_endpoint_auth_method = auth_meth # type: ignore[attr-defined] - logger.info("Cognito HTTP: public Cognito app client; token endpoint client authentication: %s", auth_meth) - + provider._forward_pkce = False # type: ignore[attr-defined] + provider._token_endpoint_auth_method = "none" # type: ignore[attr-defined] return provider @@ -358,7 +278,6 @@ async def lifespan(app): allow_methods=["*"], allow_headers=["*"], ) - cb = cognito_idp_redirect_uri() base = os.environ["MCP_BASE_URL"].strip().rstrip("/") logger.info( "Cognito HTTP: MCP %s/mcp; PAT %s/http/api-key/mcp; health %s/healthz", @@ -366,5 +285,5 @@ async def lifespan(app): base, base, ) - logger.info("Cognito HTTP: register this exact Callback URL in Cognito: %s", cb) + logger.info("Cognito HTTP: register this exact Callback URL in Cognito: %s%s", base, _DEFAULT_CALLBACK_PATH) return app diff --git a/plane_mcp/tools/workspaces.py b/plane_mcp/tools/workspaces.py index 1e9795f..4296578 100644 --- a/plane_mcp/tools/workspaces.py +++ b/plane_mcp/tools/workspaces.py @@ -6,15 +6,12 @@ from fastmcp import FastMCP from fastmcp.server.auth.auth import AccessToken from fastmcp.server.dependencies import get_access_token -from fastmcp.utilities.logging import get_logger from plane.models.users import UserLite from plane.models.workspaces import WorkspaceFeature from pydantic import BaseModel, ConfigDict from plane_mcp.client import _plane_bearer_for, get_plane_client_context -logger = get_logger(__name__) - class Workspace(BaseModel): """Workspace row from ``GET /api/users/me/workspaces/`` (shape varies; extra fields allowed).""" @@ -27,38 +24,11 @@ class Workspace(BaseModel): owner: str | None = None -def _plane_root_base() -> str: - """Return ```` (no path) for non-``/api/v1`` endpoints. - - Plane's "list user workspaces" lives at ``/api/users/me/workspaces/`` (the **app** API, - not the public ``/api/v1/`` surface — see Plane backend ``UserWorkSpacesEndpoint``). - Prefers ``PLANE_INTERNAL_BASE_URL`` when set; otherwise ``PLANE_BASE_URL`` (typical for - Cognito + public Plane URLs). - """ - base = os.getenv("PLANE_INTERNAL_BASE_URL") or os.getenv("PLANE_BASE_URL", "https://api.plane.so") - return base.rstrip("/") - - -def _httpx_verify() -> bool | str: - """Match the SDK's TLS trust: honor ``REQUESTS_CA_BUNDLE`` / ``SSL_CERT_FILE``. - - httpx ignores ``REQUESTS_CA_BUNDLE`` (which the Plane SDK / requests honors), so - when calling Traefik with a dev wildcard cert we must pass ``verify=`` - explicitly. Falls through to httpx's default trust store when neither is set. - """ - bundle = os.getenv("REQUESTS_CA_BUNDLE") or os.getenv("SSL_CERT_FILE") - return bundle or True - - def _plane_auth_headers() -> dict[str, str]: - """Build ``Authorization`` / ``X-Api-Key`` for Plane from the current MCP session. - - Cognito browser-OAuth: ``get_access_token()`` returns the validated Cognito **access** - token; we forward the matching **ID token** (looked up via - ``plane_mcp.client._plane_bearer_for``) so oauth2-proxy can resolve - ``cognito:username`` and Plane's ``ProxyAuthMiddleware`` authenticates the same - user as the web cookie flow. PAT mount: forwards ``X-Api-Key`` instead. - Stdio: falls back to ``PLANE_API_KEY``. + """Build Authorization / X-Api-Key headers for the current MCP session. + + Forwards the Cognito ID token (not access token) for oauth2-proxy compatibility. + Falls back to X-Api-Key for PAT/stdio. """ headers: dict[str, str] = {"Content-Type": "application/json"} stored: AccessToken | None = get_access_token() @@ -81,19 +51,15 @@ def register_workspace_tools(mcp: FastMCP) -> None: def list_workspaces() -> list[Workspace]: """List Plane workspaces the authenticated user belongs to. - Calls ``GET /api/users/me/workspaces/`` (Plane app API, not ``/api/v1/``) with the - current bearer token (Cognito OAuth) or API key (PAT / stdio). Plane's public API - does not list a user's workspaces — the workspace-scoped routes all require a - slug, so this tool exists to bootstrap that slug. - - Use a returned ``slug`` to set ``PLANE_WORKSPACE_SLUG`` (or send - ``X-Workspace-slug`` on PAT) before calling workspace-scoped tools. + Use a returned ``slug`` as ``workspace_slug`` on other tools, or set ``PLANE_WORKSPACE_SLUG``. Returns: List of Workspace objects (id, name, slug, owner, plus any extra fields). """ - url = f"{_plane_root_base()}/api/users/me/workspaces/" - with httpx.Client(timeout=30.0, verify=_httpx_verify()) as client: + base = (os.getenv("PLANE_INTERNAL_BASE_URL") or os.getenv("PLANE_BASE_URL", "https://api.plane.so")).rstrip("/") + url = f"{base}/api/users/me/workspaces/" + verify: bool | str = os.getenv("REQUESTS_CA_BUNDLE") or os.getenv("SSL_CERT_FILE") or True + with httpx.Client(timeout=30.0, verify=verify) as client: resp = client.get(url, headers=_plane_auth_headers()) resp.raise_for_status() data = resp.json() diff --git a/tests/test_cognito_http.py b/tests/test_cognito_http.py index a71fd18..adeba34 100644 --- a/tests/test_cognito_http.py +++ b/tests/test_cognito_http.py @@ -1,7 +1,6 @@ """Cognito HTTP callback URL resolution.""" import asyncio -import sys from unittest.mock import AsyncMock, MagicMock import pytest @@ -13,10 +12,7 @@ _allowed_client_redirect_uris, _cognito_callback_base_and_path, _PlaneCognitoProvider, - cognito_http_configuration_intended, cognito_http_env_missing, - cognito_http_env_ready, - cognito_idp_redirect_uri, validate_cognito_http_env, ) @@ -27,38 +23,11 @@ def clear_cognito_env(monkeypatch): monkeypatch.delenv(name, raising=False) -def test_cognito_http_env_ready(monkeypatch): - for name in REQUIRED_HTTP_ENV_VARS: - monkeypatch.setenv(name, "x") - assert cognito_http_env_ready() is True - - -def test_cognito_http_env_ready_false(clear_cognito_env, monkeypatch): - for i, _name in enumerate(REQUIRED_HTTP_ENV_VARS): - for n in REQUIRED_HTTP_ENV_VARS: - monkeypatch.delenv(n, raising=False) - for j, n in enumerate(REQUIRED_HTTP_ENV_VARS): - if j != i: - monkeypatch.setenv(n, "x") - assert cognito_http_env_ready() is False - - -def test_cognito_idp_redirect_from_mcp_base(monkeypatch): +def test_cognito_callback_uses_mcp_base_url(monkeypatch): monkeypatch.setenv("MCP_BASE_URL", "https://mcp.example/") - assert cognito_idp_redirect_uri() == "https://mcp.example/auth/callback" - - -def test_cognito_idp_redirect_explicit(monkeypatch): - monkeypatch.setenv("MCP_BASE_URL", "https://internal:8211") - monkeypatch.setenv("MCP_COGNITO_REDIRECT_URI", "https://public.example/cb/path") - assert cognito_idp_redirect_uri() == "https://public.example/cb/path" - - -def test_cognito_idp_redirect_rejects_query(monkeypatch): - monkeypatch.setenv("MCP_BASE_URL", "https://x.test") - monkeypatch.setenv("MCP_COGNITO_REDIRECT_URI", "https://x.test/cb?a=1") - with pytest.raises(ValueError, match="query"): - cognito_idp_redirect_uri() + b, p = _cognito_callback_base_and_path() + assert b == "https://mcp.example" + assert p == "/auth/callback" def test_allowed_client_redirect_uris_none_when_unset(clear_cognito_env, monkeypatch): @@ -79,17 +48,6 @@ def test_allowed_client_redirect_uris_when_set(monkeypatch): assert _allowed_client_redirect_uris() == ["cursor://*", "http://localhost:*/*"] -def test_cognito_http_configuration_intended(monkeypatch): - monkeypatch.delenv("COGNITO_USER_POOL_ID", raising=False) - monkeypatch.delenv("OIDC_CLIENT_ID", raising=False) - assert cognito_http_configuration_intended() is False - monkeypatch.setenv("COGNITO_USER_POOL_ID", "pool") - assert cognito_http_configuration_intended() is True - monkeypatch.delenv("COGNITO_USER_POOL_ID", raising=False) - monkeypatch.setenv("OIDC_CLIENT_ID", "cid") - assert cognito_http_configuration_intended() is True - - def test_cognito_http_env_missing_lists_blank_vars(monkeypatch): for name in REQUIRED_HTTP_ENV_VARS: monkeypatch.setenv(name, "x") @@ -104,26 +62,6 @@ def test_validate_cognito_http_env_succeeds_when_required_set(monkeypatch): validate_cognito_http_env() -def test_http_main_raises_clear_error_when_cognito_partial(monkeypatch): - monkeypatch.delenv("MCP_BASE_URL", raising=False) - monkeypatch.delenv("COGNITO_AWS_REGION", raising=False) - monkeypatch.delenv("OIDC_CLIENT_ID", raising=False) - monkeypatch.delenv("MCP_JWT_SIGNING_KEY", raising=False) - monkeypatch.setenv("COGNITO_USER_POOL_ID", "test-pool") - monkeypatch.setattr(sys, "argv", ["plane_mcp", "http"]) - import plane_mcp.__main__ as entry - - with pytest.raises(ValueError, match="Cognito is partially configured"): - entry.main() - - -def test_cognito_idp_redirect_legacy_alias(monkeypatch): - monkeypatch.setenv("MCP_BASE_URL", "https://x.test") - monkeypatch.delenv("MCP_COGNITO_REDIRECT_URI", raising=False) - monkeypatch.setenv("PLANE_MCP_COGNITO_REDIRECT_URI", "https://other.example/auth/callback") - b, p = _cognito_callback_base_and_path() - assert b == "https://other.example" - assert p == "/auth/callback" def _access_token() -> AccessToken: diff --git a/tests/test_workspaces_tool.py b/tests/test_workspaces_tool.py index 7c48117..e61ec0a 100644 --- a/tests/test_workspaces_tool.py +++ b/tests/test_workspaces_tool.py @@ -3,19 +3,7 @@ from types import SimpleNamespace from unittest.mock import patch -from plane_mcp.tools.workspaces import _httpx_verify, _plane_auth_headers, _plane_root_base - - -def test_plane_root_base_uses_internal_first(monkeypatch): - monkeypatch.setenv("PLANE_INTERNAL_BASE_URL", "http://plane-api:8000") - monkeypatch.setenv("PLANE_BASE_URL", "https://public.example/") - assert _plane_root_base() == "http://plane-api:8000" - - -def test_plane_root_base_falls_back_to_public(monkeypatch): - monkeypatch.delenv("PLANE_INTERNAL_BASE_URL", raising=False) - monkeypatch.setenv("PLANE_BASE_URL", "https://foss-pm.local.moneta.dev/") - assert _plane_root_base() == "https://foss-pm.local.moneta.dev" +from plane_mcp.tools.workspaces import _plane_auth_headers @patch("plane_mcp.tools.workspaces.get_access_token") @@ -53,27 +41,3 @@ def test_plane_auth_headers_stdio_falls_back_to_env_api_key(_mock_get_token, mon headers = _plane_auth_headers() assert headers["X-Api-Key"] == "env-key" assert "Authorization" not in headers - - -def test_httpx_verify_prefers_requests_ca_bundle_over_ssl_cert_file(monkeypatch): - monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/ca.pem") - monkeypatch.setenv("SSL_CERT_FILE", "/ssl.pem") - assert _httpx_verify() == "/ca.pem" - - -def test_httpx_verify_prefers_requests_ca_bundle(monkeypatch): - monkeypatch.delenv("SSL_CERT_FILE", raising=False) - monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/path/to/ca.pem") - assert _httpx_verify() == "/path/to/ca.pem" - - -def test_httpx_verify_falls_back_to_ssl_cert_file(monkeypatch): - monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) - monkeypatch.setenv("SSL_CERT_FILE", "/path/to/cert.pem") - assert _httpx_verify() == "/path/to/cert.pem" - - -def test_httpx_verify_defaults_true_when_no_bundle(monkeypatch): - monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) - monkeypatch.delenv("SSL_CERT_FILE", raising=False) - assert _httpx_verify() is True diff --git a/uv.lock b/uv.lock index 00ff2b8..add379d 100644 --- a/uv.lock +++ b/uv.lock @@ -916,12 +916,13 @@ wheels = [ [[package]] name = "plane-mcp-server" -version = "0.2.9" +version = "0.2.10" source = { editable = "." } dependencies = [ { name = "authlib" }, { name = "fakeredis", extra = ["lua"] }, { name = "fastmcp" }, + { name = "httpx" }, { name = "mcp" }, { name = "plane-sdk" }, { name = "py-key-value-aio", extra = ["redis"] }, @@ -939,8 +940,9 @@ requires-dist = [ { name = "authlib", specifier = ">=1.6.9" }, { name = "fakeredis", extras = ["lua"], specifier = ">=2.32.1,<2.35.0" }, { name = "fastmcp", specifier = "==2.14.4" }, + { name = "httpx", specifier = ">=0.27" }, { name = "mcp", specifier = "==1.26.0" }, - { name = "plane-sdk", specifier = "==0.2.8" }, + { name = "plane-sdk", specifier = "==0.2.10" }, { name = "py-key-value-aio", extras = ["redis"], specifier = "==0.3.0" }, { name = "pyjwt", specifier = ">=2.12.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, @@ -950,15 +952,15 @@ provides-extras = ["dev"] [[package]] name = "plane-sdk" -version = "0.2.8" +version = "0.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/9f/1fae3f1f189e959a1d9daa5c32433b20fdd1f0f3952e577d60b4443f1b07/plane_sdk-0.2.8.tar.gz", hash = "sha256:a8c48abf057aca248fb6913fe6e7263db32c16b5eac328d2d8b9d9be2a3ea2db", size = 47656, upload-time = "2026-03-23T10:53:54.798Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/4f/49c451d4760f74ba4ed1feb5ff194516ee1e489a7aeffef5ee1f051605d7/plane_sdk-0.2.10.tar.gz", hash = "sha256:1c2419ce6d9c84c2bf385ed7e05e5874fe8e2691e551c2995f7809074975f155", size = 48216, upload-time = "2026-04-22T11:52:56.746Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/3c/d308c7036239c03b6acf88dd9bcf1326b41a1f7f0d8a0e4bbfdd266e6e0e/plane_sdk-0.2.8-py3-none-any.whl", hash = "sha256:3a9290b4e70a21e69c21b28f203dfc71af4e3f9aab63faa1a8a3062eb1c01e7b", size = 71013, upload-time = "2026-03-23T10:53:53.583Z" }, + { url = "https://files.pythonhosted.org/packages/83/5e/364e40e2d46a10b9ced0bdca730d55c75660662b29387f6cd8f41447e954/plane_sdk-0.2.10-py3-none-any.whl", hash = "sha256:941cd939474b62bf3c09989e3e4f9258cfbae69b5ff0ec92839ee834cd58100b", size = 71621, upload-time = "2026-04-22T11:52:55.374Z" }, ] [[package]]