mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
3a6baf80fc
Signed-off-by: phernandez <paul@basicmachines.co> Signed-off-by: Joe P <joe@basicmemory.com> Co-authored-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude <noreply@anthropic.com>
598 lines
21 KiB
Python
598 lines
21 KiB
Python
"""Utility functions for making HTTP requests in Basic Memory MCP tools.
|
|
|
|
These functions provide a consistent interface for making HTTP requests
|
|
to the Basic Memory API, with improved error handling and logging.
|
|
"""
|
|
|
|
import typing
|
|
from typing import Optional
|
|
|
|
from httpx import Response, URL, AsyncClient, HTTPStatusError
|
|
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
|
|
from httpx._types import (
|
|
RequestContent,
|
|
RequestData,
|
|
RequestFiles,
|
|
QueryParamTypes,
|
|
HeaderTypes,
|
|
CookieTypes,
|
|
AuthTypes,
|
|
TimeoutTypes,
|
|
RequestExtensions,
|
|
)
|
|
from loguru import logger
|
|
from mcp.server.fastmcp.exceptions import ToolError
|
|
|
|
from basic_memory.mcp.tools.headers import inject_auth_header
|
|
|
|
|
|
def get_error_message(
|
|
status_code: int, url: URL | str, method: str, msg: Optional[str] = None
|
|
) -> str:
|
|
"""Get a friendly error message based on the HTTP status code.
|
|
|
|
Args:
|
|
status_code: The HTTP status code
|
|
url: The URL that was requested
|
|
method: The HTTP method used
|
|
|
|
Returns:
|
|
A user-friendly error message
|
|
"""
|
|
# Extract path from URL for cleaner error messages
|
|
if isinstance(url, str):
|
|
path = url.split("/")[-1]
|
|
else:
|
|
path = str(url).split("/")[-1] if url else "resource"
|
|
|
|
# Client errors (400-499)
|
|
if status_code == 400:
|
|
return f"Invalid request: The request to '{path}' was malformed or invalid"
|
|
elif status_code == 401: # pragma: no cover
|
|
return f"Authentication required: You need to authenticate to access '{path}'"
|
|
elif status_code == 403: # pragma: no cover
|
|
return f"Access denied: You don't have permission to access '{path}'"
|
|
elif status_code == 404:
|
|
return f"Resource not found: '{path}' doesn't exist or has been moved"
|
|
elif status_code == 409: # pragma: no cover
|
|
return f"Conflict: The request for '{path}' conflicts with the current state"
|
|
elif status_code == 429: # pragma: no cover
|
|
return "Too many requests: Please slow down and try again later"
|
|
elif 400 <= status_code < 500: # pragma: no cover
|
|
return f"Client error ({status_code}): The request for '{path}' could not be completed"
|
|
|
|
# Server errors (500-599)
|
|
elif status_code == 500:
|
|
return f"Internal server error: Something went wrong processing '{path}'"
|
|
elif status_code == 503: # pragma: no cover
|
|
return (
|
|
f"Service unavailable: The server is currently unable to handle requests for '{path}'"
|
|
)
|
|
elif 500 <= status_code < 600: # pragma: no cover
|
|
return f"Server error ({status_code}): The server encountered an error handling '{path}'"
|
|
|
|
# Fallback for any other status code
|
|
else: # pragma: no cover
|
|
return f"HTTP error {status_code}: {method} request to '{path}' failed"
|
|
|
|
|
|
async def call_get(
|
|
client: AsyncClient,
|
|
url: URL | str,
|
|
*,
|
|
params: QueryParamTypes | None = None,
|
|
headers: HeaderTypes | None = None,
|
|
cookies: CookieTypes | None = None,
|
|
auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
|
|
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
extensions: RequestExtensions | None = None,
|
|
) -> Response:
|
|
"""Make a GET request and handle errors appropriately.
|
|
|
|
Args:
|
|
client: The HTTPX AsyncClient to use
|
|
url: The URL to request
|
|
params: Query parameters
|
|
headers: HTTP headers
|
|
cookies: HTTP cookies
|
|
auth: Authentication
|
|
follow_redirects: Whether to follow redirects
|
|
timeout: Request timeout
|
|
extensions: HTTPX extensions
|
|
|
|
Returns:
|
|
The HTTP response
|
|
|
|
Raises:
|
|
ToolError: If the request fails with an appropriate error message
|
|
"""
|
|
logger.debug(f"Calling GET '{url}' params: '{params}'")
|
|
error_message = None
|
|
|
|
headers = inject_auth_header(headers)
|
|
try:
|
|
response = await client.get(
|
|
url,
|
|
params=params,
|
|
headers=headers,
|
|
cookies=cookies,
|
|
auth=auth,
|
|
follow_redirects=follow_redirects,
|
|
timeout=timeout,
|
|
extensions=extensions,
|
|
)
|
|
|
|
if response.is_success:
|
|
return response
|
|
|
|
# Handle different status codes differently
|
|
status_code = response.status_code
|
|
# get the message if available
|
|
response_data = response.json()
|
|
if isinstance(response_data, dict) and "detail" in response_data:
|
|
error_message = response_data["detail"]
|
|
else:
|
|
error_message = get_error_message(status_code, url, "PUT")
|
|
|
|
# Log at appropriate level based on status code
|
|
if 400 <= status_code < 500:
|
|
# Client errors: log as info except for 429 (Too Many Requests)
|
|
if status_code == 429: # pragma: no cover
|
|
logger.warning(f"Rate limit exceeded: GET {url}: {error_message}")
|
|
else:
|
|
logger.info(f"Client error: GET {url}: {error_message}")
|
|
else: # pragma: no cover
|
|
# Server errors: log as error
|
|
logger.error(f"Server error: GET {url}: {error_message}")
|
|
|
|
# Raise a tool error with the friendly message
|
|
response.raise_for_status() # Will always raise since we're in the error case
|
|
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
|
|
|
|
except HTTPStatusError as e:
|
|
raise ToolError(error_message) from e
|
|
|
|
|
|
async def call_put(
|
|
client: AsyncClient,
|
|
url: URL | str,
|
|
*,
|
|
content: RequestContent | None = None,
|
|
data: RequestData | None = None,
|
|
files: RequestFiles | None = None,
|
|
json: typing.Any | None = None,
|
|
params: QueryParamTypes | None = None,
|
|
headers: HeaderTypes | None = None,
|
|
cookies: CookieTypes | None = None,
|
|
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
extensions: RequestExtensions | None = None,
|
|
) -> Response:
|
|
"""Make a PUT request and handle errors appropriately.
|
|
|
|
Args:
|
|
client: The HTTPX AsyncClient to use
|
|
url: The URL to request
|
|
content: Request content
|
|
data: Form data
|
|
files: Files to upload
|
|
json: JSON data
|
|
params: Query parameters
|
|
headers: HTTP headers
|
|
cookies: HTTP cookies
|
|
auth: Authentication
|
|
follow_redirects: Whether to follow redirects
|
|
timeout: Request timeout
|
|
extensions: HTTPX extensions
|
|
|
|
Returns:
|
|
The HTTP response
|
|
|
|
Raises:
|
|
ToolError: If the request fails with an appropriate error message
|
|
"""
|
|
logger.debug(f"Calling PUT '{url}'")
|
|
error_message = None
|
|
|
|
# Inject JWT from FastMCP context if available
|
|
headers = inject_auth_header(headers)
|
|
|
|
try:
|
|
response = await client.put(
|
|
url,
|
|
content=content,
|
|
data=data,
|
|
files=files,
|
|
json=json,
|
|
params=params,
|
|
headers=headers,
|
|
cookies=cookies,
|
|
auth=auth,
|
|
follow_redirects=follow_redirects,
|
|
timeout=timeout,
|
|
extensions=extensions,
|
|
)
|
|
|
|
if response.is_success:
|
|
return response
|
|
|
|
# Handle different status codes differently
|
|
status_code = response.status_code
|
|
|
|
# get the message if available
|
|
response_data = response.json()
|
|
if isinstance(response_data, dict) and "detail" in response_data:
|
|
error_message = response_data["detail"] # pragma: no cover
|
|
else:
|
|
error_message = get_error_message(status_code, url, "PUT")
|
|
|
|
# Log at appropriate level based on status code
|
|
if 400 <= status_code < 500:
|
|
# Client errors: log as info except for 429 (Too Many Requests)
|
|
if status_code == 429: # pragma: no cover
|
|
logger.warning(f"Rate limit exceeded: PUT {url}: {error_message}")
|
|
else:
|
|
logger.info(f"Client error: PUT {url}: {error_message}")
|
|
else: # pragma: no cover
|
|
# Server errors: log as error
|
|
logger.error(f"Server error: PUT {url}: {error_message}")
|
|
|
|
# Raise a tool error with the friendly message
|
|
response.raise_for_status() # Will always raise since we're in the error case
|
|
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
|
|
|
|
except HTTPStatusError as e:
|
|
raise ToolError(error_message) from e
|
|
|
|
|
|
async def call_patch(
|
|
client: AsyncClient,
|
|
url: URL | str,
|
|
*,
|
|
content: RequestContent | None = None,
|
|
data: RequestData | None = None,
|
|
files: RequestFiles | None = None,
|
|
json: typing.Any | None = None,
|
|
params: QueryParamTypes | None = None,
|
|
headers: HeaderTypes | None = None,
|
|
cookies: CookieTypes | None = None,
|
|
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
extensions: RequestExtensions | None = None,
|
|
) -> Response:
|
|
"""Make a PATCH request and handle errors appropriately.
|
|
|
|
Args:
|
|
client: The HTTPX AsyncClient to use
|
|
url: The URL to request
|
|
content: Request content
|
|
data: Form data
|
|
files: Files to upload
|
|
json: JSON data
|
|
params: Query parameters
|
|
headers: HTTP headers
|
|
cookies: HTTP cookies
|
|
auth: Authentication
|
|
follow_redirects: Whether to follow redirects
|
|
timeout: Request timeout
|
|
extensions: HTTPX extensions
|
|
|
|
Returns:
|
|
The HTTP response
|
|
|
|
Raises:
|
|
ToolError: If the request fails with an appropriate error message
|
|
"""
|
|
logger.debug(f"Calling PATCH '{url}'")
|
|
|
|
# Inject JWT from FastMCP context if available
|
|
headers = inject_auth_header(headers)
|
|
|
|
try:
|
|
response = await client.patch(
|
|
url,
|
|
content=content,
|
|
data=data,
|
|
files=files,
|
|
json=json,
|
|
params=params,
|
|
headers=headers,
|
|
cookies=cookies,
|
|
auth=auth,
|
|
follow_redirects=follow_redirects,
|
|
timeout=timeout,
|
|
extensions=extensions,
|
|
)
|
|
|
|
if response.is_success:
|
|
return response
|
|
|
|
# Handle different status codes differently
|
|
status_code = response.status_code
|
|
|
|
# Try to extract specific error message from response body
|
|
try:
|
|
response_data = response.json()
|
|
if isinstance(response_data, dict) and "detail" in response_data:
|
|
error_message = response_data["detail"]
|
|
else:
|
|
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
|
|
except Exception: # pragma: no cover
|
|
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
|
|
|
|
# Log at appropriate level based on status code
|
|
if 400 <= status_code < 500:
|
|
# Client errors: log as info except for 429 (Too Many Requests)
|
|
if status_code == 429: # pragma: no cover
|
|
logger.warning(f"Rate limit exceeded: PATCH {url}: {error_message}")
|
|
else:
|
|
logger.info(f"Client error: PATCH {url}: {error_message}")
|
|
else: # pragma: no cover
|
|
# Server errors: log as error
|
|
logger.error(f"Server error: PATCH {url}: {error_message}") # pragma: no cover
|
|
|
|
# Raise a tool error with the friendly message
|
|
response.raise_for_status() # Will always raise since we're in the error case
|
|
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
|
|
|
|
except HTTPStatusError as e:
|
|
status_code = e.response.status_code
|
|
|
|
# Try to extract specific error message from response body
|
|
try:
|
|
response_data = e.response.json()
|
|
if isinstance(response_data, dict) and "detail" in response_data:
|
|
error_message = response_data["detail"]
|
|
else:
|
|
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
|
|
except Exception: # pragma: no cover
|
|
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
|
|
|
|
raise ToolError(error_message) from e
|
|
|
|
|
|
async def call_post(
|
|
client: AsyncClient,
|
|
url: URL | str,
|
|
*,
|
|
content: RequestContent | None = None,
|
|
data: RequestData | None = None,
|
|
files: RequestFiles | None = None,
|
|
json: typing.Any | None = None,
|
|
params: QueryParamTypes | None = None,
|
|
headers: HeaderTypes | None = None,
|
|
cookies: CookieTypes | None = None,
|
|
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
extensions: RequestExtensions | None = None,
|
|
) -> Response:
|
|
"""Make a POST request and handle errors appropriately.
|
|
|
|
Args:
|
|
client: The HTTPX AsyncClient to use
|
|
url: The URL to request
|
|
content: Request content
|
|
data: Form data
|
|
files: Files to upload
|
|
json: JSON data
|
|
params: Query parameters
|
|
headers: HTTP headers
|
|
cookies: HTTP cookies
|
|
auth: Authentication
|
|
follow_redirects: Whether to follow redirects
|
|
timeout: Request timeout
|
|
extensions: HTTPX extensions
|
|
|
|
Returns:
|
|
The HTTP response
|
|
|
|
Raises:
|
|
ToolError: If the request fails with an appropriate error message
|
|
"""
|
|
logger.debug(f"Calling POST '{url}'")
|
|
error_message = None
|
|
|
|
# Inject JWT from FastMCP context if available
|
|
headers = inject_auth_header(headers)
|
|
|
|
try:
|
|
response = await client.post(
|
|
url=url,
|
|
content=content,
|
|
data=data,
|
|
files=files,
|
|
json=json,
|
|
params=params,
|
|
headers=headers,
|
|
cookies=cookies,
|
|
auth=auth,
|
|
follow_redirects=follow_redirects,
|
|
timeout=timeout,
|
|
extensions=extensions,
|
|
)
|
|
logger.debug(f"response: {response.json()}")
|
|
|
|
if response.is_success:
|
|
return response
|
|
|
|
# Handle different status codes differently
|
|
status_code = response.status_code
|
|
# get the message if available
|
|
response_data = response.json()
|
|
if isinstance(response_data, dict) and "detail" in response_data:
|
|
error_message = response_data["detail"]
|
|
else:
|
|
error_message = get_error_message(status_code, url, "POST")
|
|
|
|
# Log at appropriate level based on status code
|
|
if 400 <= status_code < 500:
|
|
# Client errors: log as info except for 429 (Too Many Requests)
|
|
if status_code == 429: # pragma: no cover
|
|
logger.warning(f"Rate limit exceeded: POST {url}: {error_message}")
|
|
else: # pragma: no cover
|
|
logger.info(f"Client error: POST {url}: {error_message}")
|
|
else:
|
|
# Server errors: log as error
|
|
logger.error(f"Server error: POST {url}: {error_message}")
|
|
|
|
# Raise a tool error with the friendly message
|
|
response.raise_for_status() # Will always raise since we're in the error case
|
|
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
|
|
|
|
except HTTPStatusError as e:
|
|
raise ToolError(error_message) from e
|
|
|
|
|
|
async def call_delete(
|
|
client: AsyncClient,
|
|
url: URL | str,
|
|
*,
|
|
params: QueryParamTypes | None = None,
|
|
headers: HeaderTypes | None = None,
|
|
cookies: CookieTypes | None = None,
|
|
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
|
|
extensions: RequestExtensions | None = None,
|
|
) -> Response:
|
|
"""Make a DELETE request and handle errors appropriately.
|
|
|
|
Args:
|
|
client: The HTTPX AsyncClient to use
|
|
url: The URL to request
|
|
params: Query parameters
|
|
headers: HTTP headers
|
|
cookies: HTTP cookies
|
|
auth: Authentication
|
|
follow_redirects: Whether to follow redirects
|
|
timeout: Request timeout
|
|
extensions: HTTPX extensions
|
|
|
|
Returns:
|
|
The HTTP response
|
|
|
|
Raises:
|
|
ToolError: If the request fails with an appropriate error message
|
|
"""
|
|
logger.debug(f"Calling DELETE '{url}'")
|
|
error_message = None
|
|
|
|
# Inject JWT from FastMCP context if available
|
|
headers = inject_auth_header(headers)
|
|
|
|
try:
|
|
response = await client.delete(
|
|
url=url,
|
|
params=params,
|
|
headers=headers,
|
|
cookies=cookies,
|
|
auth=auth,
|
|
follow_redirects=follow_redirects,
|
|
timeout=timeout,
|
|
extensions=extensions,
|
|
)
|
|
|
|
if response.is_success:
|
|
return response
|
|
|
|
# Handle different status codes differently
|
|
status_code = response.status_code
|
|
# get the message if available
|
|
response_data = response.json()
|
|
if isinstance(response_data, dict) and "detail" in response_data:
|
|
error_message = response_data["detail"] # pragma: no cover
|
|
else:
|
|
error_message = get_error_message(status_code, url, "DELETE")
|
|
|
|
# Log at appropriate level based on status code
|
|
if 400 <= status_code < 500:
|
|
# Client errors: log as info except for 429 (Too Many Requests)
|
|
if status_code == 429: # pragma: no cover
|
|
logger.warning(f"Rate limit exceeded: DELETE {url}: {error_message}")
|
|
else:
|
|
logger.info(f"Client error: DELETE {url}: {error_message}")
|
|
else: # pragma: no cover
|
|
# Server errors: log as error
|
|
logger.error(f"Server error: DELETE {url}: {error_message}")
|
|
|
|
# Raise a tool error with the friendly message
|
|
response.raise_for_status() # Will always raise since we're in the error case
|
|
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
|
|
|
|
except HTTPStatusError as e:
|
|
raise ToolError(error_message) from e
|
|
|
|
|
|
def check_migration_status() -> Optional[str]:
|
|
"""Check if sync/migration is in progress and return status message if so.
|
|
|
|
Returns:
|
|
Status message if sync is in progress, None if system is ready
|
|
"""
|
|
try:
|
|
from basic_memory.services.sync_status_service import sync_status_tracker
|
|
|
|
if not sync_status_tracker.is_ready:
|
|
return sync_status_tracker.get_summary()
|
|
return None
|
|
except Exception:
|
|
# If there's any error checking sync status, assume ready
|
|
return None
|
|
|
|
|
|
async def wait_for_migration_or_return_status(
|
|
timeout: float = 5.0, project_name: Optional[str] = None
|
|
) -> Optional[str]:
|
|
"""Wait briefly for sync/migration to complete, or return status message.
|
|
|
|
Args:
|
|
timeout: Maximum time to wait for sync completion
|
|
project_name: Optional project name to check specific project status.
|
|
If provided, only checks that project's readiness.
|
|
If None, uses global status check (legacy behavior).
|
|
|
|
Returns:
|
|
Status message if sync is still in progress, None if ready
|
|
"""
|
|
try:
|
|
from basic_memory.services.sync_status_service import sync_status_tracker
|
|
import asyncio
|
|
|
|
# Check if we should use project-specific or global status
|
|
def is_ready() -> bool:
|
|
if project_name:
|
|
return sync_status_tracker.is_project_ready(project_name)
|
|
return sync_status_tracker.is_ready
|
|
|
|
if is_ready():
|
|
return None
|
|
|
|
# Wait briefly for sync to complete
|
|
start_time = asyncio.get_event_loop().time()
|
|
while (asyncio.get_event_loop().time() - start_time) < timeout:
|
|
if is_ready():
|
|
return None
|
|
await asyncio.sleep(0.1) # Check every 100ms
|
|
|
|
# Still not ready after timeout
|
|
if project_name:
|
|
# For project-specific checks, get project status details
|
|
project_status = sync_status_tracker.get_project_status(project_name)
|
|
if project_status and project_status.status.value == "failed":
|
|
error_msg = project_status.error or "Unknown sync error"
|
|
return f"❌ Sync failed for project '{project_name}': {error_msg}"
|
|
elif project_status:
|
|
return f"🔄 Project '{project_name}' is still syncing: {project_status.message}"
|
|
else:
|
|
return f"⚠️ Project '{project_name}' status unknown"
|
|
else:
|
|
# Fall back to global summary for legacy calls
|
|
return sync_status_tracker.get_summary()
|
|
except Exception: # pragma: no cover
|
|
# If there's any error, assume ready
|
|
return None
|