feat(mcp): accept training-data-friendly parameter aliases (#766)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-04-28 20:10:36 -05:00
committed by GitHub
parent 4d62b623db
commit ee1558ea68
13 changed files with 883 additions and 43 deletions
+27 -6
View File
@@ -1,10 +1,11 @@
"""Build context tool for Basic Memory MCP server."""
from typing import Optional, Literal
from typing import Annotated, Optional, Literal
import logfire
from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
@@ -133,14 +134,34 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def build_context(
url: MemoryUrl,
url: Annotated[
MemoryUrl,
Field(validation_alias=AliasChoices("url", "uri", "memory_url")),
],
project: Optional[str] = None,
workspace: Optional[str] = None,
depth: str | int | None = 1,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
page_size: int = 10,
max_related: int = 10,
timeframe: Annotated[
Optional[TimeFrame],
Field(
default="7d",
validation_alias=AliasChoices("timeframe", "since", "time_range", "lookback"),
),
] = "7d",
# `offset` is intentionally NOT aliased: it has different semantics
# (item-indexed vs. 1-indexed page-number).
page: Annotated[
int,
Field(default=1, validation_alias=AliasChoices("page", "page_number")),
] = 1,
page_size: Annotated[
int,
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
] = 10,
max_related: Annotated[
int,
Field(default=10, validation_alias=AliasChoices("max_related", "max_results")),
] = 10,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict | str:
+5 -2
View File
@@ -8,7 +8,7 @@ from typing import Annotated, Dict, List, Any, Optional
from loguru import logger
from fastmcp import Context
from pydantic import BeforeValidator
from pydantic import AliasChoices, BeforeValidator, Field
from basic_memory.mcp.project_context import get_project_client
from basic_memory.utils import coerce_list
@@ -24,7 +24,10 @@ async def canvas(
nodes: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
edges: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
title: str,
directory: str,
directory: Annotated[
str,
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
],
project: Optional[str] = None,
workspace: Optional[str] = None,
context: Context | None = None,
+6 -2
View File
@@ -1,9 +1,10 @@
from textwrap import dedent
from typing import Optional, Literal
from typing import Annotated, Optional, Literal
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import detect_project_from_url_prefix, get_project_client
@@ -153,7 +154,10 @@ If the note should be deleted but the operation keeps failing, send a message to
)
async def delete_note(
identifier: str,
is_directory: bool = False,
is_directory: Annotated[
bool,
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
] = False,
project: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
+31 -4
View File
@@ -1,10 +1,11 @@
"""Edit note tool for Basic Memory MCP server."""
from typing import Optional, Literal
from typing import Annotated, Optional, Literal
import logfire
from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
@@ -170,11 +171,37 @@ Error editing note '{identifier}': {error_message}
async def edit_note(
identifier: str,
operation: str,
content: str,
# Accept common replacement-content aliases. Models trained on diff/patch
# APIs reach for new_content/replacement/replace_with on first try.
content: Annotated[
str,
Field(
validation_alias=AliasChoices(
"content", "new_content", "replacement", "replace_with"
)
),
],
project: Optional[str] = None,
workspace: Optional[str] = None,
section: Optional[str] = None,
find_text: Optional[str] = None,
# Section/heading naming varies across tools; accept the descriptive forms.
section: Annotated[
Optional[str],
Field(
default=None,
validation_alias=AliasChoices("section", "section_heading", "heading"),
),
] = None,
# find_text is the highest-frequency miss per the issue: models reach for
# find/old_text/old_content/search before find_text every time.
find_text: Annotated[
Optional[str],
Field(
default=None,
validation_alias=AliasChoices(
"find_text", "find", "old_text", "old_content", "search"
),
),
] = None,
expected_replacements: Optional[int] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
+17 -3
View File
@@ -1,9 +1,10 @@
"""List directory tool for Basic Memory MCP server."""
from typing import Optional
from typing import Annotated, Optional
from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, Field
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
@@ -14,9 +15,22 @@ from basic_memory.mcp.server import mcp
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def list_directory(
dir_name: str = "/",
# `dir_name` is unusual; models reach for directory/folder/path/dir.
dir_name: Annotated[
str,
Field(
default="/",
validation_alias=AliasChoices("dir_name", "directory", "folder", "path", "dir"),
),
] = "/",
depth: int = 1,
file_name_glob: Optional[str] = None,
file_name_glob: Annotated[
Optional[str],
Field(
default=None,
validation_alias=AliasChoices("file_name_glob", "glob", "pattern", "filter"),
),
] = None,
project: Optional[str] = None,
workspace: Optional[str] = None,
context: Context | None = None,
+23 -4
View File
@@ -2,11 +2,12 @@
from pathlib import Path, PureWindowsPath
from textwrap import dedent
from typing import Optional, Literal
from typing import Annotated, Optional, Literal
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import AliasChoices, Field
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_project_client
@@ -348,9 +349,27 @@ delete_note("{identifier}")
)
async def move_note(
identifier: str,
destination_path: str = "",
destination_folder: Optional[str] = None,
is_directory: bool = False,
# Move/rename APIs across the ecosystem use `to`/`destination`/`new_path`.
destination_path: Annotated[
str,
Field(
default="",
validation_alias=AliasChoices(
"destination_path", "dest_path", "new_path", "to", "destination"
),
),
] = "",
destination_folder: Annotated[
Optional[str],
Field(
default=None,
validation_alias=AliasChoices("destination_folder", "dest_folder", "to_folder"),
),
] = None,
is_directory: Annotated[
bool,
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
] = False,
project: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
+6 -2
View File
@@ -8,11 +8,12 @@ Files are read directly without any knowledge graph processing.
import base64
import io
from typing import Optional
from typing import Annotated, Optional
from loguru import logger
from PIL import Image as PILImage
from fastmcp import Context
from pydantic import AliasChoices, Field
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.config import ConfigManager
@@ -158,7 +159,10 @@ def optimize_image(img, content_length, max_output_bytes=350000):
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def read_content(
path: str,
path: Annotated[
str,
Field(validation_alias=AliasChoices("path", "file_path", "filepath", "file")),
],
project: Optional[str] = None,
workspace: Optional[str] = None,
context: Context | None = None,
+16 -3
View File
@@ -1,13 +1,14 @@
"""Read note tool for Basic Memory MCP server."""
from textwrap import dedent
from typing import Optional, Literal, cast
from typing import Annotated, Optional, Literal, cast
import logfire
import yaml
from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
@@ -71,8 +72,20 @@ async def read_note(
identifier: str,
project: Optional[str] = None,
workspace: Optional[str] = None,
page: int = 1,
page_size: int = 10,
# Accept common pagination aliases models reach for from training data
# (page_number/limit/per_page). Schema still advertises only the canonical
# names; aliases are silently mapped at validation time.
# Why no `offset` alias: `offset` is item-indexed (skip N items) while `page`
# is 1-indexed page-number, so direct aliasing returns the wrong slice
# (e.g. offset=20,limit=10 should mean items 21-30, not page 20).
page: Annotated[
int,
Field(default=1, validation_alias=AliasChoices("page", "page_number")),
] = 1,
page_size: Annotated[
int,
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
] = 10,
output_format: Literal["text", "json"] = "text",
include_frontmatter: bool = False,
context: Context | None = None,
+23 -5
View File
@@ -2,10 +2,11 @@
from datetime import timezone
from pathlib import PurePosixPath
from typing import List, Union, Optional, Literal
from typing import Annotated, List, Union, Optional, Literal
from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, Field
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import (
@@ -38,11 +39,28 @@ from basic_memory.schemas.search import SearchItemType
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def recent_activity(
type: Union[str, List[str]] = "",
type: Annotated[
Union[str, List[str]],
Field(default="", validation_alias=AliasChoices("type", "types", "kind")),
] = "",
depth: int = 1,
timeframe: TimeFrame = "7d",
page: int = 1,
page_size: int = 10,
timeframe: Annotated[
TimeFrame,
Field(
default="7d",
validation_alias=AliasChoices("timeframe", "since", "time_range", "lookback"),
),
] = "7d",
# `offset` is intentionally NOT aliased: it has different semantics
# (item-indexed vs. 1-indexed page-number).
page: Annotated[
int,
Field(default=1, validation_alias=AliasChoices("page", "page_number")),
] = 1,
page_size: Annotated[
int,
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
] = 10,
project: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
+38 -6
View File
@@ -7,7 +7,7 @@ from typing import Annotated, List, Optional, Dict, Any, Literal
import logfire
from loguru import logger
from fastmcp import Context
from pydantic import BeforeValidator
from pydantic import AliasChoices, BeforeValidator, Field
from basic_memory.config import ConfigManager
from basic_memory.utils import coerce_dict, coerce_list
@@ -301,27 +301,51 @@ def _format_search_markdown(result: SearchResponse, project: str, query: str | N
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def search_notes(
query: Optional[str] = None,
# Accept common search-query aliases models reach for from training data.
# `q` is the universal HTTP convention; `search`/`text` are common in NL APIs.
query: Annotated[
Optional[str],
Field(default=None, validation_alias=AliasChoices("query", "q", "search", "text")),
] = None,
project: Optional[str] = None,
workspace: Optional[str] = None,
page: int = 1,
page_size: int = 10,
# `offset` is intentionally NOT aliased to `page`: offset is item-indexed
# (skip N items) while page is 1-indexed page-number. Direct aliasing would
# silently return the wrong slice.
page: Annotated[
int,
Field(default=1, validation_alias=AliasChoices("page", "page_number")),
] = 1,
page_size: Annotated[
int,
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
] = 10,
search_type: str | None = None,
output_format: Literal["text", "json"] = "text",
# Plural-vs-singular trips models constantly. Accept the singular too.
note_types: Annotated[
List[str] | None,
BeforeValidator(coerce_list),
Field(default=None, validation_alias=AliasChoices("note_types", "note_type", "types")),
"Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). "
"Case-insensitive.",
] = None,
entity_types: Annotated[
List[str] | None,
BeforeValidator(coerce_list),
Field(default=None, validation_alias=AliasChoices("entity_types", "entity_type")),
"Filter by knowledge graph item type: 'entity' (whole notes), 'observation', or "
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
"'Chapter' here — use note_types instead.",
] = None,
after_date: Optional[str] = None,
# Time-filter naming varies wildly across APIs.
after_date: Annotated[
Optional[str],
Field(
default=None,
validation_alias=AliasChoices("after_date", "since", "after", "from_date"),
),
] = None,
metadata_filters: Annotated[
Dict[str, Any] | None,
BeforeValidator(coerce_dict),
@@ -331,7 +355,15 @@ async def search_notes(
BeforeValidator(coerce_list),
] = None,
status: Optional[str] = None,
min_similarity: Optional[float] = None,
min_similarity: Annotated[
Optional[float],
Field(
default=None,
validation_alias=AliasChoices(
"min_similarity", "threshold", "similarity_threshold"
),
),
] = None,
context: Context | None = None,
) -> dict | str:
"""Search across all content in the knowledge base with comprehensive syntax support.
+12 -3
View File
@@ -1,10 +1,11 @@
"""View note tool for Basic Memory MCP server."""
from textwrap import dedent
from typing import Optional
from typing import Annotated, Optional
from loguru import logger
from fastmcp import Context
from pydantic import AliasChoices, Field
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.read_note import read_note
@@ -18,8 +19,16 @@ async def view_note(
identifier: str,
project: Optional[str] = None,
workspace: Optional[str] = None,
page: int = 1,
page_size: int = 10,
# `offset` is intentionally NOT aliased: it has different semantics
# (item-indexed vs. 1-indexed page-number).
page: Annotated[
int,
Field(default=1, validation_alias=AliasChoices("page", "page_number")),
] = 1,
page_size: Annotated[
int,
Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")),
] = 10,
context: Context | None = None,
) -> str:
"""View a markdown note as a formatted artifact.
+11 -3
View File
@@ -5,7 +5,7 @@ from typing import Annotated, List, Union, Optional, Literal
import logfire
from loguru import logger
from pydantic import BeforeValidator
from pydantic import AliasChoices, BeforeValidator, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
@@ -25,13 +25,21 @@ TagType = Union[List[str], str, None]
async def write_note(
title: str,
content: str,
directory: str,
# Folder/dir/path are interchangeable in models' training data.
directory: Annotated[
str,
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
],
project: Optional[str] = None,
workspace: Optional[str] = None,
tags: list[str] | str | None = None,
note_type: str = "note",
metadata: Annotated[dict | None, BeforeValidator(coerce_dict)] = None,
overwrite: bool | None = None,
# Force/replace are the file-write idioms models default to.
overwrite: Annotated[
bool | None,
Field(default=None, validation_alias=AliasChoices("overwrite", "force", "replace")),
] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
) -> str | dict: