diff --git a/pyproject.toml b/pyproject.toml index 7d1a322b2..f239b195d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "aiosqlite>=0.20.0", "greenlet>=3.1.1", "pydantic[email,timezone]>=2.12.0", - "mcp>=1.23.1", + "mcp>=2,<3", "pydantic-settings>=2.6.1", "loguru>=0.7.3", "pyright>=1.1.390", @@ -29,7 +29,7 @@ dependencies = [ "alembic>=1.14.1", "pillow>=11.1.0", "pybars3>=0.9.7", - "fastmcp>=3.3.1,<4", + "fastmcp==4.0.0b1", "pyjwt>=2.10.1", "python-dotenv>=1.1.0", "pytest-aio>=1.9.0", @@ -149,6 +149,9 @@ dev = [ [tool.hatch.version] source = "uv-dynamic-versioning" +[tool.uv] +constraint-dependencies = ["fastmcp-slim==4.0.0b1"] + [tool.uv-dynamic-versioning] vcs = "git" style = "pep440" diff --git a/src/basic_memory/cli/commands/command_utils.py b/src/basic_memory/cli/commands/command_utils.py index 0360be1fe..bc993aea6 100644 --- a/src/basic_memory/cli/commands/command_utils.py +++ b/src/basic_memory/cli/commands/command_utils.py @@ -65,8 +65,8 @@ async def run_project_index( force_full: If True, force a full scan bypassing watermark optimization run_in_background: If True, return immediately; if False, wait for completion """ - # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). - from mcp.server.fastmcp.exceptions import ToolError + # Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886). + from fastmcp.exceptions import ToolError # Resolve default project so get_client() can route per-project project = project or ConfigManager().default_project @@ -99,8 +99,8 @@ async def run_project_index( async def get_project_info(project: str): """Get project information via API endpoint.""" - # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). - from mcp.server.fastmcp.exceptions import ToolError + # Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886). + from fastmcp.exceptions import ToolError try: async with get_client(project_name=project) as client: diff --git a/src/basic_memory/cli/commands/doctor.py b/src/basic_memory/cli/commands/doctor.py index 43a2afb79..1d4539cd2 100644 --- a/src/basic_memory/cli/commands/doctor.py +++ b/src/basic_memory/cli/commands/doctor.py @@ -64,8 +64,8 @@ async def _delete_doctor_project( project_client: ProjectClient, project_name: str, project_id: str ) -> None: """Delete the generated doctor project without weakening the public API guard.""" - # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). - from mcp.server.fastmcp.exceptions import ToolError + # Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886). + from fastmcp.exceptions import ToolError try: await project_client.delete_project(project_id) @@ -203,8 +203,8 @@ def doctor( cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"), ) -> None: """Run local consistency checks to verify file/database indexing.""" - # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). - from mcp.server.fastmcp.exceptions import ToolError + # Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886). + from fastmcp.exceptions import ToolError try: validate_routing_flags(local, cloud) diff --git a/src/basic_memory/cli/commands/orphans.py b/src/basic_memory/cli/commands/orphans.py index 40cce8ad3..851e4412b 100644 --- a/src/basic_memory/cli/commands/orphans.py +++ b/src/basic_memory/cli/commands/orphans.py @@ -49,8 +49,8 @@ def orphans( """ from basic_memory.cli.commands.command_utils import run_with_cleanup - # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). - from mcp.server.fastmcp.exceptions import ToolError + # Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886). + from fastmcp.exceptions import ToolError try: validate_routing_flags(local, cloud) diff --git a/src/basic_memory/cli/commands/status.py b/src/basic_memory/cli/commands/status.py index 8f0a40bbc..483a9354c 100644 --- a/src/basic_memory/cli/commands/status.py +++ b/src/basic_memory/cli/commands/status.py @@ -131,8 +131,8 @@ def status( """ from basic_memory.cli.commands.command_utils import run_with_cleanup - # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). - from mcp.server.fastmcp.exceptions import ToolError + # Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886). + from fastmcp.exceptions import ToolError # Trigger: --wait with a negative --timeout # Why: a negative deadline times out on the very first poll, producing a confusing diff --git a/src/basic_memory/mcp/client_info.py b/src/basic_memory/mcp/client_info.py index 65ac7c08a..adb175b7e 100644 --- a/src/basic_memory/mcp/client_info.py +++ b/src/basic_memory/mcp/client_info.py @@ -15,7 +15,7 @@ class MCPClientInfoMiddleware(Middleware): - """Persist sanitized initialize clientInfo in FastMCP session state.""" + """Persist sanitized initialize client info as a legacy-session fallback.""" @override async def on_initialize( @@ -34,16 +34,34 @@ async def on_initialize( async def is_openai_mcp_client(context: Context | None) -> bool: - """Return whether the current MCP session identified itself as OpenAI's MCP client.""" + """Return whether the current request identified itself as OpenAI's MCP client.""" if context is None: return False + request_client_info = client_info_from_context(context) + if request_client_info is not None: + return client_info_is_openai_mcp(request_client_info) + return client_info_is_openai_mcp(await context.get_state(MCP_CLIENT_INFO_STATE_KEY)) def client_info_from_initialize(message: mt.InitializeRequest) -> ClientInfoState | None: - """Extract the normalized clientInfo payload from an initialize request.""" - client_info = message.params.clientInfo + """Extract the normalized client info payload from an initialize request.""" + return _client_info_from_implementation(message.params.client_info) + + +def client_info_from_context(context: Context) -> ClientInfoState | None: + """Extract the client identity attached to the current FastMCP request.""" + request_context = context.request_context + if request_context is None: + return None + client_params = request_context.session.client_params + if client_params is None: + return None + return _client_info_from_implementation(client_params.client_info) + + +def _client_info_from_implementation(client_info: mt.Implementation) -> ClientInfoState | None: return _client_info_from_mapping( { "name": client_info.name, diff --git a/src/basic_memory/mcp/project_context.py b/src/basic_memory/mcp/project_context.py index ed2628149..3aa6e137e 100644 --- a/src/basic_memory/mcp/project_context.py +++ b/src/basic_memory/mcp/project_context.py @@ -825,8 +825,8 @@ async def resolve_project_and_path( # Why: allow project-scoped memory URLs without requiring a separate project parameter # Outcome: attempt to resolve the prefix as a project and route to it if project_prefix: - # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). - from mcp.server.fastmcp.exceptions import ToolError + # Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886). + from fastmcp.exceptions import ToolError if cached_project and _project_matches_identifier(cached_project, project_prefix): resolved_project = await resolve_project_parameter(project_prefix, context=context) @@ -1026,8 +1026,8 @@ async def get_project_client( is_factory_mode, ) - # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). - from mcp.server.fastmcp.exceptions import ToolError + # Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886). + from fastmcp.exceptions import ToolError # When project_id (UUID) is provided, prefer it as the resolution identifier. # external_id is unambiguous across workspaces; project name can collide. diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py index 9160674ad..f1cda95a7 100644 --- a/src/basic_memory/mcp/tools/build_context.py +++ b/src/basic_memory/mcp/tools/build_context.py @@ -250,7 +250,7 @@ async def build_context( try: depth = int(depth) except ValueError: - from mcp.server.fastmcp.exceptions import ToolError + from fastmcp.exceptions import ToolError raise ToolError(f"Invalid depth parameter: '{depth}' is not a valid integer") diff --git a/src/basic_memory/mcp/tools/delete_note.py b/src/basic_memory/mcp/tools/delete_note.py index c0963efce..172a9b147 100644 --- a/src/basic_memory/mcp/tools/delete_note.py +++ b/src/basic_memory/mcp/tools/delete_note.py @@ -3,7 +3,7 @@ from loguru import logger from fastmcp import Context -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from pydantic import AliasChoices, Field from basic_memory.config import ConfigManager diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py index a08dc8a71..736b6f5db 100644 --- a/src/basic_memory/mcp/tools/edit_note.py +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -6,7 +6,7 @@ from httpx import HTTPStatusError from loguru import logger from fastmcp import Context -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from pydantic import AliasChoices, BeforeValidator, Field if TYPE_CHECKING: # pragma: no cover diff --git a/src/basic_memory/mcp/tools/move_note.py b/src/basic_memory/mcp/tools/move_note.py index cbcb3835c..83e57a348 100644 --- a/src/basic_memory/mcp/tools/move_note.py +++ b/src/basic_memory/mcp/tools/move_note.py @@ -6,7 +6,7 @@ from loguru import logger from fastmcp import Context -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from pydantic import AliasChoices, Field from basic_memory.config import ConfigManager diff --git a/src/basic_memory/mcp/tools/read_content.py b/src/basic_memory/mcp/tools/read_content.py index 957577b10..563b0354d 100644 --- a/src/basic_memory/mcp/tools/read_content.py +++ b/src/basic_memory/mcp/tools/read_content.py @@ -14,7 +14,7 @@ from PIL import Image as PILImage from fastmcp import Context from pydantic import AliasChoices, Field -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from basic_memory.config import ConfigManager from basic_memory.mcp.project_context import ( diff --git a/src/basic_memory/mcp/tools/utils.py b/src/basic_memory/mcp/tools/utils.py index 87a11a7be..f68dba85e 100644 --- a/src/basic_memory/mcp/tools/utils.py +++ b/src/basic_memory/mcp/tools/utils.py @@ -30,7 +30,7 @@ RequestExtensions, ) from loguru import logger -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from basic_memory.config import ConfigManager diff --git a/test-int/mcp/test_param_aliases_integration.py b/test-int/mcp/test_param_aliases_integration.py index 4c4760165..ce9ce978d 100644 --- a/test-int/mcp/test_param_aliases_integration.py +++ b/test-int/mcp/test_param_aliases_integration.py @@ -581,14 +581,14 @@ async def test_aliases_not_advertised_in_schema(mcp_server, app): 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"] + props = tools[tool_name].input_schema["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"] + overwrite_schema = tools["write_note"].input_schema["properties"]["overwrite"] schema_types: set[str] = set() if "type" in overwrite_schema: raw = overwrite_schema["type"] diff --git a/test-int/mcp/test_project_management_integration.py b/test-int/mcp/test_project_management_integration.py index 346d2b181..098854b78 100644 --- a/test-int/mcp/test_project_management_integration.py +++ b/test-int/mcp/test_project_management_integration.py @@ -254,12 +254,8 @@ async def test_delete_current_project_protection(mcp_server, app, test_project): # Should show error about deleting current project error_message = str(exc_info.value) - assert "delete_project" in error_message - assert ( - "currently active" in error_message - or "test-project" in error_message - or "Switch to a different project" in error_message - ) + assert "Cannot delete default project" in error_message + assert "test-project" in error_message @pytest.mark.asyncio diff --git a/test-int/mcp/test_ui_sdk_integration.py b/test-int/mcp/test_ui_sdk_integration.py index d5d26d069..a0ccf37b5 100644 --- a/test-int/mcp/test_ui_sdk_integration.py +++ b/test-int/mcp/test_ui_sdk_integration.py @@ -39,7 +39,7 @@ async def test_search_notes_ui_embedded_resource(mcp_server, app, test_project): assert len(result.content) == 1 block = result.content[0] assert block.type == "resource" - assert block.resource.mimeType == "text/html" + assert block.resource.mime_type == "text/html" assert "" in block.resource.text.lower() assert block.resource.meta is not None assert "mcpui.dev/ui-initial-render-data" in block.resource.meta @@ -70,7 +70,7 @@ async def test_read_note_ui_embedded_resource(mcp_server, app, test_project): assert len(result.content) == 1 block = result.content[0] assert block.type == "resource" - assert block.resource.mimeType == "text/html" + assert block.resource.mime_type == "text/html" assert "" in block.resource.text.lower() assert block.resource.meta is not None assert "mcpui.dev/ui-initial-render-data" in block.resource.meta diff --git a/tests/cli/test_orphans_command.py b/tests/cli/test_orphans_command.py index d06c7b3f4..262e3b154 100644 --- a/tests/cli/test_orphans_command.py +++ b/tests/cli/test_orphans_command.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager, nullcontext from unittest.mock import AsyncMock, MagicMock, patch -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from typer.testing import CliRunner from basic_memory.cli.main import app as cli_app diff --git a/tests/cli/test_project_info_errors.py b/tests/cli/test_project_info_errors.py index 74306df10..60ef15f6c 100644 --- a/tests/cli/test_project_info_errors.py +++ b/tests/cli/test_project_info_errors.py @@ -5,7 +5,7 @@ import pytest import typer -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from typer.testing import CliRunner from basic_memory.cli.app import app diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py index 44d7e4dcf..7d53b946c 100644 --- a/tests/mcp/conftest.py +++ b/tests/mcp/conftest.py @@ -6,8 +6,8 @@ import pytest import pytest_asyncio from fastapi import FastAPI +from fastmcp import FastMCP from httpx import AsyncClient, ASGITransport -from mcp.server import FastMCP from basic_memory.api.app import app as fastapi_app from basic_memory.deps import get_engine_factory, get_app_config @@ -25,6 +25,7 @@ class ContextState: def __init__(self): self._state: dict[str, object] = {} + self.request_context = None async def get_state(self, key: str): return self._state.get(key) diff --git a/tests/mcp/test_client_info.py b/tests/mcp/test_client_info.py index e6ffd944c..782b5bce5 100644 --- a/tests/mcp/test_client_info.py +++ b/tests/mcp/test_client_info.py @@ -2,10 +2,11 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any, cast, Literal import mcp.types as mt import pytest +from fastmcp import Client, Context, FastMCP from fastmcp.server.middleware import CallNext, MiddlewareContext from basic_memory.mcp.client_info import ( @@ -21,6 +22,7 @@ class FakeMCPContext: def __init__(self) -> None: self.state: dict[str, object] = {} + self.request_context = None async def set_state(self, key: str, value: object) -> None: self.state[key] = value @@ -39,9 +41,9 @@ def _initialize_context( return MiddlewareContext( message=mt.InitializeRequest( params=mt.InitializeRequestParams( - protocolVersion="2025-06-18", + protocol_version="2025-06-18", capabilities=mt.ClientCapabilities(), - clientInfo=mt.Implementation(name=name, title=title, version=version), + client_info=mt.Implementation(name=name, title=title, version=version), ) ), fastmcp_context=cast(Any, fastmcp_context), @@ -86,7 +88,7 @@ async def call_next(inner_context: MiddlewareContext[mt.InitializeRequest]) -> s @pytest.mark.asyncio async def test_is_openai_mcp_client_reads_session_state() -> None: - """The gate accepts OpenAI's versioned clientInfo label.""" + """Legacy sessions can still use client info captured during initialization.""" context = FakeMCPContext() await context.set_state( MCP_CLIENT_INFO_STATE_KEY, @@ -96,6 +98,28 @@ async def test_is_openai_mcp_client_reads_session_state() -> None: assert await is_openai_mcp_client(cast(Any, context)) is True +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["auto", "legacy"]) +async def test_is_openai_mcp_client_reads_request_identity( + mode: Literal["auto", "legacy"], +) -> None: + """Modern and legacy protocols expose identity on each FastMCP request.""" + server = FastMCP("client-info-test") + + @server.tool + async def identify(context: Context) -> bool: + return await is_openai_mcp_client(context) + + async with Client( + server, + client_info=mt.Implementation(name="openai-mcp", version="1.0.0"), + mode=mode, + ) as client: + result = await client.call_tool("identify", {}) + + assert result.data is True + + @pytest.mark.asyncio async def test_is_openai_mcp_client_rejects_missing_context() -> None: """No context means no authenticated MCP clientInfo to trust.""" diff --git a/tests/mcp/test_client_telemetry.py b/tests/mcp/test_client_telemetry.py index 53443ab82..37c46b183 100644 --- a/tests/mcp/test_client_telemetry.py +++ b/tests/mcp/test_client_telemetry.py @@ -8,7 +8,7 @@ import httpx import logfire import pytest -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from typing import Any knowledge_client_module = importlib.import_module("basic_memory.mcp.clients.knowledge") diff --git a/tests/mcp/test_project_context.py b/tests/mcp/test_project_context.py index 0677f723e..c6474e5b6 100644 --- a/tests/mcp/test_project_context.py +++ b/tests/mcp/test_project_context.py @@ -1642,7 +1642,7 @@ async def test_get_project_client_with_cloud_project_id_routes_to_workspace_with WorkspaceProjectEntry, _build_workspace_project_index, ) - from mcp.server.fastmcp.exceptions import ToolError + from fastmcp.exceptions import ToolError config = config_manager.load_config() config.projects["hermes-memory"] = ProjectEntry( @@ -1985,7 +1985,7 @@ async def test_resolve_project_and_path_keeps_workspace_qualified_canonical_path config_manager, monkeypatch, ): - from mcp.server.fastmcp.exceptions import ToolError + from fastmcp.exceptions import ToolError from basic_memory.mcp.project_context import resolve_project_and_path from basic_memory.schemas.project_info import ProjectItem @@ -2033,7 +2033,7 @@ async def test_resolve_project_and_path_preserves_personal_workspace_prefix( config_manager, monkeypatch, ): - from mcp.server.fastmcp.exceptions import ToolError + from fastmcp.exceptions import ToolError from basic_memory.mcp.project_context import resolve_project_and_path from basic_memory.schemas.project_info import ProjectItem @@ -2114,7 +2114,7 @@ async def test_resolve_project_and_path_uses_cached_workspace_for_active_route( config_manager, monkeypatch, ): - from mcp.server.fastmcp.exceptions import ToolError + from fastmcp.exceptions import ToolError import basic_memory.mcp.project_context as project_context from basic_memory.mcp.project_context import resolve_project_and_path @@ -2436,7 +2436,7 @@ async def test_local_route_clears_stale_cached_workspace(self, config_manager, m """A previous cloud workspace must not decorate later local memory URLs.""" from contextlib import asynccontextmanager - from mcp.server.fastmcp.exceptions import ToolError + from fastmcp.exceptions import ToolError import basic_memory.mcp.project_context as project_context from basic_memory.config import ProjectEntry @@ -3235,7 +3235,7 @@ async def test_resolve_project_and_path_keeps_patterns_project_qualified( Direct URLs keep workspace qualification (the link resolver handles them); patterns have no resolver fallback and must match the index form. """ - from mcp.server.fastmcp.exceptions import ToolError + from fastmcp.exceptions import ToolError from basic_memory.mcp.project_context import resolve_project_and_path from basic_memory.schemas.project_info import ProjectItem diff --git a/tests/mcp/test_project_context_scoped_key.py b/tests/mcp/test_project_context_scoped_key.py index bb037bc91..540e512d4 100644 --- a/tests/mcp/test_project_context_scoped_key.py +++ b/tests/mcp/test_project_context_scoped_key.py @@ -3,7 +3,7 @@ from typing import Any, cast import pytest -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from basic_memory.mcp.project_context import resolve_project_and_path from basic_memory.schemas.project_info import ProjectItem diff --git a/tests/mcp/test_tool_build_context.py b/tests/mcp/test_tool_build_context.py index c2c71039b..7276867cf 100644 --- a/tests/mcp/test_tool_build_context.py +++ b/tests/mcp/test_tool_build_context.py @@ -2,7 +2,7 @@ import pytest -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from basic_memory.mcp.tools import build_context, write_note diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 6deecd431..89e13cc0b 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -252,13 +252,13 @@ async def test_mcp_tool_annotations_meet_directory_requirements(): annotations = tool.to_mcp_tool().annotations assert annotations is not None, f"Tool '{tool_name}' has no annotations" assert annotations.title, f"Tool '{tool_name}' is missing annotations.title" - assert annotations.readOnlyHint is expected["readOnlyHint"], ( + assert annotations.read_only_hint is expected["readOnlyHint"], ( f"Tool '{tool_name}' readOnlyHint should be {expected['readOnlyHint']}" ) - assert annotations.destructiveHint is expected["destructiveHint"], ( + assert annotations.destructive_hint is expected["destructiveHint"], ( f"Tool '{tool_name}' destructiveHint should be {expected['destructiveHint']}" ) - assert annotations.openWorldHint is False, ( + assert annotations.open_world_hint is False, ( f"Tool '{tool_name}' openWorldHint should be False" ) @@ -269,7 +269,7 @@ async def test_edit_note_operation_schema_exposes_supported_operations(): tool_list = await mcp.list_tools() edit_note_tool = next(tool for tool in tool_list if tool.name == "edit_note") - input_schema = edit_note_tool.to_mcp_tool().inputSchema + input_schema = edit_note_tool.to_mcp_tool().input_schema operation_schema = input_schema["properties"]["operation"] assert operation_schema["type"] == "string" diff --git a/tests/mcp/test_tool_edit_note.py b/tests/mcp/test_tool_edit_note.py index bfa1069dc..7b88727a7 100644 --- a/tests/mcp/test_tool_edit_note.py +++ b/tests/mcp/test_tool_edit_note.py @@ -5,7 +5,7 @@ import httpx import pytest -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from basic_memory.mcp.clients import KnowledgeClient from basic_memory.mcp.tools.edit_note import _resolve_after_disk_recovery, edit_note diff --git a/tests/mcp/test_tool_read_content.py b/tests/mcp/test_tool_read_content.py index 64e591340..f205eff62 100644 --- a/tests/mcp/test_tool_read_content.py +++ b/tests/mcp/test_tool_read_content.py @@ -11,7 +11,7 @@ from types import SimpleNamespace import pytest -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from basic_memory.mcp.tools import read_content, write_note diff --git a/tests/mcp/test_tool_recent_activity.py b/tests/mcp/test_tool_recent_activity.py index 8ee7fb9e7..9f69dcd82 100644 --- a/tests/mcp/test_tool_recent_activity.py +++ b/tests/mcp/test_tool_recent_activity.py @@ -5,7 +5,7 @@ import pytest -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from basic_memory.mcp.tools import recent_activity from basic_memory.schemas.search import SearchItemType diff --git a/tests/mcp/test_tool_resource.py b/tests/mcp/test_tool_resource.py index 265ce83d6..6d18ca986 100644 --- a/tests/mcp/test_tool_resource.py +++ b/tests/mcp/test_tool_resource.py @@ -5,7 +5,7 @@ from PIL import Image as PILImage import pytest -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from basic_memory.mcp.tools import read_content, write_note from basic_memory.mcp.tools.read_content import ( diff --git a/tests/mcp/test_tool_utils.py b/tests/mcp/test_tool_utils.py index da9dfe8d9..f0a719921 100644 --- a/tests/mcp/test_tool_utils.py +++ b/tests/mcp/test_tool_utils.py @@ -4,8 +4,8 @@ import httpx import pytest +from fastmcp.exceptions import ToolError from httpx import HTTPStatusError, Request -from mcp.server.fastmcp.exceptions import ToolError from basic_memory.mcp.tools.utils import ( call_delete, @@ -98,6 +98,7 @@ async def test_call_get_error(mock_response): with pytest.raises(ToolError) as exc: await call_get(_client(client), "http://test.com") assert "Resource not found" in str(exc.value) + assert isinstance(exc.value.__cause__, HTTPStatusError) @pytest.mark.asyncio diff --git a/tests/mcp/test_tool_utils_cloud_auth.py b/tests/mcp/test_tool_utils_cloud_auth.py index 9c412be8d..da63053b8 100644 --- a/tests/mcp/test_tool_utils_cloud_auth.py +++ b/tests/mcp/test_tool_utils_cloud_auth.py @@ -4,7 +4,7 @@ import pytest from httpx import HTTPStatusError, Request -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from basic_memory.mcp.tools.utils import call_post diff --git a/tests/mcp/tools/test_mutating_memory_url_routing.py b/tests/mcp/tools/test_mutating_memory_url_routing.py index 6ef3772e1..7026966bd 100644 --- a/tests/mcp/tools/test_mutating_memory_url_routing.py +++ b/tests/mcp/tools/test_mutating_memory_url_routing.py @@ -3,7 +3,7 @@ import importlib import pytest -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError from basic_memory.mcp.tools.delete_note import delete_note from basic_memory.mcp.tools.move_note import move_note diff --git a/tests/mcp/tools/test_search_notes_multi_project.py b/tests/mcp/tools/test_search_notes_multi_project.py index b088ce238..ac35262f6 100644 --- a/tests/mcp/tools/test_search_notes_multi_project.py +++ b/tests/mcp/tools/test_search_notes_multi_project.py @@ -4,7 +4,7 @@ import importlib from httpx import HTTPStatusError, Request, Response -from mcp.server.fastmcp.exceptions import ToolError +from fastmcp.exceptions import ToolError import pytest from basic_memory.schemas.search import SearchItemType, SearchResponse, SearchResult diff --git a/uv.lock b/uv.lock index df31cd178..01be96f5f 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,9 @@ resolution-markers = [ "python_full_version < '3.13' and sys_platform != 'win32'", ] +[manifest] +constraints = [{ name = "fastmcp-slim", specifier = "==4.0.0b1" }] + [[package]] name = "aiofile" version = "3.11.1" @@ -370,7 +373,7 @@ requires-dist = [ { name = "dateparser", specifier = ">=1.2.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.136.1" }, { name = "fastembed", specifier = ">=0.7.4" }, - { name = "fastmcp", specifier = ">=3.3.1,<4" }, + { name = "fastmcp", specifier = "==4.0.0b1" }, { name = "filelock", specifier = ">=3.12" }, { name = "greenlet", specifier = ">=3.1.1" }, { name = "httpx", specifier = ">=0.28.0" }, @@ -378,7 +381,7 @@ requires-dist = [ { name = "logfire", specifier = ">=4.19.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "markdown-it-py", specifier = ">=3.0.0" }, - { name = "mcp", specifier = ">=1.23.1" }, + { name = "mcp", specifier = ">=2,<3" }, { name = "mdformat", specifier = ">=0.7.22" }, { name = "mdformat-frontmatter", specifier = ">=2.0.8" }, { name = "mdformat-gfm", specifier = ">=0.3.7" }, @@ -1099,21 +1102,22 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.4.4" +version = "4.0.0b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/f7/5188565d1b93ad611cbd80bf473e7ad669d1f3b689c4bedcd304e1ec3472/fastmcp-3.4.4.tar.gz", hash = "sha256:378202e26ec15b23819d9a1c0d1b0ebda096bc712720532010a0b82a45c2b1df", size = 28796458, upload-time = "2026-07-09T00:32:41.352Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/fd/e513c524bb3e296203f65bd8e9e726e9236bd3b59315e7cbdeb095662c01/fastmcp-4.0.0b1.tar.gz", hash = "sha256:f98d69588a73e1672840558641d5d0f111e207baffe001f3465713a53ebb6b4c", size = 42065171, upload-time = "2026-07-28T21:18:15.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/67/3cef84ba38a23dca1e1e776bfda8a35ab3c7a6c94a8ca81d0715de6dd3c5/fastmcp-3.4.4-py3-none-any.whl", hash = "sha256:f86f208713212260068cf55c32936839eee856fefc7808e18a032f31eb0f718e", size = 8019, upload-time = "2026-07-09T00:32:39.411Z" }, + { url = "https://files.pythonhosted.org/packages/2c/66/41b503ef852eff83f3f0c04f46cd1fc738c5374fd509384fc6b96e45918f/fastmcp-4.0.0b1-py3-none-any.whl", hash = "sha256:d66eb7b0763ffff2ae0fc573778ea25604dcb7e59769e5afaf9851a806eb1129", size = 8064, upload-time = "2026-07-28T21:18:12.688Z" }, ] [[package]] name = "fastmcp-slim" -version = "3.4.4" +version = "4.0.0b1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "mcp-types" }, { name = "platformdirs" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -1121,16 +1125,16 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203, upload-time = "2026-07-09T00:32:20.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/33/f207166aac88c6d8be1b2a82c54fecd1e04b075ef12d027aa17b3134fc0f/fastmcp_slim-4.0.0b1.tar.gz", hash = "sha256:158efb25720e0cb301711146b2d05d181de95cd91fe70e87c251ad14b123d665", size = 660081, upload-time = "2026-07-28T21:17:50.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158, upload-time = "2026-07-09T00:32:19.11Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/fbe192d2ab50bb31b284fd0eb6c72d4347df7a57d836bee4f1973ffd1d7d/fastmcp_slim-4.0.0b1-py3-none-any.whl", hash = "sha256:dd907a3db5a2f479ca958c30157e227b4f7b340a56d333eb91de95719254597a", size = 827975, upload-time = "2026-07-28T21:17:48.747Z" }, ] [package.optional-dependencies] client = [ { name = "authlib" }, { name = "exceptiongroup" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "opentelemetry-api" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, @@ -1141,7 +1145,7 @@ server = [ { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "griffelib" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "joserfc" }, { name = "jsonref" }, { name = "jsonschema-path" }, @@ -1561,6 +1565,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -1613,12 +1630,19 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] @@ -2096,15 +2120,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -2114,9 +2138,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -4169,6 +4206,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/2a/5e5e750890ada51017d18d0d4c30da696e5b5bd3180947729927628fc3cb/tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2", size = 676612, upload-time = "2026-07-07T09:58:16.256Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "ty" version = "0.0.64"