Compare commits

..

2 Commits

Author SHA1 Message Date
marcus 61cbf41900 Fix Claude Desktop loading: lazy NAS connection on first tool call
Previously the server blocked at startup waiting for query_api_info()
and login() before starting the MCP protocol. Claude Desktop has a short
initialization timeout and dropped the server before the handshake started.

Changes:
- DsmClient: add _ensure_initialized() with asyncio.Lock for thread-safe
  lazy init; called automatically at the start of request(), upload_text(),
  and download_text() on the first use.
- cli.py serve: remove upfront query_api_info() and auth.login() calls;
  the server now starts immediately ("MCP server ready" on stderr) and
  connects to the NAS on the first tool invocation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 15:13:24 +02:00
marcus 81ff649ab7 Fix stdio startup for Claude Desktop compatibility
- Replace asyncio.run() with anyio.run() in serve command: FastMCP uses
  anyio.create_task_group() internally, and anyio.run() ensures the correct
  backend context. asyncio.run() can misbehave on Windows (ProactorEventLoop).
- Add SERVER STARTING / MCP server ready messages to stderr for diagnostics.
- Replace sys.exit(1) with early return + stderr write inside anyio context;
  sys.exit inside anyio.run() is less predictable than a clean return.
- Eagerly import all modules at serve startup so ImportErrors surface on
  stderr immediately instead of silently killing the process.
- Add __main__.py to allow python -m mcp_synology_container invocation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 15:02:29 +02:00
3 changed files with 53 additions and 12 deletions
+6
View File
@@ -0,0 +1,6 @@
"""Allow running as: python -m mcp_synology_container."""
from mcp_synology_container.cli import main
if __name__ == "__main__":
main()
+24 -12
View File
@@ -284,34 +284,46 @@ async def _run_check(config_path: str | None) -> bool:
@click.option("--config", "-c", "config_path", type=click.Path(), help="Config file path") @click.option("--config", "-c", "config_path", type=click.Path(), help="Config file path")
def serve(config_path: str | None) -> None: def serve(config_path: str | None) -> None:
"""Start the MCP server in stdio mode.""" """Start the MCP server in stdio mode."""
sys.stderr.write("SERVER STARTING\n")
sys.stderr.flush()
_configure_logging("warning") _configure_logging("warning")
asyncio.run(_run_serve(config_path)) # Use anyio.run() — FastMCP uses anyio internally (anyio.create_task_group),
# and anyio.run() ensures the correct backend context is set up.
# asyncio.run() can cause issues on Windows (ProactorEventLoop + anyio).
import anyio
anyio.run(_run_serve, config_path)
async def _run_serve(config_path: str | None) -> None: async def _run_serve(config_path: str | None) -> None:
"""Initialize and run the MCP server.""" """Initialize and run the MCP server."""
# Eagerly import all modules so any ImportError surfaces immediately on stderr
# instead of silently killing the process.
from mcp_synology_container.auth import AuthManager from mcp_synology_container.auth import AuthManager
from mcp_synology_container.config import load_config from mcp_synology_container.config import load_config
from mcp_synology_container.dsm_client import DsmClient from mcp_synology_container.dsm_client import DsmClient
from mcp_synology_container.server import create_server from mcp_synology_container.server import create_server
logger.debug("Loading config from: %s", config_path or "default path")
try: try:
config = load_config(config_path) config = load_config(config_path)
except (FileNotFoundError, ValueError) as e: except (FileNotFoundError, ValueError) as e:
click.echo(click.style(f"Config error: {e}", fg="red"), err=True) sys.stderr.write(f"Config error: {e}\n")
sys.exit(1) sys.stderr.flush()
return
# Open the HTTP client and register auth — but do NOT connect to the NAS yet.
# Lazy init (_ensure_initialized) runs on the first tool call, so Claude Desktop
# sees the server as ready immediately without waiting for NAS connectivity.
async with DsmClient(config.base_url, config.connection.verify_ssl) as client: async with DsmClient(config.base_url, config.connection.verify_ssl) as client:
await client.query_api_info()
auth = AuthManager(config) auth = AuthManager(config)
client.set_auth_manager(auth) client.set_auth_manager(auth)
# Login on startup
try:
client.sid = await auth.login(client)
except Exception as e:
logger.error("Initial login failed: %s", e)
sys.exit(1)
mcp_server = create_server(config, client) mcp_server = create_server(config, client)
logger.info("MCP server starting (stdio mode)") sys.stderr.write("MCP server ready\n")
await mcp_server.run_stdio_async() sys.stderr.flush()
try:
await mcp_server.run_stdio_async()
except Exception as e:
sys.stderr.write(f"MCP server error: {e}\n")
sys.stderr.flush()
raise
+23
View File
@@ -98,6 +98,8 @@ class DsmClient:
self._sid: str | None = None self._sid: str | None = None
self._auth_manager: AuthManager | None = None self._auth_manager: AuthManager | None = None
self._reauth_lock = asyncio.Lock() self._reauth_lock = asyncio.Lock()
self._init_lock = asyncio.Lock()
self._initialized = False
logger.debug( logger.debug(
"DsmClient: base_url=%s verify_ssl=%s timeout=%d", "DsmClient: base_url=%s verify_ssl=%s timeout=%d",
self._base_url, self._base_url,
@@ -118,6 +120,24 @@ class DsmClient:
"""Register the AuthManager for automatic re-login on session errors.""" """Register the AuthManager for automatic re-login on session errors."""
self._auth_manager = auth_manager self._auth_manager = auth_manager
async def _ensure_initialized(self) -> None:
"""Connect to NAS and authenticate on first use (lazy init).
Subsequent calls are no-ops. Thread-safe via asyncio.Lock.
"""
if self._initialized:
return
async with self._init_lock:
if self._initialized: # re-check inside lock
return
logger.debug("Lazy init: querying API info from %s", self._base_url)
await self.query_api_info()
if self._auth_manager:
logger.debug("Lazy init: authenticating")
self._sid = await self._auth_manager.login(self)
self._initialized = True
logger.debug("Lazy init complete")
async def __aenter__(self) -> DsmClient: async def __aenter__(self) -> DsmClient:
logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING)
@@ -204,6 +224,7 @@ class DsmClient:
Raises: Raises:
SynologyError: On API errors. SynologyError: On API errors.
""" """
await self._ensure_initialized()
http = self._get_http() http = self._get_http()
if api not in self._api_cache: if api not in self._api_cache:
@@ -278,6 +299,7 @@ class DsmClient:
Response data dict. Response data dict.
""" """
api = "SYNO.FileStation.Upload" api = "SYNO.FileStation.Upload"
await self._ensure_initialized()
http = self._get_http() http = self._get_http()
if api not in self._api_cache: if api not in self._api_cache:
@@ -328,6 +350,7 @@ class DsmClient:
File content as string. File content as string.
""" """
api = "SYNO.FileStation.Download" api = "SYNO.FileStation.Download"
await self._ensure_initialized()
http = self._get_http() http = self._get_http()
if api not in self._api_cache: if api not in self._api_cache: