Skip to content

Commit 0ade6b0

Browse files
committed
\fix: improve tags handling in write_note tool to fix #38\n\nThis change makes the write_note tool more flexible with tag inputs by:\n1. Adding a parse_tags helper function to handle various input formats\n2. Removing explicit type annotation from the write_note signature\n3. Adding documentation on how to pass tags from external MCP clients\n4. Adding a test that reproduces the issue from bug report #38\n\nThe issue occurred in external MCP clients like Cursor where the tags\nparameter was causing type mismatch errors.\n\n\ud83e\udd16 Generated with [Claude Code](https://claude.ai/code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\
1 parent 390ff9d commit 0ade6b0

4 files changed

Lines changed: 66 additions & 10 deletions

File tree

src/basic_memory/mcp/tools/utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import typing
8+
from typing import Union, List
89

910
from httpx import Response, URL, AsyncClient, HTTPStatusError
1011
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
@@ -23,6 +24,32 @@
2324
from mcp.server.fastmcp.exceptions import ToolError
2425

2526

27+
def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
28+
"""Parse tags from various input formats into a consistent list.
29+
30+
Args:
31+
tags: Can be a list of strings, a comma-separated string, or None
32+
33+
Returns:
34+
A list of tag strings, or an empty list if no tags
35+
"""
36+
if tags is None:
37+
return []
38+
39+
if isinstance(tags, list):
40+
return tags
41+
42+
if isinstance(tags, str):
43+
return [tag.strip() for tag in tags.split(",") if tag.strip()]
44+
45+
# For any other type, try to convert to string and parse
46+
try:
47+
return parse_tags(str(tags))
48+
except (ValueError, TypeError):
49+
logger.warning(f"Couldn't parse tags from input of type {type(tags)}: {tags}")
50+
return []
51+
52+
2653
def get_error_message(status_code: int, url: URL | str, method: str) -> str:
2754
"""Get a friendly error message based on the HTTP status code.
2855

src/basic_memory/mcp/tools/write_note.py

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

3-
from typing import Optional, List
3+
from typing import List, Union
44

55
from loguru import logger
66

77
from basic_memory.mcp.async_client import client
88
from basic_memory.mcp.server import mcp
9-
from basic_memory.mcp.tools.utils import call_put
9+
from basic_memory.mcp.tools.utils import call_put, parse_tags
1010
from basic_memory.schemas import EntityResponse
1111
from basic_memory.schemas.base import Entity
1212

13+
# Define TagType as a Union that can accept either a string or a list of strings or None
14+
TagType = Union[List[str], str, None]
15+
1316

1417
@mcp.tool(
1518
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
@@ -18,7 +21,7 @@ async def write_note(
1821
title: str,
1922
content: str,
2023
folder: str,
21-
tags: Optional[List[str]] = None,
24+
tags = None, # Remove type hint completely to avoid schema issues
2225
) -> str:
2326
"""Write a markdown note to the knowledge base.
2427
@@ -40,13 +43,14 @@ async def write_note(
4043
Examples:
4144
`- depends_on [[Content Parser]] (Need for semantic extraction)`
4245
`- implements [[Search Spec]] (Initial implementation)`
43-
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
46+
`- This feature extends [[Base Design]] andst uses [[Core Utils]]`
4447
4548
Args:
4649
title: The title of the note
4750
content: Markdown content for the note, can include observations and relations
4851
folder: the folder where the file should be saved
49-
tags: Optional list of tags to categorize the note
52+
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
53+
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
5054
5155
Returns:
5256
A markdown formatted summary of the semantic content, including:
@@ -58,8 +62,11 @@ async def write_note(
5862
"""
5963
logger.info("MCP tool call", tool="write_note", folder=folder, title=title, tags=tags)
6064

65+
# Process tags using the helper function
66+
tag_list = parse_tags(tags)
67+
6168
# Create the entity request
62-
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
69+
metadata = {"tags": [f"#{tag}" for tag in tag_list]} if tag_list else None
6370
entity = Entity(
6471
title=title,
6572
folder=folder,
@@ -105,8 +112,8 @@ async def write_note(
105112
summary.append(f"- Unresolved: {unresolved}")
106113
summary.append("\nUnresolved relations will be retried on next sync.")
107114

108-
if tags:
109-
summary.append(f"\n## Tags\n- {', '.join(tags)}")
115+
if tag_list:
116+
summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
110117

111118
# Log the response with structured data
112119
logger.info(

tests/mcp/test_tool_notes.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Tests for note tools that exercise the full stack with SQLite."""
22

33
from textwrap import dedent
4-
54
import pytest
65

76
from basic_memory.mcp.tools import write_note, read_note, delete_note
@@ -187,6 +186,30 @@ async def test_delete_note_doesnt_exist(app):
187186
assert deleted is False
188187

189188

189+
@pytest.mark.asyncio
190+
async def test_write_note_with_tag_array_from_bug_report(app):
191+
"""Test creating a note with a tag array as reported in issue #38.
192+
193+
This reproduces the exact payload from the bug report where Cursor
194+
was passing an array of tags and getting a type mismatch error.
195+
"""
196+
# This is the exact payload from the bug report
197+
bug_payload = {
198+
"title": "Title",
199+
"folder": "folder",
200+
"content": "CONTENT",
201+
"tags": ["hipporag", "search", "fallback", "symfony", "error-handling"]
202+
}
203+
204+
# Try to call the function with this data directly
205+
result = await write_note(**bug_payload)
206+
207+
assert result
208+
assert "permalink: folder/title" in result
209+
assert "Tags" in result
210+
assert "hipporag" in result
211+
212+
190213
@pytest.mark.asyncio
191214
async def test_write_note_verbose(app):
192215
"""Test creating a new note.

tests/sync/test_tmp_files.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import pytest
77
from watchfiles import Change
88

9-
from basic_memory.sync.watch_service import WatchService
109

1110

1211
async def create_test_file(path: Path, content: str = "test content") -> None:

0 commit comments

Comments
 (0)