Repository: sooperset/mcp-atlassian

Issue:
[Bug]: parent_id parameter in Confluence create_page and update_page tools not functioning as expected
### Prerequisites

- [x] I have searched the [existing issues](https://github.com/sooperset/mcp-atlassian/issues) to make sure this bug has not already been reported.
- [x] I have checked the [README](https://github.com/sooperset/mcp-atlassian/blob/main/README.md) for relevant information.

### Bug Description

**1. Goal:**
To create and update Confluence pages with a specified parent page, thereby establishing a hierarchical page structure using the `parent_id` parameter.

**2. Tools and Parameters Used:**
*   `mcp_mcp-atlassian_confluence_create_page` with the `parent_id` parameter.
*   `mcp_mcp-atlassian_confluence_update_page` with the `parent_id` parameter.

**3. Expected Behavior:**
The `parent_id` parameter in both `mcp_mcp-atlassian_confluence_create_page` and `mcp_mcp-atlassian_confluence_update_page` tools is defined to accept a string (page ID) or `None` (i.e., `parent_id: str | None`).
The unit tests within the `sooperset/mcp-atlassian` GitHub repository (specifically in `tests/unit/confluence/test_pages.py`, for example, `test_create_page_success` and `test_update_page_with_parent_id`) demonstrate that the underlying Python methods are designed and tested to correctly process a string `parent_id` to link to a parent page.
Therefore, providing a valid string page ID to the `parent_id` parameter of these tools should result in the page being created or updated under the specified parent.

**4. Actual Behavior:**
When a string value is provided for the `parent_id` parameter to either `mcp_mcp-atlassian_confluence_create_page` or `mcp_mcp-atlassian_confluence_update_page`, the tools consistently fail with the error:
`Error calling tool: Parameter 'parent_id' must be of type undefined, got string`

This occurred with valid parent page IDs that were confirmed to exist and could act as parents. For example, attempting to create a page with `parent_id = "2745369565"` (a valid page ID) resulted in this error.

**5. Supporting Observations:**
*   The `mcp_mcp-atlassian_confluence_get_page_children` tool *successfully* retrieves child pages when provided with the same string `parent_id`. This indicates that the page IDs are valid and the Confluence instance correctly supports page hierarchies.
*   Manually creating a child page in the Confluence UI under the target parent page works as expected, and its children can be retrieved using `get_page_children`.

**6. Suspected Cause:**
The issue does not seem to be with the fundamental capability of Confluence or the validity of the page IDs. Given that the underlying Python code in `mcp-atlassian` is tested to handle string `parent_id`s, the problem likely lies at a higher level:
*   **Tool Schema/Definition Mismatch:** The schema definition for these tools (as exposed to the AI assistant) might incorrectly specify that `parent_id` must be `undefined` when a value is expected, conflicting with the underlying Python implementation.
*   **Request Validation Layer:** There might be a validation layer for tool calls that incorrectly rejects a string `parent_id` before the request reaches the core Python logic of the `mcp-atlassian` server.
*   **Discrepancy:** There might be a discrepancy between the tested version of the code (e.g., on GitHub) and the version deployed in the environment handling the tool calls.

**Recommendation:**
Review the schema definition and request validation logic for the `parent_id` parameter within the `mcp_mcp-atlassian_confluence_create_page` and `mcp_mcp-atlassian_confluence_update_page` tools to ensure they correctly align with the underlying Python implementation's capability to accept string page IDs.


### Steps to Reproduce

see above

### Expected Behavior

see above

### Actual Behavior

```shell
see above
```

### mcp-atlassian Version

latest

### Installation Method

Docker

### Operating System

macOS

### Python Version

3.11

### Atlassian Instance Type

Confluence Cloud

### Client Application

Cursor

### Additional Context

_No response_


Discussion:
Hi @riteshganatra,

Thank you for this detailed bug report! I've tested this in my environment and was able to successfully create and update pages using string values for `parent_id`.

**Test Success Screenshot:**
![Image](https://github.com/user-attachments/assets/536da5a0-cbcf-4c4c-a897-175690925200)

This suggests that the core `mcp-atlassian` server logic can handle string `parent_id` values, but the error `Parameter 'parent_id' must be of type undefined, got string` might be coming from elsewhere in the stack.

To help diagnose this issue, could you please provide:

1. **Environment details:**
   * Exact version of `mcp-atlassian` (image ID or digest if possible)
   * The complete `docker run` command you're using (excluding sensitive tokens)
   * Contents of any `.env` file if you're using `--env-file`

2. **Client information (Cursor):**
   * Cursor version
   * JSON snippet showing how you've configured the MCP server in Cursor

3. **Request/response data:**
   * The exact request payload sent to the MCP server when the error occurs
   * Full MCP server logs (ideally with debug logging enabled via `-vv` flag or `MCP_VERY_VERBOSE=true`)
   * The schema definition for `confluence_create_page` and `confluence_update_page` tools from the `mcp_list_tools` request

This information will help us pinpoint where the validation is failing and why your string `parent_id` is being rejected.

Thanks for your help!
1.  **Request payload sent to the MCP server (from my perspective):**
    When I attempt to call the tool, I construct a function call with arguments. Here's an example of a failed call to `mcp_mcp-atlassian_confluence_create_page` we made earlier:
    ```json
    {
      "tool_name": "mcp_mcp-atlassian_confluence_create_page",
      "parameters": {
        "content": "# Final Test Child",
        "parent_id": "2745369565", // This is the string value
        "space_key": "AM",
        "title": "Final Test Child for Parent ID"
      }
    }
    ```
    This is the logical request I initiated. The actual raw HTTP request sent by the Cursor environment to your MCP server might have additional MCP protocol wrapping, but the core parameters I sent are above. The key is that `parent_id` was indeed a string.

<img width="1160" alt="Image" src="https://github.com/user-attachments/assets/2c258753-07df-493e-808c-a34fa45bbe55" />

```json
"mcp-atlassian": {
      "command": "docker",
      "args": [
        "run",
        "-i",
        "--rm",
        "-e", "CONFLUENCE_URL",
        "-e", "CONFLUENCE_USERNAME",
        "-e", "CONFLUENCE_API_TOKEN",
        "-e", "JIRA_URL",
        "-e", "JIRA_USERNAME",
        "-e", "JIRA_API_TOKEN",
        "ghcr.io/sooperset/mcp-atlassian:latest"
      ],
      "env": {
        "CONFLUENCE_URL": "https://my.atlassian.net/wiki",
        "CONFLUENCE_USERNAME": "my email",
        "CONFLUENCE_API_TOKEN": "aa",
        "JIRA_URL": "https://my.atlassian.net",
        "JIRA_USERNAME": "my email",
        "JIRA_API_TOKEN": "aa
      }
    }
```


no env file.

please help urgently
Hi @riteshganatra,

Thank you for your detailed bug report and providing the additional information we requested.

After thorough investigation, I've determined this is likely a client-side issue specific to Cursor IDE. The same functionality works correctly when tested with Claude desktop and inspector environments, and our review of the underlying MCP and FastMCP codebase confirms the implementation is correct.

Since this appears to be an integration issue with Cursor IDE rather than a problem with our mcp-atlassian package, I'm going to close this issue. I recommend reaching out to the Cursor team about how their environment processes the parameter in tool calls.

If you discover any new information suggesting this is actually an issue with our implementation, please feel free to reopen this ticket or create a new one with those details.

Thanks for your understanding!
relevant issues: 
- https://forum.cursor.com/t/mcp-server-tool-calls-fail-with-invalid-type-for-parameter-in-tool/70831
- https://github.com/jlowin/fastmcp/issues/224
- https://github.com/getcursor/cursor/issues/2932
let's keep opening this issue to track
Hi @riteshganatra, I did the quick fix for Cursor in #407. Now this will be fine after releasing 0.10.5. Thanks for reporting!



Patch:
diff --git a/src/mcp_atlassian/servers/confluence.py b/src/mcp_atlassian/servers/confluence.py
index f75c4c3b..934b2628 100644
--- a/src/mcp_atlassian/servers/confluence.py
+++ b/src/mcp_atlassian/servers/confluence.py
@@ -7,6 +7,7 @@
 from fastmcp import Context, FastMCP
 from pydantic import Field
 
+from ..utils import convert_empty_defaults_to_none
 from .context import MainAppContext
 
 logger = logging.getLogger(__name__)
@@ -17,6 +18,7 @@
 )
 
 
+@convert_empty_defaults_to_none
 @confluence_mcp.tool(tags={"confluence", "read"})
 async def search(
     ctx: Context[Any, MainAppContext],
@@ -55,7 +57,7 @@ async def search(
         ),
     ] = 10,
     spaces_filter: Annotated[
-        str,  # TODO: Revert type hint to once Cursor IDE handles optional parameters with Union types correctly.
+        str,
         Field(
             description=(
                 "(Optional) Comma-separated list of space keys to filter results by. "
@@ -81,9 +83,6 @@ async def search(
         raise ValueError("Confluence client is not configured or available.")
     confluence = lifespan_ctx.confluence
 
-    # TODO: revert this once Cursor IDE handles optional parameters with Union types correctly.
-    actual_spaces_filter = spaces_filter if spaces_filter else None
-
     # Check if the query is a simple search term or already a CQL query
     if query and not any(
         x in query for x in ["=", "~", ">", "<", " AND ", " OR ", "currentUser()"]
@@ -94,20 +93,14 @@ async def search(
             logger.info(
                 f"Converting simple search term to CQL using siteSearch: {query}"
             )
-            pages = confluence.search(
-                query, limit=limit, spaces_filter=actual_spaces_filter
-            )
+            pages = confluence.search(query, limit=limit, spaces_filter=spaces_filter)
         except Exception as e:
             logger.warning(f"siteSearch failed ('{e}'), falling back to text search.")
             query = f'text ~ "{original_query}"'
             logger.info(f"Falling back to text search with CQL: {query}")
-            pages = confluence.search(
-                query, limit=limit, spaces_filter=actual_spaces_filter
-            )
+            pages = confluence.search(query, limit=limit, spaces_filter=spaces_filter)
     else:
-        pages = confluence.search(
-            query, limit=limit, spaces_filter=actual_spaces_filter
-        )
+        pages = confluence.search(query, limit=limit, spaces_filter=spaces_filter)
 
     search_results = [page.to_simplified_dict() for page in pages]
     return json.dumps(search_results, indent=2, ensure_ascii=False)
@@ -361,6 +354,7 @@ async def add_label(
     return json.dumps(formatted_labels, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @confluence_mcp.tool(tags={"confluence", "write"})
 async def create_page(
     ctx: Context[Any, MainAppContext],
@@ -378,9 +372,9 @@ async def create_page(
         ),
     ],
     parent_id: Annotated[
-        str,  # TODO: Revert type hint to once Cursor IDE handles optional parameters with Union types correctly.
+        str,
         Field(
-            description="Optional parent page ID. If provided, this page will be created as a child of the specified page",
+            description="(Optional) parent page ID. If provided, this page will be created as a child of the specified page",
             default="",
         ),
     ] = "",
@@ -408,14 +402,11 @@ async def create_page(
         raise ValueError("Confluence client is not configured or available.")
     confluence = lifespan_ctx.confluence
 
-    # TODO: revert this once Cursor IDE handles optional parameters with Union types correctly.
-    actual_parent_id = parent_id if parent_id else None
-
     page = confluence.create_page(
         space_key=space_key,
         title=title,
         body=content,
-        parent_id=actual_parent_id,
+        parent_id=parent_id,
         is_markdown=True,
     )
     result = page.to_simplified_dict()
diff --git a/src/mcp_atlassian/servers/jira.py b/src/mcp_atlassian/servers/jira.py
index a822e373..2ef220a0 100644
--- a/src/mcp_atlassian/servers/jira.py
+++ b/src/mcp_atlassian/servers/jira.py
@@ -11,6 +11,7 @@
 from mcp_atlassian.exceptions import MCPAtlassianAuthenticationError
 from mcp_atlassian.jira.constants import DEFAULT_READ_JIRA_FIELDS
 from mcp_atlassian.models.jira.common import JiraUser
+from mcp_atlassian.utils import convert_empty_defaults_to_none
 
 from .context import MainAppContext
 
@@ -87,15 +88,16 @@ async def get_user_profile(
     return json.dumps(response_data, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "read"})
 async def get_issue(
     ctx: Context[Any, MainAppContext],
     issue_key: Annotated[str, Field(description="Jira issue key (e.g., 'PROJ-123')")],
     fields: Annotated[
-        str | None,
+        str,
         Field(
             description=(
-                "Comma-separated list of fields to return (e.g., 'summary,status,customfield_10010'). "
+                "(Optional) Comma-separated list of fields to return (e.g., 'summary,status,customfield_10010'). "
                 "You may also provide a single field as a string (e.g., 'duedate'). "
                 "Use '*all' for all fields (including custom fields), or omit for essential fields only."
             ),
@@ -103,15 +105,15 @@ async def get_issue(
         ),
     ] = ",".join(DEFAULT_READ_JIRA_FIELDS),
     expand: Annotated[
-        str | None,
+        str,
         Field(
             description=(
-                "Optional fields to expand. Examples: 'renderedFields' (for rendered content), "
+                "(Optional) Fields to expand. Examples: 'renderedFields' (for rendered content), "
                 "'transitions' (for available status transitions), 'changelog' (for history)"
             ),
-            default=None,
+            default="",
         ),
-    ] = None,
+    ] = "",
     comment_limit: Annotated[
         int,
         Field(
@@ -122,12 +124,12 @@ async def get_issue(
         ),
     ] = 10,
     properties: Annotated[
-        str | None,
+        str,
         Field(
-            description="A comma-separated list of issue properties to return",
-            default=None,
+            description="(Optional) A comma-separated list of issue properties to return",
+            default="",
         ),
-    ] = None,
+    ] = "",
     update_history: Annotated[
         bool,
         Field(
@@ -174,6 +176,7 @@ async def get_issue(
     return json.dumps(result, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "read"})
 async def search(
     ctx: Context[Any, MainAppContext],
@@ -193,10 +196,10 @@ async def search(
         ),
     ],
     fields: Annotated[
-        str | None,
+        str,
         Field(
             description=(
-                "Comma-separated fields to return in the results. "
+                "(Optional) Comma-separated fields to return in the results. "
                 "Use '*all' for all fields, or specify individual fields like 'summary,status,assignee,priority'"
             ),
             default=",".join(DEFAULT_READ_JIRA_FIELDS),
@@ -211,23 +214,24 @@ async def search(
         Field(description="Starting index for pagination (0-based)", default=0, ge=0),
     ] = 0,
     projects_filter: Annotated[
-        str | None,
+        str,
         Field(
             description=(
-                "Comma-separated list of project keys to filter results by. "
+                "(Optional) Comma-separated list of project keys to filter results by. "
                 "Overrides the environment variable JIRA_PROJECTS_FILTER if provided."
             ),
+            default="",
         ),
-    ] = None,
+    ] = "",
     expand: Annotated[
-        str | None,
+        str,
         Field(
             description=(
-                "Optional fields to expand. Examples: 'renderedFields', 'transitions', 'changelog'"
+                "(Optional) fields to expand. Examples: 'renderedFields', 'transitions', 'changelog'"
             ),
-            default=None,
+            default="",
         ),
-    ] = None,
+    ] = "",
 ) -> str:
     """Search Jira issues using JQL (Jira Query Language).
 
@@ -413,19 +417,22 @@ async def download_attachments(
     return json.dumps(result, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "read"})
 async def get_agile_boards(
     ctx: Context[Any, MainAppContext],
     board_name: Annotated[
-        str | None, Field(description="The name of board, support fuzzy search")
-    ] = None,
+        str, Field(description="(Optional) The name of board, support fuzzy search")
+    ] = "",
     project_key: Annotated[
-        str | None, Field(description="Jira project key (e.g., 'PROJ-123')")
-    ] = None,
+        str, Field(description="(Optional) Jira project key (e.g., 'PROJ-123')")
+    ] = "",
     board_type: Annotated[
-        str | None,
-        Field(description="The type of jira board (e.g., 'scrum', 'kanban')"),
-    ] = None,
+        str,
+        Field(
+            description="(Optional) The type of jira board (e.g., 'scrum', 'kanban')"
+        ),
+    ] = "",
     start_at: Annotated[
         int,
         Field(description="Starting index for pagination (0-based)", default=0, ge=0),
@@ -464,6 +471,7 @@ async def get_agile_boards(
     return json.dumps(result, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "read"})
 async def get_board_issues(
     ctx: Context[Any, MainAppContext],
@@ -484,7 +492,7 @@ async def get_board_issues(
         ),
     ],
     fields: Annotated[
-        str | None,
+        str,
         Field(
             description=(
                 "Comma-separated fields to return in the results. "
@@ -503,7 +511,7 @@ async def get_board_issues(
         Field(description="Maximum number of results (1-50)", default=10, ge=1, le=50),
     ] = 10,
     expand: Annotated[
-        str | None,
+        str,
         Field(
             description="Optional fields to expand in the response (e.g., 'changelog').",
             default="version",
@@ -545,14 +553,15 @@ async def get_board_issues(
     return json.dumps(result, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "read"})
 async def get_sprints_from_board(
     ctx: Context[Any, MainAppContext],
     board_id: Annotated[str, Field(description="The id of board (e.g., '1000')")],
     state: Annotated[
-        str | None,
+        str,
         Field(description="Sprint state (e.g., 'active', 'future', 'closed')"),
-    ] = None,
+    ] = "",
     start_at: Annotated[
         int,
         Field(description="Starting index for pagination (0-based)", default=0, ge=0),
@@ -586,12 +595,13 @@ async def get_sprints_from_board(
     return json.dumps(result, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "read"})
 async def get_sprint_issues(
     ctx: Context[Any, MainAppContext],
     sprint_id: Annotated[str, Field(description="The id of sprint (e.g., '10001')")],
     fields: Annotated[
-        str | None,
+        str,
         Field(
             description=(
                 "Comma-separated fields to return in the results. "
@@ -658,6 +668,7 @@ async def get_link_types(ctx: Context[Any, MainAppContext]) -> str:
     return json.dumps(formatted_link_types, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "write"})
 async def create_issue(
     ctx: Context[Any, MainAppContext],
@@ -683,36 +694,36 @@ async def create_issue(
         ),
     ],
     assignee: Annotated[
-        str | None,
+        str,
         Field(
-            description="Assignee's user identifier (string): Email, display name, or account ID (e.g., 'user@example.com', 'John Doe', 'accountid:...')",
-            default=None,
+            description="(Optional) Assignee's user identifier (string): Email, display name, or account ID (e.g., 'user@example.com', 'John Doe', 'accountid:...')",
+            default="",
         ),
-    ] = None,
+    ] = "",
     description: Annotated[
         str, Field(description="Issue description", default="")
     ] = "",
     components: Annotated[
-        str | None,
+        str,
         Field(
-            description="Comma-separated list of component names to assign (e.g., 'Frontend,API')",
-            default=None,
+            description="(Optional) Comma-separated list of component names to assign (e.g., 'Frontend,API')",
+            default="",
         ),
-    ] = None,
+    ] = "",
     additional_fields: Annotated[
-        dict[str, Any] | None,
+        dict[str, Any],
         Field(
             description=(
-                "Optional dictionary of additional fields to set. Examples:\n"
+                "(Optional) Dictionary of additional fields to set. Examples:\n"
                 "- Set priority: {'priority': {'name': 'High'}}\n"
                 "- Add labels: {'labels': ['frontend', 'urgent']}\n"
                 "- Link to parent (for any issue type): {'parent': 'PROJ-123'}\n"
                 "- Set Fix Version/s: {'fixVersions': [{'id': '10020'}]}\n"
                 "- Custom fields: {'customfield_10010': 'value'}"
             ),
-            default=None,
+            default_factory=dict,
         ),
-    ] = None,
+    ] = {},  # noqa: B006
 ) -> str:
     """Create a new Jira issue with optional Epic link or parent for subtasks.
 
@@ -844,6 +855,7 @@ async def batch_create_issues(
     return json.dumps(result, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "read"})
 async def batch_get_changelogs(
     ctx: Context[Any, MainAppContext],
@@ -854,12 +866,12 @@ async def batch_get_changelogs(
         ),
     ],
     fields: Annotated[
-        list[str] | None,
+        list[str],
         Field(
-            description="Filter the changelogs by fields, e.g. ['status', 'assignee']. Default to [] for all fields.",
-            default=None,
+            description="(Optional) Filter the changelogs by fields, e.g. ['status', 'assignee']. Default to [] for all fields.",
+            default_factory=list,
         ),
-    ] = None,
+    ] = [],  # noqa: B006
     limit: Annotated[
         int,
         Field(
@@ -920,6 +932,7 @@ async def batch_get_changelogs(
     return json.dumps(results, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "write"})
 async def update_issue(
     ctx: Context[Any, MainAppContext],
@@ -934,22 +947,22 @@ async def update_issue(
         ),
     ],
     additional_fields: Annotated[
-        dict[str, Any] | None,
+        dict[str, Any],
         Field(
-            description="Optional dictionary of additional fields to update. Use this for custom fields or more complex updates.",
-            default=None,
+            description="(Optional) Dictionary of additional fields to update. Use this for custom fields or more complex updates.",
+            default_factory=dict,
         ),
-    ] = None,
+    ] = {},  # noqa: B006
     attachments: Annotated[
-        str | None,
+        str,
         Field(
             description=(
-                "Optional JSON string array or comma-separated list of file paths to attach to the issue. "
+                "(Optional) JSON string array or comma-separated list of file paths to attach to the issue. "
                 "Example: '/path/to/file1.txt,/path/to/file2.txt' or ['/path/to/file1.txt','/path/to/file2.txt']"
             ),
-            default=None,
+            default="",
         ),
-    ] = None,
+    ] = "",
 ) -> str:
     """Update an existing Jira issue including changing status, adding Epic links, updating fields, etc.
 
@@ -1090,6 +1103,7 @@ async def add_comment(
     return json.dumps(result, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "write"})
 async def add_worklog(
     ctx: Context[Any, MainAppContext],
@@ -1104,25 +1118,25 @@ async def add_worklog(
         ),
     ],
     comment: Annotated[
-        str | None,
-        Field(description="Optional comment for the worklog in Markdown format"),
-    ] = None,
+        str,
+        Field(description="(Optional) Comment for the worklog in Markdown format"),
+    ] = "",
     started: Annotated[
-        str | None,
+        str,
         Field(
             description=(
-                "Optional start time in ISO format. If not provided, the current time will be used. "
+                "(Optional) Start time in ISO format. If not provided, the current time will be used. "
                 "Example: '2023-08-01T12:00:00.000+0000'"
             )
         ),
-    ] = None,
+    ] = "",
     # Add original_estimate and remaining_estimate as per original tool
     original_estimate: Annotated[
-        str | None, Field(description="Optional new value for the original estimate")
-    ] = None,
+        str, Field(description="(Optional) New value for the original estimate")
+    ] = "",
     remaining_estimate: Annotated[
-        str | None, Field(description="Optional new value for the remaining estimate")
-    ] = None,
+        str, Field(description="(Optional) New value for the remaining estimate")
+    ] = "",
 ) -> str:
     """Add a worklog entry to a Jira issue.
 
@@ -1202,6 +1216,7 @@ async def link_to_epic(
     return json.dumps(result, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "write"})
 async def create_issue_link(
     ctx: Context[Any, MainAppContext],
@@ -1218,15 +1233,15 @@ async def create_issue_link(
         str, Field(description="The key of the outward issue (e.g., 'PROJ-456')")
     ],
     comment: Annotated[
-        str | None, Field(description="Optional comment to add to the link")
-    ] = None,
+        str, Field(description="(Optional) Comment to add to the link")
+    ] = "",
     comment_visibility: Annotated[
-        dict[str, str] | None,
+        dict[str, str],
         Field(
-            description="Optional visibility settings for the comment (e.g., {'type': 'group', 'value': 'jira-users'})",
-            default=None,
+            description="(Optional) Visibility settings for the comment (e.g., {'type': 'group', 'value': 'jira-users'})",
+            default_factory=dict,
         ),
-    ] = None,
+    ] = {},  # noqa: B006
 ) -> str:
     """Create a link between two Jira issues.
 
@@ -1308,6 +1323,7 @@ async def remove_issue_link(
     return json.dumps(result, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "write"})
 async def transition_issue(
     ctx: Context[Any, MainAppContext],
@@ -1322,25 +1338,25 @@ async def transition_issue(
         ),
     ],
     fields: Annotated[
-        dict[str, Any] | None,
+        dict[str, Any],
         Field(
             description=(
-                "Optional dictionary of fields to update during the transition. "
+                "(Optional) Dictionary of fields to update during the transition. "
                 "Some transitions require specific fields to be set (e.g., resolution). "
                 "Example: {'resolution': {'name': 'Fixed'}}"
             ),
-            default=None,
+            default_factory=dict,
         ),
-    ] = None,
+    ] = {},  # noqa: B006
     comment: Annotated[
-        str | None,
+        str,
         Field(
             description=(
-                "Comment to add during the transition (optional). "
+                "(Optional) Comment to add during the transition. "
                 "This will be visible in the issue history."
             ),
         ),
-    ] = None,
+    ] = "",
 ) -> str:
     """Transition a Jira issue to a new status.
 
@@ -1387,6 +1403,7 @@ async def transition_issue(
     return json.dumps(result, indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "write"})
 async def create_sprint(
     ctx: Context[Any, MainAppContext],
@@ -1400,7 +1417,7 @@ async def create_sprint(
     end_date: Annotated[
         str, Field(description="End time for sprint (ISO 8601 format)")
     ],
-    goal: Annotated[str | None, Field(description="Goal of the sprint")] = None,
+    goal: Annotated[str, Field(description="(Optional) Goal of the sprint")] = "",
 ) -> str:
     """Create Jira sprint for a board.
 
@@ -1436,26 +1453,25 @@ async def create_sprint(
     return json.dumps(sprint.to_simplified_dict(), indent=2, ensure_ascii=False)
 
 
+@convert_empty_defaults_to_none
 @jira_mcp.tool(tags={"jira", "write"})
 async def update_sprint(
     ctx: Context[Any, MainAppContext],
     sprint_id: Annotated[str, Field(description="The id of sprint (e.g., '10001')")],
     sprint_name: Annotated[
-        str | None, Field(description="Optional: New name for the sprint")
-    ] = None,
+        str, Field(description="(Optional) New name for the sprint")
+    ] = "",
     state: Annotated[
-        str | None,
-        Field(description="Optional: New state for the sprint (future|active|closed)"),
-    ] = None,
+        str,
+        Field(description="(Optional) New state for the sprint (future|active|closed)"),
+    ] = "",
     start_date: Annotated[
-        str | None, Field(description="Optional: New start date for the sprint")
-    ] = None,
+        str, Field(description="(Optional) New start date for the sprint")
+    ] = "",
     end_date: Annotated[
-        str | None, Field(description="Optional: New end date for the sprint")
-    ] = None,
-    goal: Annotated[
-        str | None, Field(description="Optional: New goal for the sprint")
-    ] = None,
+        str, Field(description="(Optional) New end date for the sprint")
+    ] = "",
+    goal: Annotated[str, Field(description="(Optional) New goal for the sprint")] = "",
 ) -> str:
     """Update jira sprint.
 
diff --git a/src/mcp_atlassian/utils/__init__.py b/src/mcp_atlassian/utils/__init__.py
index 855a09d1..aba995f4 100644
--- a/src/mcp_atlassian/utils/__init__.py
+++ b/src/mcp_atlassian/utils/__init__.py
@@ -4,6 +4,7 @@
 """
 
 from .date import parse_date
+from .decorators import convert_empty_defaults_to_none
 from .io import is_read_only_mode
 from .logging import setup_logging
 
@@ -23,4 +24,5 @@
     "parse_iso8601_date",
     "OAuthConfig",
     "configure_oauth_session",
+    "convert_empty_defaults_to_none",
 ]
diff --git a/src/mcp_atlassian/utils/decorators.py b/src/mcp_atlassian/utils/decorators.py
new file mode 100644
index 00000000..ece6f21d
--- /dev/null
+++ b/src/mcp_atlassian/utils/decorators.py
@@ -0,0 +1,107 @@
+import inspect
+from collections.abc import Awaitable, Callable
+from functools import wraps
+from typing import Any
+
+
+# TODO: [CursorIDE Compatibility] Remove this decorator and revert parameter signatures
+# in tool definitions (str -> str | None, default="" -> default=None, etc.)
+# once Cursor IDE properly handle optional parameters with Union types
+# and None defaults without sending them as empty strings/dicts.
+# Refs: https://github.com/jlowin/fastmcp/issues/224
+def convert_empty_defaults_to_none(func: Callable) -> Callable:
+    """
+    Decorator to convert empty string, dict, or list default values to None for function parameters.
+
+    This is a workaround for environments (like some IDEs) that send empty strings, dicts, or lists
+    instead of None for optional parameters. It ensures that downstream logic receives None
+    instead of empty values when appropriate.
+
+    Args:
+        func: The function to wrap.
+
+    Returns:
+        The wrapped function with empty defaults converted to None.
+    """
+    sig = inspect.signature(func)
+
+    @wraps(func)
+    async def wrapper(*args: Any, **kwargs: Any) -> Awaitable[Any]:
+        bound_args = sig.bind_partial(*args, **kwargs)
+        bound_args.apply_defaults()
+
+        processed_kwargs = {}
+        # Gather all arguments, including positional ones, as kwargs
+        all_passed_args = bound_args.arguments.copy()
+
+        for name, param_obj in sig.parameters.items():
+            actual_value = all_passed_args.get(
+                name
+            )  # The actual value passed (after applying defaults)
+
+            # String handling: If param.default is "" and the actual value passed is also "", convert to None
+            if (
+                param_obj.annotation is str
+                and param_obj.default == ""
+                and actual_value == ""
+            ):
+                processed_kwargs[name] = None
+            # Dictionary handling:
+            #   - Type hint is dict (or Dict, Dict[str, Any], etc.)
+            #   - The function definition's default is an empty dict {}
+            #   - If Pydantic Field's default_factory is dict (hard to detect directly with inspect)
+            #   - And the actual value passed is an empty dict {}, convert to None
+            elif (
+                (
+                    isinstance(param_obj.annotation, type)
+                    and issubclass(param_obj.annotation, dict)
+                )
+                or (
+                    hasattr(param_obj.annotation, "__origin__")
+                    and param_obj.annotation.__origin__ in (dict, dict)
+                )
+                and param_obj.default == inspect.Parameter.empty
+                and isinstance(actual_value, dict)
+                and not actual_value
+            ):  # If the actual value is an empty dict
+                # When using Pydantic Field(default_factory=dict),
+                # the function signature's param.default is inspect.Parameter.empty.
+                # Therefore, it's important to check if the actual value passed is an empty dict.
+                processed_kwargs[name] = None
+            elif (
+                isinstance(param_obj.default, dict)
+                and not param_obj.default
+                and isinstance(actual_value, dict)
+                and not actual_value
+            ):  # If default={} is specified in the function signature
+                processed_kwargs[name] = None
+            # List handling (Pydantic Field(default_factory=list) or default=[]):
+            elif (
+                (
+                    (
+                        isinstance(param_obj.annotation, type)
+                        and issubclass(param_obj.annotation, list)
+                    )
+                    or (
+                        hasattr(param_obj.annotation, "__origin__")
+                        and param_obj.annotation.__origin__ in (list, list)
+                    )
+                )
+                and param_obj.default == inspect.Parameter.empty
+                and isinstance(actual_value, list)
+                and not actual_value
+            ):
+                processed_kwargs[name] = None
+            elif (
+                isinstance(param_obj.default, list)
+                and not param_obj.default
+                and isinstance(actual_value, list)
+                and not actual_value
+            ):
+                processed_kwargs[name] = None
+            else:
+                processed_kwargs[name] = actual_value
+
+        return await func(**processed_kwargs)
+
+    return wrapper


Test patch:
diff --git a/tests/unit/servers/test_confluence_server.py b/tests/unit/servers/test_confluence_server.py
index 23907690..ece4d386 100644
--- a/tests/unit/servers/test_confluence_server.py
+++ b/tests/unit/servers/test_confluence_server.py
@@ -387,7 +387,7 @@ async def test_create_page(client, mock_confluence_fetcher):
         space_key="TEST",
         title="New Test Page",
         body="# New Page\nContent here.",
-        parent_id=None,
+        parent_id="",
         is_markdown=True,
     )
 
diff --git a/tests/unit/servers/test_jira_server.py b/tests/unit/servers/test_jira_server.py
index 109f78a1..2b09dae4 100644
--- a/tests/unit/servers/test_jira_server.py
+++ b/tests/unit/servers/test_jira_server.py
@@ -344,7 +344,7 @@ async def test_get_issue(jira_client, mock_jira_fetcher):
     mock_jira_fetcher.get_issue.assert_called_once_with(
         issue_key="TEST-123",
         fields=["summary", "description", "status"],  # Expect list now
-        expand=None,
+        expand="",
         comment_limit=10,
         properties=None,
         update_history=True,
@@ -388,8 +388,8 @@ async def test_search(jira_client, mock_jira_fetcher):
         fields=["summary", "status"],  # Expect list
         limit=10,
         start=0,  # start, not start_at for the fetcher method
-        projects_filter=None,
-        expand=None,
+        projects_filter="",
+        expand="",
     )
 
 
@@ -433,7 +433,7 @@ async def test_create_issue(jira_client, mock_jira_fetcher):
         summary="New Issue",
         issue_type="Task",
         description="This is a new task",
-        assignee=None,
+        assignee="",
         components=["Frontend", "API"],  # Expect list here
         # additional_fields are passed as **kwargs to the fetcher
         priority={"name": "Medium"},