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:
@@ -0,0 +1,668 @@
"""
Integration tests for MCP tool parameter aliases.
Verifies that MCP tools accept training-data-friendly parameter aliases
(via Pydantic AliasChoices) alongside the canonical names, so models
that reach for `offset`/`limit`/`find`/`old_text` etc. don't hit
validation errors on first use.
See: https://github.com/basicmachines-co/basic-memory/issues/690
"""
import pytest
from fastmcp import Client
# --- read_note: page / page_size aliases ---
@pytest.mark.asyncio
async def test_read_note_accepts_limit_alias_for_page_size(mcp_server, app, test_project):
"""`limit` should be accepted in place of `page_size` (true synonym — both mean
"max items per response"). Note: `offset` is intentionally NOT aliased to `page`
because offset is item-indexed while page is 1-indexed page-number — silently
aliasing would return the wrong slice. See test_offset_is_rejected below.
"""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Pagination Note",
"directory": "test",
"content": "# Pagination Note\n\nBody.",
},
)
result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "Pagination Note",
"limit": 5,
},
)
assert len(result.content) == 1
assert "# Pagination Note" in result.content[0].text
@pytest.mark.asyncio
async def test_offset_is_not_aliased_to_page(mcp_server, app, test_project):
"""`offset` must NOT be silently mapped to `page` — they have different
semantics (item-index vs. 1-indexed page number). Locks in the deliberate
omission so a future contributor doesn't add it back thinking it's harmless.
"""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Offset Reject Note",
"directory": "test",
"content": "# Offset Reject Note\n\nBody.",
},
)
# FastMCP wraps unknown kwargs in a ToolError; just assert it raises.
with pytest.raises(Exception):
await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "Offset Reject Note",
"offset": 0,
},
)
@pytest.mark.asyncio
async def test_read_note_accepts_page_number_per_page_aliases(mcp_server, app, test_project):
"""`page_number` and `per_page` should also map to `page` / `page_size`."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Per Page Note",
"directory": "test",
"content": "# Per Page Note\n\nBody.",
},
)
result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "Per Page Note",
"page_number": 1,
"per_page": 10,
},
)
assert len(result.content) == 1
assert "# Per Page Note" in result.content[0].text
@pytest.mark.asyncio
async def test_read_note_canonical_names_still_work(mcp_server, app, test_project):
"""Canonical names must keep working — aliases are additive, not a rename."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Canonical Note",
"directory": "test",
"content": "# Canonical Note\n\nBody.",
},
)
result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "Canonical Note",
"page": 1,
"page_size": 10,
},
)
assert len(result.content) == 1
assert "# Canonical Note" in result.content[0].text
# --- edit_note: find_text / content / section aliases ---
@pytest.mark.asyncio
async def test_edit_note_accepts_find_alias_for_find_text(mcp_server, app, test_project):
"""`find` should map to `find_text` — the highest-frequency miss in the issue."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Find Alias Note",
"directory": "test",
"content": "# Find Alias Note\n\nVersion v1.0.0 of the spec.",
},
)
result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Find Alias Note",
"operation": "find_replace",
"content": "v2.0.0",
"find": "v1.0.0", # alias for find_text
},
)
assert "Edited note (find_replace)" in result.content[0].text
read_result = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "Find Alias Note"},
)
assert "v2.0.0" in read_result.content[0].text
assert "v1.0.0" not in read_result.content[0].text
@pytest.mark.asyncio
async def test_edit_note_accepts_old_text_alias(mcp_server, app, test_project):
"""`old_text` (diff/patch convention) should map to `find_text`."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Old Text Note",
"directory": "test",
"content": "# Old Text Note\n\nThe quick brown fox.",
},
)
result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Old Text Note",
"operation": "find_replace",
"content": "lazy",
"old_text": "quick",
},
)
assert "Edited note (find_replace)" in result.content[0].text
@pytest.mark.asyncio
async def test_edit_note_accepts_new_content_alias_for_content(mcp_server, app, test_project):
"""`new_content` should map to `content` — `content` is ambiguous as 'replacement text'."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "New Content Note",
"directory": "test",
"content": "# New Content Note\n\nplaceholder",
},
)
result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "New Content Note",
"operation": "find_replace",
"new_content": "actual value", # alias for content
"find_text": "placeholder",
},
)
assert "Edited note (find_replace)" in result.content[0].text
read_result = await client.call_tool(
"read_note",
{"project": test_project.name, "identifier": "New Content Note"},
)
assert "actual value" in read_result.content[0].text
@pytest.mark.asyncio
async def test_edit_note_accepts_section_heading_alias(mcp_server, app, test_project):
"""`section_heading` and `heading` should map to `section`."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Section Heading Note",
"directory": "test",
"content": "# Section Heading Note\n\n## Notes\n\nold notes\n",
},
)
result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Section Heading Note",
"operation": "replace_section",
"content": "fresh notes\n",
"section_heading": "## Notes", # alias for section
},
)
assert "Edited note (replace_section)" in result.content[0].text
@pytest.mark.asyncio
async def test_edit_note_canonical_names_still_work(mcp_server, app, test_project):
"""Canonical names must keep working alongside aliases."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Edit Canonical Note",
"directory": "test",
"content": "# Edit Canonical Note\n\nold-value here.",
},
)
result = await client.call_tool(
"edit_note",
{
"project": test_project.name,
"identifier": "Edit Canonical Note",
"operation": "find_replace",
"content": "new-value",
"find_text": "old-value",
},
)
assert "Edited note (find_replace)" in result.content[0].text
# --- search_notes aliases ---
@pytest.mark.asyncio
async def test_search_notes_accepts_query_aliases(mcp_server, app, test_project):
"""`q` (HTTP convention), `search`, and `text` should all map to `query`."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Searchable Note",
"directory": "test",
"content": "# Searchable Note\n\nUnique-keyword-XYZ here.",
},
)
# Try each alias
for alias_key in ("q", "search", "text"):
result = await client.call_tool(
"search_notes",
{
"project": test_project.name,
alias_key: "Unique-keyword-XYZ",
"limit": 5, # also testing pagination alias
},
)
assert "Searchable Note" in result.content[0].text, f"alias {alias_key} failed"
@pytest.mark.asyncio
async def test_search_notes_accepts_after_date_aliases(mcp_server, app, test_project):
"""`since`/`after`/`from_date` should map to `after_date`."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Date Filter Note",
"directory": "test",
"content": "# Date Filter Note\n\nbody",
},
)
# Just verify the alias is accepted at validation time (no error)
result = await client.call_tool(
"search_notes",
{"project": test_project.name, "query": "Date Filter", "since": "1d"},
)
assert result.content # didn't error
# --- recent_activity aliases ---
@pytest.mark.asyncio
async def test_recent_activity_accepts_timeframe_aliases(mcp_server, app, test_project):
"""`since`/`time_range`/`lookback` should map to `timeframe`."""
async with Client(mcp_server) as client:
result = await client.call_tool(
"recent_activity",
{"project": test_project.name, "since": "7d", "limit": 5},
)
assert result.content # accepted, no validation error
# --- list_directory aliases ---
@pytest.mark.asyncio
async def test_list_directory_accepts_directory_alias(mcp_server, app, test_project):
"""`directory`/`folder`/`path`/`dir` should all map to `dir_name`."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Dir Test",
"directory": "list-dir-aliases",
"content": "# Dir Test\n\nbody",
},
)
for alias_key in ("directory", "folder", "path", "dir"):
result = await client.call_tool(
"list_directory",
{"project": test_project.name, alias_key: "/list-dir-aliases"},
)
assert "Dir Test" in result.content[0].text, f"alias {alias_key} failed"
@pytest.mark.asyncio
async def test_list_directory_accepts_glob_aliases(mcp_server, app, test_project):
"""`glob`/`pattern`/`filter` should map to `file_name_glob`."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Glob Target",
"directory": "glob-test",
"content": "# Glob Target\n\nbody",
},
)
result = await client.call_tool(
"list_directory",
{
"project": test_project.name,
"dir_name": "/glob-test",
"glob": "*.md",
},
)
assert "Glob Target" in result.content[0].text
# --- write_note aliases ---
@pytest.mark.asyncio
async def test_write_note_accepts_directory_aliases(mcp_server, app, test_project):
"""`folder`/`dir`/`path` should map to `directory`."""
async with Client(mcp_server) as client:
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Folder Alias Note",
"folder": "folder-alias-test", # alias
"content": "# Folder Alias Note\n\nbody",
},
)
assert "folder-alias-test" in result.content[0].text
@pytest.mark.asyncio
async def test_write_note_accepts_overwrite_aliases(mcp_server, app, test_project):
"""`force`/`replace` should map to `overwrite`."""
async with Client(mcp_server) as client:
# First create
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Overwrite Alias Note",
"directory": "overwrite-test",
"content": "v1",
},
)
# Overwrite using `force` alias
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Overwrite Alias Note",
"directory": "overwrite-test",
"content": "v2",
"force": True, # alias for overwrite
},
)
assert "Updated note" in result.content[0].text or "Created note" in result.content[0].text
# --- move_note aliases ---
@pytest.mark.asyncio
async def test_move_note_accepts_destination_aliases(mcp_server, app, test_project):
"""`to`/`dest_path`/`new_path`/`destination` should map to `destination_path`."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Move Target",
"directory": "move-src",
"content": "# Move Target\n\nbody",
},
)
result = await client.call_tool(
"move_note",
{
"project": test_project.name,
"identifier": "Move Target",
"to": "move-dest/Move Target.md", # alias for destination_path
},
)
assert "move-dest" in result.content[0].text
# --- read_content aliases ---
@pytest.mark.asyncio
async def test_read_content_accepts_file_path_alias(mcp_server, app, test_project):
"""`file_path`/`filepath`/`file` should map to `path`."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Read Content Target",
"directory": "read-content-test",
"content": "# Read Content Target\n\nraw body",
},
)
result = await client.call_tool(
"read_content",
{
"project": test_project.name,
"file_path": "read-content-test/Read Content Target.md",
},
)
# read_content returns a dict; structured content should include the file
text = result.content[0].text if result.content else ""
struct = result.structured_content if hasattr(result, "structured_content") else None
assert "raw body" in text or (struct and "raw body" in str(struct))
# --- build_context aliases ---
@pytest.mark.asyncio
async def test_build_context_accepts_url_aliases(mcp_server, app, test_project):
"""`uri`/`memory_url` should map to `url`."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Context Target",
"directory": "build-ctx",
"content": "# Context Target\n\nbody",
},
)
result = await client.call_tool(
"build_context",
{
"project": test_project.name,
"uri": "memory://build-ctx/context-target", # alias for url
},
)
# Just verify validation accepted the alias
assert result.content or result.structured_content
# --- view_note aliases (mirrors read_note pagination) ---
@pytest.mark.asyncio
async def test_view_note_accepts_pagination_aliases(mcp_server, app, test_project):
"""`page_number`/`limit`/`per_page` should map through view_note to read_note."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "View Alias Note",
"directory": "test",
"content": "# View Alias Note\n\nBody.",
},
)
result = await client.call_tool(
"view_note",
{
"project": test_project.name,
"identifier": "View Alias Note",
"page_number": 1,
"limit": 10,
},
)
assert len(result.content) == 1
assert "# View Alias Note" in result.content[0].text
# --- delete_note aliases ---
@pytest.mark.asyncio
async def test_delete_note_accepts_is_dir_alias(mcp_server, app, test_project):
"""`is_dir` should map to `is_directory` and route to single-note deletion."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Delete Alias Note",
"directory": "delete-alias-test",
"content": "# Delete Alias Note\n\nBody.",
},
)
result = await client.call_tool(
"delete_note",
{
"project": test_project.name,
"identifier": "delete-alias-test/Delete Alias Note",
"is_dir": False, # alias for is_directory
},
)
# delete_note returns a bool/dict on success; just assert no error
assert result.content or result.structured_content
# --- Schema sanity check: aliases must not appear in the advertised schema ---
@pytest.mark.asyncio
async def test_aliases_not_advertised_in_schema(mcp_server, app):
"""The JSON schema sent to models should advertise only canonical names.
Aliases are accepted at validation time but advertising them would defeat
the purpose: we want the model to learn the canonical name, with aliases
as a silent safety net for first-use mistakes.
The `must_not_have` lists below intentionally include both *accepted*
aliases (which must stay hidden from the schema) AND *rejected* aliases
that were considered but deliberately omitted (`offset` for `page`,
`limit_related` for `max_related`). Listing rejected aliases here acts
as a future-contributor guard — if anyone re-adds them, this test catches
it before the bad alias ships.
"""
async with Client(mcp_server) as client:
tools = {t.name: t for t in await client.list_tools()}
# tool_name -> (must_have_canonical, must_not_have_aliases)
checks = {
"read_note": (
["page", "page_size"],
["offset", "limit", "page_number", "per_page"],
),
"edit_note": (
["find_text", "section", "content"],
["find", "old_text", "old_content", "search", "new_content", "section_heading"],
),
"search_notes": (
["query", "page", "page_size", "note_types", "after_date", "min_similarity"],
["q", "search", "offset", "limit", "note_type", "types", "since", "after", "threshold"],
),
"recent_activity": (
["type", "timeframe", "page", "page_size"],
["types", "kind", "since", "time_range", "lookback", "offset", "limit"],
),
"list_directory": (
["dir_name", "file_name_glob"],
["directory", "folder", "path", "dir", "glob", "pattern", "filter"],
),
"write_note": (
["directory", "overwrite"],
["folder", "dir", "path", "force", "replace"],
),
"move_note": (
["destination_path", "destination_folder", "is_directory"],
["dest_path", "new_path", "to", "destination", "is_dir"],
),
"delete_note": (["is_directory"], ["is_dir"]),
"read_content": (["path"], ["file_path", "filepath", "file"]),
"view_note": (
["page", "page_size"],
["offset", "limit", "page_number", "per_page"],
),
"build_context": (
["url", "timeframe", "page", "page_size", "max_related"],
["uri", "memory_url", "since", "offset", "limit", "max_results", "limit_related"],
),
"canvas": (["directory"], ["folder", "dir", "path"]),
}
for tool_name, (must_have, must_not_have) in checks.items():
assert tool_name in tools, f"tool {tool_name} not registered"
props = tools[tool_name].inputSchema["properties"]
for canonical in must_have:
assert canonical in props, f"{tool_name}: canonical '{canonical}' missing"
for alias in must_not_have:
assert alias not in props, f"{tool_name}: alias '{alias}' leaked into schema"