Source code for openswmm_mcp.dependencies
"""Lifespan management and dependency injection for the OpenSWMM MCP server."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from fastmcp import Context
from fastmcp.exceptions import ToolError
from fastmcp.server.lifespan import lifespan
from openswmm_mcp.config import ServerSettings
from openswmm_mcp.session import SessionManager
if TYPE_CHECKING:
from collections.abc import AsyncIterator
logger = logging.getLogger(__name__)
@lifespan
async def server_lifespan(server) -> AsyncIterator[dict]:
"""FastMCP lifespan handler: bootstrap shared resources and tear them down on shutdown.
Yields a context dict containing:
session_manager: :class:`SessionManager` shared across all tool calls.
settings: :class:`ServerSettings` loaded from the environment.
"""
# 1. Build configuration from environment variables / .env
settings = ServerSettings()
# 2. Configure logging to match the requested level
log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
logging.basicConfig(
level=log_level,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
force=True,
)
logger.info("OpenSWMM MCP server starting (log_level=%s)", settings.log_level)
# 3. Create the session manager that owns all SWMM simulation sessions
session_manager = SessionManager(
max_sessions=settings.max_sessions,
working_dir=settings.working_dir,
)
# 3b. Create the managers that own interactive gym envs and
# background optimization jobs (gym_env_* / gym_*_job tools)
from openswmm_mcp.gym_support.envs import EnvManager
from openswmm_mcp.gym_support.jobs import JobManager
env_manager = EnvManager()
job_manager = JobManager()
# 4. Yield the context dict so tools can access shared state
try:
yield {
"session_manager": session_manager,
"settings": settings,
"env_manager": env_manager,
"job_manager": job_manager,
}
finally:
# 5. Cleanup on shutdown
logger.info("OpenSWMM MCP server shutting down -- cleaning up sessions")
job_manager.shutdown()
env_manager.close_all()
await session_manager.cleanup_all()
# ---------------------------------------------------------------------------
# Helper functions for extracting dependencies inside tool handlers
# ---------------------------------------------------------------------------
[docs]
def get_session_manager(ctx: Context) -> SessionManager:
"""Extract the :class:`SessionManager` from the FastMCP lifespan context.
Parameters
----------
ctx:
The FastMCP :class:`Context` injected into a tool handler.
Returns
-------
SessionManager
The shared session manager instance.
Raises
------
ToolError
If the session manager is not available in the context.
"""
try:
return ctx.lifespan_context["session_manager"]
except (KeyError, TypeError) as exc:
raise ToolError(
"Session manager is not available. The server may not have started correctly."
) from exc
[docs]
def get_settings(ctx: Context) -> ServerSettings:
"""Extract :class:`ServerSettings` from the FastMCP lifespan context.
Parameters
----------
ctx:
The FastMCP :class:`Context` injected into a tool handler.
Returns
-------
ServerSettings
The server configuration loaded at startup.
Raises
------
ToolError
If settings are not available in the context.
"""
try:
return ctx.lifespan_context["settings"]
except (KeyError, TypeError) as exc:
raise ToolError(
"Server settings are not available. The server may not have started correctly."
) from exc
[docs]
def get_env_manager(ctx: Context):
"""Extract the gym C{EnvManager} from the FastMCP lifespan context.
@param ctx: The FastMCP L{Context} injected into a tool handler.
@type ctx: L{Context}
@return: The shared interactive-env manager.
@rtype: L{EnvManager<openswmm_mcp.gym_support.envs.EnvManager>}
@raise ToolError: If the env manager is not available in the context.
"""
try:
return ctx.lifespan_context["env_manager"]
except (KeyError, TypeError) as exc:
raise ToolError(
"Gym env manager is not available. The server may not have started correctly."
) from exc
[docs]
def get_job_manager(ctx: Context):
"""Extract the gym C{JobManager} from the FastMCP lifespan context.
@param ctx: The FastMCP L{Context} injected into a tool handler.
@type ctx: L{Context}
@return: The shared background-optimization job manager.
@rtype: L{JobManager<openswmm_mcp.gym_support.jobs.JobManager>}
@raise ToolError: If the job manager is not available in the context.
"""
try:
return ctx.lifespan_context["job_manager"]
except (KeyError, TypeError) as exc:
raise ToolError(
"Gym job manager is not available. The server may not have started correctly."
) from exc
[docs]
def require_state(session, *valid_states: str) -> None:
"""Assert that *session* is in one of *valid_states*.
Parameters
----------
session:
A session object that exposes a ``.state`` attribute.
*valid_states:
One or more acceptable state strings (e.g. ``"running"``, ``"paused"``).
Raises
------
ToolError
If ``session.state`` is not among the accepted states.
"""
if session.state not in valid_states:
allowed = ", ".join(f"'{s}'" for s in valid_states)
raise ToolError(
f"Session is in state '{session.state}', but this action requires one of: {allowed}."
)
[docs]
def require_new_engine(session, feature: str) -> None:
"""Assert that *session* uses the new ``openswmm`` engine backend.
Tools that depend on new-engine-only APIs (ModelBuilder, ModelEditor,
Controls, Inflows, Infrastructure, Quality, Spatial, Tables, Statistics,
OutputReader, GeoPackage) call this guard to fail fast on legacy
sessions with a clear error code instead of an obscure ``AttributeError``
from the backend.
Parameters
----------
session:
A session object exposing ``engine_kind``.
feature:
Human-readable name of the feature being requested, used in the
error message (e.g. ``"ModelBuilder"``, ``"Spatial coordinates"``).
Raises
------
ToolError
With code :data:`~openswmm_mcp.errors.ErrorCode.NOT_SUPPORTED` when
the session was created with ``engine='legacy'``.
"""
from openswmm_mcp.errors import ErrorCode
kind = getattr(session, "engine_kind", "openswmm")
if kind != "openswmm":
raise ToolError(
f"[{ErrorCode.NOT_SUPPORTED}] {feature} requires the new openswmm "
f"engine; this session was opened with engine='{kind}'. "
f"Re-open the model with engine='openswmm' (the default) to use "
f"spatial/geometry and other new-engine tools."
)
[docs]
def require_gymnasium(feature: str = "This tool") -> None:
"""Assert that the optional ``openswmm.gymnasium`` package is importable.
Gym tool modules import ``openswmm_gymnasium`` lazily so the server
starts cleanly without the ``gym`` extra; this guard converts the
eventual ``ImportError`` into an actionable ``ToolError`` instead.
Args:
feature: Human-readable name of the feature being requested, used
in the error message (e.g. ``"gym_run_episode"``).
Raises:
ToolError: With code
:attr:`~openswmm_mcp.errors.ErrorCode.DEPENDENCY_MISSING`
when ``openswmm_gymnasium`` cannot be imported.
"""
from openswmm_mcp.errors import ErrorCode
try:
import openswmm_gymnasium # noqa: F401
except ImportError as exc:
raise ToolError(
f"[{ErrorCode.DEPENDENCY_MISSING}] {feature} requires the optional "
"openswmm.gymnasium package. Install it with: "
"pip install 'openswmm.mcp[gym]'"
) from exc