fix: normalize underscores in memory:// URLs for build_context (#329)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
This commit is contained in:
Paul Hernandez
2025-10-04 00:16:06 -05:00
committed by GitHub
parent ee83b0e5a8
commit f5a11f3911
2 changed files with 192 additions and 11 deletions
+21 -11
View File
@@ -100,20 +100,30 @@ class ContextService:
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
)
normalized_path: Optional[str] = None
if memory_url:
path = memory_url_path(memory_url)
# Pattern matching - use search
if "*" in path:
logger.debug(f"Pattern search for '{path}'")
primary = await self.search_repository.search(
permalink_match=path, limit=limit, offset=offset
)
# Check for wildcards before normalization
has_wildcard = "*" in path
# Direct lookup for exact path
else:
logger.debug(f"Direct lookup for '{path}'")
if has_wildcard:
# For wildcard patterns, normalize each segment separately to preserve the *
parts = path.split("*")
normalized_parts = [
generate_permalink(part, split_extension=False) if part else ""
for part in parts
]
normalized_path = "*".join(normalized_parts)
logger.debug(f"Pattern search for '{normalized_path}'")
primary = await self.search_repository.search(
permalink=path, limit=limit, offset=offset
permalink_match=normalized_path, limit=limit, offset=offset
)
else:
# For exact paths, normalize the whole thing
normalized_path = generate_permalink(path, split_extension=False)
logger.debug(f"Direct lookup for '{normalized_path}'")
primary = await self.search_repository.search(
permalink=normalized_path, limit=limit, offset=offset
)
else:
logger.debug(f"Build context for '{types}'")
@@ -151,7 +161,7 @@ class ContextService:
# Create metadata dataclass
metadata = ContextMetadata(
uri=memory_url_path(memory_url) if memory_url else None,
uri=normalized_path if memory_url else None,
types=types,
depth=depth,
timeframe=since.isoformat() if since else None,
@@ -0,0 +1,171 @@
"""Integration test for build_context with underscore in memory:// URLs."""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_build_context_underscore_normalization(mcp_server, app, test_project):
"""Test that build_context normalizes underscores in relation types."""
async with Client(mcp_server) as client:
# Create parent note
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Parent Entity",
"folder": "testing",
"content": "# Parent Entity\n\nMain entity for testing underscore relations.",
"tags": "test,parent",
},
)
# Create child notes with different relation formats
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Child with Underscore",
"folder": "testing",
"content": """# Child with Underscore
- part_of [[Parent Entity]]
- related_to [[Parent Entity]]
""",
"tags": "test,child",
},
)
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Child with Hyphen",
"folder": "testing",
"content": """# Child with Hyphen
- part-of [[Parent Entity]]
- related-to [[Parent Entity]]
""",
"tags": "test,child",
},
)
# Test 1: Search with underscore format should return results
# Relation permalinks are: source/relation_type/target
# So child-with-underscore/part-of/parent-entity
result_underscore = await client.call_tool(
"build_context",
{
"project": test_project.name,
"url": "memory://testing/*/part_of/*parent*", # Using underscore
},
)
# Parse response
assert len(result_underscore.content) == 1
response_text = result_underscore.content[0].text # pyright: ignore
assert '"results"' in response_text
# Both relations should be found since they both connect to parent-entity
# The system should normalize the underscore to hyphen internally
assert "part-of" in response_text.lower()
# Test 2: Search with hyphen format should also return results
result_hyphen = await client.call_tool(
"build_context",
{
"project": test_project.name,
"url": "memory://testing/*/part-of/*parent*", # Using hyphen
},
)
response_text_hyphen = result_hyphen.content[0].text # pyright: ignore
assert '"results"' in response_text_hyphen
assert "part-of" in response_text_hyphen.lower()
# Test 3: Test with related_to/related-to as well
result_related = await client.call_tool(
"build_context",
{
"project": test_project.name,
"url": "memory://testing/*/related_to/*parent*", # Using underscore
},
)
response_text_related = result_related.content[0].text # pyright: ignore
assert '"results"' in response_text_related
assert "related-to" in response_text_related.lower()
# Test 4: Test exact path (non-wildcard) with underscore
# Exact relation permalink would be child/relation/target
result_exact = await client.call_tool(
"build_context",
{
"project": test_project.name,
"url": "memory://testing/child-with-underscore/part_of/testing/parent-entity",
},
)
response_text_exact = result_exact.content[0].text # pyright: ignore
assert '"results"' in response_text_exact
assert "part-of" in response_text_exact.lower()
@pytest.mark.asyncio
async def test_build_context_complex_underscore_paths(mcp_server, app, test_project):
"""Test build_context with complex paths containing underscores."""
async with Client(mcp_server) as client:
# Create notes with underscores in titles and relations
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "workflow_manager_agent",
"folder": "specs",
"content": """# Workflow Manager Agent
Specification for the workflow manager agent.
""",
"tags": "spec,workflow",
},
)
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "task_parser",
"folder": "components",
"content": """# Task Parser
- part_of [[workflow_manager_agent]]
- implements_for [[workflow_manager_agent]]
""",
"tags": "component,parser",
},
)
# Test with underscores in all parts of the path
# Relations are created as: task-parser/part-of/workflow-manager-agent
# So search for */part_of/* or */part-of/* to find them
test_cases = [
"memory://components/*/part_of/*workflow*",
"memory://components/*/part-of/*workflow*",
"memory://*/task*/part_of/*",
"memory://*/task*/part-of/*",
]
for url in test_cases:
result = await client.call_tool(
"build_context", {"project": test_project.name, "url": url}
)
# All variations should work and find the related content
assert len(result.content) == 1
response = result.content[0].text # pyright: ignore
assert '"results"' in response
# The relation should be found showing part-of connection
assert "part-of" in response.lower(), f"Failed for URL: {url}"