Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ PLANE_TEST_MCP_URL=http://localhost:8211
# Use when reverse-proxying alongside other apps.
# MCP_PATH_PREFIX=

# Optional: Logging — when true, include user info (PII such as the display
# name) in logs alongside the opaque user id. Defaults to false.
# LOG_USER_INFO=false

# ---------------------------------------------------------------------------
# Redis / Token storage (HTTP / SSE modes)
# ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@ Integration tests in `tests/test_integration.py` use `FastMCP.Client` with `Stre
| `PLANE_INTERNAL_BASE_URL` | http/sse (optional) | Internal URL for server-to-server calls |
| `REDIS_HOST` / `REDIS_PORT` | http/sse (optional) | Token storage (falls back to in-memory) |
| `PLANE_OAUTH_PROVIDER_*` | http/sse OAuth | OAuth client credentials and base URL |
| `LOG_USER_INFO` | all (optional, default: false) | When `true`, include user info (PII such as display name) in logs alongside the opaque user id |
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ export PLANE_WORKSPACE_SLUG="your-workspace-slug"

**Note**: For remote HTTP transports (OAuth or PAT), authentication is handled via the connection method (OAuth flow or PAT headers) and does not require these environment variables.

### Logging

The server emits structured JSON logs. Each tool call is logged with its tool name, duration, status, and (when available) the opaque user id and workspace slug.

- `LOG_USER_INFO`: When `true`, include user info (PII such as the display name) in logs alongside the opaque user id. Defaults to `false` so PII is never logged unless explicitly opted in. Only the OAuth and PAT (header) HTTP transports carry a display name; stdio is unaffected.

```bash
export LOG_USER_INFO="true"
```

## Available Tools

The server provides comprehensive tools for interacting with Plane. All tools use Pydantic models from the Plane SDK for type safety and validation.
Expand Down Expand Up @@ -287,6 +297,15 @@ The server provides comprehensive tools for interacting with Plane. All tools us
| `create_work_item_relation` | Create relations for a work item |
| `remove_work_item_relation` | Remove a relation from a work item |

### Work Item Relation Definitions

| Tool Name | Description |
|-----------|-------------|
| `list_work_item_relation_definitions` | List workspace custom relation definitions |
| `create_work_item_relation_definition` | Create a workspace relation definition |
| `update_work_item_relation_definition` | Update a relation definition |
| `delete_work_item_relation_definition` | Delete a relation definition |

### Work Item Activities

| Tool Name | Description |
Expand Down
44 changes: 35 additions & 9 deletions plane_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,38 @@

from plane_mcp.server import get_header_mcp, get_oauth_mcp, get_stdio_mcp

LOG_USER_INFO: bool = os.getenv("LOG_USER_INFO", "").lower() == "true"


class UserContextFilter(logging.Filter):
"""Attach the authenticated user's id to every log record.
"""Attach authenticated user/workspace context to every log record.

Pulls the current request's access token via FastMCP's dependency, which
returns None (never raises) outside a request context — so startup logs and
stdio mode simply carry no user info. Only the opaque user id is recorded;
PII such as the display name / email is intentionally never logged.
returns None (never raises) outside a request context — so startup logs fall
back to environment config and otherwise carry no user info.

