Skip to content

Commit 01d4dbe

Browse files
committed
Fix Claude Desktop parameter serialization issues with comprehensive testing
- Add normalize_data_source_ids() utility function for DRY parameter handling - Update tool type annotations to accept Union[str, List[str]] for Claude Desktop compatibility - Handle JSON-encoded strings, plain strings, and proper arrays uniformly - Add 13 comprehensive tests covering all parameter formats and edge cases - Refactor both codebase_search and codebase_consultant tools to use shared utility - Maintain backward compatibility with existing proper array inputs - All 39 tests pass ensuring robustness Fixes the "Input validation error" issues when Claude Desktop sends incorrectly serialized array parameters as JSON strings or plain strings.
1 parent 8c9efc6 commit 01d4dbe

File tree

5 files changed

+199
-8
lines changed

5 files changed

+199
-8
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Tests for parameter normalization functionality."""
2+
3+
import pytest
4+
import json
5+
from utils.errors import normalize_data_source_ids
6+
7+
8+
class TestNormalizeDataSourceIds:
9+
"""Test the normalize_data_source_ids function with various input formats."""
10+
11+
def test_proper_array_input(self):
12+
"""Test that proper arrays are passed through unchanged."""
13+
input_data = ["repo1", "repo2", "repo3"]
14+
result = normalize_data_source_ids(input_data)
15+
assert result == ["repo1", "repo2", "repo3"]
16+
17+
def test_single_string_input(self):
18+
"""Test that single string is converted to array."""
19+
input_data = "repo1"
20+
result = normalize_data_source_ids(input_data)
21+
assert result == ["repo1"]
22+
23+
def test_json_encoded_string_input(self):
24+
"""Test that JSON-encoded strings are properly parsed."""
25+
input_data = '["repo1", "repo2"]'
26+
result = normalize_data_source_ids(input_data)
27+
assert result == ["repo1", "repo2"]
28+
29+
def test_malformed_json_string_fallback(self):
30+
"""Test that malformed JSON strings fall back to single ID."""
31+
input_data = '["repo1", "repo2"' # Missing closing bracket
32+
result = normalize_data_source_ids(input_data)
33+
assert result == ['["repo1", "repo2"'] # Treated as single ID
34+
35+
def test_empty_inputs(self):
36+
"""Test various empty input types."""
37+
assert normalize_data_source_ids(None) == []
38+
assert normalize_data_source_ids("") == []
39+
assert normalize_data_source_ids([]) == []
40+
41+
def test_mixed_array_with_dicts(self):
42+
"""Test arrays containing both strings and dict objects."""
43+
input_data = [
44+
"repo1",
45+
{"id": "repo2", "type": "repository"},
46+
"repo3",
47+
{"id": "workspace1", "type": "workspace"}
48+
]
49+
result = normalize_data_source_ids(input_data)
50+
assert result == ["repo1", "repo2", "repo3", "workspace1"]
51+
52+
def test_dict_without_id(self):
53+
"""Test that dicts without 'id' field are skipped."""
54+
input_data = [
55+
"repo1",
56+
{"name": "some-repo", "type": "repository"}, # No 'id' field
57+
"repo2"
58+
]
59+
result = normalize_data_source_ids(input_data)
60+
assert result == ["repo1", "repo2"]
61+
62+
def test_empty_strings_preserved(self):
63+
"""Test that empty strings in arrays are preserved (might be intentional)."""
64+
input_data = ["repo1", "", "repo2", " ", "repo3"]
65+
result = normalize_data_source_ids(input_data)
66+
assert result == ["repo1", "", "repo2", " ", "repo3"] # All strings preserved
67+
68+
def test_non_list_non_string_input(self):
69+
"""Test handling of unexpected input types."""
70+
result = normalize_data_source_ids(123)
71+
assert result == ["123"]
72+
73+
result = normalize_data_source_ids({"id": "repo1"})
74+
assert result == ["{'id': 'repo1'}"]
75+
76+
def test_claude_desktop_scenarios(self):
77+
"""Test specific scenarios from Claude Desktop serialization issues."""
78+
# Scenario 1: JSON string as seen in Claude Desktop logs
79+
claude_input_1 = '["67db4097fa23c0a98a8495c2"]'
80+
result_1 = normalize_data_source_ids(claude_input_1)
81+
assert result_1 == ["67db4097fa23c0a98a8495c2"]
82+
83+
# Scenario 2: Plain string as seen in Claude Desktop logs
84+
claude_input_2 = "67db4097fa23c0a98a8495c2"
85+
result_2 = normalize_data_source_ids(claude_input_2)
86+
assert result_2 == ["67db4097fa23c0a98a8495c2"]
87+
88+
# Scenario 3: Multiple IDs in JSON string
89+
claude_input_3 = '["repo1", "repo2", "workspace1"]'
90+
result_3 = normalize_data_source_ids(claude_input_3)
91+
assert result_3 == ["repo1", "repo2", "workspace1"]
92+
93+
def test_edge_cases(self):
94+
"""Test various edge cases."""
95+
# Whitespace-only JSON string
96+
assert normalize_data_source_ids("[]") == []
97+
assert normalize_data_source_ids("[ ]") == []
98+
99+
# Single item JSON array
100+
assert normalize_data_source_ids('["single"]') == ["single"]
101+
102+
# JSON array with empty strings
103+
assert normalize_data_source_ids('["repo1", "", "repo2"]') == ["repo1", "", "repo2"]
104+
105+
106+
class TestParameterNormalizationIntegration:
107+
"""Integration tests to ensure parameter normalization works in tool contexts."""
108+
109+
def test_search_tool_parameter_handling(self):
110+
"""Test that search tool properly normalizes various parameter formats."""
111+
from tools.search import codebase_search
112+
import inspect
113+
114+
# Verify the function accepts Union[str, List[str]]
115+
sig = inspect.signature(codebase_search)
116+
data_source_ids_param = sig.parameters['data_source_ids']
117+
118+
# The annotation should accept both str and List[str]
119+
assert 'Union' in str(data_source_ids_param.annotation) or 'str' in str(data_source_ids_param.annotation)
120+
121+
def test_consultant_tool_parameter_handling(self):
122+
"""Test that consultant tool properly normalizes various parameter formats."""
123+
from tools.chat import codebase_consultant
124+
import inspect
125+
126+
# Verify the function accepts Union[str, List[str]]
127+
sig = inspect.signature(codebase_consultant)
128+
data_sources_param = sig.parameters['data_sources']
129+
130+
# The annotation should accept both str and List[str]
131+
assert 'Union' in str(data_sources_param.annotation) or 'str' in str(data_sources_param.annotation)
132+
133+
134+
if __name__ == "__main__":
135+
pytest.main([__file__, "-v"])

src/tools/chat.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
"""Chat completions tool implementation."""
22

33
import json
4-
from typing import Dict, List, Optional
4+
from typing import Dict, List, Optional, Union
55
from urllib.parse import urljoin
66

77
import httpx
88
from fastmcp import Context
99

1010
from core import CodeAliveContext, get_api_key_from_context, log_api_request, log_api_response
11-
from utils import handle_api_error, format_data_source_ids
11+
from utils import handle_api_error, format_data_source_ids, normalize_data_source_ids
1212

1313

1414
async def codebase_consultant(
1515
ctx: Context,
1616
question: str,
17-
data_sources: Optional[List[str]] = None,
17+
data_sources: Optional[Union[str, List[str]]] = None,
1818
conversation_id: Optional[str] = None
1919
) -> str:
2020
"""
@@ -68,6 +68,9 @@ async def codebase_consultant(
6868
"""
6969
context: CodeAliveContext = ctx.request_context.lifespan_context
7070

71+
# Normalize data source IDs (handles Claude Desktop serialization issues)
72+
data_sources = normalize_data_source_ids(data_sources)
73+
7174
if not question or not question.strip():
7275
return "Error: No question provided. Please provide a question to ask the consultant."
7376

src/tools/search.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
"""Search tool implementation."""
22

3-
from typing import Dict, List, Optional
3+
from typing import Dict, List, Optional, Union
44
from urllib.parse import urljoin
55

66
import httpx
77
from fastmcp import Context
88

99
from core import CodeAliveContext, get_api_key_from_context, log_api_request, log_api_response
10-
from utils import transform_search_response_to_xml, handle_api_error
10+
from utils import transform_search_response_to_xml, handle_api_error, normalize_data_source_ids
1111

1212

1313
async def codebase_search(
1414
ctx: Context,
1515
query: str,
16-
data_source_ids: Optional[List[str]] = None,
16+
data_source_ids: Optional[Union[str, List[str]]] = None,
1717
mode: str = "auto",
1818
include_content: bool = False
1919
) -> Dict:
@@ -94,6 +94,9 @@ async def codebase_search(
9494
"""
9595
context: CodeAliveContext = ctx.request_context.lifespan_context
9696

97+
# Normalize data source IDs (handles Claude Desktop serialization issues)
98+
data_source_ids = normalize_data_source_ids(data_source_ids)
99+
97100
# Validate inputs
98101
if not query or not query.strip():
99102
return {"error": "Query cannot be empty. Please provide a search term, function name, or description of the code you're looking for."}

src/utils/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Utility functions for CodeAlive MCP server."""
22

33
from .response_transformer import transform_search_response_to_xml
4-
from .errors import handle_api_error, format_data_source_ids
4+
from .errors import handle_api_error, format_data_source_ids, normalize_data_source_ids
55

66
__all__ = [
77
'transform_search_response_to_xml',
88
'handle_api_error',
9-
'format_data_source_ids'
9+
'format_data_source_ids',
10+
'normalize_data_source_ids'
1011
]

src/utils/errors.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,55 @@ async def handle_api_error(
5151
return f"Error: {error_msg}. Please check your input parameters and try again."
5252

5353

54+
def normalize_data_source_ids(data_sources) -> list:
55+
"""
56+
Normalize data source IDs from various Claude Desktop serialization formats.
57+
58+
Handles:
59+
- Proper arrays: ["id1", "id2"]
60+
- JSON-encoded strings: "[\"id1\", \"id2\"]"
61+
- Plain strings: "id1"
62+
- None/empty values
63+
64+
Args:
65+
data_sources: Data sources in any format from Claude Desktop
66+
67+
Returns:
68+
List of string IDs: ["id1", "id2"]
69+
"""
70+
import json
71+
72+
if not data_sources:
73+
return []
74+
75+
# Handle string inputs (Claude Desktop serialization issue)
76+
if isinstance(data_sources, str):
77+
# Handle JSON-encoded string
78+
if data_sources.startswith('['):
79+
try:
80+
data_sources = json.loads(data_sources)
81+
except json.JSONDecodeError:
82+
# If parsing fails, treat as single ID
83+
return [data_sources]
84+
else:
85+
# Single ID as string
86+
return [data_sources]
87+
88+
# Handle non-list types
89+
if not isinstance(data_sources, list):
90+
return [str(data_sources)]
91+
92+
# Already a list - extract string IDs
93+
result = []
94+
for ds in data_sources:
95+
if isinstance(ds, str):
96+
result.append(ds)
97+
elif isinstance(ds, dict) and ds.get("id"):
98+
result.append(ds["id"])
99+
100+
return result
101+
102+
54103
def format_data_source_ids(data_sources: Optional[list]) -> list:
55104
"""
56105
Convert various data source formats to the API's expected format.

0 commit comments

Comments
 (0)