diff --git a/src/basic_memory/mcp/async_client.py b/src/basic_memory/mcp/async_client.py index 644394f9f..882c627cb 100644 --- a/src/basic_memory/mcp/async_client.py +++ b/src/basic_memory/mcp/async_client.py @@ -1,5 +1,5 @@ import os -from httpx import ASGITransport, AsyncClient +from httpx import ASGITransport, AsyncClient, Timeout from loguru import logger from basic_memory.api.app import app as fastapi_app @@ -14,14 +14,25 @@ def create_client() -> AsyncClient: proxy_base_url = os.getenv("BASIC_MEMORY_PROXY_URL", None) logger.info(f"BASIC_MEMORY_PROXY_URL: {proxy_base_url}") + # Configure timeout for longer operations like write_note + # Default httpx timeout is 5 seconds which is too short for file operations + timeout = Timeout( + connect=10.0, # 10 seconds for connection + read=30.0, # 30 seconds for reading response + write=30.0, # 30 seconds for writing request + pool=30.0, # 30 seconds for connection pool + ) + if proxy_base_url: # Use HTTP transport to proxy endpoint logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}") - return AsyncClient(base_url=proxy_base_url) + return AsyncClient(base_url=proxy_base_url, timeout=timeout) else: # Default: use ASGI transport for local API (development mode) logger.debug("Creating ASGI client for local Basic Memory API") - return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test") + return AsyncClient( + transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout + ) # Create shared async client diff --git a/src/basic_memory/mcp/tools/headers.py b/src/basic_memory/mcp/tools/headers.py index a7d151819..5cfc4b428 100644 --- a/src/basic_memory/mcp/tools/headers.py +++ b/src/basic_memory/mcp/tools/headers.py @@ -26,13 +26,19 @@ def inject_auth_header(headers: HeaderTypes | None = None) -> HeaderTypes: headers = headers.copy() http_headers = get_http_headers() - logger.debug(f"HTTP headers: {http_headers}") + + # Log only non-sensitive header keys for debugging + if logger.opt(lazy=True).debug: + sensitive_headers = {"authorization", "cookie", "x-api-key", "x-auth-token", "api-key"} + safe_headers = {k for k in http_headers.keys() if k.lower() not in sensitive_headers} + logger.debug(f"HTTP headers present: {list(safe_headers)}") authorization = http_headers.get("Authorization") or http_headers.get("authorization") if authorization: headers["Authorization"] = authorization # type: ignore - logger.debug("Injected JWT token into authorization request headers") + # Log only that auth was injected, not the token value + logger.debug("Injected authorization header into request") else: - logger.debug("No authorization found in request headers") + logger.debug("No authorization header found in request") return headers diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index 5bf6a3957..c53d8d652 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -130,7 +130,8 @@ async def read_note( query=identifier, search_type="title", project=project, context=context ) - if title_results and title_results.results: + # Handle both SearchResponse object and error strings + if title_results and hasattr(title_results, "results") and title_results.results: result = title_results.results[0] # Get the first/best match if result.permalink: try: @@ -159,7 +160,8 @@ async def read_note( ) # We didn't find a direct match, construct a helpful error message - if not text_results or not text_results.results: + # Handle both SearchResponse object and error strings + if not text_results or not hasattr(text_results, "results") or not text_results.results: # No results at all return format_not_found_message(active_project.name, identifier) else: diff --git a/tests/api/test_async_client.py b/tests/api/test_async_client.py index d629f256a..2ef3de77f 100644 --- a/tests/api/test_async_client.py +++ b/tests/api/test_async_client.py @@ -1,7 +1,7 @@ """Tests for async_client configuration.""" from unittest.mock import patch -from httpx import AsyncClient, ASGITransport +from httpx import AsyncClient, ASGITransport, Timeout from basic_memory.mcp.async_client import create_client @@ -25,3 +25,25 @@ def test_create_client_uses_http_when_proxy_env_set(): assert not isinstance(client._transport, ASGITransport) # When using remote API, no base_url is set (dynamic from headers) assert str(client.base_url) == "http://localhost:8000" + + +def test_create_client_configures_extended_timeouts(): + """Test that create_client configures 30-second timeouts for long operations.""" + with patch.dict("os.environ", {}, clear=True): + client = create_client() + + # Verify timeout configuration + assert isinstance(client.timeout, Timeout) + assert client.timeout.connect == 10.0 # 10 seconds for connection + assert client.timeout.read == 30.0 # 30 seconds for reading + assert client.timeout.write == 30.0 # 30 seconds for writing + assert client.timeout.pool == 30.0 # 30 seconds for pool + + # Also test with proxy URL + with patch.dict("os.environ", {"BASIC_MEMORY_PROXY_URL": "http://localhost:8000"}): + client = create_client() + + # Same timeout configuration should apply + assert isinstance(client.timeout, Timeout) + assert client.timeout.read == 30.0 + assert client.timeout.write == 30.0