diff --git a/README.md b/README.md index f407630..b5d1c54 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,29 @@ Connect to the hosted Plane MCP server using a Personal Access Token (PAT). } ``` -### 4. SSE Transport (Legacy) +### 4. HTTP with AWS Cognito (self-hosted) -⚠️ **Legacy Transport**: SSE (Server-Sent Events) transport is maintained for backward compatibility. New implementations should use the HTTP transport (sections 2 or 3) instead. +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 `/`. + +| 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` | + +**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:** `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`). + +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) + +⚠️ **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. @@ -332,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 | @@ -342,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/__main__.py b/plane_mcp/__main__.py index d943d90..7d046a9 100644 --- a/plane_mcp/__main__.py +++ b/plane_mcp/__main__.py @@ -13,6 +13,10 @@ 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_missing, +) from plane_mcp.server import get_header_mcp, get_oauth_mcp, get_stdio_mcp @@ -87,6 +91,38 @@ def main() -> None: return if server_mode == ServerMode.HTTP: + 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) + 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 (AUTH_TYPE=SSO): /mcp, /http/api-key/mcp, GET /healthz" + ) + 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..273d592 100644 --- a/plane_mcp/client.py +++ b/plane_mcp/client.py @@ -1,12 +1,13 @@ """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 from fastmcp.utilities.logging import get_logger from plane import PlaneClient +from plane.errors import ConfigurationError logger = get_logger(__name__) @@ -18,24 +19,27 @@ class PlaneClientContext(NamedTuple): workspace_slug: str -def get_plane_client_context() -> PlaneClientContext: +def _plane_bearer_for(token: str, claims: dict[str, Any] | None) -> str: + """Return the Cognito ID token from claims when present, otherwise the access token. + + 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. """ - Initialize and return a PlaneClient instance with workspace context. + 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 + return token - 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 - 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) +def get_plane_client_context(workspace_slug_from_client: str | None = None) -> PlaneClientContext: + """ + Initialize and return a PlaneClient instance with workspace context. - Returns: - PlaneClientContext containing configured PlaneClient instance and workspace slug + Workspace slug precedence: ``workspace_slug_from_client`` > token claim > ``PLANE_WORKSPACE_SLUG``. Raises: - ConfigurationError: If access token is not available or workspace slug is missing + 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", "") @@ -49,13 +53,15 @@ def get_plane_client_context() -> PlaneClientContext: # 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( @@ -68,7 +74,17 @@ def get_plane_client_context() -> PlaneClientContext: api_key=api_key, ) + slug = (workspace_slug_from_client or workspace_slug or "").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 new file mode 100644 index 0000000..2055a6c --- /dev/null +++ b/plane_mcp/cognito_http.py @@ -0,0 +1,289 @@ +"""AWS Cognito browser OAuth for streamable HTTP MCP (see README, "HTTP with AWS Cognito"). + +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 +to the MCP session (see ``plane_mcp.client._plane_bearer_for``). Set ``PLANE_WORKSPACE_SLUG`` +when tokens have no ``workspace_slug`` claim. + +PAT mount: ``{MCP_BASE_URL}/http/api-key/mcp``. +""" + +from __future__ import annotations + +import logging +import os +from contextlib import asynccontextmanager +from typing import Any, cast + +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(f"fastmcp.{__name__}") + +REQUIRED_HTTP_ENV_VARS = ( + "MCP_BASE_URL", + "COGNITO_USER_POOL_ID", + "COGNITO_AWS_REGION", + "OIDC_CLIENT_ID", + "MCP_JWT_SIGNING_KEY", + "MCP_ALLOWED_CLIENT_REDIRECT_URIS", +) + +_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).""" + base = os.environ["MCP_BASE_URL"].strip().rstrip("/") + return base, _DEFAULT_CALLBACK_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. ``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: + 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 None + jti_mapping = await self._jti_mapping_store.get(key=jti) + if not jti_mapping: + 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 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 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 None + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + """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 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) + + 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_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 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). + + 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 + out = [uri.strip() for uri in raw.split(",") if uri.strip()] + return out or None + + +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 _build_cognito_provider() -> AWSCognitoProvider: + validate_cognito_http_env() + base_url, redirect_path = _cognito_callback_base_and_path() + 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 + # 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=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=True, + jwt_signing_key=jwt_signing_key, + ) + provider = _PlaneCognitoProvider(**kwargs) + provider._forward_pkce = False # type: ignore[attr-defined] + provider._token_endpoint_auth_method = "none" # type: ignore[attr-defined] + 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: + 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=["*"], + ) + 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%s", base, _DEFAULT_CALLBACK_PATH) + return app 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..ad245eb 100644 --- a/plane_mcp/tools/users.py +++ b/plane_mcp/tools/users.py @@ -10,12 +10,22 @@ 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. + 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. + 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 a24e0b9..4296578 100644 --- a/plane_mcp/tools/workspaces.py +++ b/plane_mcp/tools/workspaces.py @@ -1,35 +1,99 @@ """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 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 + + +class Workspace(BaseModel): + """Workspace row from ``GET /api/users/me/workspaces/`` (shape varies; extra fields allowed).""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) -from plane_mcp.client import get_plane_client_context + id: str | None = None + name: str | None = None + slug: str | None = None + owner: str | None = None + + +def _plane_auth_headers() -> dict[str, str]: + """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() + 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 get_workspace_members() -> list[UserLite]: + def list_workspaces() -> list[Workspace]: + """List Plane workspaces the authenticated user belongs to. + + 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). + """ + 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() + 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(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() @@ -40,6 +104,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. @@ -51,11 +116,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/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", diff --git a/tests/test_client_context.py b/tests/test_client_context.py new file mode 100644 index 0000000..0da7b0d --- /dev/null +++ b/tests/test_client_context.py @@ -0,0 +1,66 @@ +"""Tests for Plane client context resolution.""" + +from types import SimpleNamespace + +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" + + +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_cognito_http.py b/tests/test_cognito_http.py new file mode 100644 index 0000000..adeba34 --- /dev/null +++ b/tests/test_cognito_http.py @@ -0,0 +1,116 @@ +"""Cognito HTTP callback URL resolution.""" + +import asyncio +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_env_missing, + validate_cognito_http_env, +) + + +@pytest.fixture +def clear_cognito_env(monkeypatch): + for name in REQUIRED_HTTP_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def test_cognito_callback_uses_mcp_base_url(monkeypatch): + monkeypatch.setenv("MCP_BASE_URL", "https://mcp.example/") + 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): + monkeypatch.delenv("MCP_ALLOWED_CLIENT_REDIRECT_URIS", raising=False) + 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:*/*"] + + +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 _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" diff --git a/tests/test_workspaces_tool.py b/tests/test_workspaces_tool.py new file mode 100644 index 0000000..e61ec0a --- /dev/null +++ b/tests/test_workspaces_tool.py @@ -0,0 +1,43 @@ +"""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 + + +@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_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) + 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 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]]