plane-mcp-server-ce 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. plane_mcp/__init__.py +1 -0
  2. plane_mcp/__main__.py +208 -0
  3. plane_mcp/auth/__init__.py +7 -0
  4. plane_mcp/auth/plane_header_auth_provider.py +73 -0
  5. plane_mcp/auth/plane_oauth_provider.py +374 -0
  6. plane_mcp/aws_secrets.py +102 -0
  7. plane_mcp/ce_compat.py +87 -0
  8. plane_mcp/client.py +74 -0
  9. plane_mcp/instructions.py +13 -0
  10. plane_mcp/middleware.py +21 -0
  11. plane_mcp/server.py +95 -0
  12. plane_mcp/storage.py +166 -0
  13. plane_mcp/tools/__init__.py +146 -0
  14. plane_mcp/tools/cycles.py +402 -0
  15. plane_mcp/tools/initiatives.py +266 -0
  16. plane_mcp/tools/intake.py +173 -0
  17. plane_mcp/tools/labels.py +154 -0
  18. plane_mcp/tools/milestones.py +202 -0
  19. plane_mcp/tools/modules.py +334 -0
  20. plane_mcp/tools/pages.py +201 -0
  21. plane_mcp/tools/pql.py +34 -0
  22. plane_mcp/tools/pql_reference.py +345 -0
  23. plane_mcp/tools/projects.py +676 -0
  24. plane_mcp/tools/roles.py +56 -0
  25. plane_mcp/tools/states.py +177 -0
  26. plane_mcp/tools/users.py +21 -0
  27. plane_mcp/tools/work_item_activities.py +73 -0
  28. plane_mcp/tools/work_item_attachments.py +378 -0
  29. plane_mcp/tools/work_item_comments.py +188 -0
  30. plane_mcp/tools/work_item_links.py +149 -0
  31. plane_mcp/tools/work_item_properties.py +720 -0
  32. plane_mcp/tools/work_item_relation_definitions.py +147 -0
  33. plane_mcp/tools/work_item_relations.py +98 -0
  34. plane_mcp/tools/work_item_types.py +289 -0
  35. plane_mcp/tools/work_items.py +689 -0
  36. plane_mcp/tools/work_logs.py +130 -0
  37. plane_mcp/tools/workspaces.py +145 -0
  38. plane_mcp_server_ce-0.3.0.dist-info/METADATA +498 -0
  39. plane_mcp_server_ce-0.3.0.dist-info/RECORD +43 -0
  40. plane_mcp_server_ce-0.3.0.dist-info/WHEEL +5 -0
  41. plane_mcp_server_ce-0.3.0.dist-info/entry_points.txt +2 -0
  42. plane_mcp_server_ce-0.3.0.dist-info/licenses/LICENSE +22 -0
  43. plane_mcp_server_ce-0.3.0.dist-info/top_level.txt +1 -0
