Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d459418
feat: add OpenTelemetry OTLP tracing via OtelMiddleware
anchapin Apr 15, 2026
dab3153
refactor: move telemetry deps into [dev] extras, remove OTEL_ENABLED …
anchapin Apr 15, 2026
7846767
feat: replace custom OTel middleware with openllmetry (traceloop-sdk)
anchapin Apr 15, 2026
b7d6ddc
fix: address rubber-duck review findings for telemetry module
anchapin Apr 15, 2026
57be573
fix: resolve remaining rubber-duck findings
anchapin Apr 15, 2026
75a1904
cleanup: remove dead telemetry constants from config, add tracing docs
anchapin Apr 15, 2026
3402edf
docs: add SECURITY.md and advanced evaluation template for MCP client…
anchapin Apr 15, 2026
aae73c5
fix: address rubber-duck review findings on openllmetry-poc branch
anchapin Apr 15, 2026
32c1534
docs: add per-client MCP setup guides and token context performance doc
anchapin Apr 15, 2026
012e460
fix: correct client guide docs based on live testing
anchapin Apr 15, 2026
cb07037
fix: harden telemetry, gitignore .env, add ECM package example + test
anchapin Apr 15, 2026
dd7103f
docs: fix Example 20 window ECM -- opaque material wrong for glazing
anchapin Apr 15, 2026
fff226a
refactor: move traceloop-sdk to [telemetry] optional extra
anchapin Apr 15, 2026
61ac8a9
docs: add local LLM benchmark results and token overhead measurements
anchapin Apr 15, 2026
7b5f078
fix: add opentelemetry-sdk to dev deps, convert test docstrings to in…
anchapin Apr 15, 2026
8fc5b13
fix: address QAQC review findings before draft PR
anchapin Apr 15, 2026
8959ce8
fix: tighten opentelemetry-sdk constraint to >=1.38.0 in [dev] extras
anchapin Apr 15, 2026
44cfc5e
docs: close CHANGELOG gap for opentelemetry-sdk version constraint fix
anchapin Apr 15, 2026
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
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# openstudio-mcp environment variable template.
# Copy to .env and fill in your values — .env is gitignored and must never be committed.

# ---------------------------------------------------------------------------
# Telemetry (optional — requires pip install 'openstudio-mcp[telemetry]')
# Leave TRACELOOP_BASE_URL unset to disable tracing entirely (zero overhead).
# ---------------------------------------------------------------------------

# OTLP HTTP endpoint. Examples:
# Local Jaeger: http://localhost:4318
# Traceloop cloud: https://api.traceloop.com
TRACELOOP_BASE_URL=

# API key — required only for Traceloop cloud, not for generic OTLP backends.
TRACELOOP_API_KEY=

# Service name shown on every span.
OTEL_SERVICE_NAME=openstudio-mcp

# Set to "false" to use synchronous span export (useful in development).
OTEL_EXPORT_BATCH=true

# IMPORTANT PRIVACY SETTING: when "true" (default), tool arguments and outputs
# — including file paths, model parameters, and simulation results — are
# exported to the OTLP backend. Set to "false" unless you have reviewed the
# data being exported and your backend is self-hosted or trusted.
TRACELOOP_TRACE_CONTENT=false
15 changes: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ jobs:
mkdir -p runs
docker run --rm -v "$PWD:/repo" -v "$PWD/runs:/runs" openstudio-mcp:dev bash -lc 'cd /repo && pytest -vv -m "not integration"'

- name: Smoke-install telemetry extra
# Validates that traceloop-sdk and its deps install cleanly from the
# pinned constraint in [telemetry]. Catches packaging drift that would
# make openstudio-mcp[telemetry] uninstallable without needing a full
# Docker rebuild.
run: |
docker run --rm -v "$PWD:/repo" openstudio-mcp:dev bash -lc \
'pip install --quiet -e "/repo[telemetry]" && python -c "from traceloop.sdk import Traceloop; print(\"traceloop-sdk OK\")"'

- name: Save Docker image
run: docker save openstudio-mcp:dev | gzip > /tmp/image.tar.gz

Expand Down Expand Up @@ -66,8 +75,8 @@ jobs:
EXTRA_ENV="-e MCP_OSW_PATH=tests/assets/SEB_model/SEB4_baseboard/workflow.osw -e EXPECTED_EUI=1.8750760248144998 -e EXPECTED_EUI_RTOL=0.02 -e EXPECTED_EUI_ATOL=0.0"
;;
2)
# common_measures, hvac_systems, geometry, zone terminal, skill_energy_report
FILES="tests/test_common_measures.py tests/test_hvac_systems.py tests/test_replace_zone_terminal.py tests/test_geometry.py tests/test_skill_energy_report.py"
# common_measures, hvac_systems, geometry, zone terminal, skill_energy_report, ecm_package
FILES="tests/test_common_measures.py tests/test_hvac_systems.py tests/test_replace_zone_terminal.py tests/test_geometry.py tests/test_skill_energy_report.py tests/test_skill_ecm_package.py"
EXTRA_ENV=""
;;
3)
Expand All @@ -82,7 +91,7 @@ jobs:
;;
5)
# HVAC supply sim smoke tests + hvac_validation + bar_building + concurrent regression
FILES="tests/test_hvac_supply_sim.py tests/test_hvac_validation.py tests/test_bar_building.py tests/test_concurrent_tools.py tests/test_stdout_logger_silence.py"
FILES="tests/test_hvac_supply_sim.py tests/test_hvac_validation.py tests/test_bar_building.py tests/test_concurrent_tools.py tests/test_stdout_logger_silence.py tests/test_telemetry.py"
EXTRA_ENV=""
;;
esac
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,11 @@ Thumbs.db
# Code review artifacts
docs/review/

# Environment / secrets
.env
.env.*
!.env.example

# Codex CLI
.codex/
.mcp.json
15 changes: 15 additions & 0 deletions .mcp.json.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"mcpServers": {
"openstudio-mcp": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"-v", "/ABSOLUTE/PATH/TO/inputs:/inputs",
"-v", "/ABSOLUTE/PATH/TO/runs:/runs",
"-v", "/ABSOLUTE/PATH/TO/openstudio-mcp/.claude/skills:/skills:ro",
"-e", "OPENSTUDIO_MCP_MODE=prod",
"openstudio-mcp:dev", "openstudio-mcp"
]
}
}
}
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## [Unreleased]

### Added
- **Optional OpenLLMetry tracing**: `pip install 'openstudio-mcp[telemetry]'` + `TRACELOOP_BASE_URL` env var enables distributed tracing via traceloop-sdk. Zero overhead when unset. Key operations (`run_simulation`, `apply_measure`, `create_measure`, `create_*_building`, `run_qaqc_checks`) emit named spans; every FastMCP tool call is auto-instrumented via `McpInstrumentor`.
- **Per-client setup guides**: `docs/clients/` — detailed MCP config examples, tool limits, and performance notes for Claude Code, Claude Desktop, VS Code Copilot, Windsurf, Gemini CLI, and Cursor.
- **Token context performance doc**: `docs/clients/token-context-performance.md` — benchmark of how each client handles the 142-tool surface and context overhead.
- **SECURITY.md**: disclosure policy and supported versions.
- **ECM package example**: `docs/examples/20_deep_retrofit_package.md` — wall insulation + thermostat + window + PV stack with expected EUI ranges.
- **`.mcp.json.example`**: ready-to-use Claude Code MCP config.
- **Docker tracing stack**: `docker/docker-compose.tracing.yml` + `docker/otel-collector-config.yaml` for local Jaeger/OTEL collector.
- **`test_telemetry.py`**: 20 unit tests for telemetry module (no Docker required) — includes startup-wiring and decorator-coverage regression tests.
- **`.env.example`**: template for telemetry environment variables with privacy guidance.
- **`test_stdout_logger_silence.py`**: integration tests verifying Polyhedron/Space Logger warnings are fully suppressed after `silence_openstudio_stdout_logger()`.

### Fixed
- **ECM package example**: window ECM was incorrectly using `create_standard_opaque_material`; now correctly notes that glazing requires `SimpleGlazing` authored via `create_measure`.
- **README tracing Docker example**: corrected image tag from `openstudio-mcp:dev` to `openstudio-mcp:tracing` (the dev image does not include traceloop-sdk); added build command and explanatory note.
- **`TRACELOOP_TRACE_CONTENT` docs**: expanded to warn that the default (`true`) exports tool arguments and outputs to the OTLP backend; recommends `false` as the safe starting point.
- **`opentelemetry-sdk` version constraint**: tightened `[dev]` lower bound from `>=1.20` to `>=1.38.0` to match `traceloop-sdk`'s actual minimum; prevents pip resolving an incompatible version when both extras are installed.

## [0.9.0] - 2026-04-10

