mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(mcp): accept training-data-friendly parameter aliases (#766)
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user