Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions tests/test_bundled_mcp_optional_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Regression cover for optional tool arguments in the bundled MCP servers.

A parameter written ``start_line: int = None`` makes FastMCP advertise
``{"type": "integer", "default": null}``: the default is null, but the type
excludes null. A client that sends the advertised default back explicitly
(``"start_line": null``) is rejected with a validation error before the tool
runs. Every argument whose schema default is null must also accept null.
"""

from __future__ import annotations

import asyncio
import sys
from pathlib import Path

import pytest
from pydantic import ValidationError

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

import tools.code_implementation_server as code_implementation_server
import tools.document_segmentation_server as document_segmentation_server

SERVERS = {
"code-implementation": code_implementation_server.mcp,
"document-segmentation": document_segmentation_server.mcp,
}


def _accepts_null(prop: dict) -> bool:
if prop.get("type") == "null":
return True
if isinstance(prop.get("type"), list) and "null" in prop["type"]:
return True
return any(_accepts_null(option) for option in prop.get("anyOf", []))


def _null_default_args(server):
tools = asyncio.run(server.list_tools())
for tool in tools:
for name, prop in tool.inputSchema.get("properties", {}).items():
if "default" in prop and prop["default"] is None:
yield tool.name, name, prop


@pytest.mark.parametrize("server_name", sorted(SERVERS))
def test_null_default_arguments_accept_null(server_name):
server = SERVERS[server_name]
found = list(_null_default_args(server))
assert found, f"{server_name}: expected optional arguments with a null default"

rejected = []
for tool_name, arg_name, prop in found:
tool = server._tool_manager.get_tool(tool_name)
required = {req: "x" for req in tool.parameters.get("required", [])}
if not _accepts_null(prop):
rejected.append(f"{tool_name}.{arg_name}: schema {prop}")
continue
try:
tool.fn_metadata.arg_model.model_validate({**required, arg_name: None})
except ValidationError as exc:
rejected.append(f"{tool_name}.{arg_name}: {exc}")

assert not rejected, "null default but null rejected:\n" + "\n".join(rejected)
4 changes: 2 additions & 2 deletions tools/code_implementation_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def log_operation(action: str, details: Dict[str, Any]):

@mcp.tool()
async def read_file(
file_path: str, start_line: int = None, end_line: int = None
file_path: str, start_line: int | None = None, end_line: int | None = None
) -> str:
"""
Read file content, supports specifying line number range
Expand Down Expand Up @@ -1184,7 +1184,7 @@ async def search_code(
pattern: str,
file_pattern: str = "*.json",
use_regex: bool = False,
search_directory: str = None,
search_directory: str | None = None,
) -> str:
"""
Search patterns in code files
Expand Down
4 changes: 2 additions & 2 deletions tools/document_segmentation_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1627,9 +1627,9 @@ async def analyze_and_segment_document(
async def read_document_segments(
paper_dir: str,
query_type: str,
keywords: List[str] = None,
keywords: List[str] | None = None,
max_segments: int = 3,
max_total_chars: int = None,
max_total_chars: int | None = None,
) -> str:
"""
Intelligently retrieve relevant document segments based on query type
Expand Down