Skip to content
Open
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
250 changes: 139 additions & 111 deletions plane_mcp/tools/pages.py
Original file line number Diff line number Diff line change
@@ -1,67 +1,78 @@
"""Page-related tools for Plane MCP Server."""

from typing import Any
from typing import Any, Optional

from fastmcp import FastMCP
from plane.models.pages import CreatePage, Page

from plane_mcp.client import get_plane_client_context

from plane.models.pages import CreatePage, Page


def register_page_tools(mcp: FastMCP) -> None:
"""Register all page-related tools with the MCP server."""

@mcp.tool()
def retrieve_workspace_page(
page_id: str,
) -> Page:
def list_project_pages(
project_id: str,
per_page: int = 100,
cursor: str = "",
) -> str:
"""
Retrieve a workspace page by ID.

List all pages in a project with pagination.
Args:
page_id: UUID of the page
expand: Optional comma-separated list of fields to expand
fields: Optional comma-separated list of fields to include

project_id: UUID of the project
per_page: Number of items per page (default: 100)
cursor: Pagination cursor string
Returns:
Page object
JSON string with pages list
"""
client, workspace_slug = get_plane_client_context()

return client.pages.retrieve_workspace_page(

# Use the SDK to list project pages
result = client.projects.pages.list(
workspace_slug=workspace_slug,
page_id=page_id,
project_id=project_id,
per_page=per_page,
cursor=cursor if cursor else None,
)


# Return as JSON string
return str(result.model_dump_json())

@mcp.tool()
def retrieve_project_page(
project_id: str,
page_id: str,
) -> Page:
def list_workspace_pages(
per_page: int = 100,
cursor: str = "",
) -> str:
"""
Retrieve a project page by ID.

List all workspace pages with pagination.
Args:
project_id: UUID of the project
page_id: UUID of the page
expand: Optional comma-separated list of fields to expand
fields: Optional comma-separated list of fields to include

per_page: Number of items per page (default: 100)
cursor: Pagination cursor string

Returns:
Page object
JSON string with pages list
"""
client, workspace_slug = get_plane_client_context()

return client.pages.retrieve_project_page(

# Use the SDK to list workspace pages
result = client.pages.list(
workspace_slug=workspace_slug,
project_id=project_id,
page_id=page_id,
per_page=per_page,
cursor=cursor if cursor else None,
)


# Return as JSON string
return str(result.model_dump_json())

@mcp.tool()
def create_workspace_page(
name: str,
description_html: str,
def update_page(
page_id: str,
name: str | None = None,
description_html: str | None = None,
access: int | None = None,
color: str | None = None,
is_locked: bool | None = None,
Expand All @@ -70,95 +81,112 @@ def create_workspace_page(
logo_props: dict[str, Any] | None = None,
external_id: str | None = None,
external_source: str | None = None,
) -> Page:
parent_page: str | None = None,
) -> str:
"""
Create a workspace page.

Update an existing page.
Args:
page_id: UUID of the page
name: Page name
description_html: Page content in HTML format
access: Access level for the page (integer)
access: Access level (0=private, 1=public, 2=secret)
color: Page color
is_locked: Whether the page is locked
archived_at: Archive timestamp (ISO 8601 format)
view_props: View properties dictionary
logo_props: Logo properties dictionary
external_id: External system identifier
external_source: External system source name

archived_at: Archive timestamp
view_props: View properties
logo_props: Logo properties
external_id: External ID
external_source: External source
parent_page: Parent page ID (for nested pages)

Returns:
Created Page object
Updated Page object
"""
client, workspace_slug = get_plane_client_context()

data = CreatePage(
name=name,
description_html=description_html,
access=access,
color=color,
is_locked=is_locked,
archived_at=archived_at,
view_props=view_props,
logo_props=logo_props,
external_id=external_id,
external_source=external_source,
)

return client.pages.create_workspace_page(

# Build update data with only provided fields
update_data = {}
if name is not None:
update_data["name"] = name
if description_html is not None:
update_data["description_html"] = description_html
if access is not None:
update_data["access"] = access
if color is not None:
update_data["color"] = color
if is_locked is not None:
update_data["is_locked"] = is_locked
if parent_page is not None:
update_data["parent_page"] = parent_page
if archived_at is not None:
update_data["archived_at"] = archived_at
if view_props is not None:
update_data["view_props"] = view_props
if logo_props is not None:
update_data["logo_props"] = logo_props
if external_id is not None:
update_data["external_id"] = external_id
if external_source is not None:
update_data["external_source"] = external_source

# Use SDK to update the page
result = client.pages.update(
workspace_slug=workspace_slug,
data=data,
page_id=page_id,
**update_data,
)


return str(result.model_dump_json())

@mcp.tool()
def create_project_page(
project_id: str,
name: str,
description_html: str,
access: int | None = None,
color: str | None = None,
is_locked: bool | None = None,
archived_at: str | None = None,
view_props: dict[str, Any] | None = None,
logo_props: dict[str, Any] | None = None,
external_id: str | None = None,
external_source: str | None = None,
) -> Page:
def delete_page(
page_id: str,
) -> str:
"""
Create a project page.

Delete a page.
Args:
project_id: UUID of the project
name: Page name
description_html: Page content in HTML format
access: Access level for the page (integer)
color: Page color
is_locked: Whether the page is locked
archived_at: Archive timestamp (ISO 8601 format)
view_props: View properties dictionary
logo_props: Logo properties dictionary
external_id: External system identifier
external_source: External system source name

page_id: UUID of the page

Returns:
Created Page object
Success message
"""
client, workspace_slug = get_plane_client_context()

data = CreatePage(
name=name,
description_html=description_html,
access=access,
color=color,
is_locked=is_locked,
archived_at=archived_at,
view_props=view_props,
logo_props=logo_props,
external_id=external_id,
external_source=external_source,
)

return client.pages.create_project_page(

result = client.pages.delete(
workspace_slug=workspace_slug,
project_id=project_id,
data=data,
page_id=page_id,
)

return str(result.model_dump_json())

@mcp.tool()
def search_pages(
query: str,
workspace_level: bool = False,
) -> str:
"""
Search for pages by query string.

Args:
query: Search query
workspace_level: Search workspace pages (True) or project pages (False)

Returns:
JSON string with search results
"""
client, workspace_slug = get_plane_client_context()

# Determine search endpoint based on level
if workspace_level:
result = client.pages.list(
workspace_slug=workspace_slug,
query=query,
)
else:
# For project-level, we'd need project_id
# This is a limitation - let's return error for now
return '{"error": "Project page search requires project_id. Use workspace_level=True for workspace-wide search."}'

Comment on lines +164 to +191
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

Incomplete implementation: project-level search defaults to error.

The workspace_level parameter defaults to False, but the project-level search path (lines 187-190) returns a hardcoded error because project_id is not accepted as a parameter. This is confusing UX since the default behavior returns an error.

Consider one of these approaches:

  1. Add project_id: str | None = None parameter to support project-level search
  2. Change the default to workspace_level: bool = True so the working path is the default
  3. Remove the workspace_level parameter entirely if project-level search isn't planned
♻️ Option 1: Add project_id parameter
     `@mcp.tool`()
     def search_pages(
         query: str,
+        project_id: str | None = None,
         workspace_level: bool = False,
     ) -> str:
         """
         Search for pages by query string.
         
         Args:
             query: Search query
+            project_id: UUID of the project (required when workspace_level=False)
             workspace_level: Search workspace pages (True) or project pages (False)
         
         Returns:
             JSON string with search results
         """
         client, workspace_slug = get_plane_client_context()
         
         # Determine search endpoint based on level
         if workspace_level:
             result = client.pages.list(
                 workspace_slug=workspace_slug,
                 query=query,
             )
+        elif project_id:
+            result = client.projects.pages.list(
+                workspace_slug=workspace_slug,
+                project_id=project_id,
+                query=query,
+            )
         else:
-            # For project-level, we'd need project_id
-            # This is a limitation - let's return error for now
             return '{"error": "Project page search requires project_id. Use workspace_level=True for workspace-wide search."}'
         
         return result.model_dump_json()
♻️ Option 2: Default to workspace-level search
     def search_pages(
         query: str,
-        workspace_level: bool = False,
+        workspace_level: bool = True,
     ) -> str:
📝 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
@mcp.tool()
def search_pages(
query: str,
workspace_level: bool = False,
) -> str:
"""
Search for pages by query string.
Args:
query: Search query
workspace_level: Search workspace pages (True) or project pages (False)
Returns:
JSON string with search results
"""
client, workspace_slug = get_plane_client_context()
# Determine search endpoint based on level
if workspace_level:
result = client.pages.list(
workspace_slug=workspace_slug,
query=query,
)
else:
# For project-level, we'd need project_id
# This is a limitation - let's return error for now
return '{"error": "Project page search requires project_id. Use workspace_level=True for workspace-wide search."}'
`@mcp.tool`()
def search_pages(
query: str,
workspace_level: bool = True,
) -> str:
"""
Search for pages by query string.
Args:
query: Search query
workspace_level: Search workspace pages (True) or project pages (False)
Returns:
JSON string with search results
"""
client, workspace_slug = get_plane_client_context()
# Determine search endpoint based on level
if workspace_level:
result = client.pages.list(
workspace_slug=workspace_slug,
query=query,
)
else:
# For project-level, we'd need project_id
# This is a limitation - let's return error for now
return '{"error": "Project page search requires project_id. Use workspace_level=True for workspace-wide search."}'
🤖 Prompt for AI Agents
In `@plane_mcp/tools/pages.py` around lines 164 - 191, The search_pages function
currently defaults to project-level (workspace_level=False) but returns a
hardcoded error because it lacks project_id; update the signature of
search_pages to accept project_id: str | None = None and adjust logic in
search_pages (and any calls to get_plane_client_context if needed) to: if
workspace_level is True call client.pages.list with workspace_slug and query;
else if project_id is provided call client.pages.list with project_id and query;
else return a clear error prompting the caller to supply project_id. Ensure
references to the function name search_pages and the parameter workspace_level
are updated where used and validate/handle project_id None appropriately before
calling client.pages.list.

return str(result.model_dump_json())