Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions src/basic_memory/cli/commands/command_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions src/basic_memory/cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/basic_memory/cli/commands/orphans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/basic_memory/cli/commands/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 22 additions & 4 deletions src/basic_memory/mcp/client_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/basic_memory/mcp/project_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/delete_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/move_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/read_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions test-int/mcp/test_param_aliases_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
8 changes: 2 additions & 6 deletions test-int/mcp/test_project_management_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions test-int/mcp/test_ui_sdk_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<!doctype html>" in block.resource.text.lower()
assert block.resource.meta is not None
assert "mcpui.dev/ui-initial-render-data" in block.resource.meta
Expand Down Expand Up @@ -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 "<!doctype html>" in block.resource.text.lower()
assert block.resource.meta is not None
assert "mcpui.dev/ui-initial-render-data" in block.resource.meta
2 changes: 1 addition & 1 deletion tests/cli/test_orphans_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/test_project_info_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/mcp/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
32 changes: 28 additions & 4 deletions tests/mcp/test_client_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion tests/mcp/test_client_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading