working tree of plane mcp#2
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a Cognito-backed HTTP mode for the Plane MCP server and introduces a workspace bootstrap tool to discover workspace slugs for authenticated users.
Changes:
- Introduces
plane_mcp.cognito_httpto run FastMCP withAWSCognitoProvider, plus a Starlette app with/mcp,/http/api-key/mcp, and/healthz. - Adds a new
list_workspacestool (and supporting auth/base-url helpers) to fetch/api/users/me/workspaces/. - Adds unit tests for Cognito redirect/allowed-URI logic and workspace auth/base-url helpers; updates README for the new Cognito mode and transport URLs/tool inventory.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
plane_mcp/cognito_http.py |
New Cognito HTTP Starlette/FastMCP wiring, redirect-uri resolution, client redirect allowlisting, and token-claim adjustments. |
plane_mcp/__main__.py |
Adds runtime selection to start Cognito HTTP server when required env vars are present. |
plane_mcp/tools/workspaces.py |
Adds list_workspaces plus auth/base-url helpers and a Pydantic Workspace model. |
plane_mcp/client.py |
Adds _plane_bearer_for to prefer forwarding an attached Cognito ID token; tweaks workspace slug claim handling/docs. |
tests/test_cognito_http.py |
Unit tests for Cognito env readiness, redirect URI building/validation, and allowed redirect URI parsing. |
tests/test_workspaces_tool.py |
Unit tests for Plane base URL selection and auth header construction across OAuth/PAT/env modes. |
README.md |
Documents Cognito HTTP mode and updates remote transport docs/tool listing. |
Comments suppressed due to low confidence (1)
README.md:75
- The PAT transport URL is inconsistent: this section says
https://mcp.plane.so/api-key/mcp, but the example config below (and the server mounts inplane_mcp/__main__.py/plane_mcp/cognito_http.py) use/http/api-key/mcp. Please make the URL consistent (likely/http/api-key/mcp) so users don’t copy a broken endpoint.
Connect to the hosted Plane MCP server using a Personal Access Token (PAT).
**URL**: `https://mcp.plane.so/api-key/mcp`
**Headers**:
- `Authorization: Bearer <PAT_TOKEN>`
- `X-Workspace-slug: <SLUG>`
**MCP Client Configuration** (for tools like Claude Desktop without native remote MCP support):
```json
{
"mcpServers": {
"plane": {
"command": "npx",
"args": ["mcp-remote@latest", "https://mcp.plane.so/http/api-key/mcp"],
"headers": {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| | `get_me` | Get current authenticated user information | | ||
|
|
||
| **Total Tools**: 100+ tools across 20 categories | ||
| **Total Tools**: 55+ tools across 8 categories |
There was a problem hiding this comment.
The README’s tool documentation no longer lists several tool categories that are still registered in code (e.g., register_epic_tools, register_milestone_tools, register_label_tools, register_state_tools, etc. in plane_mcp/tools/__init__.py). As a result, the stated “55+ tools across 8 categories” is misleading—please update the README tool list / totals (or remove the tools) to match the actual exported tool surface.
| **Total Tools**: 55+ tools across 8 categories | |
| **Tool coverage**: The server exports the tools documented above along with additional registered categories, including epic, milestone, label, and state tools. For the complete current tool registry, refer to `plane_mcp/tools/__init__.py`. |
| class Workspace(BaseModel): | ||
| """Plane workspace summary returned from ``GET /api/v1/workspaces/``. | ||
|
|
||
| Pressingly Plane (and upstream Plane) include other fields (logo, organization size, | ||
| timestamps, etc.); ``extra="allow"`` keeps them on the model so tool consumers can | ||
| still inspect everything without a schema mismatch breaking the call. |
There was a problem hiding this comment.
The Workspace model docstring says it is returned from GET /api/v1/workspaces/, but the new list_workspaces() tool actually calls /api/users/me/workspaces/. Please update the docstring to reflect the real endpoint to avoid confusing tool consumers and future maintainers.
| import os | ||
|
|
||
| import httpx | ||
| from fastmcp import FastMCP |
There was a problem hiding this comment.
workspaces.py now imports and uses httpx directly, but httpx is not listed as a direct dependency in pyproject.toml. Please add it as an explicit dependency (even if it currently arrives transitively) to avoid installs breaking if upstream dependency graphs change.
| @@ -41,21 +69,20 @@ def get_plane_client_context() -> PlaneClientContext: | |||
| workspace_slug = os.getenv("PLANE_WORKSPACE_SLUG", "") | |||
|
|
|||
There was a problem hiding this comment.
The docstring warns that PLANE_INTERNAL_BASE_URL “skips Traefik+oauth2-proxy” and is only safe for PAT auth, but the implementation always prefers PLANE_INTERNAL_BASE_URL even when using an OAuth bearer token. Either adjust the selection logic (e.g., prefer PLANE_BASE_URL when access_token is used) or update the docs so behavior and guidance match.
| "resource binding (COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH). Prefer aligning PLANE_MCP_BASE_URL " | ||
| "/ MCP_BASE_URL with the MCP URL your client uses.", |
There was a problem hiding this comment.
This warning message references PLANE_MCP_BASE_URL, but the code and required env vars use MCP_BASE_URL (and there’s no other mention of PLANE_MCP_BASE_URL in the repo). Please fix the env var name in the log text to avoid sending operators to a non-existent setting.
| "resource binding (COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH). Prefer aligning PLANE_MCP_BASE_URL " | |
| "/ MCP_BASE_URL with the MCP URL your client uses.", | |
| "resource binding (COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH). Prefer aligning MCP_BASE_URL " | |
| "with the MCP URL your client uses.", |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Prefers ``PLANE_INTERNAL_BASE_URL`` (server-to-server); otherwise ``PLANE_BASE_URL``. | ||
| """ | ||
| base = os.getenv("PLANE_INTERNAL_BASE_URL") or os.getenv("PLANE_BASE_URL", "https://api.plane.so") |
There was a problem hiding this comment.
We are not setting PLANE_INTERNAL_BASE_URL in environment.
| def get_me(workspace_slug: str | None = None) -> UserLite: | ||
| """ | ||
| Get current user information. | ||
|
|
||
| Args: | ||
| workspace_slug: Optional; overrides default workspace for this request context. | ||
|
|
||
| Returns: | ||
| UserLite object containing current user information | ||
| """ | ||
| client, workspace_slug = get_plane_client_context() | ||
| client, _workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) | ||
| return client.users.get_me() |
| def _allowed_client_redirect_uris() -> list[str] | None: | ||
| """Redirect URI patterns for MCP OAuth Dynamic Client Registration (fnmatch wildcards). | ||
|
|
||
| ``None`` means allow **any** client redirect URI (FastMCP default; needed for clients that use | ||
| custom schemes such as ``cursor://``). Set ``MCP_ALLOWED_CLIENT_REDIRECT_URIS`` to a | ||
| comma-separated list to restrict patterns in production. | ||
| """ | ||
| raw = os.getenv("MCP_ALLOWED_CLIENT_REDIRECT_URIS", "").strip() | ||
| if not raw: | ||
| return None |
| 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=_consent_enabled(), | ||
| jwt_signing_key=jwt_signing_key, | ||
| ) | ||
| provider = _PlaneCognitoProvider(**kwargs) | ||
|
|
||
| forward_pkce = os.getenv("COGNITO_OIDC_FORWARD_PKCE", "false").strip().lower() in ("1", "true", "yes") | ||
| provider._forward_pkce = forward_pkce # type: ignore[attr-defined] | ||
| if forward_pkce: | ||
| logger.info("Cognito HTTP: COGNITO_OIDC_FORWARD_PKCE=true (PKCE sent to Cognito authorize/token)") | ||
| else: | ||
| logger.info("Cognito HTTP: upstream PKCE off (default). Tools use Cognito access token as Plane Bearer.") | ||
|
|
||
| auth_meth = os.getenv("COGNITO_TOKEN_ENDPOINT_AUTH_METHOD", "none").strip() or "none" | ||
| provider._token_endpoint_auth_method = auth_meth # type: ignore[attr-defined] | ||
| logger.info("Cognito HTTP: public Cognito app client; token endpoint client authentication: %s", auth_meth) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ``None`` means allow **any** client redirect URI (FastMCP default; needed for clients that use | ||
| custom schemes such as ``cursor://``). Set ``MCP_ALLOWED_CLIENT_REDIRECT_URIS`` to a | ||
| comma-separated list to restrict patterns in production. | ||
| """ | ||
| raw = os.getenv("MCP_ALLOWED_CLIENT_REDIRECT_URIS", "").strip() | ||
| if not raw: | ||
| return None | ||
| return [uri.strip() for uri in raw.split(",") if uri.strip()] | ||
|
|
||
|
|
| return validated | ||
| upstream_id = getattr(jti_mapping, "upstream_token_id", None) or ( | ||
| jti_mapping.get("upstream_token_id") if isinstance(jti_mapping, dict) else None | ||
| ) | ||
| if not upstream_id: | ||
| return validated | ||
| ts = await self._upstream_token_store.get(key=upstream_id) | ||
| raw = getattr(ts, "raw_token_data", None) if ts else None | ||
| if raw is None and isinstance(ts, dict): | ||
| raw = ts.get("raw_token_data") | ||
| id_token = (raw or {}).get("id_token") if isinstance(raw, dict) else None | ||
| if not isinstance(id_token, str) or not id_token: | ||
| return validated | ||
| new_claims = dict(validated.claims or {}) | ||
| new_claims["id_token"] = id_token | ||
| return validated.model_copy(update={"claims": new_claims}) | ||
| except Exception as exc: | ||
| logger.warning("Cognito HTTP: failed to attach upstream id_token to claims: %s", exc) | ||
| return validated |
| def get_me(workspace_slug: str | None = None) -> UserLite: | ||
| """ | ||
| Get current user information. | ||
|
|
||
| Args: | ||
| workspace_slug: Optional; overrides default workspace for this request context. | ||
|
|
||
| Returns: | ||
| UserLite object containing current user information | ||
| """ | ||
| client, workspace_slug = get_plane_client_context() | ||
| client, _workspace_slug = get_plane_client_context(workspace_slug_from_client=workspace_slug) | ||
| return client.users.get_me() |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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) |
| 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)}" |
| async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: | ||
| """Allow MCP clients whose ``resource`` URL does not match ``MCP_BASE_URL`` (e.g. localhost vs Traefik). | ||
|
|
||
| FastMCP rejects mismatched ``resource`` with ``invalid_target`` before any redirect — Cursor often | ||
| sends ``http://127.0.0.1:<port>/mcp`` while ``MCP_BASE_URL`` is ``https://foss-pm-mcp...``. | ||
| Clear ``resource`` so consent/upstream Cognito flow runs; upstream authorize drops ``resource`` anyway. | ||
| Set ``COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH=false`` to enforce strict binding. | ||
| """ | ||
| relax = os.getenv("COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH", "true").strip().lower() in ( | ||
| "1", | ||
| "true", | ||
| "yes", | ||
| ) | ||
| server_resource = getattr(self, "_resource_url", None) | ||
| client_resource = getattr(params, "resource", None) | ||
| if ( | ||
| relax | ||
| and client_resource is not None | ||
| and server_resource is not None | ||
| and str(client_resource) != str(server_resource) | ||
| ): | ||
| logger.warning( | ||
| "Cognito OAuth: client resource %s != server MCP resource %s; continuing without " | ||
| "resource binding (COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH). Prefer aligning MCP_BASE_URL " | ||
| "with the MCP URL your client uses.", | ||
| client_resource, | ||
| server_resource, | ||
| ) | ||
| params = params.model_copy(update={"resource": None}) | ||
| return await super().authorize(client, params) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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) |
| def list_workspaces() -> list[Workspace]: | ||
| """List Plane workspaces the authenticated user belongs to. | ||
|
|
||
| Calls ``GET /api/users/me/workspaces/`` (Plane app API, not ``/api/v1/``) with the | ||
| current bearer token (Cognito OAuth) or API key (PAT / stdio). Plane's public API | ||
| does not list a user's workspaces — the workspace-scoped routes all require a | ||
| slug, so this tool exists to bootstrap that slug. | ||
|
|
||
| Use a returned ``slug`` to set ``PLANE_WORKSPACE_SLUG`` (or send | ||
| ``X-Workspace-slug`` on PAT) before calling workspace-scoped tools. | ||
|
|
||
| Returns: | ||
| List of Workspace objects (id, name, slug, owner, plus any extra fields). | ||
| """ | ||
| url = f"{_plane_root_base()}/api/users/me/workspaces/" | ||
| with httpx.Client(timeout=30.0, verify=_httpx_verify()) as client: | ||
| resp = client.get(url, headers=_plane_auth_headers()) |
| url = f"{_plane_root_base()}/api/users/me/workspaces/" | ||
| with httpx.Client(timeout=30.0, verify=_httpx_verify()) as client: | ||
| resp = client.get(url, headers=_plane_auth_headers()) | ||
| resp.raise_for_status() | ||
| data = resp.json() | ||
| items = data["results"] if isinstance(data, dict) and "results" in data else data | ||
| if not isinstance(items, list): | ||
| raise ValueError(f"Unexpected workspaces payload from {url}: {type(items).__name__}") | ||
| return [Workspace.model_validate(item) for item in items] |
Removed ~170 lines of over-engineered configurability that had safe, fixed values in practice: - AUTH_TYPE=SSO explicit opt-in replaces heuristic env-var sniffing (cognito_http_env_ready / cognito_http_configuration_intended) - Dropped MCP_COGNITO_REDIRECT_URI — MCP_BASE_URL is always the public URL by definition (OAuth clients must reach it), so a separate redirect URI override is never needed - Hardcoded COGNITO_OIDC_FORWARD_PKCE=false, COGNITO_TOKEN_ENDPOINT_AUTH_METHOD=none, COGNITO_REQUIRE_CONSENT=true, COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH=true — all were already the defaults and never changed - Inlined _plane_root_base() and _httpx_verify() one-use helpers - Restored _plane_bearer_for info log (id_token forwarding); dropped the warning (we only use Cognito SSO, never Plane OAuth, so absence of id_token is never an actionable signal) - Added targeted tests for _plane_bearer_for and id_token header preference Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
b19e23b to
fe3b414
Compare
|
Added a simplification/debloat commit on top of this PR. What changed: Removed ~170 lines of configurable knobs that all had safe fixed values in practice — nobody was ever setting Biggest change: No behavior change for the actual deployed configuration. All existing tests pass, added targeted tests for |
hunzlahmalik
left a comment
There was a problem hiding this comment.
Review — Cognito HTTP + ID-token forwarding + list_workspaces
Reviewed with Pressingly/plane#17 and Pressingly/foss-server-bundle#86 (one feature, ship together). The engineering is sound and genuinely well-tested — _PlaneCognitoProvider, _plane_bearer_for, the get_plane_client_context hard-fail, and the auth-header helper all have targeted tests. I checked the two things that looked risky and they're fine: self.jwt_issuer is a real FastMCP property (oauth_proxy/proxy.py:600, used the same way at :1578), and the _jwt_issuer mock in the tests resolves through it. One doc issue worth blocking on + a couple of low polish items.
🟡 Medium
- README §4 documents the wrong activation trigger (inline). It says "When all of the following environment variables are set…" and
AUTH_TYPEisn't in the Required list — but the refactor (fe3b414) gated activation onAUTH_TYPE=SSO(__main__.py:if auth_type == "SSO":). Set every Cognito var, omitAUTH_TYPE=SSO, and you silently get Plane-OAuth/SSE instead, with no error. The devstackplane-mcp-oauth.mdwas updated for this; the README wasn't. AddAUTH_TYPE=SSOto Required and fix the trigger sentence.
🟢 Low
load_access_tokenswallows all exceptions → 401 (inline).except Exception: … return Nonecan't tell "id_token genuinely absent" from "transient Redis error on_upstream_token_store.get," so an infra blip bounces the whole session as an auth failure. The hard-fail posture is deliberate; consider distinguishing the two cases.client_secret=MCP_JWT_SIGNING_KEY(inline). Reusing one secret for two roles is only safe because_token_endpoint_auth_method="none"means it's never transmitted. A future change flipping that auth method leaks the session-signing key as a client secret.- Cosmetic:
workspace_slugwas threaded through every tool but theArgs:docstrings are only partly updated; andlist_workspaceshits/api/users/me/workspaces/(internal web API, session-auth) while the SDK tools hit/api/v1/— worth a one-line note since the two families authenticate differently.
Good calls: get_plane_client_context now raising ConfigurationError on an unresolved slug (no more malformed /api/v1/workspaces/work-items/... 404s), and httpx added as a direct dep instead of relying on it transitively.
| ### 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 `/`. |
There was a problem hiding this comment.
🟡 Medium — stale trigger. The refactor (fe3b414) changed activation to AUTH_TYPE=SSO (plane_mcp/__main__.py: if auth_type == "SSO":), but this still says "When all of the following environment variables are set…" and AUTH_TYPE is missing from the Required list below. Net effect: set every Cognito var, omit AUTH_TYPE=SSO, and you silently fall through to Plane-OAuth/SSE with no error. Add AUTH_TYPE=SSO to Required and reword the trigger.
| new_claims = dict(validated.claims or {}) | ||
| new_claims["id_token"] = id_token | ||
| return validated.model_copy(update={"claims": new_claims}) | ||
| except Exception as exc: |
There was a problem hiding this comment.
🟢 Low. This except Exception: return None makes any failure here (e.g. a transient _upstream_token_store / Redis error) indistinguishable from "id_token absent," and returning None rejects the whole session as invalid auth. Consider catching narrowly / re-raising infra errors so a store hiccup isn't surfaced as a 401.
| 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, |
There was a problem hiding this comment.
🟢 Low. client_secret=jwt_signing_key reuses MCP_JWT_SIGNING_KEY for two distinct roles. Safe only because _token_endpoint_auth_method="none" (set just below) means it's never sent to Cognito. If that auth method ever changes, the session-signing key leaks as a client secret. A dummy client_secret (or a sharper comment) would be safer.
Plane MCP server — Cognito HTTP mode & Plane identity alignment (Pressingly/plane-mcp-server#2)
Summary
This PR adds Cognito-backed streamable HTTP for the Plane MCP server (FastMCP
AWSCognitoProvider), alist_workspacesbootstrap tool so callers can discover workspace slugs before hitting/api/v1/workspaces/<slug>/..., and—critically—a fix so browser OAuth MCP callers authenticate as the same Plane user as the web UI when Plane sits behind Traefik + oauth2-proxy ForwardAuth withOAUTH2_PROXY_USER_ID_CLAIM=cognito:username.No AWS Console changes are required (no Cognito Lambda until ops chooses to add one).
Problem statement
MCP sessions validate Cognito access tokens at the MCP layer (correct).
Plane API requests must carry
Authorization: Bearer …through Traefik → oauth2-proxy → Plane. oauth2-proxy is configured for the web cookie flow, wherecognito:usernameappears on ID tokens, not access tokens.Cognito access tokens in typical setups expose
username(nocognito:prefix),sub, and often noemailthat Plane/oauth2-proxy can treat like the web user.emailon ID tokens may still be the placeholdercognito:default_val.Forwarding only the access token makes oauth2-proxy fall back to
subfor user identity. Plane’sProxyAuthMiddlewarethen resolves (sub-derived email / synthetic identity) → a different Django user than the one created via the browser SSO session → 403 / wrong workspace membership.Internal-network hacks don’t survive multi-host production: bypassing Traefik and injecting
X-Auth-Request-Emailagainstplane-api:8000couples MCP to Plane placement and weakens the guarantee that only Traefik/oauth2-proxy may mintX-Auth-Request-*headers.Solution (high level)
AWSCognitoProvider(plane_mcp.cognito_http), mountsGET /healthz,/→ MCP,/http/api-key/…for PAT modemcp-remoteOAuth → MCP validates FastMCP-issued reference JWTs → upstream Cognito tokens in Redis-compatible storeBearerto Plane’s public HTTPS URL (sameaudas access token; JWKS validation succeeds). ID token containscognito:username, matching oauth2-proxy’s--user-id-claimand Plane’s proxy-auth expectations_PlaneCognitoProvider.load_access_tokenoverridesOAuthProxy.load_access_token: after upstream validation, copyraw_token_data["id_token"]intoAccessToken.claims["id_token"].plane_mcp.client._plane_bearer_forprefersclaims["id_token"]when buildingPlaneClientandlist_workspaceshttpx callslist_workspacesuseshttpxwithverify=fromREQUESTS_CA_BUNDLE/SSL_CERT_FILE(httpx does not honorREQUESTS_CA_BUNDLEimplicitly;requests/PlaneClientstill use env as today)COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH(default on): clear mismatched MCPresourcevsMCP_BASE_URLso Cursor localresourceURLs don’t die withinvalid_targetbefore CognitoMCP_ALLOWED_CLIENT_REDIRECT_URISunset → allow any (needed forcursor://etc.)Architecture
flowchart LR subgraph Client C[Cursor / mcp-remote] end subgraph MCP["plane-mcp-server"] FM[FastMCP Cognito HTTP] JWT[FastMCP reference JWT] SWAP[_PlaneCognitoProvider.load_access_token] TOK[id_token in AccessToken.claims] end subgraph Edge T[Traefik TLS] O[oauth2-proxy ForwardAuth] end subgraph Plane P[plane-api ProxyAuthMiddleware + REST] end subgraph IdP["AWS Cognito"] CO[Cognito OIDC] end C -->|HTTPS OAuth / MCP JSON-RPC| FM FM <-->|Authorization code + tokens| CO FM --> JWT SWAP --> TOK C -->|tools/call + Bearer FastMCP JWT| SWAP TOK -->|Bearer Cognito ID token| T T --> O O -->|X-Auth-Request-* from JWT claims| PValidate upstream Cognito access token inside MCP before exposing tools; Plane upstream receives
Bearer <id_token>.Request path for a tool call (conceptual):
load_access_tokenverifies that JWT, loads upstream CognitoUpstreamTokenSet, validates access token with Cognito JWKS, attachesid_tokenstring intoclaims.PlaneClient/httpxcallshttps://<plane-host>/...withAuthorization: Bearer <id_token>.X-Auth-Request-Userfromcognito:username→ Plane resolves the same user as the browser.Decisions not taken (and why)
cognito:usernameto access tokensplane-api:8000+ syntheticX-Auth-Request-*from MCPsha256(access_token)lookup in Redisupstream_token_idkeyed by FastMCP JWTjti— fix belongs insideload_access_tokenFiles touched (expected)
plane_mcp/cognito_http.py— Cognito HTTP app,_PlaneCognitoProvider,load_access_token, redirect/resource relaxation.plane_mcp/client.py—_plane_bearer_for(preferclaims["id_token"]).plane_mcp/tools/workspaces.py—list_workspaces,_plane_auth_headers,_httpx_verify.plane_mcp/__main__.py— runtime branch for Cognito HTTP vs existing modes.tests/— Cognito env / redirect tests; workspace auth/header tests.README.md— Cognito HTTP URLs, env vars, transport docs.Related devstack repo (foss-server-bundle-devstack)
Compose wires
MCP_BASE_URL,COGNITO_*,OIDC_*,REDIS_*→ Valkey,PLANE_BASE_URL(Traefik Plane host), optionalPLANE_MCP_*aliases.AGENTS.mddocuments Bearer = Cognito ID token for plane-mcp.Follow-ups (optional)
README.mdPAT URL line with/http/api-key/mcpeverywhere (Copilot nit).pyproject.toml: addhttpxas a direct dependency iflist_workspaceskeepshttpx(Copilot nit).Workspacemodel docstring: clarifyGET /api/users/me/workspaces/vs/api/v1/workspaces/(Copilot nit).cognito:usernameinto access tokens → may simplifyclaims["id_token"]forwarding.Testing
https://<host>/mcp, completes OAuth,tools/listsucceeds.list_workspacesreturns workspaces for the same user as the Plane web UI.list_projects(or any/api/v1/workspaces/<slug>/...) succeeds for that user withPLANE_WORKSPACE_SLUGset.user_idconsistent with web SSO for bothpython-requestsandpython-httpxcallers.cognito:username(numeric user id), not baresubUUID.Checklist (template compliance)