### Added
Expand Down
56 changes: 46 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,19 +106,21 @@ For simulation outputs (results, SQL, HTML reports), these are already in `/runs

### Other MCP Hosts

[VS Code Copilot](https://code.visualstudio.com/), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Windsurf](https://windsurf.com/), and [Gemini CLI](https://github.com/google-gemini/gemini-cli) also support MCP with similar JSON config. See the [MCP documentation](https://modelcontextprotocol.io/quickstart/user) for host-specific setup.
See **[`docs/clients/`](docs/clients/index.md)** for per-client setup guides with config files, tool limits, and performance notes.

### Client Compatibility

| Client | Status | Notes |
|--------|--------|-------|
| Claude Desktop | Full support | All 142 tools available |
| Claude Code | Full support | ToolSearch auto-defers tools for efficient discovery |
| VS Code Copilot | Compatible | MCP support via config |
| Windsurf | Compatible | Under 100-tool limit |
| Gemini CLI | Compatible | Use includeTools/excludeTools if needed |
| Cursor | Not compatible | 40-tool hard cap — use Windsurf or Claude Code instead |
| OpenAI API | Compatible | Use defer_loading for best results |
| Client | Tool Limit | Status | Guide |
|--------|-----------|--------|-------|
| Claude Code | Unlimited (ToolSearch) | ✅ Best | [claude-code.md](docs/clients/claude-code.md) |
| Claude Desktop | ~100 practical | ✅ Full | [claude-desktop.md](docs/clients/claude-desktop.md) |
| VS Code Copilot | 128 hard | ✅ Full | [vs-code-copilot.md](docs/clients/vs-code-copilot.md) |
| Windsurf | 100 hard | ⚠️ Partial | [windsurf.md](docs/clients/windsurf.md) — manual tool selection required |
| Gemini CLI | 100 soft / 512 API | ⚠️ Partial | [gemini-cli.md](docs/clients/gemini-cli.md) — use `includeTools` |
| Cursor | 40 hard | ❌ Incompatible | [cursor.md](docs/clients/cursor.md) — 40-tool cap |
| OpenAI API | 128 (recommends ~10) | ✅ Compatible | Use `defer_loading` for best results |

See [token context & performance](docs/clients/token-context-performance.md) for a breakdown of how each client handles the 142-tool surface.

---

Expand Down Expand Up @@ -532,6 +534,40 @@ In **prod mode**, stdout is reserved exclusively for MCP JSON-RPC messages. Logs

---

## Tracing (OpenLLMetry)

Distributed tracing via [traceloop-sdk](https://github.com/traceloop/openllmetry) is available as an optional extra. Install it, then set `TRACELOOP_BASE_URL` to enable it:

```bash
pip install 'openstudio-mcp[telemetry]'
```

Or with Docker (requires the tracing image built with `--build-arg TELEMETRY=1`):

```bash
# Build the tracing-enabled image once:
docker build --build-arg TELEMETRY=1 -t openstudio-mcp:tracing -f docker/Dockerfile .

# Run with tracing enabled:
docker run --rm -i \
-e TRACELOOP_BASE_URL=http://host.docker.internal:4318 \
openstudio-mcp:tracing openstudio-mcp
```

The standard `openstudio-mcp:dev` image does **not** include `traceloop-sdk`. Using it with `TRACELOOP_BASE_URL` set will log a warning and disable tracing — it will not work silently.

| Variable | Default | Description |
|----------|---------|-------------|
| `TRACELOOP_BASE_URL` | *(unset — disabled)* | OTLP endpoint, e.g. `http://localhost:4318` or `https://api.traceloop.com` |
| `TRACELOOP_API_KEY` | *(unset)* | API key for Traceloop cloud (not needed for generic OTLP) |
| `OTEL_SERVICE_NAME` | `openstudio-mcp` | Service name on every span |
| `OTEL_EXPORT_BATCH` | `true` | Set `false` for synchronous export in development |
| `TRACELOOP_TRACE_CONTENT` | `true` | **Set `false` to protect privacy** — when `true`, tool arguments and outputs (including file paths and model data) are exported to the OTLP backend. Recommended: start with `false` and enable only if your backend is self-hosted or you have reviewed the data. |

Tracing is **off by default** and has zero overhead when `TRACELOOP_BASE_URL` is unset. Key operations (`run_simulation`, `apply_measure`, `create_measure`, the three `create_*_building` variants, and `run_qaqc_checks`) emit named spans. Every FastMCP tool call is auto-instrumented via `McpInstrumentor`.

---

## Architecture

- **Transport:** stdio (container spawned by host)
Expand Down
108 changes: 108 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Security Policy

## Scope

This document covers the `openstudio-mcp` MCP server — a container-bound process that gives
AI agents programmatic control of building energy models via the OpenStudio SDK.

---

## Path Safety

### Allowed Roots

All file operations (`read_file`, `copy_file`, `run_osw`, `validate_osw`, etc.) are restricted
to a fixed set of allowed path roots enforced by `is_path_allowed()` in `mcp_server/config.py`:

