Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/`.

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.


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

Expand Down Expand Up @@ -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 |
Expand All @@ -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

Expand Down
36 changes: 36 additions & 0 deletions plane_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
48 changes: 32 additions & 16 deletions plane_mcp/client.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand All @@ -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", "")
Expand All @@ -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(
Expand All @@ -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,
)
Loading