Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@ The server supports two transport modes:
| `execute_code` | Runs validated Python code against the connected STAAD.Pro model |
| `get_status` | Returns connection state, STAAD version, model path, analysis status |

### File I/O

The `execute_code` tool supports optional **server-side file I/O** for bulk data workflows.
Instead of passing large datasets through the agent's context window, the server reads/writes
CSV and XLSX files directly and injects the data into the sandbox as the `__input__` variable.

| Parameter | Description |
|-----------|-------------|
| `input_path` | Path to a `.csv` or `.xlsx` file. The server reads and parses it, then injects the data as the immutable `__input__` variable in the sandbox. |
| `output_path` | Path where the sandbox return value will be written. The return value must be a list-of-lists (CSV) or a `{sheet_name: {columns, rows}}` dict (multi-sheet XLSX). |
| `overwrite` | Allow overwriting an existing output file (default `false`). |

**Path containment:** File paths must resolve inside a configured allowed boundary before any read/write occurs.
The server supports both **client-configured MCP roots** and **server-configured allowed directories** (via `--allowed-dirs` or `user_config.allowed_directories` in the manifest).
The server validates paths against these boundaries before any file access.

**Limits:** Max file size 50 MB, max 100K rows, max 500 columns, max 50 input sheets.
Comment thread
josha-bentley marked this conversation as resolved.

## Security Notes

Expand Down
18 changes: 17 additions & 1 deletion mcpb/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,28 @@
"documentation": "https://github.com/BentleySystems/openstaad-mcp",
"support": "https://www.bentley.com/support/",
"icon": "assets/icon.png",
"user_config": {
"allowed_directories": {
"type": "directory",
"title": "Allowed Directories",
"description": "Select directories the openSTAAD server can access",
"multiple": true,
"required": true,
"default": [
"${HOME}/Desktop",
Comment thread
josha-bentley marked this conversation as resolved.
"${HOME}/Documents"
]
}
},
"server": {
"type": "binary",
"entry_point": "openstaad-mcp.exe",
"mcp_config": {
"command": "${__dirname}/openstaad-mcp.exe",
"args": []
"args": [
"--allowed-dirs",
"${user_config.allowed_directories}"
]
}
},
"tools": [
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ dependencies = [
"fastmcp>=3.2.3,<4",
"packaging>=24",
"starlette>=1.0.0,<2.0.0",
"pydantic>=2,<3",
"pywin32>=311; platform_system == 'Windows'",
"openstaadpy @ git+https://github.com/BentleySystems/openstaadpy.git@306b77ba6a4bed68fd91f21df19c9fd5fc9fb2e1",
"openpyxl>=3.1,<4",
"defusedxml>=0.7",
"chardet>=5,<8",
]

[project.urls]
Expand Down
8 changes: 5 additions & 3 deletions src/openstaad_mcp/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import threading
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from typing import Any, TypeVar

from openstaad_mcp.version import check_version_warning

Expand Down Expand Up @@ -185,12 +185,14 @@ def _scan() -> None:
# connect_and_run — per-execution STA thread
# ---------------------------------------------------------------------------

T = TypeVar("T")


def connect_and_run(
fn: Callable[[Any], Any],
fn: Callable[[Any], T],
file_path: str,
timeout: float = 120.0,
) -> Any:
) -> T:
"""Connect to the STAAD.Pro instance that has *file_path* open and run *fn*.

Spins a short-lived daemon thread that calls
Expand Down
47 changes: 47 additions & 0 deletions src/openstaad_mcp/file_io/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
---------------------------------------------------------------------------------------------
Copyright (c) Bentley Systems, Incorporated. All rights reserved.
See LICENSE.md in the project root for license terms and full copyright notice.
---------------------------------------------------------------------------------------------

Server-side file I/O for the ``execute_code`` tool.

All operations run **outside** the sandbox. The sandbox never touches the
filesystem -- it receives pre-parsed data via ``__input__`` and returns a
structured value that this module writes to disk.

Sub-modules
-----------
``readers`` -- BaseReader, CSVReader, XLSXReader
``writers`` -- BaseWriter, CSVWriter, XLSXWriter
``models`` -- Pydantic models for return-value validation
``validation`` -- validate_return_value, validate_args_allowed_dirs, deep_freeze
``helpers`` -- get_allowed_dirs, get_input_data, dispatch functions
"""

from openstaad_mcp.file_io.helpers import (
get_allowed_dirs,
get_input_data,
read_input_file,
write_output_file,
)
from openstaad_mcp.file_io.readers import CSVReader, XLSXReader
from openstaad_mcp.file_io.validation import (
deep_freeze,
validate_args_allowed_dirs,
validate_return_value,
)
from openstaad_mcp.file_io.writers import CSVWriter