| Root | Default | Env Override |
|---|---|---|
| `/runs` | Run outputs, simulation artifacts | `OPENSTUDIO_MCP_RUN_ROOT` |
| `/inputs` | User-provided models and weather files | `OPENSTUDIO_MCP_INPUT_ROOT` |
| `/repo` | Server source code (read-only use cases) | — |
| Bundled measures dirs | ComStock and common measures | — |
| Skills dir | Skill Markdown guides | — |

Any path that resolves (after symlink expansion) outside these roots is rejected with
`{"ok": false, "error": "invalid_path"}`. Symlink traversal is prevented by calling
`Path.resolve()` before comparison.

### Path Traversal Mitigations

| Attack Vector | Mitigation |
|---|---|
| `../../etc/passwd` in `file_path` | `Path.resolve()` + allowlist check |
| `../../etc` in `copy_file` `destination` | Same: both source and destination validated |
| `../model.osm` in `seed_file` (OSW) | Flattened to `basename` before staging into run dir |
| Symlink escape from `/runs` | `resolve()` follows symlinks before allowlist check |

### What Is Not Protected

- **Denial of service** via large file reads: `read_file` defaults to 50 KB (`max_bytes=50_000`)
but callers can override `max_bytes`. No upper-bound cap is enforced — consider adding one
if exposing this server to untrusted clients.
- **EnergyPlus subprocess**: The simulation runner (`run_simulation`, `run_osw`) invokes
`openstudio run` as a subprocess. The OSM/OSW content is caller-controlled; a malicious model
could cause unexpected EnergyPlus behavior. The container boundary is the primary mitigation.

---

## Container Isolation

The server is designed to run inside a Docker container with explicit volume mounts:

```
docker run --rm \
-v "/path/to/models:/inputs" \
-v "/path/to/outputs:/runs" \
openstudio-mcp:latest
```

- The host filesystem is **not mounted** except for the two explicit volumes.
- By default, the server performs no outbound network calls; OpenStudio/EnergyPlus
are fully offline. **Exception:** when `TRACELOOP_BASE_URL` is set, the server
exports traces to that OTLP endpoint.
- The server process runs as the user defined by the container runtime. The repo
Dockerfile does not set a `USER` instruction — it runs as root by default.
Production deployments should add a non-root `USER` in a derived image.

---

## Stdout / MCP Transport Integrity

OpenStudio's SWIG bindings emit log warnings to C stdout. This would corrupt the JSON-RPC
transport (MCP communicates over stdio). Two mitigations are applied at startup in `server.py`:

1. `silence_openstudio_stdout_logger()` — sets OpenStudio's standard-out logger to `Fatal`
level, suppressing operational warnings.
2. `redirect_c_stdout_to_stderr()` — permanently redirects C-level stdout (fd 1) to stderr,
with Python's `sys.stdout` on a private pipe to the MCP client. This is a backstop for
any C-extension output that bypasses the logger.

These mitigations prevent log injection into the MCP JSON-RPC stream.

---

## Reporting a Vulnerability

Please **do not** open a public GitHub issue for security vulnerabilities.

Email the maintainers directly or use GitHub's
[private security advisory](https://github.com/settings/security-advisories) feature. Include:

- A description of the vulnerability and its impact
- Steps to reproduce (minimal repro preferred)
- Affected versions or commits

We aim to acknowledge reports within 72 hours and provide a fix or mitigation within 14 days
for confirmed issues.

---

## Known Limitations / Out of Scope

- **Authentication / authorization**: The MCP server has no built-in auth. Access control is
the responsibility of the MCP client and the host environment.
- **EnergyPlus model content**: The server executes whatever EnergyPlus model the caller
provides. Malicious model content is an EnergyPlus concern, not an MCP server concern.
- **Multi-tenancy**: The server holds a single shared in-memory model. It is not designed for
simultaneous untrusted multi-user access.
7 changes: 6 additions & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ COPY pyproject.toml /repo/pyproject.toml
COPY mcp_server /repo/mcp_server
COPY docker /repo/docker

# TELEMETRY=1 installs traceloop-sdk + opentelemetry instrumentation for MCP.
# Default off to keep the image lean. Set to 1 for the tracing variant:
# docker build --build-arg TELEMETRY=1 -t openstudio-mcp:tracing -f docker/Dockerfile .
ARG TELEMETRY=0
RUN pip install --no-cache-dir -U pip \
&& pip install --no-cache-dir -e ".[dev]"
&& pip install --no-cache-dir -e ".[dev]" \
&& if [ "$TELEMETRY" = "1" ]; then pip install --no-cache-dir -e ".[telemetry]"; fi

# (Optional) If you want the container to include any other repo files too:
# COPY . /repo
Expand Down
Loading
Loading