Always logs the opaque user id (sub claim) and the workspace slug; neither is
PII. The display name IS PII and is only included when LOG_USER_INFO=true.
"""

def filter(self, record: logging.LogRecord) -> bool:
user_id = None
display_name = None
workspace_slug = None
try:
token = get_access_token()
if token:
user_id = token.claims.get("sub")
workspace_slug = token.claims.get("workspace_slug")
if LOG_USER_INFO:
display_name = token.claims.get("display_name")
except Exception as exc:
# Never let logging enrichment break a request, but leave a signal.
record.user_context_enrichment_error = type(exc).__name__
record.user_id = user_id
record.display_name = display_name
# stdio mode has no token; fall back to the configured workspace.
record.workspace_slug = workspace_slug or os.getenv("PLANE_WORKSPACE_SLUG") or None
return True


Expand All @@ -47,19 +59,33 @@ def format(self, record: logging.LogRecord) -> str:
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
# The logging middleware emits a JSON object as its log message. Promote
# those keys to top-level fields (event, method, tool, duration_ms, ...)
raw_message = record.getMessage()
try:
parsed = json.loads(raw_message)
except (ValueError, TypeError):
parsed = None
if isinstance(parsed, dict):
log_entry.update(parsed)
else:
log_entry["message"] = raw_message
user_id = getattr(record, "user_id", None)
if user_id:
log_entry["user_id"] = user_id
workspace_slug = getattr(record, "workspace_slug", None)
if workspace_slug:
log_entry["workspace_slug"] = workspace_slug
display_name = getattr(record, "display_name", None)
if display_name:
log_entry["display_name"] = display_name
err = getattr(record, "user_context_enrichment_error", None)
if err:
log_entry["user_context_enrichment_error"] = err
if record.exc_info and record.exc_info[1]:
log_entry["error"] = {
"type": type(record.exc_info[1]).__name__,
"message": str(record.exc_info[1]),
}
log_entry["error"] = str(record.exc_info[1])
log_entry["error_type"] = type(record.exc_info[1]).__name__
return json.dumps(log_entry)


Expand Down
10 changes: 9 additions & 1 deletion plane_mcp/auth/plane_oauth_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@

logger = get_logger(__name__)

# When true, user info (PII such as the display name) is included in logs.
# Defaults to false so PII is never logged unless explicitly opted in.
LOG_USER_INFO: bool = os.getenv("LOG_USER_INFO", "").lower() == "true"


DEFAULT_PLANE_BASE_URL = "https://api.plane.so"

Expand Down Expand Up @@ -158,7 +162,11 @@ async def verify_token(self, token: str) -> AccessToken | None:

expires_at = int(time.time() + 3600)

logger.info(f"User: ({user.id}) - {user.display_name}")
# display_name is PII — only log it when explicitly opted in.
if LOG_USER_INFO:
logger.info(f"User verified: ({user.id}) - {user.display_name}")
else:
logger.info(f"User verified: ({user.id})")

installations_response = await client.get(
f"{base_url}/auth/o/app-installation/",
Expand Down
2 changes: 0 additions & 2 deletions plane_mcp/instructions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"""Server-level instructions sent once to MCP clients (FastMCP `instructions` param)."""

SERVER_INSTRUCTIONS = """
## Epics

Expand Down
21 changes: 21 additions & 0 deletions plane_mcp/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Custom FastMCP middleware for the Plane MCP Server."""

from __future__ import annotations

from fastmcp.server.middleware import MiddlewareContext
from fastmcp.server.middleware.logging import StructuredLoggingMiddleware


class PlaneLoggingMiddleware(StructuredLoggingMiddleware):
"""StructuredLoggingMiddleware that also records the tool name."""

def _with_tool_name(self, context: MiddlewareContext, message: dict) -> dict:
if context.method == "tools/call":
message["tool"] = getattr(context.message, "name", "unknown")
return message

def _create_after_message(self, context: MiddlewareContext, start_time: float) -> dict:
return self._with_tool_name(context, super()._create_after_message(context, start_time))

def _create_error_message(self, context: MiddlewareContext, start_time: float, error: Exception) -> dict:
return self._with_tool_name(context, super()._create_error_message(context, start_time, error))
8 changes: 4 additions & 4 deletions plane_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
import os

from fastmcp import FastMCP
from fastmcp.server.middleware.logging import StructuredLoggingMiddleware
from mcp.types import Icon

from plane_mcp.auth import PlaneHeaderAuthProvider, PlaneOAuthProvider
from plane_mcp.instructions import SERVER_INSTRUCTIONS
from plane_mcp.middleware import PlaneLoggingMiddleware
from plane_mcp.storage import build_token_store
from plane_mcp.tools import register_tools

Expand Down Expand Up @@ -47,7 +47,7 @@ def get_oauth_mcp(base_path: str = "/") -> FastMCP:
],
),
)
oauth_mcp.add_middleware(StructuredLoggingMiddleware(include_payloads=True))
oauth_mcp.add_middleware(PlaneLoggingMiddleware(include_payloads=True))
register_tools(oauth_mcp)
return oauth_mcp

Expand All @@ -60,7 +60,7 @@ def get_header_mcp():
required_scopes=["read", "write"],
),
)
header_mcp.add_middleware(StructuredLoggingMiddleware(include_payloads=True))
header_mcp.add_middleware(PlaneLoggingMiddleware(include_payloads=True))
register_tools(header_mcp)
return header_mcp

Expand All @@ -70,6 +70,6 @@ def get_stdio_mcp():
"Plane MCP Server (stdio)",
instructions=SERVER_INSTRUCTIONS,
)
stdio_mcp.add_middleware(StructuredLoggingMiddleware(include_payloads=True))
stdio_mcp.add_middleware(PlaneLoggingMiddleware(include_payloads=True))
register_tools(stdio_mcp)
return stdio_mcp
2 changes: 2 additions & 0 deletions plane_mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from plane_mcp.tools.work_item_comments import register_work_item_comment_tools
from plane_mcp.tools.work_item_links import register_work_item_link_tools
from plane_mcp.tools.work_item_properties import register_work_item_property_tools
from plane_mcp.tools.work_item_relation_definitions import register_work_item_relation_definition_tools
from plane_mcp.tools.work_item_relations import register_work_item_relation_tools
from plane_mcp.tools.work_item_types import register_work_item_type_tools
from plane_mcp.tools.work_items import register_work_item_tools
Expand All @@ -33,6 +34,7 @@ def register_tools(mcp: FastMCP) -> None:
register_work_item_attachment_tools(mcp)
register_work_item_comment_tools(mcp)
register_work_item_link_tools(mcp)
register_work_item_relation_definition_tools(mcp)
register_work_item_relation_tools(mcp)
register_work_log_tools(mcp)
register_cycle_tools(mcp)
Expand Down
147 changes: 147 additions & 0 deletions plane_mcp/tools/work_item_relation_definitions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Work item relation definition tools for Plane MCP Server."""

from typing import Any, get_args

from fastmcp import FastMCP
from plane.models.work_item_relation_definitions import (
CreateWorkItemRelationDefinition,
PaginatedWorkItemRelationDefinitionResponse,
UpdateWorkItemRelationDefinition,
WorkItemRelationDefinition,
)
from plane.models.work_items import DependencyTypeEnum

from plane_mcp.client import get_plane_client_context


def register_work_item_relation_definition_tools(mcp: FastMCP) -> None:
"""Register work item relation definition tools with the MCP server."""

@mcp.tool()
def list_work_item_relation_definitions(
is_default: bool | None = None,
is_active: bool | None = None,
) -> dict[str, Any]:
"""List every relation type usable with create_work_item_relation.

Match the user's wording against an entry here before creating a relation.
built_in_dependencies are fixed scheduling/blocking types; custom_definitions
are workspace-specific, each with an outward and inward label. custom_definitions
are also what create/update/delete_work_item_relation_definition manage.

Args:
is_default: Filter custom definitions to default/non-default only.
is_active: Filter custom definitions to active/inactive only.

Returns:
built_in_dependencies: relation_type values for the dependency path.
custom_definitions: workspace definitions; use the id plus the matched
outward or inward label.
"""
client, workspace_slug = get_plane_client_context()
results: list[WorkItemRelationDefinition] = []
cursor: str | None = None
while True:
page: PaginatedWorkItemRelationDefinitionResponse = client.work_item_relation_definitions.list(
workspace_slug=workspace_slug,
is_default=is_default,
is_active=is_active,
per_page=100,
cursor=cursor,
)
results.extend(page.results)
cursor = page.next_cursor
if not page.next_page_results or not cursor:
break
return {
"built_in_dependencies": list(get_args(DependencyTypeEnum)),
"custom_definitions": [d.model_dump() for d in results],
}

@mcp.tool()
def create_work_item_relation_definition(
name: str,
outward: str | None = None,
inward: str | None = None,
is_active: bool | None = None,
color: str | None = None,
) -> WorkItemRelationDefinition:
"""Create a new workspace relation definition.

A relation definition describes a named relationship type with an
outward label (how the source item describes the target) and an
inward label (how the target item describes the source).

Args:
name: Unique name for this relation definition.
outward: Label describing the relation from the source item's perspective.
inward: Label describing the relation from the target item's perspective.
is_active: Whether this definition is active and available for use.
color: Hex color code for UI display.

Returns:
Created WorkItemRelationDefinition object.
"""
client, workspace_slug = get_plane_client_context()
data = CreateWorkItemRelationDefinition(
name=name,
outward=outward,
inward=inward,
is_active=is_active,
color=color,
)
return client.work_item_relation_definitions.create(
workspace_slug=workspace_slug,
data=data,
)

@mcp.tool()
def update_work_item_relation_definition(
definition_id: str,
name: str | None = None,
outward: str | None = None,
inward: str | None = None,
is_active: bool | None = None,
color: str | None = None,
) -> WorkItemRelationDefinition:
"""Update an existing workspace relation definition.

Args:
definition_id: UUID of the relation definition to update.
name: New name for this definition.
outward: Updated outward label.
inward: Updated inward label.
is_active: Updated active status.
color: Updated hex color code.

Returns:
Updated WorkItemRelationDefinition object.
"""
client, workspace_slug = get_plane_client_context()
data = UpdateWorkItemRelationDefinition(
name=name,
outward=outward,
inward=inward,
is_active=is_active,
color=color,
)
return client.work_item_relation_definitions.update(
workspace_slug=workspace_slug,
definition_id=definition_id,
data=data,
)

@mcp.tool()
def delete_work_item_relation_definition(
definition_id: str,
) -> None:
"""Delete a workspace relation definition.

Args:
definition_id: UUID of the relation definition to delete.
"""
client, workspace_slug = get_plane_client_context()
client.work_item_relation_definitions.delete(
workspace_slug=workspace_slug,
definition_id=definition_id,
)
Loading
Loading