Skip to content

working tree of plane mcp#2

Merged
jawad-khan merged 6 commits into
foss-mainfrom
jawad/plane-mcp-for-devstack
Jun 4, 2026
Merged

working tree of plane mcp#2
jawad-khan merged 6 commits into
foss-mainfrom
jawad/plane-mcp-for-devstack

Conversation

@jawad-khan

@jawad-khan jawad-khan commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

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), a list_workspaces bootstrap 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 with OAUTH2_PROXY_USER_ID_CLAIM=cognito:username.

No AWS Console changes are required (no Cognito Lambda until ops chooses to add one).


Problem statement

  1. MCP sessions validate Cognito access tokens at the MCP layer (correct).

  2. Plane API requests must carry Authorization: Bearer … through Traefik → oauth2-proxy → Plane. oauth2-proxy is configured for the web cookie flow, where cognito:username appears on ID tokens, not access tokens.

  3. Cognito access tokens in typical setups expose username (no cognito: prefix), sub, and often no email that Plane/oauth2-proxy can treat like the web user. email on ID tokens may still be the placeholder cognito:default_val.

  4. Forwarding only the access token makes oauth2-proxy fall back to sub for user identity. Plane’s ProxyAuthMiddleware then resolves (sub-derived email / synthetic identity) → a different Django user than the one created via the browser SSO session → 403 / wrong workspace membership.

  5. Internal-network hacks don’t survive multi-host production: bypassing Traefik and injecting X-Auth-Request-Email against plane-api:8000 couples MCP to Plane placement and weakens the guarantee that only Traefik/oauth2-proxy may mint X-Auth-Request-* headers.


Solution (high level)

Piece Choice
MCP ingress FastMCP + AWSCognitoProvider (plane_mcp.cognito_http), mounts GET /healthz, / → MCP, /http/api-key/… for PAT mode
Cursor ↔ MCP Cursor/mcp-remote OAuth → MCP validates FastMCP-issued reference JWTs → upstream Cognito tokens in Redis-compatible store
Plane ↔ oauth2-proxy Forward the Cognito ID token as Bearer to Plane’s public HTTPS URL (same aud as access token; JWKS validation succeeds). ID token contains cognito:username, matching oauth2-proxy’s --user-id-claim and Plane’s proxy-auth expectations
Where the swap happens _PlaneCognitoProvider.load_access_token overrides OAuthProxy.load_access_token: after upstream validation, copy raw_token_data["id_token"] into AccessToken.claims["id_token"]. plane_mcp.client._plane_bearer_for prefers claims["id_token"] when building PlaneClient and list_workspaces httpx calls
Self-signed TLS in dev list_workspaces uses httpx with verify= from REQUESTS_CA_BUNDLE / SSL_CERT_FILE (httpx does not honor REQUESTS_CA_BUNDLE implicitly; requests/PlaneClient still use env as today)
OAuth ergonomics COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH (default on): clear mismatched MCP resource vs MCP_BASE_URL so Cursor local resource URLs don’t die with invalid_target before Cognito
DCR redirects MCP_ALLOWED_CLIENT_REDIRECT_URIS unset → allow any (needed for cursor:// 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| P
Loading

Validate upstream Cognito access token inside MCP before exposing tools; Plane upstream receives Bearer <id_token>.

Request path for a tool call (conceptual):

  1. Cursor sends MCP traffic with the FastMCP session token.
  2. load_access_token verifies that JWT, loads upstream Cognito UpstreamTokenSet, validates access token with Cognito JWKS, attaches id_token string into claims.
  3. PlaneClient / httpx calls https://<plane-host>/... with Authorization: Bearer <id_token>.
  4. Traefik runs ForwardAuth → oauth2-proxy validates JWT → sets X-Auth-Request-User from cognito:username → Plane resolves the same user as the browser.

Decisions not taken (and why)

Alternative Why we didn’t ship it
Cognito Pre-Token-Generation Lambda adding cognito:username to access tokens Correct long-term; requires AWS access this repo doesn’t assume
plane-api:8000 + synthetic X-Auth-Request-* from MCP Breaks multi-host separation of duties; headers must only originate at oauth2-proxy
sha256(access_token) lookup in Redis Wrong key shape — FastMCP stores upstream tokens under random upstream_token_id keyed by FastMCP JWT jti — fix belongs inside load_access_token

Files touched (expected)

  • plane_mcp/cognito_http.py — Cognito HTTP app, _PlaneCognitoProvider, load_access_token, redirect/resource relaxation.
  • plane_mcp/client.py_plane_bearer_for (prefer claims["id_token"]).
  • plane_mcp/tools/workspaces.pylist_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), optional PLANE_MCP_* aliases. AGENTS.md documents Bearer = Cognito ID token for plane-mcp.


Follow-ups (optional)

  • Align README.md PAT URL line with /http/api-key/mcp everywhere (Copilot nit).
  • pyproject.toml: add httpx as a direct dependency if list_workspaces keeps httpx (Copilot nit).
  • Workspace model docstring: clarify GET /api/users/me/workspaces/ vs /api/v1/workspaces/ (Copilot nit).
  • When AWS allows: Cognito Pre Token Generation V2 → inject cognito:username into access tokens → may simplify claims["id_token"] forwarding.

Testing

  • Cognito HTTP: Cursor connects to https://<host>/mcp, completes OAuth, tools/list succeeds.
  • list_workspaces returns workspaces for the same user as the Plane web UI.
  • list_projects (or any /api/v1/workspaces/<slug>/...) succeeds for that user with PLANE_WORKSPACE_SLUG set.
  • Plane API logs show user_id consistent with web SSO for both python-requests and python-httpx callers.
  • oauth2-proxy access logs show cognito:username (numeric user id), not bare sub UUID.

Checklist (template compliance)

  • Single logical change set (Cognito HTTP + identity alignment + workspace bootstrap).
  • Tests updated for redirect/auth helpers.
  • CHANGELOG updated if this repo maintains one for releases.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_http to run FastMCP with AWSCognitoProvider, plus a Starlette app with /mcp, /http/api-key/mcp, and /healthz.
  • Adds a new list_workspaces tool (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 in plane_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.

Comment thread README.md Outdated
| `get_me` | Get current authenticated user information |

**Total Tools**: 100+ tools across 20 categories
**Total Tools**: 55+ tools across 8 categories

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
**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`.

Copilot uses AI. Check for mistakes.
Comment thread plane_mcp/tools/workspaces.py Outdated
Comment on lines +19 to +24
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.

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to 6
import os

import httpx
from fastmcp import FastMCP

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread plane_mcp/client.py Outdated
Comment on lines 56 to 70
@@ -41,21 +69,20 @@ def get_plane_client_context() -> PlaneClientContext:
workspace_slug = os.getenv("PLANE_WORKSPACE_SLUG", "")

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread plane_mcp/cognito_http.py Outdated
Comment on lines +174 to +175
"resource binding (COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH). Prefer aligning PLANE_MCP_BASE_URL "
"/ MCP_BASE_URL with the MCP URL your client uses.",

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"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.",

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread plane_mcp/tools/workspaces.py Outdated
Comment on lines +35 to +37
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")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not setting PLANE_INTERNAL_BASE_URL in environment.

Comment thread plane_mcp/tools/users.py
Comment on lines +13 to 24
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()
Comment thread plane_mcp/cognito_http.py
Comment on lines +233 to +242
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
Comment thread plane_mcp/cognito_http.py Outdated
Comment on lines +277 to +304
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)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread plane_mcp/cognito_http.py Outdated
Comment on lines +236 to +245
``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()]


Comment thread plane_mcp/cognito_http.py Outdated
Comment on lines +135 to +153
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
Comment thread plane_mcp/tools/users.py
Comment on lines +13 to 24
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()

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread plane_mcp/tools/users.py
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)}"
Comment thread plane_mcp/cognito_http.py
Comment on lines +158 to +187
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)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread plane_mcp/tools/users.py
Comment on lines +17 to +30
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)
Comment on lines +81 to +97
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())
Comment thread plane_mcp/tools/workspaces.py Outdated
Comment on lines +95 to +103
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>
@aznszn aznszn force-pushed the jawad/plane-mcp-for-devstack branch from b19e23b to fe3b414 Compare June 2, 2026 10:58
@aznszn

aznszn commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

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 COGNITO_OIDC_FORWARD_PKCE, COGNITO_TOKEN_ENDPOINT_AUTH_METHOD, COGNITO_RELAX_OAUTH_RESOURCE_MISMATCH, COGNITO_REQUIRE_CONSENT, or MCP_COGNITO_REDIRECT_URI. Hardcoded the defaults, deleted the dead paths.

Biggest change: AUTH_TYPE=SSO explicit opt-in replaces the old heuristic that sniffed env vars to decide whether to start in Cognito mode. Explicit is better — no more partial-config footguns.

No behavior change for the actual deployed configuration. All existing tests pass, added targeted tests for _plane_bearer_for and the id_token header preference.

@hunzlahmalik hunzlahmalik left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_TYPE isn't in the Required list — but the refactor (fe3b414) gated activation on AUTH_TYPE=SSO (__main__.py: if auth_type == "SSO":). Set every Cognito var, omit AUTH_TYPE=SSO, and you silently get Plane-OAuth/SSE instead, with no error. The devstack plane-mcp-oauth.md was updated for this; the README wasn't. Add AUTH_TYPE=SSO to Required and fix the trigger sentence.

🟢 Low

  • load_access_token swallows all exceptions → 401 (inline). except Exception: … return None can'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_slug was threaded through every tool but the Args: docstrings are only partly updated; and list_workspaces hits /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.

Comment thread README.md
### 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 `/`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread plane_mcp/cognito_http.py
new_claims = dict(validated.claims or {})
new_claims["id_token"] = id_token
return validated.model_copy(update={"claims": new_claims})
except Exception as exc:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

Comment thread plane_mcp/cognito_http.py
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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

@jawad-khan jawad-khan merged commit e2f0859 into foss-main Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants