Skip to content

feat: add fastmcp based MCP server #54

Merged
Prashant-Surya merged 24 commits intocanaryfrom
feat/fastmcp-py-mcp
Dec 29, 2025
Merged

feat: add fastmcp based MCP server #54
Prashant-Surya merged 24 commits intocanaryfrom
feat/fastmcp-py-mcp

Conversation

@Prashant-Surya
Copy link
Member

@Prashant-Surya Prashant-Surya commented Dec 18, 2025

Description

Add new python MCP that

  • Supports both self-hosted (using stdio) and cloud using all transports.
  • Supports PAT based auth with Stdio and http transport uses OAuth

Delete existing Plane MCP Server code

Type of Change

  • Feature (non-breaking change which adds functionality)
  • Improvement (change that would cause existing functionality to not work as expected)
  • Code refactoring
  • Performance improvements
  • Documentation update

Test Scenarios

  1. Verified OAuth based flow with HTTP transport
  2. Verified OAuth based flow with SSE transport
  3. Verified Header(PAT) auth flow using HTTP transport.
  4. Verified PAT flow with STDIO transport

Summary by CodeRabbit

  • New Features

    • Migrated to Python-based FastMCP server with enhanced authentication (OAuth, API key header support).
    • Added Docker containerization support.
    • Introduced multi-deployment modes: Stdio, HTTP (OAuth/PAT), and SSE transports.
    • Expanded tool suite covering projects, cycles, modules, work items, and more.
  • Documentation

    • Comprehensive README overhaul with transport modes, authentication configuration, and usage examples.
  • Chores

    • Updated license copyright attribution.
    • Configured Python packaging with PyPI release automation.
    • Updated project configuration for Python tooling.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 18, 2025

Walkthrough

Replaces a Node.js/TypeScript MCP server implementation with a Python/FastMCP-based implementation. Removes all TypeScript source files and Node.js tooling; adds Python modules for OAuth and header authentication providers, multiple server modes (stdio, HTTP, SSE), tool registrations for Plane integration, and corresponding Docker and configuration files.

Changes

Cohort / File(s) Change Summary
Configuration & Build System
.dockerignore, .gitignore, .prettierrc, eslint.config.mjs, package.json, pyproject.toml, tsconfig.json
Removes Node.js/TypeScript configuration (.prettierrc, eslint.config.mjs, tsconfig.json, package.json) and adds Python project config (pyproject.toml). Expands .gitignore with comprehensive Python-specific patterns.
Docker & Deployment
Dockerfile
Adds Python 3.11 Slim-based Docker image to run plane_mcp module with uv package manager, exposing port 8211 for FastMCP HTTP server.
GitHub Workflows
.github/workflows/build_check.yml, .github/workflows/ci.yml, .github/workflows/publish.yml, .github/workflows/publish-pypi.yml
Removes Node.js CI/CD workflows (build_check.yml, ci.yml, publish.yml); adds Python PyPI publishing workflow (publish-pypi.yml) with build, validation, and GitHub Release automation.
Documentation & Licensing
LICENSE, README.md
Updates copyright to "Plane MCP Server Contributors". Comprehensively rewrites README to document Python+FastMCP implementation, transport modes (stdio, OAuth HTTP, PAT HTTP, SSE), authentication configuration, tool catalog, and deprecation notice for Node.js version.
Python Implementation: Auth Providers
plane_mcp/auth/__init__.py, plane_mcp/auth/plane_header_auth_provider.py, plane_mcp/auth/plane_oauth_provider.py
Adds auth module with two provider classes: PlaneHeaderAuthProvider (validates x-workspace-slug header) and PlaneOAuthProvider (complete OAuth flow with token verification against Plane API and app installation context).
Python Implementation: Server Setup
plane_mcp/__init__.py, plane_mcp/__main__.py, plane_mcp/client.py, plane_mcp/server.py
Adds server initialization with multi-mode support (STDIO, HTTP, SSE); combined lifecycle management for multiple MCP apps; PlaneClientContext factory for auth method selection; and three MCP factory functions (get_oauth_mcp, get_header_mcp, get_stdio_mcp).
Python Implementation: Tool Registrations
plane_mcp/tools/__init__.py, plane_mcp/tools/cycles.py, plane_mcp/tools/initiatives.py, plane_mcp/tools/intake.py, plane_mcp/tools/modules.py, plane_mcp/tools/projects.py, plane_mcp/tools/users.py, plane_mcp/tools/work_item_properties.py, plane_mcp/tools/work_items.py
Adds nine tool modules providing CRUD and list operations for Plane entities (cycles, initiatives, intake work items, modules, projects, users, work item properties, work items) with integration into MCP framework.
TypeScript Source Removal
src/common/request-helper.ts, src/common/version.ts, src/index.ts, src/schemas.ts, src/server.ts, src/tools/cycle-issues.ts, src/tools/cycles.ts, src/tools/index.ts, src/tools/issues.ts, src/tools/metadata.ts, src/tools/module-issues.ts, src/tools/modules.ts, src/tools/projects.ts, src/tools/user.ts, src/tools/work-log.ts
Removes entire TypeScript/Node.js implementation including HTTP request helpers, server bootstrap, all tool registrations (cycles, issues, metadata, modules, projects, users, work-logs), and schemas.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI as CLI/Docker
    participant Main as __main__.py<br/>(ServerMode)
    participant OAuth as OAuth MCP<br/>App
    participant Header as Header MCP<br/>App
    participant SSE as SSE MCP<br/>App
    participant Starlette as Starlette<br/>App + CORS
    participant Uvicorn
    participant PlaneAPI as Plane API

    User->>CLI: Start server (HTTP mode)
    CLI->>Main: Invoke main()
    
    rect rgb(100, 150, 200)
    Note over Main: HTTP Mode: Multi-MCP Setup
    Main->>OAuth: get_oauth_mcp()<br/>(init with PlaneOAuthProvider)
    OAuth->>PlaneAPI: Token verification endpoint configured
    
    Main->>Header: get_header_mcp()<br/>(init with PlaneHeaderAuthProvider)
    
    Main->>SSE: get_sse_mcp()<br/>(init for SSE transport)
    end
    
    rect rgb(150, 100, 200)
    Note over Main: Lifespan Management
    Main->>OAuth: Enter combined_lifespan context
    Main->>Header: Setup lifespans
    Main->>SSE: Setup lifespans
    end
    
    rect rgb(100, 200, 150)
    Note over Main: Route Assembly & CORS
    Main->>Starlette: Mount OAuth app at /.well-known/openid-configuration
    Main->>Starlette: Mount Header app at /v1/http/headers
    Main->>Starlette: Mount SSE app at /sse
    Main->>Starlette: Attach permissive CORS policy
    end
    
    Main->>Uvicorn: Run on 0.0.0.0:8211<br/>(FASTMCP_PORT)
    Main->>Uvicorn: Set startup logging
    
    User->>Uvicorn: HTTP request (e.g., OAuth token exchange)
    Uvicorn->>Starlette: Route to mounted app
    Starlette->>OAuth: Process auth/request
    OAuth->>PlaneAPI: Validate token
    PlaneAPI-->>OAuth: User + app installation context
    OAuth-->>Starlette: Response
    Starlette-->>Uvicorn: Response with CORS headers
    Uvicorn-->>User: JSON response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Areas requiring extra attention:

  • Authentication Providers (plane_mcp/auth/plane_oauth_provider.py, plane_mcp/auth/plane_header_auth_provider.py): Verify OAuth flow correctness, token verification logic, API error handling, and security assumptions (redirect URIs, JWT signing, scope validation).
  • Server Initialization & Lifecycle (plane_mcp/__main__.py, plane_mcp/server.py): Review combined_lifespan context manager, multi-app mounting in Starlette, CORS configuration, and environment variable handling across modes.
  • Tool Registrations (plane_mcp/tools/*): Verify consistency of tool parameter handling, error propagation from Plane API, data model conversions, and pagination/query parameter support across all nine tool modules.
  • Client Context & Auth Selection (plane_mcp/client.py): Review auth method priority (api_key_env vs. api_key_header vs. oauth) and token type determination logic.
  • Configuration & Removal of Node.js Build (pyproject.toml, Dockerfile, removed package.json): Confirm dependency versions, build process, Docker image sizing, and that all necessary Python dependencies are declared.

Poem

🐇 A burrow of changes from Node to Py,
FastMCP helpers now reach the sky,
Three auth flows dance in harmony,
OAuth, Headers, SSE—so free!
Tools aplenty, planes take flight,
From TypeScript's dusk to Python's light!

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: add fastmcp based MCP server' accurately describes the main change: introducing a new FastMCP-based Python implementation to replace the existing Node.js MCP server, with support for multiple transports and authentication methods.
Docstring Coverage ✅ Passed Docstring coverage is 92.11% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/fastmcp-py-mcp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 18

🧹 Nitpick comments (9)
.github/workflows/publish-pypi.yml (1)

41-49: Version extraction could be more robust.

The current grep | cut approach may break if the pyproject.toml format changes or if there are multiple version = strings. Consider using a dedicated TOML parser for reliability.

🔎 Alternative using Python:
       - name: Get version from pyproject.toml
         id: package-version
         run: |
-          VERSION=$(grep "version = " pyproject.toml | cut -d'"' -f2 | head -1)
+          VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
           if [ -z "$VERSION" ]; then
             echo "Error: Could not extract version from pyproject.toml"
             exit 1
           fi
           echo "current-version=$VERSION" >> $GITHUB_OUTPUT
plane_mcp/auth/plane_oauth_provider.py (2)

127-129: Consider reducing token exposure in logs.

Logging the first 20 characters of tokens, even in info-level logs, could expose sensitive information in production environments. Consider using debug level or redacting entirely.

🔎 Apply this diff:
-        logger.info(
-            f"verify_token called with token (first 20 chars): {token[:20] if token else 'None'}..."
-        )
+        logger.debug("verify_token called with token")

197-202: Consider more specific exception handling.

The broad except Exception catch could mask unexpected errors. Consider catching specific exceptions or at least logging with warning/error level for unexpected cases.

🔎 Apply this diff:
         except httpx.RequestError as e:
-            logger.info(f"Failed to verify Plane token (request error): {e}")
+            logger.warning(f"Failed to verify Plane token (request error): {e}")
             return None
         except Exception as e:
-            logger.info(f"Failed to verify Plane token: {e}", exc_info=True)
+            logger.warning(f"Failed to verify Plane token: {e}", exc_info=True)
             return None
plane_mcp/tools/users.py (1)

20-21: Prefix unused workspace_slug with underscore.

The static analysis correctly flags that workspace_slug is unpacked but never used in this function.

🔎 Apply this diff:
-        client, workspace_slug = get_plane_client_context()
+        client, _ = get_plane_client_context()
plane_mcp/tools/cycles.py (1)

28-38: Docstring incorrectly lists workspace_slug as a parameter.

The docstring mentions workspace_slug: The workspace slug identifier as an argument, but it's not an actual function parameter—it's obtained internally from get_plane_client_context(). This pattern repeats across all tools in this file.

🔎 Example fix for list_cycles:
         """
         List all cycles in a project.

         Args:
-            workspace_slug: The workspace slug identifier
             project_id: UUID of the project
             params: Optional query parameters as a dictionary

         Returns:
             List of Cycle objects
         """

The same fix applies to all other tool functions in this file (create_cycle, retrieve_cycle, update_cycle, delete_cycle, list_archived_cycles, add_work_items_to_cycle, remove_work_item_from_cycle, list_cycle_work_items, transfer_cycle_work_items, archive_cycle, unarchive_cycle).

plane_mcp/__main__.py (1)

72-72: Unused lambda parameter.

The app parameter in the lambda is unused (flagged by static analysis). Use _ to indicate intentionally unused.

🔎 Suggested fix:
-            lifespan=lambda app: combined_lifespan(oauth_app, header_app, sse_app),
+            lifespan=lambda _: combined_lifespan(oauth_app, header_app, sse_app),
plane_mcp/tools/work_items.py (1)

89-89: Parameter type shadows Python built-in.

Using type as a parameter name shadows Python's built-in type() function. Consider renaming to work_item_type or type_name for clarity. Same applies to line 253 in update_work_item.

plane_mcp/tools/intake.py (2)

48-69: Inconsistent API design with work_items.py.

This file uses data: dict[str, Any] for create/update operations, while work_items.py uses explicit typed parameters (e.g., name: str, assignees: list[str] | None). The explicit parameter approach in work_items.py is more discoverable and type-safe for MCP tool consumers.

Consider aligning the intake tools to use explicit parameters for consistency, or documenting the expected dict keys in the docstring.


63-69: Consider catching validation errors for clearer error messages.

If data contains invalid keys or values, CreateIntakeWorkItem(**data) will raise a Pydantic ValidationError. The raw error may be confusing to MCP clients. Consider wrapping with a try/except to provide a clearer error message about which fields are invalid.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c598a3f and 6ec3113.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (45)
  • .dockerignore (1 hunks)
  • .github/workflows/build_check.yml (0 hunks)
  • .github/workflows/ci.yml (0 hunks)
  • .github/workflows/publish-pypi.yml (1 hunks)
  • .github/workflows/publish.yml (0 hunks)
  • .gitignore (1 hunks)
  • .prettierrc (0 hunks)
  • Dockerfile (1 hunks)
  • LICENSE (2 hunks)
  • README.md (1 hunks)
  • eslint.config.mjs (0 hunks)
  • package.json (0 hunks)
  • plane_mcp/__init__.py (1 hunks)
  • plane_mcp/__main__.py (1 hunks)
  • plane_mcp/auth/__init__.py (1 hunks)
  • plane_mcp/auth/plane_header_auth_provider.py (1 hunks)
  • plane_mcp/auth/plane_oauth_provider.py (1 hunks)
  • plane_mcp/client.py (1 hunks)
  • plane_mcp/server.py (1 hunks)
  • plane_mcp/tools/__init__.py (1 hunks)
  • plane_mcp/tools/cycles.py (1 hunks)
  • plane_mcp/tools/initiatives.py (1 hunks)
  • plane_mcp/tools/intake.py (1 hunks)
  • plane_mcp/tools/modules.py (1 hunks)
  • plane_mcp/tools/projects.py (1 hunks)
  • plane_mcp/tools/users.py (1 hunks)
  • plane_mcp/tools/work_item_properties.py (1 hunks)
  • plane_mcp/tools/work_items.py (1 hunks)
  • pyproject.toml (1 hunks)
  • src/common/request-helper.ts (0 hunks)
  • src/common/version.ts (0 hunks)
  • src/index.ts (0 hunks)
  • src/schemas.ts (0 hunks)
  • src/server.ts (0 hunks)
  • src/tools/cycle-issues.ts (0 hunks)
  • src/tools/cycles.ts (0 hunks)
  • src/tools/index.ts (0 hunks)
  • src/tools/issues.ts (0 hunks)
  • src/tools/metadata.ts (0 hunks)
  • src/tools/module-issues.ts (0 hunks)
  • src/tools/modules.ts (0 hunks)
  • src/tools/projects.ts (0 hunks)
  • src/tools/user.ts (0 hunks)
  • src/tools/work-log.ts (0 hunks)
  • tsconfig.json (0 hunks)
💤 Files with no reviewable changes (22)
  • src/common/version.ts
  • .github/workflows/publish.yml
  • src/tools/user.ts
  • src/tools/cycles.ts
  • src/server.ts
  • src/tools/metadata.ts
  • src/tools/module-issues.ts
  • src/tools/cycle-issues.ts
  • src/tools/modules.ts
  • .github/workflows/build_check.yml
  • src/tools/work-log.ts
  • src/tools/index.ts
  • tsconfig.json
  • .github/workflows/ci.yml
  • src/tools/projects.ts
  • src/tools/issues.ts
  • src/common/request-helper.ts
  • package.json
  • .prettierrc
  • src/schemas.ts
  • src/index.ts
  • eslint.config.mjs
🧰 Additional context used
🧬 Code graph analysis (12)
plane_mcp/auth/__init__.py (2)
plane_mcp/auth/plane_header_auth_provider.py (1)
  • PlaneHeaderAuthProvider (10-39)
plane_mcp/auth/plane_oauth_provider.py (1)
  • PlaneOAuthProvider (205-360)
plane_mcp/tools/users.py (1)
plane_mcp/client.py (1)
  • get_plane_client_context (21-73)
plane_mcp/tools/work_items.py (1)
plane_mcp/client.py (1)
  • get_plane_client_context (21-73)
plane_mcp/tools/intake.py (1)
plane_mcp/client.py (1)
  • get_plane_client_context (21-73)
plane_mcp/auth/plane_header_auth_provider.py (1)
plane_mcp/auth/plane_oauth_provider.py (1)
  • verify_token (125-202)
plane_mcp/server.py (3)
plane_mcp/auth/plane_header_auth_provider.py (1)
  • PlaneHeaderAuthProvider (10-39)
plane_mcp/auth/plane_oauth_provider.py (1)
  • PlaneOAuthProvider (205-360)
plane_mcp/tools/__init__.py (1)
  • register_tools (15-24)
plane_mcp/tools/cycles.py (2)
plane_mcp/client.py (1)
  • get_plane_client_context (21-73)
plane_mcp/tools/work_items.py (1)
  • list_work_items (22-67)
plane_mcp/tools/projects.py (1)
plane_mcp/client.py (1)
  • get_plane_client_context (21-73)
plane_mcp/tools/initiatives.py (1)
plane_mcp/client.py (1)
  • get_plane_client_context (21-73)
plane_mcp/tools/modules.py (2)
plane_mcp/client.py (1)
  • get_plane_client_context (21-73)
plane_mcp/tools/work_items.py (1)
  • list_work_items (22-67)
plane_mcp/__main__.py (1)
plane_mcp/server.py (3)
  • get_header_mcp (40-48)
  • get_oauth_mcp (14-37)
  • get_stdio_mcp (51-56)
plane_mcp/tools/work_item_properties.py (1)
plane_mcp/client.py (1)
  • get_plane_client_context (21-73)
🪛 actionlint (1.7.9)
.github/workflows/publish-pypi.yml

18-18: the runner of "actions/setup-python@v4" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)

🪛 LanguageTool
README.md

[grammar] ~16-~16: Ensure spelling is correct
Context: ...t doesn't require installation. ### 1. Stdio Transport (for local use) **MCP Client...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[style] ~250-~250: Consider using a less common alternative to make your writing sound more unique and professional.
Context: ...ontributing Contributions are welcome! Please feel free to submit a Pull Request. ## Deprecation ...

(FEEL_FREE_TO_STYLE_ME)


[grammar] ~256-~256: Use a hyphen to join words.
Context: ...sitory represents the new Python+FastMCP based implementation of the Plane MCP se...

(QB_NEW_EN_HYPHEN)

🪛 Ruff (0.14.8)
plane_mcp/tools/users.py

20-20: Unpacked variable workspace_slug is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

plane_mcp/__main__.py

44-44: Avoid specifying long messages outside the exception class

(TRY003)


46-46: Avoid specifying long messages outside the exception class

(TRY003)


72-72: Unused lambda argument: app

(ARG005)


84-84: Possible binding to all interfaces

(S104)

plane_mcp/auth/plane_oauth_provider.py

172-172: Abstract raise to an inner function

(TRY301)


172-172: Avoid specifying long messages outside the exception class

(TRY003)


200-200: Do not catch blind exception: Exception

(BLE001)


309-311: Avoid specifying long messages outside the exception class

(TRY003)


313-316: Avoid specifying long messages outside the exception class

(TRY003)

🔇 Additional comments (17)
LICENSE (1)

3-3: LGTM!

The updated copyright notice appropriately reflects the contributor-based ownership model for this open-source project.

pyproject.toml (1)

1-50: LGTM!

The project configuration is well-structured with pinned dependencies for reproducibility, appropriate Python version requirement, and consistent tooling configuration for Black and Ruff.

.gitignore (1)

1-57: LGTM!

Comprehensive .gitignore configuration covering Python build artifacts, virtual environments, IDE files, testing caches, and environment files.

plane_mcp/tools/initiatives.py (1)

17-151: Implementation pattern is sound.

The tools follow a consistent pattern: obtain client context, construct request payloads using Plane SDK models, and delegate to the appropriate client methods. The tool registration via @mcp.tool() decorator is idiomatic for FastMCP.

plane_mcp/tools/modules.py (1)

20-303: Comprehensive module tooling implementation.

The module tools provide complete CRUD operations plus archive/unarchive and work item association management. The implementation is consistent with the pattern established in other tool modules.

README.md (1)

1-290: Comprehensive and well-structured documentation.

The README provides clear guidance for all transport methods, authentication options, and tool categories. The deprecation notice with migration guidance is helpful for users transitioning from the Node.js version.

plane_mcp/auth/plane_oauth_provider.py (1)

205-360: Well-designed OAuth provider implementation.

The PlaneOAuthProvider class provides a clean abstraction over Plane's OAuth flow with sensible defaults, environment variable fallbacks, and comprehensive documentation. The integration with FastMCP's OAuthProxy is appropriate.

plane_mcp/__init__.py (1)

1-1: LGTM!

Simple and clean package initialization with an appropriate module docstring.

Dockerfile (1)

1-33: LGTM!

Well-structured Dockerfile with good practices:

  • Uses slim base image to minimize attack surface
  • Cleans up apt lists to reduce image size
  • Leverages uv for faster dependency installation
  • Separates ENTRYPOINT and CMD allowing transport mode override
.dockerignore (1)

1-42: LGTM!

Comprehensive .dockerignore with appropriate patterns for a Python project. Good exclusion of development artifacts, IDE files, and test caches to minimize build context size.

plane_mcp/auth/__init__.py (1)

1-7: LGTM!

Clean package initialization with explicit __all__ exports following Python conventions.

plane_mcp/tools/__init__.py (1)

1-24: LGTM!

Clean centralized entry point for tool registration. The pattern of aggregating individual register_*_tools functions into a single register_tools function provides good modularity and maintainability.

plane_mcp/tools/cycles.py (1)

20-328: LGTM on the implementation!

Comprehensive and well-structured cycle management tooling. The pattern of obtaining client context and delegating to typed SDK methods is consistent and maintainable.

plane_mcp/client.py (1)

14-73: Overall implementation is solid.

The authentication flow correctly handles multiple auth methods (OAuth, API key via env, API key via headers) and constructs the appropriate PlaneClient. The NamedTuple pattern for the return value is clean and Pythonic.

plane_mcp/__main__.py (1)

75-81: Permissive CORS configuration.

allow_origins=["*"] with allow_credentials=True is very permissive. This may be intentional for MCP client compatibility, but be aware that it allows any origin to make credentialed requests. For production deployments, consider restricting origins if specific MCP clients are known.

plane_mcp/server.py (1)

50-56: LGTM!

The stdio MCP correctly omits auth configuration since authentication is handled via environment variables (PLANE_API_KEY, PLANE_WORKSPACE_SLUG) which are validated in __main__.py before this function is called.

plane_mcp/tools/work_items.py (1)

18-68: LGTM!

The list_work_items tool implementation is clean: properly constructs query params, calls the client API, and returns results. The pattern is consistent across all work item tools.

Comment on lines +17 to +20
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Consider upgrading to actions/setup-python@v6.

The current stable version is v6. Upgrade from @v4 to @v6 for improved compatibility and the latest features.

      - name: Set up Python
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v6
         with:
           python-version: "3.10"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.10"
🧰 Tools
🪛 actionlint (1.7.9)

18-18: the runner of "actions/setup-python@v4" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)

🤖 Prompt for AI Agents
In .github/workflows/publish-pypi.yml around lines 17 to 20, update the GitHub
Action reference from actions/setup-python@v4 to actions/setup-python@v6 to use
the latest stable release; edit the uses line to point to
actions/setup-python@v6 (keeping the existing python-version input as-is) and
commit the workflow change.

Comment on lines +19 to +23
class ServerMode(Enum):
STDIO = "stdio"
SSE = "sse"
HTTP = "http"

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

SSE enum value is defined but not handled.

The ServerMode.SSE enum is defined but there's no corresponding if server_mode == ServerMode.SSE: branch. If a user runs with python -m plane_mcp sse, the function exits without doing anything (no explicit handling, no error). Either add SSE mode handling or remove the enum value if SSE is always served under HTTP mode.

🤖 Prompt for AI Agents
In plane_mcp/__main__.py around lines 19 to 23, ServerMode.SSE is declared but
never handled; if invoked with "sse" nothing happens. Fix by either (A) adding
an explicit branch for ServerMode.SSE that performs the intended behavior (e.g.,
start an SSE server or delegate to the HTTP start routine with an SSE flag)
mirroring how STDIO/HTTP are handled, ensuring any required imports/parameters
are passed and a clear log/error is emitted on failure; or (B) if SSE is not
supported, remove the SSE enum value and ensure argument parsing/usage docs are
updated to prevent users from selecting it, and add a guard that raises a clear
error if an unknown/unsupported mode is provided.

Comment on lines +37 to +39
server_mode = ServerMode.STDIO
if len(sys.argv) > 1:
server_mode = ServerMode(sys.argv[1])
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing error handling for invalid server mode argument.

If an invalid mode is passed via sys.argv[1], ServerMode(sys.argv[1]) raises a ValueError with an unclear message. Consider catching this and providing a helpful error message listing valid modes.

🔎 Suggested fix:
     server_mode = ServerMode.STDIO
     if len(sys.argv) > 1:
-        server_mode = ServerMode(sys.argv[1])
+        try:
+            server_mode = ServerMode(sys.argv[1])
+        except ValueError:
+            valid_modes = ", ".join(m.value for m in ServerMode)
+            raise SystemExit(f"Invalid server mode: {sys.argv[1]}. Valid modes: {valid_modes}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
server_mode = ServerMode.STDIO
if len(sys.argv) > 1:
server_mode = ServerMode(sys.argv[1])
server_mode = ServerMode.STDIO
if len(sys.argv) > 1:
try:
server_mode = ServerMode(sys.argv[1])
except ValueError:
valid_modes = ", ".join(m.value for m in ServerMode)
raise SystemExit(f"Invalid server mode: {sys.argv[1]}. Valid modes: {valid_modes}")
🤖 Prompt for AI Agents
In plane_mcp/__main__.py around lines 37 to 39, the code directly constructs
ServerMode from sys.argv[1] which will raise a ValueError for invalid input;
wrap the conversion in a try/except catching ValueError, print a clear error
message that includes the invalid value and the list of valid modes (derive
names from ServerMode, e.g., ", ".join(m.name for m in ServerMode)), and exit
with a non-zero status; keep the default to ServerMode.STDIO when no arg is
provided.

allow_headers=["*"],
)

logger.info("Starting HTTP server at URLs: /mcp and /header/mcp")
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inaccurate log message.

The log states URLs are /mcp and /header/mcp, but the actual mounts are /http (OAuth), /http/api-key (header auth), and / (SSE). Update to reflect actual routes.

🔎 Suggested fix:
-        logger.info("Starting HTTP server at URLs: /mcp and /header/mcp")
+        logger.info("Starting HTTP server with routes: /http (OAuth), /http/api-key (header auth), / (SSE)")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.info("Starting HTTP server at URLs: /mcp and /header/mcp")
logger.info("Starting HTTP server with routes: /http (OAuth), /http/api-key (header auth), / (SSE)")
🤖 Prompt for AI Agents
In plane_mcp/__main__.py around line 83, the logger.info call currently reports
incorrect routes; change the log message to list the actual mounted routes —
"/http" (OAuth), "/http/api-key" (header auth), and "/" (SSE) — so the log
accurately reflects the server endpoints and their auth types.

Comment on lines +14 to +39
async def verify_token(self, token: str) -> AccessToken | None:
try:
from fastmcp.server.dependencies import get_http_headers

headers = get_http_headers()

if token:
workspace_slug = headers.get("x-workspace-slug")
if workspace_slug:
logger.info("Using API key from HTTP headers")
expires_at = int(time.time() + 3600)
return AccessToken(
token=token,
client_id="api_key_header_user",
scopes=["read", "write"],
expires_at=expires_at,
claims={
"auth_method": "api_key_header",
"workspace_slug": workspace_slug,
},
)
else:
logger.warning("x-api-key header found but x-workspace-slug is missing")
except RuntimeError:
# No active HTTP request available (e.g., stdio transport)
logger.debug("No active HTTP request available for header check")
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

❓ Verification inconclusive

Script executed:

find . -type f -name "*.py" | grep -E "(auth|plane)" | head -20

Repository: makeplane/plane-mcp-server


Repository: makeplane/plane-mcp-server
Exit code: 0

stdout:

./plane_mcp/__init__.py
./plane_mcp/__main__.py
./plane_mcp/auth/__init__.py
./plane_mcp/auth/plane_header_auth_provider.py
./plane_mcp/auth/plane_oauth_provider.py
./plane_mcp/client.py
./plane_mcp/server.py
./plane_mcp/tools/__init__.py
./plane_mcp/tools/cycles.py
./plane_mcp/tools/initiatives.py
./plane_mcp/tools/intake.py
./plane_mcp/tools/modules.py
./plane_mcp/tools/projects.py
./plane_mcp/tools/users.py
./plane_mcp/tools/work_item_properties.py
./plane_mcp/tools/work_items.py

Script executed:

cat -n plane_mcp/auth/plane_header_auth_provider.py

Repository: makeplane/plane-mcp-server


Repository: makeplane/plane-mcp-server
Exit code: 0

stdout:

     1	import time
     2	
     3	from fastmcp.server.auth import TokenVerifier
     4	from fastmcp.server.auth.auth import AccessToken
     5	from fastmcp.utilities.logging import get_logger
     6	
     7	logger = get_logger(__name__)
     8	
     9	
    10	class PlaneHeaderAuthProvider(TokenVerifier):
    11	    def __init__(self, required_scopes: list[str] | None = None):
    12	        super().__init__(required_scopes=required_scopes)
    13	
    14	    async def verify_token(self, token: str) -> AccessToken | None:
    15	        try:
    16	            from fastmcp.server.dependencies import get_http_headers
    17	
    18	            headers = get_http_headers()
    19	
    20	            if token:
    21	                workspace_slug = headers.get("x-workspace-slug")
    22	                if workspace_slug:
    23	                    logger.info("Using API key from HTTP headers")
    24	                    expires_at = int(time.time() + 3600)
    25	                    return AccessToken(
    26	                        token=token,
    27	                        client_id="api_key_header_user",
    28	                        scopes=["read", "write"],
    29	                        expires_at=expires_at,
    30	                        claims={
    31	                            "auth_method": "api_key_header",
    32	                            "workspace_slug": workspace_slug,
    33	                        },
    34	                    )
    35	                else:
    36	                    logger.warning("x-api-key header found but x-workspace-slug is missing")
    37	        except RuntimeError:
    38	            # No active HTTP request available (e.g., stdio transport)
    39	            logger.debug("No active HTTP request available for header check")

Script executed:

cat -n plane_mcp/auth/plane_oauth_provider.py

Repository: makeplane/plane-mcp-server


Repository: makeplane/plane-mcp-server
Exit code: 0

stdout:

     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	
    44	DEFAULT_PLANE_BASE_URL = "https://api.plane.so"
    45	
    46	
    47	class WorkspaceDetail(BaseModel):
    48	    """Workspace detail information."""
    49	
    50	    name: str
    51	    slug: str
    52	    id: str
    53	    logo_url: str | None = None
    54	
    55	
    56	class PlaneOAuthAppInstallation(BaseModel):
    57	    """Plane OAuth app installation information."""
    58	
    59	    id: str
    60	    workspace_detail: WorkspaceDetail
    61	    created_at: str
    62	    updated_at: str
    63	    deleted_at: str | None = None
    64	    status: str
    65	    created_by: str | None = None
    66	    updated_by: str | None = None
    67	    workspace: str
    68	    application: str
    69	    installed_by: str
    70	    app_bot: str
    71	    webhook: str | None = None
    72	
    73	
    74	class PlaneOAuthProviderSettings(BaseSettings):
    75	    """Settings for Plane OAuth provider."""
    76	
    77	    model_config = SettingsConfigDict(
    78	        env_prefix="PLANE_OAUTH_PROVIDER_",
    79	        env_file=ENV_FILE,
    80	        extra="ignore",
    81	    )
    82	
    83	    client_id: str | None = None
    84	    client_secret: SecretStr | None = None
    85	    base_url: AnyHttpUrl | str | None = None
    86	    issuer_url: AnyHttpUrl | str | None = None
    87	    redirect_path: str | None = None
    88	    required_scopes: list[str] | None = None
    89	    timeout_seconds: int | None = None
    90	    allowed_client_redirect_uris: list[str] | None = None
    91	    jwt_signing_key: str | None = None
    92	    plane_base_url: str | None = None
    93	
    94	    @field_validator("required_scopes", mode="before")
    95	    @classmethod
    96	    def _parse_scopes(cls, v):
    97	        return parse_scopes(v)
    98	
    99	
   100	class PlaneOAuthTokenVerifier(TokenVerifier):
   101	    """Token verifier for Plane OAuth tokens.
   102	
   103	    Plane OAuth tokens are verified by calling Plane's API to check if they're
   104	    valid and get user info.
   105	    """
   106	
   107	    def __init__(
   108	        self,
   109	        *,
   110	        required_scopes: list[str] | None = None,
   111	        timeout_seconds: int = 10,
   112	        plane_base_url: str | None = None,
   113	    ):
   114	        """Initialize the Plane token verifier.
   115	
   116	        Args:
   117	            required_scopes: Required OAuth scopes (currently not enforced by Plane API)
   118	            timeout_seconds: HTTP request timeout
   119	            plane_base_url: Base URL for Plane API (defaults to https://api.plane.so)
   120	        """
   121	        super().__init__(required_scopes=required_scopes)
   122	        self.timeout_seconds = timeout_seconds
   123	        self.plane_base_url = plane_base_url or os.getenv("PLANE_BASE_URL", DEFAULT_PLANE_BASE_URL)
   124	
   125	    async def verify_token(self, token: str) -> AccessToken | None:
   126	        """Verify Plane OAuth token by calling Plane API."""
   127	        logger.info(
   128	            f"verify_token called with token (first 20 chars): {token[:20] if token else 'None'}..."
   129	        )
   130	        try:
   131	            # Build the user endpoint URL
   132	            base_url = self.plane_base_url.rstrip("/")
   133	            user_url = f"{base_url}/api/v1/users/me/"
   134	            logger.info(f"Verifying token against: {user_url}")
   135	
   136	            async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
   137	                # Get current user info to verify token
   138	                response = await client.get(
   139	                    user_url,
   140	                    headers={
   141	                        "Authorization": f"Bearer {token}",
   142	                        "Content-Type": "application/json",
   143	                    },
   144	                )
   145	
   146	                logger.info(f"Plane API response status: {response.status_code}")
   147	                if response.status_code != 200:
   148	                    logger.info(
   149	                        f"Plane token verification failed: {response.status_code} - {response.text[:200]}"
   150	                    )
   151	                    return None
   152	
   153	                # Parse user data
   154	                user_data = response.json()
   155	                user = UserLite.model_validate(user_data)
   156	
   157	                expires_at = int(time.time() + 3600)
   158	
   159	                logger.info(f"User: ({user.id}) - {user.display_name}")
   160	
   161	                installations_response = await client.get(
   162	                    f"{base_url}/auth/o/app-installation/",
   163	                    headers={
   164	                        "Authorization": f"Bearer {token}",
   165	                        "Content-Type": "application/json",
   166	                    },
   167	                )
   168	
   169	                installations: list[PlaneOAuthAppInstallation] = installations_response.json()
   170	
   171	                if not installations:
   172	                    raise ValueError("No app installations found")
   173	
   174	                installation = installations[0]
   175	
   176	                # Create AccessToken with Plane user info
   177	                return AccessToken(
   178	                    token=token,
   179	                    client_id=user.id or "unknown",
   180	                    scopes=["read", "write"],  # Plane doesn't expose scopes in user endpoint
   181	                    expires_at=expires_at,  # Plane tokens don't typically expire
   182	                    claims={
   183	                        "auth_method": "oauth",
   184	                        "sub": user.id or "unknown",
   185	                        "email": user.email,
   186	                        "first_name": user.first_name,
   187	                        "last_name": user.last_name,
   188	                        "display_name": user.display_name,
   189	                        "avatar": user.avatar,
   190	                        "avatar_url": user.avatar_url,
   191	                        "plane_user_data": user_data,
   192	                        "workspace_slug": installation.get("workspace_detail", {}).get("slug"),
   193	                        "workspace": installation.get("workspace_detail", {}),
   194	                    },
   195	                )
   196	
   197	        except httpx.RequestError as e:
   198	            logger.info(f"Failed to verify Plane token (request error): {e}")
   199	            return None
   200	        except Exception as e:
   201	            logger.info(f"Failed to verify Plane token: {e}", exc_info=True)
   202	            return None
   203	
   204	
   205	class PlaneOAuthProvider(OAuthProxy):
   206	    """Complete Plane OAuth provider for FastMCP.
   207	
   208	    This provider makes it trivial to add Plane OAuth protection to any
   209	    FastMCP server. Just provide your Plane OAuth app credentials and
   210	    a base URL, and you're ready to go.
   211	
   212	    Features:
   213	    - Transparent OAuth proxy to Plane
   214	    - Automatic token validation via Plane API
   215	    - User information extraction
   216	    - Minimal configuration required
   217	
   218	    Example:
   219	        ```python
   220	        from fastmcp import FastMCP
   221	        from plane_mcp.plane_oauth_provider import PlaneOAuthProvider
   222	
   223	        auth = PlaneOAuthProvider(
   224	            client_id="your-client-id",
   225	            client_secret="your-client-secret",
   226	            base_url="https://my-server.com",
   227	            plane_base_url="https://api.plane.so"
   228	        )
   229	
   230	        mcp = FastMCP("My App", auth=auth)
   231	        ```
   232	    """
   233	
   234	    def __init__(
   235	        self,
   236	        *,
   237	        client_id: str | NotSetT = NotSet,
   238	        client_secret: str | NotSetT = NotSet,
   239	        base_url: AnyHttpUrl | str | NotSetT = NotSet,
   240	        issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
   241	        redirect_path: str | NotSetT = NotSet,
   242	        required_scopes: list[str] | NotSetT = NotSet,
   243	        timeout_seconds: int | NotSetT = NotSet,
   244	        allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
   245	        client_storage: AsyncKeyValue | None = None,
   246	        jwt_signing_key: str | bytes | NotSetT = NotSet,
   247	        require_authorization_consent: bool = True,
   248	        plane_base_url: str | NotSetT = NotSet,
   249	    ):
   250	        """Initialize Plane OAuth provider.
   251	
   252	        Args:
   253	            client_id: Plane OAuth app client ID
   254	            client_secret: Plane OAuth app client secret
   255	            base_url: Public URL where OAuth endpoints will be accessible
   256	                (includes any mount path)
   257	            issuer_url: Issuer URL for OAuth metadata (defaults to base_url).
   258	                Use root-level URL to avoid 404s during discovery when mounting
   259	                under a path.
   260	            redirect_path: Redirect path configured in Plane OAuth app
   261	                (defaults to "/auth/callback")
   262	            required_scopes: Required Plane scopes
   263	                (currently not enforced by Plane API)
   264	            timeout_seconds: HTTP request timeout for Plane API calls
   265	            allowed_client_redirect_uris: List of allowed redirect URI patterns
   266	                for MCP clients. If None (default), all URIs are allowed.
   267	                If empty list, no URIs are allowed.
   268	            client_storage: Storage backend for OAuth state
   269	                (client registrations, encrypted tokens). If None, a DiskStore
   270	                will be created in the data directory (derived from
   271	                `platformdirs`). The disk store will be encrypted using a key
   272	                derived from the JWT Signing Key.
   273	            jwt_signing_key: Secret for signing FastMCP JWT tokens
   274	                (any string or bytes). If bytes are provided, they will be used
   275	                as is. If a string is provided, it will be derived into a
   276	                32-byte key. If not provided, the upstream client secret will be
   277	                used to derive a 32-byte key using PBKDF2.
   278	            require_authorization_consent: Whether to require user consent
   279	                before authorizing clients (default True). When True, users see
   280	                a consent screen before being redirected to Plane. When False,
   281	                authorization proceeds directly without user confirmation.
   282	                SECURITY WARNING: Only disable for local development or
   283	                testing environments.
   284	            plane_base_url: Base URL for Plane API
   285	                (defaults to https://api.plane.so or PLANE_BASE_URL env var)
   286	        """
   287	
   288	        settings = PlaneOAuthProviderSettings.model_validate(
   289	            {
   290	                k: v
   291	                for k, v in {
   292	                    "client_id": client_id,
   293	                    "client_secret": client_secret,
   294	                    "base_url": base_url,
   295	                    "issuer_url": issuer_url,
   296	                    "redirect_path": redirect_path,
   297	                    "required_scopes": required_scopes,
   298	                    "timeout_seconds": timeout_seconds,
   299	                    "allowed_client_redirect_uris": allowed_client_redirect_uris,
   300	                    "jwt_signing_key": jwt_signing_key,
   301	                    "plane_base_url": plane_base_url,
   302	                }.items()
   303	                if v is not NotSet
   304	            }
   305	        )
   306	
   307	        # Validate required settings
   308	        if not settings.client_id:
   309	            raise ValueError(
   310	                "client_id is required - set via parameter or PLANE_OAUTH_PROVIDER_CLIENT_ID"
   311	            )
   312	        if not settings.client_secret:
   313	            raise ValueError(
   314	                "client_secret is required - set via parameter or "
   315	                "PLANE_OAUTH_PROVIDER_CLIENT_SECRET"
   316	            )
   317	
   318	        # Apply defaults
   319	        timeout_seconds_final = settings.timeout_seconds or 10
   320	        required_scopes_final = settings.required_scopes or []
   321	        allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
   322	        plane_base_url_final = settings.plane_base_url or os.getenv(
   323	            "PLANE_BASE_URL", DEFAULT_PLANE_BASE_URL
   324	        )
   325	
   326	        # Create Plane token verifier
   327	        token_verifier = PlaneOAuthTokenVerifier(
   328	            required_scopes=required_scopes_final,
   329	            timeout_seconds=timeout_seconds_final,
   330	            plane_base_url=plane_base_url_final,
   331	        )
   332	
   333	        # Extract secret string from SecretStr
   334	        client_secret_str = (
   335	            settings.client_secret.get_secret_value() if settings.client_secret else ""
   336	        )
   337	
   338	        # Initialize OAuth proxy with Plane endpoints
   339	        super().__init__(
   340	            upstream_authorization_endpoint=(f"{plane_base_url_final}/auth/o/authorize-app/"),
   341	            upstream_token_endpoint=f"{plane_base_url_final}/auth/o/token/",
   342	            upstream_client_id=settings.client_id,
   343	            upstream_client_secret=client_secret_str,
   344	            token_verifier=token_verifier,
   345	            base_url=settings.base_url,
   346	            redirect_path=settings.redirect_path,
   347	            issuer_url=settings.issuer_url
   348	            or settings.base_url,  # Default to base_url if not specified
   349	            allowed_client_redirect_uris=allowed_client_redirect_uris_final,
   350	            client_storage=client_storage,
   351	            jwt_signing_key=settings.jwt_signing_key,
   352	            require_authorization_consent=require_authorization_consent,
   353	            valid_scopes=["read", "write"],
   354	        )
   355	
   356	        logger.info(
   357	            "Initialized Plane OAuth provider for client %s with scopes: %s",
   358	            settings.client_id,
   359	            required_scopes_final,
   360	        )

Missing explicit return None and no token validation against Plane API.

Two observations:

  1. The function lacks an explicit return None at the end (after line 39). While Python implicitly returns None, adding it explicitly improves clarity, matching the pattern in PlaneOAuthTokenVerifier which explicitly returns None in all error paths.

  2. More critically, PlaneHeaderAuthProvider.verify_token doesn't validate the provided token against the Plane API. It only checks for the presence of the x-workspace-slug header and constructs an AccessToken if found, accepting any arbitrary string as a token. In contrast, PlaneOAuthTokenVerifier.verify_token calls the Plane API endpoint (/api/v1/users/me/) to verify tokens. This means any string passed as a token will be accepted if the workspace header is present—a security risk unless upstream validation is guaranteed.

Add explicit return None at the end, and clarify whether the header-based API key should be validated against the Plane API for security parity with OAuth validation, or document that this pattern relies on upstream validation only.

Comment on lines +28 to +43
"""
List all modules in a project.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
params: Optional query parameters as a dictionary

Returns:
List of Module objects
"""
client, workspace_slug = get_plane_client_context()
response: PaginatedModuleResponse = client.modules.list(
workspace_slug=workspace_slug, project_id=project_id, params=params
)
return response.results
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Same docstring inconsistency as in initiatives.py.

All function docstrings in this file document workspace_slug as an argument, but it's obtained from get_plane_client_context() rather than passed as a parameter. Consider removing these misleading argument descriptions.

🤖 Prompt for AI Agents
In plane_mcp/tools/modules.py around lines 28 to 43, the function docstring
incorrectly documents workspace_slug as a parameter even though it is retrieved
internally via get_plane_client_context(); update the docstring to remove
workspace_slug from the Args section (or replace that entry with a brief note
that workspace_slug is derived from get_plane_client_context()) and ensure the
remaining Args list correctly reflects only project_id and params, keeping the
description and return value intact.

Comment on lines +24 to +60
def list_projects(
cursor: str | None = None,
per_page: int | None = None,
expand: str | None = None,
fields: str | None = None,
order_by: str | None = None,
) -> list[Project]:
"""
List all projects in a workspace.

Args:
workspace_slug: The workspace slug identifier
cursor: Pagination cursor for getting next set of results
per_page: Number of results per page (1-100)
expand: Comma-separated list of related fields to expand in response
fields: Comma-separated list of fields to include in response
order_by: Field to order results by. Prefix with '-' for descending order

Returns:
List of Project objects
"""
client, workspace_slug = get_plane_client_context()

params = PaginatedQueryParams(
cursor=cursor,
per_page=per_page,
expand=expand,
fields=fields,
order_by=order_by,
)

response: PaginatedProjectResponse = client.projects.list(
workspace_slug=workspace_slug,
params=params,
)

return response.results
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove workspace_slug from docstrings.

All nine tool functions document workspace_slug as a parameter in their docstrings, but it is not part of any function signature. Instead, workspace_slug is obtained internally via get_plane_client_context(). This documentation inconsistency could confuse MCP tool consumers.

Apply similar fixes as in work_item_properties.py: remove the workspace_slug line from the Args section of each function's docstring:

  • list_projects (line 35)
  • create_project (line 88)
  • retrieve_project (line 144)
  • update_project (line 183)
  • delete_project (line 248)
  • get_project_worklog_summary (line 260)
  • get_project_members (line 279)
  • get_project_features (line 297)
  • update_project_features (line 321)

Also applies to: 63-136, 139-151, 154-240, 243-252, 255-269, 272-289, 292-304, 307-348

🤖 Prompt for AI Agents
In plane_mcp/tools/projects.py edit the docstrings at the specified ranges
(lines ~24-60, 63-136, 139-151, 154-240, 243-252, 255-269, 272-289, 292-304,
307-348) to remove the incorrect "workspace_slug" entry from each function's
Args section (specifically remove the workspace_slug parameter line in
list_projects at ~line 35 and the same line in create_project, retrieve_project,
update_project, delete_project, get_project_worklog_summary,
get_project_members, get_project_features, update_project_features), leaving the
rest of the Arg descriptions intact; ensure docstrings reflect that
workspace_slug is obtained internally via get_plane_client_context() and run a
quick lint/check to keep formatting consistent.

Comment on lines +27 to +50
def list_work_item_properties(
project_id: str,
type_id: str,
params: dict[str, Any] | None = None,
) -> list[WorkItemProperty]:
"""
List work item properties for a work item type.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
type_id: UUID of the work item type
params: Optional query parameters as a dictionary

Returns:
List of WorkItemProperty objects
"""
client, workspace_slug = get_plane_client_context()
return client.work_item_properties.list(
workspace_slug=workspace_slug,
project_id=project_id,
type_id=type_id,
params=params,
)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove workspace_slug from docstrings.

All five tool functions document workspace_slug as a parameter in their docstrings, but it is not part of the function signature. Instead, workspace_slug is obtained internally via get_plane_client_context(). This documentation inconsistency could confuse MCP tool consumers (including LLM clients).

🔎 Apply this diff to remove workspace_slug from docstrings:

For list_work_item_properties:

         """
         List work item properties for a work item type.
 
         Args:
-            workspace_slug: The workspace slug identifier
             project_id: UUID of the project
             type_id: UUID of the work item type
             params: Optional query parameters as a dictionary

For create_work_item_property:

         """
         Create a new work item property.
 
         Args:
-            workspace_slug: The workspace slug identifier
             project_id: UUID of the project
             type_id: UUID of the work item type
             display_name: Display name for the property

For retrieve_work_item_property:

         """
         Retrieve a work item property by ID.
 
         Args:
-            workspace_slug: The workspace slug identifier
             project_id: UUID of the project
             type_id: UUID of the work item type
             work_item_property_id: UUID of the property

For update_work_item_property:

         """
         Update a work item property by ID.
 
         Args:
-            workspace_slug: The workspace slug identifier
             project_id: UUID of the project
             type_id: UUID of the work item type
             work_item_property_id: UUID of the property

For delete_work_item_property:

         """
         Delete a work item property by ID.
 
         Args:
-            workspace_slug: The workspace slug identifier
             project_id: UUID of the project
             type_id: UUID of the work item type
             work_item_property_id: UUID of the property

Also applies to: 53-129, 132-155, 158-237, 240-260

🤖 Prompt for AI Agents
In plane_mcp/tools/work_item_properties.py around lines 27 to 50 (and similarly
for the other affected ranges 53-129, 132-155, 158-237, 240-260), the docstrings
incorrectly list workspace_slug as a parameter although the function obtains
workspace_slug internally via get_plane_client_context(); remove any mention of
workspace_slug from each function's Args section and update the parameter list
to only include the actual function parameters (project_id, type_id, params,
etc.), leaving the rest of the docstring intact (description, Returns) so docs
match the signature and avoid confusion for tool consumers.

Comment on lines +203 to +214
# Convert settings dict to appropriate settings object if needed
processed_settings: PropertySettings = None
if settings and property_type:
prop_type = (
property_type.value if isinstance(property_type, PropertyType) else property_type
)
if prop_type == "TEXT" and isinstance(settings, dict):
processed_settings = TextAttributeSettings(**settings)
elif prop_type == "DATETIME" and isinstance(settings, dict):
processed_settings = DateAttributeSettings(**settings)
else:
processed_settings = settings
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Settings are silently dropped when property_type is not provided.

The condition on line 205 requires both settings and property_type to process the settings. If a user provides settings but not property_type (e.g., updating settings for an existing TEXT property without re-specifying the type), processed_settings remains None, and the user's settings are silently lost.

🔎 Apply this diff to preserve settings when property_type is not provided:
 # Convert settings dict to appropriate settings object if needed
-processed_settings: PropertySettings = None
+processed_settings: PropertySettings = settings
 if settings and property_type:
     prop_type = (
         property_type.value if isinstance(property_type, PropertyType) else property_type
     )
     if prop_type == "TEXT" and isinstance(settings, dict):
         processed_settings = TextAttributeSettings(**settings)
     elif prop_type == "DATETIME" and isinstance(settings, dict):
         processed_settings = DateAttributeSettings(**settings)
-    else:
-        processed_settings = settings
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Convert settings dict to appropriate settings object if needed
processed_settings: PropertySettings = None
if settings and property_type:
prop_type = (
property_type.value if isinstance(property_type, PropertyType) else property_type
)
if prop_type == "TEXT" and isinstance(settings, dict):
processed_settings = TextAttributeSettings(**settings)
elif prop_type == "DATETIME" and isinstance(settings, dict):
processed_settings = DateAttributeSettings(**settings)
else:
processed_settings = settings
# Convert settings dict to appropriate settings object if needed
processed_settings: PropertySettings = settings
if settings and property_type:
prop_type = (
property_type.value if isinstance(property_type, PropertyType) else property_type
)
if prop_type == "TEXT" and isinstance(settings, dict):
processed_settings = TextAttributeSettings(**settings)
elif prop_type == "DATETIME" and isinstance(settings, dict):
processed_settings = DateAttributeSettings(**settings)
🤖 Prompt for AI Agents
In plane_mcp/tools/work_item_properties.py around lines 203 to 214,
processed_settings is set to None unless both settings and property_type are
provided, which drops user-supplied settings when property_type is omitted;
change the logic so that if settings is provided and property_type is missing,
processed_settings is set to settings (preserving whatever object or dict was
passed), and if property_type is provided keep the existing conversion branches
(coerce to TextAttributeSettings or DateAttributeSettings when prop_type matches
and settings is a dict), otherwise fall back to assigning processed_settings =
settings.

Comment on lines +35 to +37
Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Docstring lists workspace_slug as an argument but it's not a parameter.

The docstring says workspace_slug: The workspace slug identifier but workspace_slug is obtained from get_plane_client_context(), not passed as an argument. This appears in multiple functions throughout this file. Remove these misleading docstring entries.

🤖 Prompt for AI Agents
In plane_mcp/tools/work_items.py around lines 35 to 37, the docstring
incorrectly lists workspace_slug as a parameter even though it is not passed to
the function (it’s derived from get_plane_client_context()); remove the
misleading workspace_slug entry from the docstring (and similarly from other
functions in this file that list workspace_slug but do not accept it) so
parameter docs only list actual function parameters and update any parameter
ordering/formatting to remain consistent.

@Prashant-Surya Prashant-Surya merged commit 754faa9 into canary Dec 29, 2025
1 check passed
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.

2 participants