fix: update FastMCP initialization for API changes

- Remove deprecated auth_server_provider parameter
- Use auth parameter correctly with OAuthProvider instead of AuthSettings
- Fixes type error after dependency updates

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
phernandez
2025-06-03 09:08:17 -05:00
parent c13d4b1511
commit ac08a8d024
22 changed files with 135 additions and 90 deletions
@@ -57,7 +57,6 @@ def upgrade() -> None:
""")
def downgrade() -> None:
"""Downgrade database schema to use old search index."""
# Drop the updated search_index table
@@ -274,4 +274,4 @@ async def delete_entities(
background_tasks.add_task(search_service.delete_by_permalink, permalink)
result = DeleteEntitiesResponse(deleted=deleted)
return result
return result
@@ -145,7 +145,9 @@ async def remove_project(
try:
old_project = await project_service.get_project(name)
if not old_project: # pragma: no cover
raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover
raise HTTPException(
status_code=404, detail=f"Project: '{name}' does not exist"
) # pragma: no cover
await project_service.remove_project(name)
@@ -186,7 +188,9 @@ async def set_default_project(
# get the new project
new_default_project = await project_service.get_project(name)
if not new_default_project: # pragma: no cover
raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover
raise HTTPException(
status_code=404, detail=f"Project: '{name}' does not exist"
) # pragma: no cover
await project_service.set_default_project(name)
+1 -1
View File
@@ -179,7 +179,7 @@ class ConfigManager:
def save_config(self, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
try:
try:
self.config_file.write_text(json.dumps(config.model_dump(), indent=2))
except Exception as e: # pragma: no cover
logger.error(f"Failed to save config: {e}")
+1 -2
View File
@@ -106,6 +106,5 @@ auth_settings, auth_provider = create_auth_config()
mcp = FastMCP(
name="Basic Memory",
log_level="DEBUG",
auth_server_provider=auth_provider,
auth=auth_settings,
auth=auth_provider,
)
+1 -1
View File
@@ -31,7 +31,7 @@ async def move_note(
Examples:
- Move to new folder: move_note("My Note", "work/notes/my-note.md")
- Move by permalink: move_note("my-note-permalink", "archive/old-notes/my-note.md")
- Move by permalink: move_note("my-note-permalink", "archive/old-notes/my-note.md")
- Specify project: move_note("My Note", "archive/my-note.md", project="work-project")
Note: This operation moves notes within the specified project only. Moving notes
+2 -1
View File
@@ -22,6 +22,7 @@ from basic_memory.config import WATCH_STATUS_JSON
from basic_memory.utils import generate_permalink
from basic_memory.config import config_manager
class ProjectService:
"""Service for managing Basic Memory projects."""
@@ -545,4 +546,4 @@ class ProjectService:
database_size=db_size_readable,
watch_status=watch_status,
timestamp=datetime.now(),
)
)
+1 -1
View File
@@ -148,7 +148,7 @@ class SearchService:
# If parsing fails, treat as single tag
return [tags] if tags.strip() else []
return [] # pragma: no cover
return [] # pragma: no cover
async def index_entity(
self,
+4 -2
View File
@@ -379,7 +379,9 @@ class SyncService:
updates = {"file_path": new_path}
# If configured, also update permalink to match new path
if self.app_config.update_permalinks_on_move and self.file_service.is_markdown(new_path):
if self.app_config.update_permalinks_on_move and self.file_service.is_markdown(
new_path
):
# generate new permalink value
new_permalink = await self.entity_service.resolve_permalink(new_path)
@@ -505,4 +507,4 @@ class SyncService:
f"duration_ms={duration_ms}"
)
return result
return result
+15 -7
View File
@@ -49,10 +49,8 @@ async def test_my_mcp_tool(mcp_server, app):
The `app` fixture ensures FastAPI dependency overrides are active, and
`mcp_server` provides the MCP server with proper project session initialization.
"""
import os
from typing import AsyncGenerator
from unittest import mock
from unittest.mock import patch
import pytest
import pytest_asyncio
@@ -110,21 +108,29 @@ async def test_project(tmp_path, engine_factory) -> Project:
project = await project_repository.create(project_data)
return project
@pytest.fixture
def config_home(tmp_path, monkeypatch) -> Path:
monkeypatch.setenv("HOME", str(tmp_path))
return tmp_path
@pytest.fixture(scope="function")
def app_config(config_home, test_project, tmp_path, monkeypatch) -> BasicMemoryConfig:
"""Create test app configuration."""
projects = {test_project.name: str(test_project.path)}
app_config = BasicMemoryConfig(env="test", projects=projects, default_project=test_project.name, update_permalinks_on_move=True)
app_config = BasicMemoryConfig(
env="test",
projects=projects,
default_project=test_project.name,
update_permalinks_on_move=True,
)
# Set the module app_config instance project list (like regular tests)
monkeypatch.setattr("basic_memory.config.app_config", app_config)
return app_config
@pytest.fixture
def config_manager(app_config: BasicMemoryConfig, config_home, monkeypatch) -> ConfigManager:
config_manager = ConfigManager()
@@ -145,6 +151,7 @@ def config_manager(app_config: BasicMemoryConfig, config_home, monkeypatch) -> C
return config_manager
@pytest.fixture
def project_session(test_project: Project):
# initialize the project session with the test project
@@ -166,9 +173,10 @@ def project_config(test_project, monkeypatch):
return project_config
@pytest.fixture(scope="function")
def app(app_config, project_config, engine_factory, test_project, project_session, config_manager) -> FastAPI:
def app(
app_config, project_config, engine_factory, test_project, project_session, config_manager
) -> FastAPI:
"""Create test FastAPI application with single project."""
app = fastapi_app
@@ -228,4 +236,4 @@ def mcp_server(app_config, search_service):
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
"""Create test client that both MCP and tests will use."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
yield client
yield client
+1 -1
View File
@@ -37,4 +37,4 @@ def project_url(test_project: Project) -> str:
"""
# Make sure this matches what's in tests/conftest.py for test_project creation
# The permalink should be generated from "Test Project Context"
return f"/{test_project.permalink}"
return f"/{test_project.permalink}"
+1 -1
View File
@@ -930,7 +930,7 @@ async def test_move_entity_success(client: AsyncClient, project_url):
assert response.status_code == 200
response_model = EntityResponse.model_validate(response.json())
assert response_model.file_path == "target/MovedNote.md"
# Verify original entity no longer exists
response = await client.get(f"{project_url}/knowledge/entities/{original_permalink}")
assert response.status_code == 404
+9 -9
View File
@@ -149,25 +149,25 @@ async def test_remove_project_endpoint(test_config, client, project_service):
# First create a test project to remove
test_project_name = "test-remove-project"
await project_service.add_project(test_project_name, "/tmp/test-remove-project")
# Verify it exists
project = await project_service.get_project(test_project_name)
assert project is not None
# Remove the project
response = await client.delete(f"/projects/{test_project_name}")
# Verify response
assert response.status_code == 200
data = response.json()
# Check response structure
assert "message" in data
assert "status" in data
assert data["status"] == "success"
assert "old_project" in data
assert data["old_project"]["name"] == test_project_name
# Verify project is actually removed
removed_project = await project_service.get_project(test_project_name)
assert removed_project is None
@@ -179,20 +179,20 @@ async def test_set_default_project_endpoint(test_config, client, project_service
# Create a test project to set as default
test_project_name = "test-default-project"
await project_service.add_project(test_project_name, "/tmp/test-default-project")
# Set it as default
response = await client.put(f"/projects/{test_project_name}/default")
# Verify response
assert response.status_code == 200
data = response.json()
# Check response structure
assert "message" in data
assert "status" in data
assert data["status"] == "success"
assert "new_project" in data
assert data["new_project"]["name"] == test_project_name
# Verify it's actually set as default
assert project_service.default_project == test_project_name
+1 -1
View File
@@ -29,4 +29,4 @@ async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
@pytest.fixture
def cli_env(project_config, client, test_config):
"""Set up CLI environment with correct project session."""
return {"project_config": project_config, "client": client}
return {"project_config": project_config, "client": client}
+1 -1
View File
@@ -439,4 +439,4 @@ def test_ensure_migrations_handles_errors(mock_initialize_database, project_conf
# Call the function - should not raise exception
ensure_initialization(project_config)
# We're just making sure it doesn't crash by calling it
# We're just making sure it doesn't crash by calling it
-2
View File
@@ -5,7 +5,6 @@ import json
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
from basic_memory.config import config
def test_info_stats_command(cli_env, test_graph, project_session):
@@ -15,7 +14,6 @@ def test_info_stats_command(cli_env, test_graph, project_session):
# Run the command
result = runner.invoke(cli_app, ["project", "info"])
# Verify exit code
assert result.exit_code == 0
+30 -16
View File
@@ -1,16 +1,13 @@
"""Common test fixtures."""
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from textwrap import dedent
from typing import AsyncGenerator
from unittest import mock
from unittest.mock import patch
import pytest
import pytest_asyncio
from loguru import logger
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
import basic_memory.mcp.project_session
@@ -49,42 +46,52 @@ def anyio_backend():
def project_root() -> Path:
return Path(__file__).parent.parent
@pytest.fixture
def config_home(tmp_path, monkeypatch) -> Path:
# Patch HOME environment variable for the duration of the test
monkeypatch.setenv("HOME", str(tmp_path))
return tmp_path
@pytest.fixture(scope="function", autouse=True)
def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
"""Create test app configuration."""
# Create a basic config without depending on test_project to avoid circular dependency
projects = {"test-project": str(config_home)}
app_config = BasicMemoryConfig(env="test", projects=projects, default_project="test-project", update_permalinks_on_move=True)
app_config = BasicMemoryConfig(
env="test",
projects=projects,
default_project="test-project",
update_permalinks_on_move=True,
)
# Patch the module app_config instance for the duration of the test
monkeypatch.setattr("basic_memory.config.app_config", app_config)
return app_config
@pytest.fixture(autouse=True)
def config_manager(app_config: BasicMemoryConfig, project_config: ProjectConfig, config_home: Path, monkeypatch) -> ConfigManager:
def config_manager(
app_config: BasicMemoryConfig, project_config: ProjectConfig, config_home: Path, monkeypatch
) -> ConfigManager:
# Create a new ConfigManager that uses the test home directory
config_manager = ConfigManager()
# Update its paths to use the test directory
config_manager.config_dir = config_home / ".basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Override the config directly instead of relying on disk load
config_manager.config = app_config
# Ensure the config file is written to disk
config_manager.save_config(app_config)
# Patch the config_manager in all locations where it's imported
monkeypatch.setattr("basic_memory.config.config_manager", config_manager)
monkeypatch.setattr("basic_memory.services.project_service.config_manager", config_manager)
# Mock get_project_config to return test project config for test-project, fallback for others
def mock_get_project_config(project_name=None):
if project_name == "test-project" or project_name is None:
@@ -92,18 +99,24 @@ def config_manager(app_config: BasicMemoryConfig, project_config: ProjectConfig,
# For any other project name, return a default config pointing to test location
fallback_config = ProjectConfig(name=project_name or "main", home=Path(config_home))
return fallback_config
monkeypatch.setattr("basic_memory.mcp.project_session.get_project_config", mock_get_project_config)
monkeypatch.setattr(
"basic_memory.mcp.project_session.get_project_config", mock_get_project_config
)
# Patch the project config that CLI commands import (only modules that actually import config)
monkeypatch.setattr("basic_memory.cli.commands.project.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.sync.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.status.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.import_memory_json.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.import_claude_projects.config", project_config)
monkeypatch.setattr("basic_memory.cli.commands.import_claude_conversations.config", project_config)
monkeypatch.setattr(
"basic_memory.cli.commands.import_claude_conversations.config", project_config
)
monkeypatch.setattr("basic_memory.cli.commands.import_chatgpt.config", project_config)
return config_manager
@pytest.fixture(autouse=True)
def project_session(test_project: Project):
# initialize the project session with the test project
@@ -134,17 +147,18 @@ class TestConfig:
app_config: BasicMemoryConfig
config_manager: ConfigManager
@pytest.fixture
def test_config(config_home, project_config, app_config, config_manager) -> TestConfig:
"""All test configuration fixtures"""
@dataclass
class TestConfig:
config_home: Path
project_config: ProjectConfig
app_config: BasicMemoryConfig
config_manager: ConfigManager
return TestConfig(config_home, project_config, app_config, config_manager)
@@ -501,4 +515,4 @@ def test_files(project_config, project_root) -> dict[str, Path]:
async def synced_files(sync_service, project_config, test_files):
# Initial sync - should create forward reference
await sync_service.sync(project_config.home)
return test_files
return test_files
+16 -3
View File
@@ -167,7 +167,11 @@ async def test_move_note_nonexistent_note(client):
# Should raise an exception from the API with friendly error message
error_msg = str(exc_info.value)
assert "Entity not found" in error_msg or "Invalid request" in error_msg or "malformed" in error_msg
assert (
"Entity not found" in error_msg
or "Invalid request" in error_msg
or "malformed" in error_msg
)
@pytest.mark.asyncio
@@ -222,7 +226,11 @@ async def test_move_note_destination_exists(client):
# Should raise an exception (400 gets wrapped as malformed request)
error_msg = str(exc_info.value)
assert "Destination already exists" in error_msg or "Invalid request" in error_msg or "malformed" in error_msg
assert (
"Destination already exists" in error_msg
or "Invalid request" in error_msg
or "malformed" in error_msg
)
@pytest.mark.asyncio
@@ -244,7 +252,12 @@ async def test_move_note_same_location(client):
# Should raise an exception (400 gets wrapped as malformed request)
error_msg = str(exc_info.value)
assert "Destination already exists" in error_msg or "same location" in error_msg or "Invalid request" in error_msg or "malformed" in error_msg
assert (
"Destination already exists" in error_msg
or "same location" in error_msg
or "Invalid request" in error_msg
or "malformed" in error_msg
)
@pytest.mark.asyncio
+4 -5
View File
@@ -1230,7 +1230,7 @@ async def test_move_entity_success(
# Move entity
assert entity.permalink == "original/test-note"
result = await entity_service.move_entity(
await entity_service.move_entity(
identifier=entity.permalink,
destination_path="moved/test-note.md",
project_config=project_config,
@@ -1276,14 +1276,13 @@ async def test_move_entity_with_permalink_update(
app_config = BasicMemoryConfig(update_permalinks_on_move=True)
# Move entity
result = await entity_service.move_entity(
await entity_service.move_entity(
identifier=entity.permalink,
destination_path="moved/test-note.md",
project_config=project_config,
app_config=app_config,
)
# Verify entity was found by new path (since permalink changed)
moved_entity = await entity_service.link_resolver.resolve_link("moved/test-note.md")
assert moved_entity is not None
@@ -1473,7 +1472,7 @@ async def test_move_entity_by_title(
app_config = BasicMemoryConfig(update_permalinks_on_move=False)
# Move by title
result = await entity_service.move_entity(
await entity_service.move_entity(
identifier="Test Note", # Use title instead of permalink
destination_path="moved/test-note.md",
project_config=project_config,
@@ -1657,4 +1656,4 @@ async def test_move_entity_with_complex_observations(
relation_targets = {rel.to_name for rel in moved_entity.relations}
assert "Branch Strategy" in relation_targets
assert "Multiple" in relation_targets
assert "Links" in relation_targets
assert "Links" in relation_targets
+22 -17
View File
@@ -13,6 +13,7 @@ from basic_memory.schemas import (
from basic_memory.services.project_service import ProjectService
from basic_memory.config import ConfigManager
def test_projects_property(project_service: ProjectService):
"""Test the projects property."""
# Get the projects
@@ -63,7 +64,9 @@ def test_current_project_property(project_service: ProjectService):
@pytest.mark.asyncio
async def test_project_operations_sync_methods(app_config, project_service: ProjectService, config_manager: ConfigManager, tmp_path):
async def test_project_operations_sync_methods(
app_config, project_service: ProjectService, config_manager: ConfigManager, tmp_path
):
"""Test adding, switching, and removing a project using ConfigManager directly.
This test uses the ConfigManager directly instead of the async methods.
@@ -241,24 +244,24 @@ async def test_get_project_method(project_service: ProjectService, tmp_path):
"""Test the get_project method directly."""
test_project_name = f"test-get-project-{os.urandom(4).hex()}"
test_project_path = str(tmp_path / "test-get-project")
# Make sure the test directory exists
os.makedirs(test_project_path, exist_ok=True)
try:
# Test getting a non-existent project
result = await project_service.get_project("non-existent-project")
assert result is None
# Add a project
await project_service.add_project(test_project_name, test_project_path)
# Test getting an existing project
result = await project_service.get_project(test_project_name)
assert result is not None
assert result.name == test_project_name
assert result.path == test_project_path
finally:
# Clean up
if test_project_name in project_service.projects:
@@ -266,36 +269,38 @@ async def test_get_project_method(project_service: ProjectService, tmp_path):
@pytest.mark.asyncio
async def test_set_default_project_config_db_mismatch(project_service: ProjectService, config_manager: ConfigManager, tmp_path):
async def test_set_default_project_config_db_mismatch(
project_service: ProjectService, config_manager: ConfigManager, tmp_path
):
"""Test set_default_project when project exists in config but not in database."""
test_project_name = f"test-mismatch-project-{os.urandom(4).hex()}"
test_project_path = str(tmp_path / "test-mismatch-project")
# Make sure the test directory exists
# Make sure the test directory exists
os.makedirs(test_project_path, exist_ok=True)
original_default = project_service.default_project
try:
# Add project to config only (not to database)
config_manager.add_project(test_project_name, test_project_path)
# Verify it's in config but not in database
assert test_project_name in project_service.projects
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is None
# Try to set as default - this should trigger the error log on line 142
await project_service.set_default_project(test_project_name)
# Should still update config despite database mismatch
assert project_service.default_project == test_project_name
finally:
# Restore original default
if original_default:
config_manager.set_default_project(original_default)
# Clean up
if test_project_name in project_service.projects:
config_manager.remove_project(test_project_name)
config_manager.remove_project(test_project_name)
+10 -10
View File
@@ -202,7 +202,7 @@ async def test_search_entity_type(search_service, test_graph):
async def test_extract_entity_tags_exception_handling(search_service):
"""Test the _extract_entity_tags method exception handling (lines 147-151)."""
from basic_memory.models.knowledge import Entity
# Create entity with string tags that will cause parsing to fail and fall back to single tag
entity_with_invalid_tags = Entity(
title="Test Entity",
@@ -210,23 +210,23 @@ async def test_extract_entity_tags_exception_handling(search_service):
entity_metadata={"tags": "just a string"}, # This will fail ast.literal_eval
content_type="text/markdown",
file_path="test/test-entity.md",
project_id=1
project_id=1,
)
# This should trigger the except block on lines 147-149
result = search_service._extract_entity_tags(entity_with_invalid_tags)
assert result == ['just a string']
assert result == ["just a string"]
# Test with empty string (should return empty list) - covers line 149
entity_with_empty_tags = Entity(
title="Test Entity Empty",
entity_type="test",
entity_type="test",
entity_metadata={"tags": ""},
content_type="text/markdown",
file_path="test/test-entity-empty.md",
project_id=1
project_id=1,
)
result = search_service._extract_entity_tags(entity_with_empty_tags)
assert result == []
@@ -234,10 +234,10 @@ async def test_extract_entity_tags_exception_handling(search_service):
@pytest.mark.asyncio
async def test_delete_entity_without_permalink(search_service, sample_entity):
"""Test deleting an entity that has no permalink (edge case)."""
# Set the entity permalink to None to trigger the else branch on line 355
sample_entity.permalink = None
# This should trigger the delete_by_entity_id path (line 355) in handle_delete
await search_service.handle_delete(sample_entity)
+8 -5
View File
@@ -439,8 +439,12 @@ modified: 2024-01-01
# Verify outgoing relations by checking actual targets
a_outgoing_targets = {rel.to_id for rel in entity_a.outgoing_relations}
assert entity_b.id in a_outgoing_targets, f"A should depend on B. A's targets: {a_outgoing_targets}, B's ID: {entity_b.id}"
assert entity_c.id in a_outgoing_targets, f"A should depend on C. A's targets: {a_outgoing_targets}, C's ID: {entity_c.id}"
assert entity_b.id in a_outgoing_targets, (
f"A should depend on B. A's targets: {a_outgoing_targets}, B's ID: {entity_b.id}"
)
assert entity_c.id in a_outgoing_targets, (
f"A should depend on C. A's targets: {a_outgoing_targets}, C's ID: {entity_c.id}"
)
assert len(entity_a.outgoing_relations) == 2, "A should have exactly 2 outgoing relations"
b_outgoing_targets = {rel.to_id for rel in entity_b.outgoing_relations}
@@ -454,14 +458,13 @@ modified: 2024-01-01
# Verify incoming relations by checking actual sources
a_incoming_sources = {rel.from_id for rel in entity_a.incoming_relations}
assert entity_c.id in a_incoming_sources, "A should have incoming relation from C"
b_incoming_sources = {rel.from_id for rel in entity_b.incoming_relations}
assert entity_a.id in b_incoming_sources, "B should have incoming relation from A"
c_incoming_sources = {rel.from_id for rel in entity_c.incoming_relations}
assert entity_a.id in c_incoming_sources, "C should have incoming relation from A"
assert entity_b.id in c_incoming_sources, "C should have incoming relation from B"
@pytest.mark.asyncio