plane_mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Plane MCP Server - A Model Context Protocol server for Plane integration."""
plane_mcp/__main__.py ADDED
@@ -0,0 +1,208 @@
1
+ """Main entry point for the Plane MCP Server."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import sys
7
+ from contextlib import asynccontextmanager
8
+ from datetime import datetime, timezone
9
+ from enum import Enum
10
+
11
+ import uvicorn
12
+ from fastmcp.server.dependencies import get_access_token
13
+ from starlette.applications import Starlette
14
+ from starlette.middleware.cors import CORSMiddleware
15
+ from starlette.routing import Mount
16
+
17
+ from plane_mcp.server import get_header_mcp, get_oauth_mcp, get_stdio_mcp
18
+
19
+ LOG_USER_INFO: bool = os.getenv("LOG_USER_INFO", "").lower() == "true"
20
+
21
+
22
+ class UserContextFilter(logging.Filter):
23
+ """Attach authenticated user/workspace context to every log record.
24
+
25
+ Pulls the current request's access token via FastMCP's dependency, which
26
+ returns None (never raises) outside a request context — so startup logs fall
27
+ back to environment config and otherwise carry no user info.
28
+
29
+ Always logs the opaque user id (sub claim) and the workspace slug; neither is
30
+ PII. The display name IS PII and is only included when LOG_USER_INFO=true.
31
+ """
32
+
33
+ def filter(self, record: logging.LogRecord) -> bool:
34
+ user_id = None
35
+ display_name = None
36
+ workspace_slug = None
37
+ try:
38
+ token = get_access_token()
39
+ if token:
40
+ user_id = token.claims.get("sub")
41
+ workspace_slug = token.claims.get("workspace_slug")
42
+ if LOG_USER_INFO:
43
+ display_name = token.claims.get("display_name")
44
+ except Exception as exc:
45
+ # Never let logging enrichment break a request, but leave a signal.
46
+ record.user_context_enrichment_error = type(exc).__name__
47
+ record.user_id = user_id
48
+ record.display_name = display_name
49
+ # stdio mode has no token; fall back to the configured workspace.
50
+ record.workspace_slug = workspace_slug or os.getenv("PLANE_WORKSPACE_SLUG") or None
51
+ return True
52
+
53
+
54
+ class JSONFormatter(logging.Formatter):
55
+ """JSON log formatter for structured logging (Datadog, ELK, etc.)."""
56
+
57
+ def format(self, record: logging.LogRecord) -> str:
58
+ log_entry = {
59
+ "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
60
+ "level": record.levelname,
61
+ "logger": record.name,
62
+ }
63
+ # The logging middleware emits a JSON object as its log message. Promote
64
+ # those keys to top-level fields (event, method, tool, duration_ms, ...)
65
+ raw_message = record.getMessage()
66
+ try:
67
+ parsed = json.loads(raw_message)
68
+ except (ValueError, TypeError):
69
+ parsed = None
70
+ if isinstance(parsed, dict):
71
+ log_entry.update(parsed)
72
+ else:
73
+ log_entry["message"] = raw_message
74
+ user_id = getattr(record, "user_id", None)
75
+ if user_id:
76
+ log_entry["user_id"] = user_id
77
+ workspace_slug = getattr(record, "workspace_slug", None)
78
+ if workspace_slug:
79
+ log_entry["workspace_slug"] = workspace_slug
80
+ display_name = getattr(record, "display_name", None)
81
+ if display_name:
82
+ log_entry["display_name"] = display_name
83
+ err = getattr(record, "user_context_enrichment_error", None)
84
+ if err:
85
+ log_entry["user_context_enrichment_error"] = err
86
+ if record.exc_info and record.exc_info[1]:
87
+ log_entry["error"] = str(record.exc_info[1])
88
+ log_entry["error_type"] = type(record.exc_info[1]).__name__
89
+ return json.dumps(log_entry)
90
+
91
+
92
+ def configure_json_logging():
93
+ """Replace FastMCP's Rich handlers with a JSON formatter on the fastmcp logger."""
94
+ fastmcp_logger = logging.getLogger("fastmcp")
95
+
96
+ # Remove all existing handlers (Rich)
97
+ for handler in fastmcp_logger.handlers[:]:
98
+ fastmcp_logger.removeHandler(handler)
99
+
100
+ handler = logging.StreamHandler(sys.stderr)
101
+ handler.setFormatter(JSONFormatter())
102
+ handler.addFilter(UserContextFilter())
103
+ fastmcp_logger.addHandler(handler)
104
+ fastmcp_logger.setLevel(logging.INFO)
105
+ fastmcp_logger.propagate = False
106
+
107
+
108
+ configure_json_logging()
109
+
110
+ logger = logging.getLogger("fastmcp.plane_mcp")
111
+
112
+
113
+ class ServerMode(Enum):
114
+ STDIO = "stdio"
115
+ SSE = "sse"
116
+ HTTP = "http"
117
+
118
+
119
+ @asynccontextmanager
120
+ async def combined_lifespan(oauth_app, header_app, sse_app):
121
+ """Combine lifespans from both OAuth and Header MCP apps."""
122
+ # Start both lifespans
123
+ async with oauth_app.lifespan(oauth_app):
124
+ async with header_app.lifespan(header_app):
125
+ async with sse_app.lifespan(sse_app):
126
+ yield
127
+
128
+
129
+ def main() -> None:
130
+ """Run the MCP server."""
131
+ if len(sys.argv) > 1 and sys.argv[1] in {"-h", "--help"}:
132
+ print("Usage: plane-mcp-server-ce [stdio|http|sse]")
133
+ return
134
+
135
+ server_mode = ServerMode.STDIO
136
+ if len(sys.argv) > 1:
137
+ server_mode = ServerMode(sys.argv[1])
138
+
139
+ if server_mode == ServerMode.STDIO:
140
+ # Validate API_KEY and PLANE_WORKSPACE_SLUG are set
141
+ if not os.getenv("PLANE_API_KEY"):
142
+ raise ValueError("PLANE_API_KEY is not set")
143
+ if not os.getenv("PLANE_WORKSPACE_SLUG"):
144
+ raise ValueError("PLANE_WORKSPACE_SLUG is not set")
145
+
146
+ get_stdio_mcp().run()
147
+ return
148
+
149
+ if server_mode == ServerMode.HTTP:
150
+ prefix = os.getenv("MCP_PATH_PREFIX") or ""
151
+
152
+ oauth_mcp = get_oauth_mcp(prefix + "/http")
153
+ oauth_app = oauth_mcp.http_app(stateless_http=True)
154
+ header_app = get_header_mcp().http_app(stateless_http=True)
155
+
156
+ sse_mcp = get_oauth_mcp(prefix)
157
+ sse_app = sse_mcp.http_app(transport="sse")
158
+
159
+ # mcp_path is appended to the auth provider's base_url to form the
160
+ # advertised resource URL. base_url already carries the prefix, so these
161
+ # stay at /mcp and /sse to avoid double-prefixing.
162
+ oauth_well_known = oauth_mcp.auth.get_well_known_routes(mcp_path="/mcp")
163
+ sse_well_known = sse_mcp.auth.get_well_known_routes(mcp_path="/sse")
164
+
165
+ app = Starlette(
166
+ routes=[
167
+ # Well-known routes for OAuth and Header HTTP
168
+ *oauth_well_known,
169
+ *sse_well_known,
170
+ # Mount both MCP servers
171
+ Mount(prefix + "/http/api-key", app=header_app),
172
+ Mount(prefix + "/http", app=oauth_app),
173
+ Mount(prefix or "/", app=sse_app),
174
+ ],
175
+ lifespan=lambda app: combined_lifespan(oauth_app, header_app, sse_app),
176
+ )
177
+
178
+ app.add_middleware(
179
+ CORSMiddleware,
180
+ allow_origins=["*"],
181
+ allow_credentials=False,
182
+ allow_methods=["*"],
183
+ allow_headers=["*"],
184
+ )
185
+
186
+ # Configure uvicorn loggers to use JSON formatting too
187
+ for uv_logger_name in ("uvicorn", "uvicorn.error"):
188
+ uv_logger = logging.getLogger(uv_logger_name)
189
+ for h in uv_logger.handlers[:]:
190
+ uv_logger.removeHandler(h)
191
+ uv_handler = logging.StreamHandler(sys.stderr)
192
+ uv_handler.setFormatter(JSONFormatter())
193
+ uv_handler.addFilter(UserContextFilter())
194
+ uv_logger.addHandler(uv_handler)
195
+
196
+ logger.info("Starting HTTP server at URLs: /mcp and /header/mcp")
197
+ uvicorn.run(
198
+ app,
199
+ host="0.0.0.0",
200
+ port=8211,
201
+ log_level="info",
202
+ access_log=False,
203
+ )
204
+ return
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
@@ -0,0 +1,7 @@
1
+ from plane_mcp.auth.plane_header_auth_provider import PlaneHeaderAuthProvider
2
+ from plane_mcp.auth.plane_oauth_provider import PlaneOAuthProvider
3
+
4
+ __all__ = [
5
+ "PlaneHeaderAuthProvider",
6
+ "PlaneOAuthProvider",
7
+ ]
@@ -0,0 +1,73 @@
1
+ import os
2
+ import time
3
+
4
+ import httpx
5
+ from fastmcp.server.auth import TokenVerifier
6
+ from fastmcp.server.auth.auth import AccessToken
7
+ from fastmcp.utilities.logging import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+ DEFAULT_PLANE_BASE_URL = "https://api.plane.so"
12
+
13
+
14
+ class PlaneHeaderAuthProvider(TokenVerifier):
15
+ def __init__(self, required_scopes: list[str] | None = None, timeout_seconds: int = 10):
16
+ super().__init__(required_scopes=required_scopes)
17
+ self.timeout_seconds = timeout_seconds
18
+
19
+ async def _validate_api_key(self, token: str) -> bool:
20
+ """Validate the API key by calling the Plane API."""
21
+ base_url = (os.getenv("PLANE_INTERNAL_BASE_URL") or os.getenv("PLANE_BASE_URL", DEFAULT_PLANE_BASE_URL)).rstrip(
22
+ "/"
23
+ )
24
+ user_url = f"{base_url}/api/v1/users/me/"
25
+
26
+ try:
27
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
28
+ response = await client.get(
29
+ user_url,
30
+ headers={
31
+ "x-api-key": token,
32
+ "Content-Type": "application/json",
33
+ },
34
+ )
35
+ if response.status_code != 200:
36
+ logger.warning("API key validation failed: %s", response.status_code)
37
+ return False
38
+ return True
39
+ except httpx.RequestError as e:
40
+ logger.warning("API key validation request failed: %s", e)
41
+ return False
42
+
43
+ async def verify_token(self, token: str) -> AccessToken | None:
44
+ try:
45
+ from fastmcp.server.dependencies import get_http_headers
46
+
47
+ headers = get_http_headers()
48
+
49
+ if token:
50
+ workspace_slug = headers.get("x-workspace-slug")
51
+ if not workspace_slug:
52
+ logger.warning("x-api-key header found but x-workspace-slug is missing")
53
+ return None
54
+
55
+ if not await self._validate_api_key(token):
56
+ logger.warning("API key validation against Plane API failed")
57
+ return None
58
+
59
+ logger.info("API key validated successfully via Plane API")
60
+ expires_at = int(time.time() + 3600)
61
+ return AccessToken(
62
+ token=token,
63
+ client_id="api_key_header_user",
64
+ scopes=["read", "write"],
65
+ expires_at=expires_at,
66
+ claims={
67
+ "auth_method": "api_key_header",
68
+ "workspace_slug": workspace_slug,
69
+ },
70
+ )
71
+ except RuntimeError:
72
+ # No active HTTP request available (e.g., stdio transport)
73
+ logger.debug("No active HTTP request available for header check")
@@ -0,0 +1,374 @@
1
+ """Plane OAuth provider for FastMCP.
2
+
3
+ This module provides a complete Plane OAuth integration that's ready to use
4
+ with just a client ID and client secret. It handles all the complexity of
5
+ Plane's OAuth flow, token validation, and user management.
6
+
7
+ Example:
8
+ ```python
9
+ from fastmcp import FastMCP
10
+ from plane_mcp.plane_oauth_provider import PlaneOAuthProvider
11
+
12
+ # Simple Plane OAuth protection
13
+ auth = PlaneOAuthProvider(
14
+ client_id="your-plane-client-id",
15
+ client_secret="your-plane-client-secret",
16
+ base_url="https://api.plane.so"
17
+ )
18
+
19
+ mcp = FastMCP("My Protected Server", auth=auth)
20
+ ```
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import time
27
+
28
+ import httpx
29
+ from fastmcp.server.auth import TokenVerifier
30
+ from fastmcp.server.auth.auth import AccessToken
31
+ from fastmcp.server.auth.oauth_proxy import OAuthProxy
32
+ from fastmcp.settings import ENV_FILE
33
+ from fastmcp.utilities.auth import parse_scopes
34
+ from fastmcp.utilities.logging import get_logger
35
+ from fastmcp.utilities.types import NotSet, NotSetT
36
+ from key_value.aio.protocols import AsyncKeyValue
37
+ from plane.models.users import UserLite
38
+ from pydantic import AnyHttpUrl, BaseModel, SecretStr, field_validator
39
+ from pydantic_settings import BaseSettings, SettingsConfigDict
40
+
41
+ logger = get_logger(__name__)
42
+
43
+ # When true, user info (PII such as the display name) is included in logs.
44
+ # Defaults to false so PII is never logged unless explicitly opted in.
45
+ LOG_USER_INFO: bool = os.getenv("LOG_USER_INFO", "").lower() == "true"
46
+
47
+
48
+ DEFAULT_PLANE_BASE_URL = "https://api.plane.so"
49
+
50
+
51
+ class WorkspaceDetail(BaseModel):
52
+ """Workspace detail information."""
53
+
54
+ name: str
55
+ slug: str
56
+ id: str
57
+ logo_url: str | None = None
58
+
59
+
60
+ class PlaneOAuthAppInstallation(BaseModel):
61
+ """Plane OAuth app installation information."""
62
+
63
+ id: str
64
+ workspace_detail: WorkspaceDetail
65
+ created_at: str
66
+ updated_at: str
67
+ deleted_at: str | None = None
68
+ status: str
69
+ created_by: str | None = None
70
+ updated_by: str | None = None
71
+ workspace: str
72
+ application: str
73
+ installed_by: str
74
+ app_bot: str
75
+ webhook: str | None = None
76
+
77
+
78
+ class PlaneOAuthProviderSettings(BaseSettings):
79
+ """Settings for Plane OAuth provider."""
80
+
81
+ model_config = SettingsConfigDict(
82
+ env_prefix="PLANE_OAUTH_PROVIDER_",
83
+ env_file=ENV_FILE,
84
+ extra="ignore",
85
+ )
86
+
87
+ client_id: str | None = None
88
+ client_secret: SecretStr | None = None
89
+ base_url: AnyHttpUrl | str | None = None
90
+ issuer_url: AnyHttpUrl | str | None = None
91
+ redirect_path: str | None = None
92
+ required_scopes: list[str] | None = None
93
+ timeout_seconds: int | None = None
94
+ allowed_client_redirect_uris: list[str] | None = None
95
+ jwt_signing_key: str | None = None
96
+ plane_base_url: str | None = None
97
+ plane_internal_base_url: str | None = None # Internal URL for server-to-server calls
98
+ enable_cimd: bool = False
99
+
100
+ @field_validator("required_scopes", mode="before")
101
+ @classmethod
102
+ def _parse_scopes(cls, v):
103
+ return parse_scopes(v)
104
+
105
+
106
+ class PlaneOAuthTokenVerifier(TokenVerifier):
107
+ """Token verifier for Plane OAuth tokens.
108
+
109
+ Plane OAuth tokens are verified by calling Plane's API to check if they're
110
+ valid and get user info.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ *,
116
+ required_scopes: list[str] | None = None,
117
+ timeout_seconds: int = 10,
118
+ plane_base_url: str | None = None,
119
+ ):
120
+ """Initialize the Plane token verifier.
121
+
122
+ Args:
123
+ required_scopes: Required OAuth scopes (currently not enforced by Plane API)
124
+ timeout_seconds: HTTP request timeout
125
+ plane_base_url: Base URL for Plane API (defaults to https://api.plane.so)
126
+ """
127
+ super().__init__(required_scopes=required_scopes)
128
+ self.timeout_seconds = timeout_seconds
129
+ self.plane_base_url = plane_base_url or os.getenv("PLANE_BASE_URL", DEFAULT_PLANE_BASE_URL)
130
+
131
+ async def verify_token(self, token: str) -> AccessToken | None:
132
+ """Verify Plane OAuth token by calling Plane API."""
133
+ logger.info(f"verify_token called, token_present={bool(token)}, token_length={len(token) if token else 0}")
134
+ try:
135
+ # Build the user endpoint URL
136
+ base_url = self.plane_base_url.rstrip("/")
137
+ user_url = f"{base_url}/api/v1/users/me/"
138
+ logger.info(f"Verifying token against: {user_url}")
139
+
140
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
141
+ # Get current user info to verify token
142
+ response = await client.get(
143
+ user_url,
144
+ headers={
145
+ "Authorization": f"Bearer {token}",
146
+ "Content-Type": "application/json",
147
+ },
148
+ )
149
+
150
+ logger.info(f"Plane API response status: {response.status_code}")
151
+ if response.status_code != 200:
152
+ logger.info(
153
+ "Plane token verification failed: %s - %s",
154
+ response.status_code,
155
+ response.text[:200],
156
+ )
157
+ return None
158
+
159
+ # Parse user data
160
+ user_data = response.json()
161
+ user = UserLite.model_validate(user_data)
162
+
163
+ expires_at = int(time.time() + 3600)
164
+
165
+ # display_name is PII — only log it when explicitly opted in.
166
+ if LOG_USER_INFO:
167
+ logger.info(f"User verified: ({user.id}) - {user.display_name}")
168
+ else:
169
+ logger.info(f"User verified: ({user.id})")
170
+
171
+ installations_response = await client.get(
172
+ f"{base_url}/auth/o/app-installation/",
173
+ headers={
174
+ "Authorization": f"Bearer {token}",
175
+ "Content-Type": "application/json",
176
+ },
177
+ )
178
+
179
+ installations: list[PlaneOAuthAppInstallation] = installations_response.json()
180
+
181
+ if not installations:
182
+ raise ValueError("No app installations found")
183
+
184
+ installation = installations[0]
185
+
186
+ # Create AccessToken with Plane user info
187
+ return AccessToken(
188
+ token=token,
189
+ client_id=user.id or "unknown",
190
+ scopes=["read", "write"], # Plane doesn't expose scopes in user endpoint
191
+ expires_at=expires_at,
192
+ claims={
193
+ "auth_method": "oauth",
194
+ "sub": user.id or "unknown",
195
+ "email": user.email,
196
+ "first_name": user.first_name,
197
+ "last_name": user.last_name,
198
+ "display_name": user.display_name,
199
+ "avatar": user.avatar,
200
+ "avatar_url": user.avatar_url,
201
+ "plane_user_data": user_data,
202
+ "workspace_slug": installation.get("workspace_detail", {}).get("slug"),
203
+ "workspace": installation.get("workspace_detail", {}),
204
+ },
205
+ )
206
+
207
+ except httpx.RequestError as e:
208
+ logger.info(f"Failed to verify Plane token (request error): {e}")
209
+ return None
210
+ except Exception as e:
211
+ logger.info(f"Failed to verify Plane token: {e}", exc_info=True)
212
+ return None
213
+
214
+
215
+ class PlaneOAuthProvider(OAuthProxy):
216
+ """Complete Plane OAuth provider for FastMCP.
217
+
218
+ This provider makes it trivial to add Plane OAuth protection to any
219
+ FastMCP server. Just provide your Plane OAuth app credentials and
220
+ a base URL, and you're ready to go.
221
+
222
+ Features:
223
+ - Transparent OAuth proxy to Plane
224
+ - Automatic token validation via Plane API
225
+ - User information extraction
226
+ - Minimal configuration required
227
+
228
+ Example:
229
+ ```python
230
+ from fastmcp import FastMCP
231
+ from plane_mcp.plane_oauth_provider import PlaneOAuthProvider
232
+
233
+ auth = PlaneOAuthProvider(
234
+ client_id="your-client-id",
235
+ client_secret="your-client-secret",
236
+ base_url="https://my-server.com",
237
+ plane_base_url="https://api.plane.so"
238
+ )
239
+
240
+ mcp = FastMCP("My App", auth=auth)
241
+ ```
242
+ """
243
+
244
+ def __init__(
245
+ self,
246
+ *,
247
+ client_id: str | NotSetT = NotSet,
248
+ client_secret: str | NotSetT = NotSet,
249
+ base_url: AnyHttpUrl | str | NotSetT = NotSet,
250
+ issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
251
+ redirect_path: str | NotSetT = NotSet,
252
+ required_scopes: list[str] | NotSetT = NotSet,
253
+ timeout_seconds: int | NotSetT = NotSet,
254
+ allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
255
+ client_storage: AsyncKeyValue | None = None,
256
+ jwt_signing_key: str | bytes | NotSetT = NotSet,
257
+ require_authorization_consent: bool = True,
258
+ plane_base_url: str | NotSetT = NotSet,
259
+ plane_internal_base_url: str | NotSetT = NotSet,
260
+ enable_cimd: bool | NotSetT = NotSet,
261
+ ):
262
+ """Initialize Plane OAuth provider.
263
+
264
+ Args:
265
+ client_id: Plane OAuth app client ID
266
+ client_secret: Plane OAuth app client secret
267
+ base_url: Public URL where OAuth endpoints will be accessible
268
+ (includes any mount path)
269
+ issuer_url: Issuer URL for OAuth metadata (defaults to base_url).
270
+ Use root-level URL to avoid 404s during discovery when mounting
271
+ under a path.
272
+ redirect_path: Redirect path configured in Plane OAuth app
273
+ (defaults to "/auth/callback")
274
+ required_scopes: Required Plane scopes
275
+ (currently not enforced by Plane API)
276
+ timeout_seconds: HTTP request timeout for Plane API calls
277
+ allowed_client_redirect_uris: List of allowed redirect URI patterns
278
+ for MCP clients. If None (default), all URIs are allowed.
279
+ If empty list, no URIs are allowed.
280
+ client_storage: Storage backend for OAuth state
281
+ (client registrations, encrypted tokens). If None, a DiskStore
282
+ will be created in the data directory (derived from
283
+ `platformdirs`). The disk store will be encrypted using a key
284
+ derived from the JWT Signing Key.
285
+ jwt_signing_key: Secret for signing FastMCP JWT tokens
286
+ (any string or bytes). If bytes are provided, they will be used
287
+ as is. If a string is provided, it will be derived into a
288
+ 32-byte key. If not provided, the upstream client secret will be
289
+ used to derive a 32-byte key using PBKDF2.
290
+ require_authorization_consent: Whether to require user consent
291
+ before authorizing clients (default True). When True, users see
292
+ a consent screen before being redirected to Plane. When False,
293
+ authorization proceeds directly without user confirmation.
294
+ SECURITY WARNING: Only disable for local development or
295
+ testing environments.
296
+ plane_base_url: Base URL for Plane API
297
+ (defaults to https://api.plane.so or PLANE_BASE_URL env var)
298
+ enable_cimd: Whether to enable CIMD (Client ID Metadata Document) support.
299
+ Defaults to False. Can be set via the PLANE_OAUTH_PROVIDER_ENABLE_CIMD environment variable.
300
+ """
301
+
302
+ settings = PlaneOAuthProviderSettings.model_validate(
303
+ {
304
+ k: v
305
+ for k, v in {
306
+ "client_id": client_id,
307
+ "client_secret": client_secret,
308
+ "base_url": base_url,
309
+ "issuer_url": issuer_url,
310
+ "redirect_path": redirect_path,
311
+ "required_scopes": required_scopes,
312
+ "timeout_seconds": timeout_seconds,
313
+ "allowed_client_redirect_uris": allowed_client_redirect_uris,
314
+ "jwt_signing_key": jwt_signing_key,
315
+ "plane_base_url": plane_base_url,
316
+ "plane_internal_base_url": plane_internal_base_url,
317
+ "enable_cimd": enable_cimd,
318
+ }.items()
319
+ if v is not NotSet
320
+ }
321
+ )
322
+
323
+ # Validate required settings
324
+ if not settings.client_id:
325
+ raise ValueError("client_id is required - set via parameter or PLANE_OAUTH_PROVIDER_CLIENT_ID")
326
+ if not settings.client_secret:
327
+ raise ValueError("client_secret is required - set via parameter or PLANE_OAUTH_PROVIDER_CLIENT_SECRET")
328
+
329
+ # Apply defaults
330
+ timeout_seconds_final = settings.timeout_seconds or 10
331
+ required_scopes_final = settings.required_scopes or []
332
+ allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
333
+ plane_base_url_final = settings.plane_base_url or os.getenv("PLANE_BASE_URL", DEFAULT_PLANE_BASE_URL)
334
+ # Internal URL for server-to-server calls (token exchange, API verification)
335
+ # Falls back to external URL if not set
336
+ plane_internal_url = (
337
+ settings.plane_internal_base_url or os.getenv("PLANE_INTERNAL_BASE_URL") or plane_base_url_final
338
+ )
339
+
340
+ # Create Plane token verifier (uses internal URL for server-to-server calls)
341
+ token_verifier = PlaneOAuthTokenVerifier(
342
+ required_scopes=required_scopes_final,
343
+ timeout_seconds=timeout_seconds_final,
344
+ plane_base_url=plane_internal_url,
345
+ )
346
+
347
+ # Extract secret string from SecretStr
348
+ client_secret_str = settings.client_secret.get_secret_value() if settings.client_secret else ""
349
+
350
+ # Initialize OAuth proxy with Plane endpoints
351
+ # Authorization: external URL (user's browser)
352
+ # Token exchange: internal URL (server-to-server)
353
+ super().__init__(
354
+ upstream_authorization_endpoint=f"{plane_base_url_final}/auth/o/authorize-app/",
355
+ upstream_token_endpoint=f"{plane_internal_url}/auth/o/token/",
356
+ upstream_client_id=settings.client_id,
357
+ upstream_client_secret=client_secret_str,
358
+ token_verifier=token_verifier,
359
+ base_url=settings.base_url,
360
+ redirect_path=settings.redirect_path,
361
+ issuer_url=settings.issuer_url or settings.base_url, # Default to base_url if not specified
362
+ allowed_client_redirect_uris=allowed_client_redirect_uris_final,
363
+ client_storage=client_storage,
364
+ jwt_signing_key=settings.jwt_signing_key,
365
+ require_authorization_consent=require_authorization_consent,
366
+ valid_scopes=["read", "write"],
367
+ enable_cimd=settings.enable_cimd,
368
+ )
369
+
370
+ logger.info(
371
+ "Initialized Plane OAuth provider for client %s with scopes: %s",
372
+ settings.client_id,
373
+ required_scopes_final,
374
+ )