Skip to content

Commit 87285ad

Browse files
committed
feat(mcp): accept training-data-friendly parameter aliases
Models reach for common parameter names from training data (offset/limit instead of page/page_size, find/old_text instead of find_text, etc.) on first use of MCP tools. Pydantic validation errors fire, the model loses trust in the tool, and falls back to filesystem reads — bypassing the knowledge graph entirely. Use Pydantic v2 AliasChoices on tool parameters so the canonical names are still the only thing advertised in the JSON schema, but common alternatives are silently accepted at validation time. Aliases added across 12 tools: - pagination: page←offset/page_number, page_size←limit/per_page - search: query←q/search/text, after_date←since/after/from_date, min_similarity←threshold/similarity_threshold, note_types←note_type/types - time: timeframe←since/time_range/lookback - paths: directory←folder/dir/path, dir_name←directory/folder/..., path←file_path/filepath/file - move: destination_path←to/dest_path/new_path/destination - edit: find_text←find/old_text/old_content/search, content←new_content/replacement/replace_with, section←section_heading/heading - flags: overwrite←force/replace, is_directory←is_dir - context: url←uri/memory_url, max_related←max_results Closes #690 Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 4d62b62 commit 87285ad

13 files changed

Lines changed: 774 additions & 43 deletions

src/basic_memory/mcp/tools/build_context.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Build context tool for Basic Memory MCP server."""
22

3-
from typing import Optional, Literal
3+
from typing import Annotated, Optional, Literal
44

55
import logfire
66
from loguru import logger
77
from fastmcp import Context
8+
from pydantic import AliasChoices, Field
89

910
from basic_memory.config import ConfigManager
1011
from basic_memory.mcp.project_context import (
@@ -133,14 +134,35 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
133134
annotations={"readOnlyHint": True, "openWorldHint": False},
134135
)
135136
async def build_context(
136-
url: MemoryUrl,
137+
url: Annotated[
138+
MemoryUrl,
139+
Field(validation_alias=AliasChoices("url", "uri", "memory_url")),
140+
],
137141
project: Optional[str] = None,
138142
workspace: Optional[str] = None,
139143
depth: str | int | None = 1,
140-
timeframe: Optional[TimeFrame] = "7d",
141-
page: int = 1,
142-
page_size: int = 10,
143-
max_related: int = 10,
144+
timeframe: Annotated[
145+
Optional[TimeFrame],
146+
Field(
147+
default="7d",
148+
validation_alias=AliasChoices("timeframe", "since", "time_range", "lookback"),
149+
),
150+
] = "7d",
151+
page: Annotated[
152+
int,
153+
Field(default=1, validation_alias=AliasChoices("page", "offset", "page_number")),
154+
] = 1,
155+
page_size: Annotated[
156+
int,
157+
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
158+
] = 10,
159+
max_related: Annotated[
160+
int,
161+
Field(
162+
default=10,
163+
validation_alias=AliasChoices("max_related", "max_results", "limit_related"),
164+
),
165+
] = 10,
144166
output_format: Literal["json", "text"] = "json",
145167
context: Context | None = None,
146168
) -> dict | str:

src/basic_memory/mcp/tools/canvas.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from loguru import logger
1010
from fastmcp import Context
11-
from pydantic import BeforeValidator
11+
from pydantic import AliasChoices, BeforeValidator, Field
1212

1313
from basic_memory.mcp.project_context import get_project_client
1414
from basic_memory.utils import coerce_list
@@ -24,7 +24,10 @@ async def canvas(
2424
nodes: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
2525
edges: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
2626
title: str,
27-
directory: str,
27+
directory: Annotated[
28+
str,
29+
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
30+
],
2831
project: Optional[str] = None,
2932
workspace: Optional[str] = None,
3033
context: Context | None = None,

src/basic_memory/mcp/tools/delete_note.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from textwrap import dedent
2-
from typing import Optional, Literal
2+
from typing import Annotated, Optional, Literal
33

44
from loguru import logger
55
from fastmcp import Context
66
from mcp.server.fastmcp.exceptions import ToolError
7+
from pydantic import AliasChoices, Field
78

89
from basic_memory.config import ConfigManager
910
from basic_memory.mcp.project_context import detect_project_from_url_prefix, get_project_client
@@ -153,7 +154,10 @@ def _format_delete_error_response(project: str, error_message: str, identifier:
153154
)
154155
async def delete_note(
155156
identifier: str,
156-
is_directory: bool = False,
157+
is_directory: Annotated[
158+
bool,
159+
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
160+
] = False,
157161
project: Optional[str] = None,
158162
workspace: Optional[str] = None,
159163
output_format: Literal["text", "json"] = "text",

src/basic_memory/mcp/tools/edit_note.py

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

3-
from typing import Optional, Literal
3+
from typing import Annotated, Optional, Literal
44

55
import logfire
66
from loguru import logger
77
from fastmcp import Context
8+
from pydantic import AliasChoices, Field
89

910
from basic_memory.config import ConfigManager
1011
from basic_memory.mcp.project_context import (
@@ -170,11 +171,37 @@ def _format_error_response(
170171
async def edit_note(
171172
identifier: str,
172173
operation: str,
173-
content: str,
174+
# Accept common replacement-content aliases. Models trained on diff/patch
175+
# APIs reach for new_content/replacement/replace_with on first try.
176+
content: Annotated[
177+
str,
178+
Field(
179+
validation_alias=AliasChoices(
180+
"content", "new_content", "replacement", "replace_with"
181+
)
182+
),
183+
],
174184
project: Optional[str] = None,
175185
workspace: Optional[str] = None,
176-
section: Optional[str] = None,
177-
find_text: Optional[str] = None,
186+
# Section/heading naming varies across tools; accept the descriptive forms.
187+
section: Annotated[
188+
Optional[str],
189+
Field(
190+
default=None,
191+
validation_alias=AliasChoices("section", "section_heading", "heading"),
192+
),
193+
] = None,
194+
# find_text is the highest-frequency miss per the issue: models reach for
195+
# find/old_text/old_content/search before find_text every time.
196+
find_text: Annotated[
197+
Optional[str],
198+
Field(
199+
default=None,
200+
validation_alias=AliasChoices(
201+
"find_text", "find", "old_text", "old_content", "search"
202+
),
203+
),
204+
] = None,
178205
expected_replacements: Optional[int] = None,
179206
output_format: Literal["text", "json"] = "text",
180207
context: Context | None = None,

src/basic_memory/mcp/tools/list_directory.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""List directory tool for Basic Memory MCP server."""
22

3-
from typing import Optional
3+
from typing import Annotated, Optional
44

55
from loguru import logger
66
from fastmcp import Context
7+
from pydantic import AliasChoices, Field
78

89
from basic_memory.mcp.project_context import get_project_client
910
from basic_memory.mcp.server import mcp
@@ -14,9 +15,22 @@
1415
annotations={"readOnlyHint": True, "openWorldHint": False},
1516
)
1617
async def list_directory(
17-
dir_name: str = "/",
18+
# `dir_name` is unusual; models reach for directory/folder/path/dir.
19+
dir_name: Annotated[
20+
str,
21+
Field(
22+
default="/",
23+
validation_alias=AliasChoices("dir_name", "directory", "folder", "path", "dir"),
24+
),
25+
] = "/",
1826
depth: int = 1,
19-
file_name_glob: Optional[str] = None,
27+
file_name_glob: Annotated[
28+
Optional[str],
29+
Field(
30+
default=None,
31+
validation_alias=AliasChoices("file_name_glob", "glob", "pattern", "filter"),
32+
),
33+
] = None,
2034
project: Optional[str] = None,
2135
workspace: Optional[str] = None,
2236
context: Context | None = None,

src/basic_memory/mcp/tools/move_note.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
from pathlib import Path, PureWindowsPath
44
from textwrap import dedent
5-
from typing import Optional, Literal
5+
from typing import Annotated, Optional, Literal
66

77
from loguru import logger
88
from fastmcp import Context
99
from mcp.server.fastmcp.exceptions import ToolError
10+
from pydantic import AliasChoices, Field
1011

1112
from basic_memory.mcp.server import mcp
1213
from basic_memory.mcp.project_context import get_project_client
@@ -348,9 +349,27 @@ def _format_move_error_response(error_message: str, identifier: str, destination
348349
)
349350
async def move_note(
350351
identifier: str,
351-
destination_path: str = "",
352-
destination_folder: Optional[str] = None,
353-
is_directory: bool = False,
352+
# Move/rename APIs across the ecosystem use `to`/`destination`/`new_path`.
353+
destination_path: Annotated[
354+
str,
355+
Field(
356+
default="",
357+
validation_alias=AliasChoices(
358+
"destination_path", "dest_path", "new_path", "to", "destination"
359+
),
360+
),
361+
] = "",
362+
destination_folder: Annotated[
363+
Optional[str],
364+
Field(
365+
default=None,
366+
validation_alias=AliasChoices("destination_folder", "dest_folder", "to_folder"),
367+
),
368+
] = None,
369+
is_directory: Annotated[
370+
bool,
371+
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
372+
] = False,
354373
project: Optional[str] = None,
355374
workspace: Optional[str] = None,
356375
output_format: Literal["text", "json"] = "text",

src/basic_memory/mcp/tools/read_content.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
import base64
99
import io
1010

11-
from typing import Optional
11+
from typing import Annotated, Optional
1212

1313
from loguru import logger
1414
from PIL import Image as PILImage
1515
from fastmcp import Context
16+
from pydantic import AliasChoices, Field
1617
from mcp.server.fastmcp.exceptions import ToolError
1718

1819
from basic_memory.config import ConfigManager
@@ -158,7 +159,10 @@ def optimize_image(img, content_length, max_output_bytes=350000):
158159
annotations={"readOnlyHint": True, "openWorldHint": False},
159160
)
160161
async def read_content(
161-
path: str,
162+
path: Annotated[
163+
str,
164+
Field(validation_alias=AliasChoices("path", "file_path", "filepath", "file")),
165+
],
162166
project: Optional[str] = None,
163167
workspace: Optional[str] = None,
164168
context: Context | None = None,

src/basic_memory/mcp/tools/read_note.py

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

33
from textwrap import dedent
4-
from typing import Optional, Literal, cast
4+
from typing import Annotated, Optional, Literal, cast
55

66
import logfire
77
import yaml
88

99
from loguru import logger
1010
from fastmcp import Context
11+
from pydantic import AliasChoices, Field
1112

1213
from basic_memory.config import ConfigManager
1314
from basic_memory.mcp.project_context import (
@@ -71,8 +72,17 @@ async def read_note(
7172
identifier: str,
7273
project: Optional[str] = None,
7374
workspace: Optional[str] = None,
74-
page: int = 1,
75-
page_size: int = 10,
75+
# Accept common pagination aliases models reach for from training data
76+
# (offset/limit/page_number/per_page). Schema still advertises only the
77+
# canonical names; aliases are silently mapped at validation time.
78+
page: Annotated[
79+
int,
80+
Field(default=1, validation_alias=AliasChoices("page", "offset", "page_number")),
81+
] = 1,
82+
page_size: Annotated[
83+
int,
84+
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
85+
] = 10,
7686
output_format: Literal["text", "json"] = "text",
7787
include_frontmatter: bool = False,
7888
context: Context | None = None,

src/basic_memory/mcp/tools/recent_activity.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
from datetime import timezone
44
from pathlib import PurePosixPath
5-
from typing import List, Union, Optional, Literal
5+
from typing import Annotated, List, Union, Optional, Literal
66

77
from loguru import logger
88
from fastmcp import Context
9+
from pydantic import AliasChoices, Field
910

1011
from basic_memory.mcp.async_client import get_client
1112
from basic_memory.mcp.project_context import (
@@ -38,11 +39,26 @@
3839
annotations={"readOnlyHint": True, "openWorldHint": False},
3940
)
4041
async def recent_activity(
41-
type: Union[str, List[str]] = "",
42+
type: Annotated[
43+
Union[str, List[str]],
44+
Field(default="", validation_alias=AliasChoices("type", "types", "kind")),
45+
] = "",
4246
depth: int = 1,
43-
timeframe: TimeFrame = "7d",
44-
page: int = 1,
45-
page_size: int = 10,
47+
timeframe: Annotated[
48+
TimeFrame,
49+
Field(
50+
default="7d",
51+
validation_alias=AliasChoices("timeframe", "since", "time_range", "lookback"),
52+
),
53+
] = "7d",
54+
page: Annotated[
55+
int,
56+
Field(default=1, validation_alias=AliasChoices("page", "offset", "page_number")),
57+
] = 1,
58+
page_size: Annotated[
59+
int,
60+
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
61+
] = 10,
4662
project: Optional[str] = None,
4763
workspace: Optional[str] = None,
4864
output_format: Literal["text", "json"] = "text",

0 commit comments

Comments
 (0)