Files
basicmachines-co-basic-memory/test-int/mcp/test_param_aliases_integration.py
2026-05-23 14:19:43 -05:00

566 lines
20 KiB
Python

"""
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: pagination params removed in #693 (were no-ops) ---
# The `page` / `page_size` parameters were removed because the API endpoint
# silently dropped them. Search-fallback pagination is unrelated to read_note.
# --- 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_overwrite_canonical_via_mcp(mcp_server, app, test_project):
"""Canonical overwrite=True must reach the handler (#818 regression)."""
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Overwrite Canonical Note",
"directory": "overwrite-test",
"content": "v1",
},
)
blocked = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Overwrite Canonical Note",
"directory": "overwrite-test",
"content": "v2",
},
)
assert "# Error: Note already exists" in blocked.content[0].text
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Overwrite Canonical Note",
"directory": "overwrite-test",
"content": "v2",
"overwrite": True,
},
)
assert "# Updated 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: pagination params removed in #693 (delegates to read_note) ---
# --- 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 has no pagination params (#693 — they were no-ops; removed).
# The must_not_have list still includes the rejected aliases so future
# contributors don't reintroduce them.
"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 pagination params removed in #693 (delegates to read_note).
"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"
# #818: AliasChoices on optional bool broke external-client JSON schema (null-only).
overwrite_schema = tools["write_note"].inputSchema["properties"]["overwrite"]
schema_types: set[str] = set()
if "type" in overwrite_schema:
raw = overwrite_schema["type"]
if isinstance(raw, str):
schema_types.add(raw)
else:
schema_types.update(raw)
for option in overwrite_schema.get("anyOf", ()):
if "type" in option:
schema_types.add(option["type"])
assert "boolean" in schema_types, (
f"write_note overwrite must expose boolean in schema, got {overwrite_schema}"
)