Skip to content

Commit 1f48bef

Browse files
phernandezclaude
andcommitted
fix(mcp): correct type annotation for client factory in async_client.py
Fix typecheck error by using AbstractAsyncContextManager instead of AsyncIterator for the _client_factory type annotation. Changes: - Import AbstractAsyncContextManager from contextlib - Update _client_factory type from Callable[[], AsyncIterator[AsyncClient]] to Callable[[], AbstractAsyncContextManager[AsyncClient]] - Update set_client_factory() parameter type to match The factory needs to be a callable that returns an async context manager (with __aenter__ and __aexit__), not just an async iterator. Testing: - Typecheck passed (0 errors, 0 warnings) - Full test suite passed (all tests including integration tests) Related to SPEC-16 MCP Cloud Service Consolidation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent def8cb3 commit 1f48bef

6 files changed

Lines changed: 95 additions & 79 deletions

File tree

src/basic_memory/cli/commands/mcp.py

Lines changed: 72 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -20,70 +20,75 @@
2020
import threading
2121
from basic_memory.services.initialization import initialize_file_sync
2222

23-
24-
@app.command()
25-
def mcp(
26-
transport: str = typer.Option("stdio", help="Transport type: stdio, streamable-http, or sse"),
27-
host: str = typer.Option(
28-
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
29-
),
30-
port: int = typer.Option(8000, help="Port for HTTP transports"),
31-
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
32-
project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
33-
): # pragma: no cover
34-
"""Run the MCP server with configurable transport options.
35-
36-
This command starts an MCP server using one of three transport options:
37-
38-
- stdio: Standard I/O (good for local usage)
39-
- streamable-http: Recommended for web deployments (default)
40-
- sse: Server-Sent Events (for compatibility with existing clients)
41-
"""
42-
43-
# Validate and set project constraint if specified
44-
if project:
45-
config_manager = ConfigManager()
46-
project_name, _ = config_manager.get_project(project)
47-
if not project_name:
48-
typer.echo(f"No project found named: {project}", err=True)
49-
raise typer.Exit(1)
50-
51-
# Set env var with validated project name
52-
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
53-
logger.info(f"MCP server constrained to project: {project_name}")
54-
55-
app_config = ConfigManager().config
56-
57-
def run_file_sync():
58-
"""Run file sync in a separate thread with its own event loop."""
59-
loop = asyncio.new_event_loop()
60-
asyncio.set_event_loop(loop)
61-
try:
62-
loop.run_until_complete(initialize_file_sync(app_config))
63-
except Exception as e:
64-
logger.error(f"File sync error: {e}", err=True)
65-
finally:
66-
loop.close()
67-
68-
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
69-
if app_config.sync_changes:
70-
# Start the sync thread
71-
sync_thread = threading.Thread(target=run_file_sync, daemon=True)
72-
sync_thread.start()
73-
logger.info("Started file sync in background")
74-
75-
# Now run the MCP server (blocks)
76-
logger.info(f"Starting MCP server with {transport.upper()} transport")
77-
78-
if transport == "stdio":
79-
mcp_server.run(
80-
transport=transport,
81-
)
82-
elif transport == "streamable-http" or transport == "sse":
83-
mcp_server.run(
84-
transport=transport,
85-
host=host,
86-
port=port,
87-
path=path,
88-
log_level="INFO",
89-
)
23+
config = ConfigManager().config
24+
25+
if not config.cloud_mode_enabled:
26+
27+
@app.command()
28+
def mcp(
29+
transport: str = typer.Option(
30+
"stdio", help="Transport type: stdio, streamable-http, or sse"
31+
),
32+
host: str = typer.Option(
33+
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
34+
),
35+
port: int = typer.Option(8000, help="Port for HTTP transports"),
36+
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
37+
project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
38+
): # pragma: no cover
39+
"""Run the MCP server with configurable transport options.
40+
41+
This command starts an MCP server using one of three transport options:
42+
43+
- stdio: Standard I/O (good for local usage)
44+
- streamable-http: Recommended for web deployments (default)
45+
- sse: Server-Sent Events (for compatibility with existing clients)
46+
"""
47+
48+
# Validate and set project constraint if specified
49+
if project:
50+
config_manager = ConfigManager()
51+
project_name, _ = config_manager.get_project(project)
52+
if not project_name:
53+
typer.echo(f"No project found named: {project}", err=True)
54+
raise typer.Exit(1)
55+
56+
# Set env var with validated project name
57+
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
58+
logger.info(f"MCP server constrained to project: {project_name}")
59+
60+
app_config = ConfigManager().config
61+
62+
def run_file_sync():
63+
"""Run file sync in a separate thread with its own event loop."""
64+
loop = asyncio.new_event_loop()
65+
asyncio.set_event_loop(loop)
66+
try:
67+
loop.run_until_complete(initialize_file_sync(app_config))
68+
except Exception as e:
69+
logger.error(f"File sync error: {e}", err=True)
70+
finally:
71+
loop.close()
72+
73+
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
74+
if app_config.sync_changes:
75+
# Start the sync thread
76+
sync_thread = threading.Thread(target=run_file_sync, daemon=True)
77+
sync_thread.start()
78+
logger.info("Started file sync in background")
79+
80+
# Now run the MCP server (blocks)
81+
logger.info(f"Starting MCP server with {transport.upper()} transport")
82+
83+
if transport == "stdio":
84+
mcp_server.run(
85+
transport=transport,
86+
)
87+
elif transport == "streamable-http" or transport == "sse":
88+
mcp_server.run(
89+
transport=transport,
90+
host=host,
91+
port=port,
92+
path=path,
93+
log_level="INFO",
94+
)

src/basic_memory/mcp/async_client.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from contextlib import asynccontextmanager
1+
from contextlib import asynccontextmanager, AbstractAsyncContextManager
22
from typing import AsyncIterator, Callable, Optional
33

44
from httpx import ASGITransport, AsyncClient, Timeout
@@ -9,10 +9,10 @@
99

1010

1111
# Optional factory override for dependency injection
12-
_client_factory: Optional[Callable[[], AsyncIterator[AsyncClient]]] = None
12+
_client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
1313

1414

15-
def set_client_factory(factory: Callable[[], AsyncIterator[AsyncClient]]) -> None:
15+
def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncClient]]) -> None:
1616
"""Override the default client factory (for cloud app, testing, etc).
1717
1818
Args:
@@ -75,9 +75,7 @@ async def get_client() -> AsyncIterator[AsyncClient]:
7575
# CLI cloud mode: inject auth when creating client
7676
from basic_memory.cli.auth import CLIAuth
7777

78-
auth = CLIAuth(
79-
client_id=config.cloud_client_id, authkit_domain=config.cloud_domain
80-
)
78+
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
8179
token = await auth.get_valid_token()
8280

8381
if not token:

src/basic_memory/mcp/tools/list_directory.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ async def list_directory(
9797
# Format the results
9898
output_lines = []
9999
if file_name_glob:
100-
output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):")
100+
output_lines.append(
101+
f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):"
102+
)
101103
else:
102104
output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
103105
output_lines.append("")

src/basic_memory/mcp/tools/recent_activity.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,10 @@ async def recent_activity(
229229
)
230230

231231
guidance_lines.extend(
232-
["", "Session reminder: Remember their project choice throughout this conversation."]
232+
[
233+
"",
234+
"Session reminder: Remember their project choice throughout this conversation.",
235+
]
233236
)
234237

235238
guidance = "\n".join(guidance_lines)

src/basic_memory/mcp/tools/sync_status.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,13 @@ async def sync_status(project: Optional[str] = None, context: Context | None = N
136136
all_sync_projects = sync_status_tracker.get_all_projects()
137137

138138
active_projects = [
139-
p for p in all_sync_projects.values() if p.status.value in ["scanning", "syncing"]
139+
p
140+
for p in all_sync_projects.values()
141+
if p.status.value in ["scanning", "syncing"]
142+
]
143+
failed_projects = [
144+
p for p in all_sync_projects.values() if p.status.value == "failed"
140145
]
141-
failed_projects = [p for p in all_sync_projects.values() if p.status.value == "failed"]
142146

143147
if active_projects:
144148
status_lines.extend(

src/basic_memory/mcp/tools/write_note.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,9 @@ async def write_note(
134134
project_path = active_project.home
135135
if folder and not validate_project_path(folder, project_path):
136136
logger.warning(
137-
"Attempted path traversal attack blocked", folder=folder, project=active_project.name
137+
"Attempted path traversal attack blocked",
138+
folder=folder,
139+
project=active_project.name,
138140
)
139141
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
140142

@@ -198,7 +200,9 @@ async def write_note(
198200
summary.append(f"- Resolved: {resolved}")
199201
if unresolved:
200202
summary.append(f"- Unresolved: {unresolved}")
201-
summary.append("\nNote: Unresolved relations point to entities that don't exist yet.")
203+
summary.append(
204+
"\nNote: Unresolved relations point to entities that don't exist yet."
205+
)
202206
summary.append(
203207
"They will be automatically resolved when target entities are created or during sync operations."
204208
)

0 commit comments

Comments
 (0)