Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,21 @@ IDFKIT_MCP_TRANSPORT=streamable-http IDFKIT_MCP_HOST=0.0.0.0 IDFKIT_MCP_PORT=800

## Storage Directories

Hosted deployments (HTTP transport, multi-replica, shared volumes) can redirect
file storage away from the ephemeral container filesystem via environment
variables.
Hosted deployments (HTTP transport, multi-replica, shared volumes) must set
explicit storage and output directories. The CLI fails fast for non-stdio
transports unless `IDFKIT_MCP_UPLOAD_DIR`, `IDFKIT_MCP_OUTPUT_DIRS`, and
`IDFKIT_MCP_SIMULATION_DIR` are configured.

Local `stdio` keeps permissive server-local file reads for desktop workflows.
Network transports disable `load_model(file_path=...)` unless
`IDFKIT_MCP_INPUT_DIRS` is set; hosted ChatGPT App deployments should normally
use uploads plus `load_model(upload_name=...)`.

### `IDFKIT_MCP_UPLOAD_DIR`

Where files dropped into the `file_manager` UI are stored. When unset, uploads
live in-memory on the Python process and are lost when the container restarts —
fine for `stdio` and single-container deployments. When set, uploads are written
fine for `stdio` only. Required for HTTP/SSE transports. When set, uploads are written
to `<IDFKIT_MCP_UPLOAD_DIR>/<session_id>/<filename>` with a sidecar
`<filename>.meta.json`. Point this at a shared volume (e.g. EFS) so concurrent
replicas can all resolve `load_model(upload_name=...)` calls.
Expand All @@ -130,16 +136,19 @@ replicas can all resolve `load_model(upload_name=...)` calls.
IDFKIT_MCP_UPLOAD_DIR=/mnt/idfkit-uploads idfkit-mcp --transport http
```

Cleanup: `clear_session()` removes the caller's scope directory. Abandoned
sessions are not swept automatically — run a periodic cleanup (e.g. delete
scopes older than 24 h) in production.
Cleanup: `clear_session()` resets model and simulation state but preserves
uploaded files so callers can reload them. Abandoned upload scopes are not swept
automatically — run a periodic cleanup (e.g. delete scopes older than 24 h) in
production.

### `IDFKIT_MCP_SIMULATION_DIR`

Default parent directory for EnergyPlus run output. When unset, each
`run_simulation` call creates a fresh temp directory. When set, each run writes
to `<IDFKIT_MCP_SIMULATION_DIR>/<session_id>-<utc-timestamp>/`. An explicit
`output_directory` argument on the tool call always wins.
Default parent directory for EnergyPlus run output. When unset in `stdio`, each
`run_simulation` call creates a fresh temp directory. Required for HTTP/SSE
transports. When set, each run writes to
`<IDFKIT_MCP_SIMULATION_DIR>/<session_id>-<utc-timestamp>/`. An explicit
`output_directory` argument on the tool call must also resolve under this root
for HTTP/SSE transports.

```bash
IDFKIT_MCP_SIMULATION_DIR=/mnt/idfkit-simulations idfkit-mcp --transport http
Expand All @@ -152,7 +161,8 @@ user-named output paths) may resolve into. Prevents a misbehaving agent from
writing outside a sanctioned area.

- Colon-separated on POSIX, semicolon-separated on Windows.
- Defaults to the current working directory when unset.
- Defaults to the current working directory when unset for `stdio`.
- Required for HTTP/SSE transports.

```bash
IDFKIT_MCP_OUTPUT_DIRS=/workspace:/mnt/outputs idfkit-mcp
Expand All @@ -161,6 +171,20 @@ IDFKIT_MCP_OUTPUT_DIRS=/workspace:/mnt/outputs idfkit-mcp
Paths that resolve outside every listed root are rejected with a `ToolError`,
including attempts via `..` traversal or symlinks.

### `IDFKIT_MCP_INPUT_DIRS`

Optional whitelist for direct server-local input paths such as
`load_model(file_path=...)` and `convert_osm_to_idf(osm_path=...)`.

- `stdio`: unset means direct local file paths are allowed.
- HTTP/SSE: unset disables direct server-local file paths; use uploaded files
and `load_model(upload_name=...)` instead.
- Colon-separated on POSIX, semicolon-separated on Windows.

```bash
IDFKIT_MCP_INPUT_DIRS=/mnt/idfkit-inputs idfkit-mcp --transport http
```

## Log Verbosity

Control log output with the `IDFKIT_MCP_LOG_LEVEL` environment variable.
Expand Down
2 changes: 2 additions & 0 deletions docs/tools/model-read.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Notes:
- Loading resets previous simulation result state.
- Persists session state to disk for automatic recovery across server restarts.
- For uploads, bytes are pulled from the `FileUpload` store (in-memory or disk-backed via `IDFKIT_MCP_UPLOAD_DIR`) and materialized to a per-session cache before parsing. `clear_session` removes the materialized file and the upload scope.
- For HTTP/SSE transports, direct `file_path` access is disabled unless the operator sets `IDFKIT_MCP_INPUT_DIRS`. Hosted deployments should prefer `upload_name`.

## `convert_osm_to_idf`

Expand All @@ -34,6 +35,7 @@ Parameters:
Behavior:

- Validates input/output extensions and file existence.
- For HTTP/SSE transports, `osm_path` must resolve under `IDFKIT_MCP_INPUT_DIRS` and `output_path` must resolve under `IDFKIT_MCP_OUTPUT_DIRS`.
- Fails safely if OpenStudio SDK is unavailable.
- Writes IDF, then loads it with the same state semantics as `load_model`.
- Returns conversion metadata plus standard model summary fields.
Expand Down
1 change: 1 addition & 0 deletions docs/tools/simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Behavior:
- Returns runtime, output directory, and error counts.
- Returns the resolved EnergyPlus executable, install directory, and version.
- Stores result in server state for follow-up tools.
- For HTTP/SSE transports, `IDFKIT_MCP_SIMULATION_DIR` is required and any explicit `output_directory` must resolve under it.

## `list_output_variables`

Expand Down
20 changes: 20 additions & 0 deletions src/idfkit_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,29 @@ def _parse_args() -> argparse.Namespace:
return args


def _require_http_environment(args: argparse.Namespace) -> None:
"""Fail fast when network transports are missing deployment path policy."""
from idfkit_mcp.tools._path_validation import set_active_transport

set_active_transport(args.transport)
if args.transport == "stdio":
return

required = ("IDFKIT_MCP_UPLOAD_DIR", "IDFKIT_MCP_OUTPUT_DIRS", "IDFKIT_MCP_SIMULATION_DIR")
missing = [name for name in required if not os.environ.get(name)]
if missing:
joined = ", ".join(missing)
raise SystemExit(
f"{args.transport} transport requires explicit deployment path configuration. "
f"Set: {joined}. Direct load_model(file_path=...) remains disabled unless "
"IDFKIT_MCP_INPUT_DIRS is also set; uploaded files can still be loaded with upload_name."
)


def main() -> None:
"""CLI entry point with configurable transport."""
args = _parse_args()
_require_http_environment(args)
kwargs: dict[str, object] = {"transport": args.transport}
if args.transport != "stdio":
kwargs["host"] = args.host
Expand Down
40 changes: 37 additions & 3 deletions src/idfkit_mcp/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,20 @@ def _restore_model(self, data: dict[str, Any]) -> None:
try:
from idfkit import load_epjson, load_idf

from idfkit_mcp.tools._path_validation import validate_restored_path

extra_roots = [session_uploads_dir(self.session_id)]
import os

upload_root = os.environ.get("IDFKIT_MCP_UPLOAD_DIR")
if upload_root:
extra_roots.append(Path(upload_root) / self.session_id)
fp = validate_restored_path(
fp,
label="Restored model path",
env_var="IDFKIT_MCP_INPUT_DIRS",
extra_roots=extra_roots,
)
doc = (
load_epjson(str(fp), strict=True)
if fp.suffix.lower() in (".epjson", ".json")
Expand All @@ -429,6 +443,13 @@ def _restore_simulation(self, data: dict[str, Any]) -> None:
try:
from idfkit.simulation.result import SimulationResult as SimResult

from idfkit_mcp.tools._path_validation import validate_restored_path

rd = validate_restored_path(
rd,
label="Restored simulation run directory",
env_var="IDFKIT_MCP_SIMULATION_DIR",
)
self.simulation_result = SimResult.from_directory(rd)
logging.getLogger(__name__).info("Restored simulation result from session: %s", rd)
except Exception:
Expand All @@ -442,9 +463,22 @@ def _restore_weather(self, data: dict[str, Any]) -> None:
if weather_str is None or self.weather_file is not None:
return
wp = Path(weather_str)
if wp.exists():
self.weather_file = wp
logging.getLogger(__name__).info("Restored weather file from session: %s", wp)
if not wp.exists():
return
try:
from idfkit_mcp.tools._path_validation import validate_restored_path

wp = validate_restored_path(
wp,
label="Restored weather file",
env_var="IDFKIT_MCP_INPUT_DIRS",
extra_roots=[_cache_base_dir()],
)
except Exception:
logging.getLogger(__name__).warning("Failed to restore weather file from %s", wp, exc_info=True)
return
self.weather_file = wp
logging.getLogger(__name__).info("Restored weather file from session: %s", wp)

def clear_session(self) -> None:
"""Delete the session file and reset model/simulation state.
Expand Down
134 changes: 113 additions & 21 deletions src/idfkit_mcp/tools/_path_validation.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Output path validation for MCP tools."""
"""Transport-aware path validation for MCP tools."""

from __future__ import annotations

Expand All @@ -7,32 +7,57 @@

from fastmcp.exceptions import ToolError

_ACTIVE_TRANSPORT_ENV = "IDFKIT_MCP_ACTIVE_TRANSPORT"

def _allowed_roots() -> list[Path]:

def normalize_transport(transport: str | None) -> str:
"""Return the canonical transport name used by security policy checks."""
if transport == "streamable-http":
return "http"
return transport or "stdio"


def active_transport() -> str:
"""Return the currently configured transport, defaulting to local stdio."""
return normalize_transport(os.environ.get(_ACTIVE_TRANSPORT_ENV) or os.environ.get("IDFKIT_MCP_TRANSPORT"))


def restricted_transport_enabled() -> bool:
"""Return True when the server is running in a network transport mode."""
return active_transport() != "stdio"


def set_active_transport(transport: str) -> None:
"""Expose CLI-selected transport to validators that run during tool calls."""
os.environ[_ACTIVE_TRANSPORT_ENV] = normalize_transport(transport)


def _env_roots(env_var: str) -> list[Path]:
env = os.environ.get(env_var)
if not env:
return []
sep = ";" if os.name == "nt" else ":"
return [Path(p).resolve() for p in env.split(sep) if p.strip()]


def _allowed_output_roots() -> list[Path]:
"""Return the list of directories that output paths may resolve into.

Reads ``IDFKIT_MCP_OUTPUT_DIRS`` (colon-separated on POSIX,
semicolon-separated on Windows). Falls back to CWD when the variable
is unset.
semicolon-separated on Windows). Falls back to CWD for stdio only.
"""
env = os.environ.get("IDFKIT_MCP_OUTPUT_DIRS")
if env:
sep = ";" if os.name == "nt" else ":"
return [Path(p).resolve() for p in env.split(sep) if p.strip()]
roots = _env_roots("IDFKIT_MCP_OUTPUT_DIRS")
if roots:
return roots
if restricted_transport_enabled():
raise ToolError(
"IDFKIT_MCP_OUTPUT_DIRS is required for non-stdio transports. "
"Set it to one or more directories where user-named output files may be written."
)
return [Path.cwd().resolve()]


def validate_output_path(path: Path, *, label: str = "Output path") -> Path:
"""Ensure *path* resolves within an allowed output directory.

Allowed directories come from ``IDFKIT_MCP_OUTPUT_DIRS`` (colon-separated),
falling back to the current working directory when the variable is unset.

Both relative paths and absolute paths that fall inside an allowed
directory are accepted. Raises :class:`ToolError` for anything that
escapes all allowed roots (including via ``..`` traversal or symlinks).
"""
roots = _allowed_roots()
def _validate_with_roots(path: Path, roots: list[Path], *, env_var: str, label: str) -> Path:
cwd = Path.cwd().resolve()
resolved = path.resolve() if path.is_absolute() else (cwd / path).resolve()
for root in roots:
Expand All @@ -44,6 +69,73 @@ def validate_output_path(path: Path, *, label: str = "Output path") -> Path:
return resolved
dirs = ", ".join(str(r) for r in roots)
raise ToolError(
f"{label} must be within an allowed directory ({dirs}). "
f"Got: '{path}'. Set IDFKIT_MCP_OUTPUT_DIRS to add more directories."
f"{label} must be within an allowed directory ({dirs}). Got: '{path}'. Set {env_var} to add more directories."
)


def validate_input_path(path: Path, *, label: str = "Input path") -> Path:
"""Validate a server-local input path.

Local stdio clients keep direct filesystem access. Network transports must
explicitly opt in via ``IDFKIT_MCP_INPUT_DIRS``; otherwise callers should
use upload-backed workflows instead of server-local paths.
"""
if not restricted_transport_enabled():
return path

roots = _env_roots("IDFKIT_MCP_INPUT_DIRS")
if not roots:
raise ToolError(
f"{label} is disabled for non-stdio transports. "
"Upload the model and call load_model(upload_name=...), or set IDFKIT_MCP_INPUT_DIRS "
"to allow specific server-local input directories."
)
return _validate_with_roots(path, roots, env_var="IDFKIT_MCP_INPUT_DIRS", label=label)


def validate_output_path(path: Path, *, label: str = "Output path") -> Path:
"""Ensure *path* resolves within an allowed output directory.

Allowed directories come from ``IDFKIT_MCP_OUTPUT_DIRS`` (colon-separated),
falling back to the current working directory for stdio when the variable is unset.

Both relative paths and absolute paths that fall inside an allowed
directory are accepted. Raises :class:`ToolError` for anything that
escapes all allowed roots (including via ``..`` traversal or symlinks).
"""
return _validate_with_roots(
path,
_allowed_output_roots(),
env_var="IDFKIT_MCP_OUTPUT_DIRS",
label=label,
)


def validate_simulation_output_dir(path: Path, *, label: str = "Simulation output directory") -> Path:
"""Validate a caller-specified EnergyPlus run directory."""
if not restricted_transport_enabled():
return path

roots = _env_roots("IDFKIT_MCP_SIMULATION_DIR")
if not roots:
raise ToolError("IDFKIT_MCP_SIMULATION_DIR is required for non-stdio transports.")
return _validate_with_roots(path, roots, env_var="IDFKIT_MCP_SIMULATION_DIR", label=label)


def validate_restored_path(
path: Path,
*,
label: str,
env_var: str,
extra_roots: list[Path] | None = None,
) -> Path:
"""Validate a path read from a persisted session file."""
if not restricted_transport_enabled():
return path

roots = _env_roots(env_var)
if extra_roots:
roots.extend(root.resolve() for root in extra_roots)
if not roots:
raise ToolError(f"{env_var} is required to restore {label.lower()} for non-stdio transports.")
return _validate_with_roots(path, roots, env_var=env_var, label=label)
10 changes: 7 additions & 3 deletions src/idfkit_mcp/tools/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ def load_model(
path = dest_dir / upload_name
path.write_bytes(data)
else:
path = Path(file_path) # type: ignore[arg-type]
from idfkit_mcp.tools._path_validation import validate_input_path

path = validate_input_path(Path(file_path), label="Model file path") # type: ignore[arg-type]

if path.suffix.lower() in (".epjson", ".json"):
doc = load_epjson(str(path), version=ver, strict=True)
Expand Down Expand Up @@ -110,8 +112,10 @@ def convert_osm_to_idf(
) from None
openstudio = cast(Any, openstudio)

input_path = Path(osm_path)
out_path = Path(output_path)
from idfkit_mcp.tools._path_validation import validate_input_path, validate_output_path

input_path = validate_input_path(Path(osm_path), label="OSM input path")
out_path = validate_output_path(Path(output_path), label="OSM output path")

if input_path.suffix.lower() != ".osm":
raise ToolError(f"Input file must have .osm extension: '{input_path}'.")
Expand Down
Loading
Loading