__all__ = [
"CSVReader",
"CSVWriter",
"XLSXReader",
"deep_freeze",
"get_allowed_dirs",
"get_input_data",
"read_input_file",
"validate_args_allowed_dirs",
"validate_return_value",
"write_output_file",
]
38 changes: 38 additions & 0 deletions src/openstaad_mcp/file_io/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
---------------------------------------------------------------------------------------------
Copyright (c) Bentley Systems, Incorporated. All rights reserved.
See LICENSE.md in the project root for license terms and full copyright notice.
---------------------------------------------------------------------------------------------
"""
# ── File I/O limits ──────────────────────────────────────────────────────

# Maximum file size on disk (bytes) for read operations.
MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 # 50 MB

# Row / column / sheet caps for input files.
MAX_INPUT_ROWS = 100_000
MAX_INPUT_COLUMNS = 500
MAX_INPUT_SHEETS = 50

# Row / column / sheet caps for output (return value → file).
MAX_OUTPUT_ROWS = 100_000
MAX_OUTPUT_COLUMNS = 500
MAX_OUTPUT_SHEETS = 20
Comment thread
josha-bentley marked this conversation as resolved.
Outdated

# Excel's own limit on sheet-tab names.
MAX_SHEET_NAME_LENGTH = 31

# Allowed file extensions for file I/O paths.
ALLOWED_FILE_EXTENSIONS: frozenset[str] = frozenset({".csv", ".xlsx"})

# Temp-file naming for atomic writes.
TEMP_FILE_PREFIX = ".~omcp_"

# Orphan temp files older than this (seconds) are cleaned up.
STALE_TEMP_AGE_SECONDS = 3600

# Number of sample rows included in summaries returned to the agent.
SAMPLE_ROW_COUNT = 5

# Maximum character length for a single cell value (Excel limit: 32,767 characters).
MAX_CELL_SIZE = 32_767
107 changes: 107 additions & 0 deletions src/openstaad_mcp/file_io/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""
---------------------------------------------------------------------------------------------
Copyright (c) Bentley Systems, Incorporated. All rights reserved.
See LICENSE.md in the project root for license terms and full copyright notice.
---------------------------------------------------------------------------------------------

Server-level helpers and public dispatch functions for file I/O."""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Any

from fastmcp.server.context import Context
from mcp.shared.exceptions import McpError

from openstaad_mcp.file_io.path_validator import FileIOError, parse_roots_to_dirs, validate_io_path
from openstaad_mcp.file_io.readers import BaseReader, CSVReader, XLSXReader
from openstaad_mcp.file_io.validation import deep_freeze, validate_return_value
from openstaad_mcp.file_io.writers import BaseWriter, CSVWriter, XLSXWriter

logger = logging.getLogger(__name__)


# ═══════════════════════════════════════════════════════════════════════════
# Reader/writer factory helpers
# ═══════════════════════════════════════════════════════════════════════════

_READERS: dict[str, type[BaseReader]] = {".csv": CSVReader, ".xlsx": XLSXReader}
_WRITERS: dict[str, type[BaseWriter]] = {".csv": CSVWriter, ".xlsx": XLSXWriter}


def _get_reader(path: Path) -> BaseReader:
ext = path.suffix.lower()
cls = _READERS.get(ext)
if cls is None:
raise FileIOError("UNSUPPORTED_FORMAT", f"Cannot read '{ext}' files")
return cls(path)


def _get_writer(path: Path) -> BaseWriter:
ext = path.suffix.lower()
cls = _WRITERS.get(ext)
if cls is None:
raise FileIOError("UNSUPPORTED_FORMAT", f"Cannot write '{ext}' files")
return cls(path)


# ═══════════════════════════════════════════════════════════════════════════
# Public dispatch functions
# ═══════════════════════════════════════════════════════════════════════════


def read_input_file(
path: Path,
*,
sheet: str | None = None,
start_row: int = 0,
max_rows: int | None = None,
has_header: bool | None = None,
) -> tuple[Any, dict[str, Any]]:
"""Read a CSV or XLSX file and return ``(data, summary)``."""
reader = _get_reader(path)
data = reader.read(start_row=start_row, max_rows=max_rows, sheet=sheet, has_header=has_header)
summary = reader.build_summary(data)
return data, summary


def write_output_file(path: str, data: Any, allowed_dirs: list[Path], *, overwrite: bool = False) -> dict[str, Any]:
"""Validate path, validate return value, write atomically, return summary."""
resolved = validate_io_path(path, allowed_dirs, mode="write")
validate_return_value(resolved, data)
writer = _get_writer(resolved)
return writer.write(data, overwrite=overwrite)
Comment thread
silvestre-perret-bentley marked this conversation as resolved.


# ═══════════════════════════════════════════════════════════════════════════
# Server-level helpers (called from server.py)
# ═══════════════════════════════════════════════════════════════════════════


async def get_allowed_dirs(
ctx: Context, args_allowed_dirs: list[Path], input_path: str | None, output_path: str | None
) -> list[Path]:
"""Resolve MCP roots into a list of allowed directories."""
logger.debug("Args allowed dirs: %s", args_allowed_dirs)
allowed_dirs: list[Path] = [Path(el) for el in args_allowed_dirs]
if input_path is not None or output_path is not None:
try:
roots = await ctx.list_roots()
logger.debug(f"Received MCP roots: {roots}")
except McpError as exc:
logger.error(f"Error listing MCP roots: {exc}")
roots = []
allowed_dirs += parse_roots_to_dirs(roots)
logger.debug(f"Allowed directories for file I/O: {allowed_dirs}")
return allowed_dirs


async def get_input_data(input_path: str | None, allowed_dirs: list[Path]) -> tuple[Any, dict[str, Any] | None]:
"""Validate path, read file, freeze data. Returns ``(data, summary)``."""
if input_path is None:
return None, None
resolved_input = validate_io_path(input_path, allowed_dirs, mode="read")
data, input_summary = read_input_file(resolved_input)
return deep_freeze(data), input_summary
Loading
Loading