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.
- plane_mcp/__init__.py +1 -0
- plane_mcp/__main__.py +208 -0
- plane_mcp/auth/__init__.py +7 -0
- plane_mcp/auth/plane_header_auth_provider.py +73 -0
- plane_mcp/auth/plane_oauth_provider.py +374 -0
- plane_mcp/aws_secrets.py +102 -0
- plane_mcp/ce_compat.py +87 -0
- plane_mcp/client.py +74 -0
- plane_mcp/instructions.py +13 -0
- plane_mcp/middleware.py +21 -0
- plane_mcp/server.py +95 -0
- plane_mcp/storage.py +166 -0
- plane_mcp/tools/__init__.py +146 -0
- plane_mcp/tools/cycles.py +402 -0
- plane_mcp/tools/initiatives.py +266 -0
- plane_mcp/tools/intake.py +173 -0
- plane_mcp/tools/labels.py +154 -0
- plane_mcp/tools/milestones.py +202 -0
- plane_mcp/tools/modules.py +334 -0
- plane_mcp/tools/pages.py +201 -0
- plane_mcp/tools/pql.py +34 -0
- plane_mcp/tools/pql_reference.py +345 -0
- plane_mcp/tools/projects.py +676 -0
- plane_mcp/tools/roles.py +56 -0
- plane_mcp/tools/states.py +177 -0
- plane_mcp/tools/users.py +21 -0
- plane_mcp/tools/work_item_activities.py +73 -0
- plane_mcp/tools/work_item_attachments.py +378 -0
- plane_mcp/tools/work_item_comments.py +188 -0
- plane_mcp/tools/work_item_links.py +149 -0
- plane_mcp/tools/work_item_properties.py +720 -0
- plane_mcp/tools/work_item_relation_definitions.py +147 -0
- plane_mcp/tools/work_item_relations.py +98 -0
- plane_mcp/tools/work_item_types.py +289 -0
- plane_mcp/tools/work_items.py +689 -0
- plane_mcp/tools/work_logs.py +130 -0
- plane_mcp/tools/workspaces.py +145 -0
- plane_mcp_server_ce-0.3.0.dist-info/METADATA +498 -0
- plane_mcp_server_ce-0.3.0.dist-info/RECORD +43 -0
- plane_mcp_server_ce-0.3.0.dist-info/WHEEL +5 -0
- plane_mcp_server_ce-0.3.0.dist-info/entry_points.txt +2 -0
- plane_mcp_server_ce-0.3.0.dist-info/licenses/LICENSE +22 -0
- 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,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
|
+
)
|