Skip to content

Commit a5c9e77

Browse files
phernandezclaude
andauthored
fix: coerce string params to list/dict in MCP tools (#657)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 30a8935 commit a5c9e77

6 files changed

Lines changed: 278 additions & 9 deletions

File tree

src/basic_memory/mcp/tools/canvas.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
"""
55

66
import json
7-
from typing import Dict, List, Any, Optional
7+
from typing import Annotated, Dict, List, Any, Optional
88

99
from loguru import logger
1010
from fastmcp import Context
11+
from pydantic import BeforeValidator
1112

1213
from basic_memory.mcp.project_context import get_project_client
14+
from basic_memory.utils import coerce_list
1315
from basic_memory.mcp.server import mcp
1416
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
1517

@@ -19,8 +21,8 @@
1921
annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},
2022
)
2123
async def canvas(
22-
nodes: List[Dict[str, Any]],
23-
edges: List[Dict[str, Any]],
24+
nodes: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
25+
edges: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
2426
title: str,
2527
directory: str,
2628
project: Optional[str] = None,

src/basic_memory/mcp/tools/search.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66

77
from loguru import logger
88
from fastmcp import Context
9+
from pydantic import BeforeValidator
910

1011
from basic_memory.config import ConfigManager
12+
from basic_memory.utils import coerce_dict, coerce_list
1113
from basic_memory.mcp.container import get_container
1214
from basic_memory.mcp.project_context import (
1315
detect_project_from_url_prefix,
@@ -307,18 +309,26 @@ async def search_notes(
307309
output_format: Literal["text", "json"] = "text",
308310
note_types: Annotated[
309311
List[str] | None,
312+
BeforeValidator(coerce_list),
310313
"Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). "
311314
"Case-insensitive.",
312315
] = None,
313316
entity_types: Annotated[
314317
List[str] | None,
318+
BeforeValidator(coerce_list),
315319
"Filter by knowledge graph item type: 'entity' (whole notes), 'observation', or "
316320
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
317321
"'Chapter' here — use note_types instead.",
318322
] = None,
319323
after_date: Optional[str] = None,
320-
metadata_filters: Optional[Dict[str, Any]] = None,
321-
tags: Optional[List[str]] = None,
324+
metadata_filters: Annotated[
325+
Dict[str, Any] | None,
326+
BeforeValidator(coerce_dict),
327+
] = None,
328+
tags: Annotated[
329+
List[str] | None,
330+
BeforeValidator(coerce_list),
331+
] = None,
322332
status: Optional[str] = None,
323333
min_similarity: Optional[float] = None,
324334
context: Context | None = None,

src/basic_memory/mcp/tools/write_note.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
"""Write note tool for Basic Memory MCP server."""
22

33
import textwrap
4-
from typing import List, Union, Optional, Literal
4+
from typing import Annotated, List, Union, Optional, Literal
55

66
from loguru import logger
7+
from pydantic import BeforeValidator
78

89
from basic_memory.config import ConfigManager
910
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
1011
from basic_memory.mcp.server import mcp
1112
from fastmcp import Context
1213
from basic_memory.schemas.base import Entity
13-
from basic_memory.utils import parse_tags, validate_project_path
14+
from basic_memory.utils import coerce_dict, parse_tags, validate_project_path
1415

1516
# Define TagType as a Union that can accept either a string or a list of strings or None
1617
TagType = Union[List[str], str, None]
@@ -28,7 +29,7 @@ async def write_note(
2829
workspace: Optional[str] = None,
2930
tags: list[str] | str | None = None,
3031
note_type: str = "note",
31-
metadata: dict | None = None,
32+
metadata: Annotated[dict | None, BeforeValidator(coerce_dict)] = None,
3233
overwrite: bool | None = None,
3334
output_format: Literal["text", "json"] = "text",
3435
context: Context | None = None,

src/basic_memory/utils.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""Utility functions for basic-memory."""
22

3+
import json
34
import os
45

56
import logging
67
import re
78
import sys
89
from datetime import datetime, timezone
910
from pathlib import Path
10-
from typing import Protocol, Union, runtime_checkable, List, Optional
11+
from typing import Any, Protocol, Union, runtime_checkable, List, Optional
1112

1213
from loguru import logger
1314
from unidecode import unidecode
@@ -385,6 +386,36 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
385386
return []
386387

387388

389+
def coerce_list(v: Any) -> Any:
390+
"""Coerce string input to list for MCP clients that serialize lists as strings."""
391+
if v is None:
392+
return v
393+
if isinstance(v, str):
394+
try:
395+
parsed = json.loads(v)
396+
if isinstance(parsed, list):
397+
return parsed
398+
except (json.JSONDecodeError, TypeError):
399+
pass
400+
# Single string value — wrap in a list
401+
return [v]
402+
return v
403+
404+
405+
def coerce_dict(v: Any) -> Any:
406+
"""Coerce string input to dict for MCP clients that serialize dicts as strings."""
407+
if v is None:
408+
return v
409+
if isinstance(v, str):
410+
try:
411+
parsed = json.loads(v)
412+
if isinstance(parsed, dict):
413+
return parsed
414+
except (json.JSONDecodeError, TypeError):
415+
pass
416+
return v
417+
418+
388419
def normalize_newlines(multiline: str) -> str:
389420
"""Replace any \r\n, \r, or \n with the native newline.
390421
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Integration tests for MCP tools accepting string-serialized list/dict params.
2+
3+
Goes through the full FastMCP Client → validate_call → tool function path,
4+
which is where Pydantic rejects strings for list/dict params.
5+
"""
6+
7+
import pytest
8+
from fastmcp import Client
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_search_notes_entity_types_as_string(mcp_server, app, test_project):
13+
"""search_notes should accept entity_types as a JSON string via MCP protocol."""
14+
async with Client(mcp_server) as client:
15+
await client.call_tool(
16+
"write_note",
17+
{
18+
"project": test_project.name,
19+
"title": "Entity Type Coerce Test",
20+
"directory": "test",
21+
"content": "# Test\nContent for entity type coercion",
22+
},
23+
)
24+
25+
# MCP client sends entity_types as a string
26+
result = await client.call_tool(
27+
"search_notes",
28+
{
29+
"project": test_project.name,
30+
"query": "coercion",
31+
"entity_types": '["entity"]',
32+
},
33+
)
34+
text = result.content[0].text
35+
assert "Search Failed" not in text
36+
37+
38+
@pytest.mark.asyncio
39+
async def test_search_notes_note_types_as_string(mcp_server, app, test_project):
40+
"""search_notes should accept note_types as a JSON string via MCP protocol."""
41+
async with Client(mcp_server) as client:
42+
await client.call_tool(
43+
"write_note",
44+
{
45+
"project": test_project.name,
46+
"title": "Note Type Coerce Test",
47+
"directory": "test",
48+
"content": "# Test\nContent for note type coercion",
49+
},
50+
)
51+
52+
result = await client.call_tool(
53+
"search_notes",
54+
{
55+
"project": test_project.name,
56+
"query": "coercion",
57+
"note_types": '["note"]',
58+
},
59+
)
60+
text = result.content[0].text
61+
assert "Search Failed" not in text
62+
63+
64+
@pytest.mark.asyncio
65+
async def test_search_notes_tags_as_string(mcp_server, app, test_project):
66+
"""search_notes should accept tags as a JSON string via MCP protocol."""
67+
async with Client(mcp_server) as client:
68+
await client.call_tool(
69+
"write_note",
70+
{
71+
"project": test_project.name,
72+
"title": "Tags Coerce Test",
73+
"directory": "test",
74+
"content": "# Test\nTagged content for coercion",
75+
"tags": "alpha",
76+
},
77+
)
78+
79+
result = await client.call_tool(
80+
"search_notes",
81+
{
82+
"project": test_project.name,
83+
"query": "tagged",
84+
"tags": '["alpha"]',
85+
},
86+
)
87+
text = result.content[0].text
88+
assert "Search Failed" not in text
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_search_notes_metadata_filters_as_string(mcp_server, app, test_project):
93+
"""search_notes should accept metadata_filters as a JSON string via MCP protocol."""
94+
async with Client(mcp_server) as client:
95+
await client.call_tool(
96+
"write_note",
97+
{
98+
"project": test_project.name,
99+
"title": "Metadata Coerce Test",
100+
"directory": "test",
101+
"content": "# Test\nMetadata content for coercion",
102+
},
103+
)
104+
105+
result = await client.call_tool(
106+
"search_notes",
107+
{
108+
"project": test_project.name,
109+
"query": "metadata",
110+
"metadata_filters": '{"type": "note"}',
111+
},
112+
)
113+
text = result.content[0].text
114+
assert "Search Failed" not in text
115+
116+
117+
@pytest.mark.asyncio
118+
async def test_write_note_metadata_as_string(mcp_server, app, test_project):
119+
"""write_note should accept metadata as a JSON string via MCP protocol."""
120+
async with Client(mcp_server) as client:
121+
result = await client.call_tool(
122+
"write_note",
123+
{
124+
"project": test_project.name,
125+
"title": "String Metadata Note",
126+
"directory": "test",
127+
"content": "# Test\nWith string metadata",
128+
"metadata": '{"priority": "high"}',
129+
},
130+
)
131+
text = result.content[0].text
132+
assert "Created note" in text or "Updated note" in text
133+
134+
135+
@pytest.mark.asyncio
136+
async def test_canvas_nodes_edges_as_string(mcp_server, app, test_project):
137+
"""canvas should accept nodes and edges as JSON strings via MCP protocol."""
138+
import json
139+
140+
nodes = [
141+
{
142+
"id": "n1",
143+
"type": "text",
144+
"text": "Hello",
145+
"x": 0,
146+
"y": 0,
147+
"width": 200,
148+
"height": 100,
149+
}
150+
]
151+
edges = [
152+
{"id": "e1", "fromNode": "n1", "toNode": "n1", "label": "self"}
153+
]
154+
155+
async with Client(mcp_server) as client:
156+
result = await client.call_tool(
157+
"canvas",
158+
{
159+
"project": test_project.name,
160+
"title": "Coerce Canvas Test",
161+
"directory": "test",
162+
"nodes": json.dumps(nodes),
163+
"edges": json.dumps(edges),
164+
},
165+
)
166+
text = result.content[0].text
167+
assert "Created" in text or "Updated" in text

tests/test_coerce.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Tests for coerce_list and coerce_dict utility functions.
2+
3+
These must fail until the helpers are implemented in utils.py.
4+
"""
5+
6+
7+
from basic_memory.utils import coerce_list, coerce_dict
8+
9+
10+
class TestCoerceList:
11+
"""Tests for coerce_list."""
12+
13+
def test_none_passthrough(self):
14+
assert coerce_list(None) is None
15+
16+
def test_native_list_passthrough(self):
17+
assert coerce_list(["a", "b"]) == ["a", "b"]
18+
19+
def test_json_array_string(self):
20+
assert coerce_list('["entity", "observation"]') == ["entity", "observation"]
21+
22+
def test_single_string_wrapped(self):
23+
assert coerce_list("entity") == ["entity"]
24+
25+
def test_non_json_string_wrapped(self):
26+
assert coerce_list("not-json") == ["not-json"]
27+
28+
def test_json_object_string_wrapped(self):
29+
"""A JSON object string is not a list, so wrap it."""
30+
assert coerce_list('{"key": "val"}') == ['{"key": "val"}']
31+
32+
def test_int_passthrough(self):
33+
"""Non-string, non-None values pass through unchanged."""
34+
assert coerce_list(42) == 42
35+
36+
37+
class TestCoerceDict:
38+
"""Tests for coerce_dict."""
39+
40+
def test_none_passthrough(self):
41+
assert coerce_dict(None) is None
42+
43+
def test_native_dict_passthrough(self):
44+
assert coerce_dict({"k": "v"}) == {"k": "v"}
45+
46+
def test_json_object_string(self):
47+
assert coerce_dict('{"status": "draft"}') == {"status": "draft"}
48+
49+
def test_non_json_string_passthrough(self):
50+
"""Non-parseable strings pass through (Pydantic will reject them)."""
51+
assert coerce_dict("not-json") == "not-json"
52+
53+
def test_json_array_string_passthrough(self):
54+
"""A JSON array string is not a dict, so pass through."""
55+
assert coerce_dict('["a", "b"]') == '["a", "b"]'
56+
57+
def test_int_passthrough(self):
58+
assert coerce_dict(42) == 42

0 commit comments

Comments
 (0)