Single source of truth for project rules (Claude Code imports this file from CLAUDE.md;
Codex, Copilot coding agent, Cursor and Gemini CLI read it directly). Copilot code review
reads the condensed .github/copilot-instructions.md; keep it in sync when rules change.
MCP server giving AI agents full control of building energy modeling —
create buildings, author measures, configure HVAC, run EnergyPlus sims, extract
results — all through 150+ MCP tools backed by the OpenStudio SDK
(exact roster: EXPECTED_TOOLS in tests/test_skill_registration.py).
- New files target ~250 lines; don't grow a file past ~400 without splitting by responsibility. Don't split artificially. Legacy large files are grandfathered — split opportunistically when touched
- Every MCP tool must have an integration test. New behavior, bug fixes, and security hardening need tests too — not just the happy path
- Integration tests must be added to
.github/workflows/ci.yml— append to the lightest shard'sFILES=list (5 amd64 shards, keep balanced; each runs ~12-14 min as of 2026-08) - Follow testing rules in
.claude/rules/testing.md. Critical: every test needs# Regression:or# Validates:comment; never delete failing tests or weaken assertions; assert exact values not existence; integration tests mock nothing; unit tests never importopenstudio - Operations return
{"ok": True/False, ...}— never raise through MCP - Use
openstudioPython bindings directly - All OpenStudio attribute access must handle
is_initialized()checks _extract_*functions return dicts withsnake_casekeys matching OpenStudio attribute names- Tool functions keep
_toolsuffix internally; MCP-visible names strip it via@mcp.tool(name="...") - Never commit generated/temp files —
.gitignorecovers__pycache__/,*.pyc,runs/,.claude/*(except tracked.claude/skills/and.claude/rules/),.pytest_cache/. Test artifacts go toruns/. Only permanent reference models go intests/assets/ - Bundled measures get wrapper tools with typed args — don't expose raw
apply_measureas primary interface - No
getattr()or string-based dispatch — every OpenStudio API method called directly (grepable, lintable, visible in stack traces) - MCP clients may send
list[str]as JSON strings — uselist[str] | strtype annotation +parse_str_list()fromosm_helpers.py - Multi-user isolation: new persistent user data MUST live under an identity-scoped root (
user_run_root()/user_measures_root()), never a process-global path constant; never add a per-user dir to_SHARED_READ_ROOTS; validate path args viais_path_allowed(..., write=…). The sandbox covers execution only — seedocs/security-isolation.md - Tool roster has ONE source of truth:
EXPECTED_TOOLSintests/test_skill_registration.py. Add/remove a tool → edit that set (one line per tool; merges cleanly across branches).test_tool_count/test_tags_coveragederive from it — never hardcode a tool-count literal in tests. Docs/instructions say "150+ tools", not an exact count - List tools (
list_*): filters are primary,max_results(default 10, 0=unlimited) is the safety net, brief fields by default, explicit typed filter params (no generic filter dict). Uselist_paginated()+build_list_response()fromosm_helpers.pyso truncated responses carrycount/total_available/truncated; put common-filter examples in the docstring - No
shell=Truein subprocess calls
More rules for specific tasks (read when relevant):
.claude/rules/testing.md— test tiers, assertion quality, review checklist.claude/rules/component-types.md— adding HVAC component / setpoint manager types.claude/rules/api-reference.md— OpenStudio SDK, CLI, external resources
- Titles are a plain description of the problem or change. No bracketed prefixes like
[Critical],[High],[Bug],[Security] - Severity goes in a label:
severity:critical,severity:high,severity:mediumorseverity:low(e.g.gh issue create --label bug --label severity:high). Type goes inbug/enhancement/documentation - One problem per issue. A multi-finding audit becomes separate issues, or one tracking issue with a checklist
- Prefer the issue forms in
.github/ISSUE_TEMPLATE/(gh issue create --template "Bug report")
- Each skill lives in
mcp_server/skills/<name>/ tools.pyexportsregister(mcp)— MCP tool definitions onlyoperations.py— business logic, returns plain dicts, no MCP awarenessREADME.md— internal dev notes; LLM-facing skills live in.claude/skills/(served via get_skill)- Key modules:
model_manager.py(load/get/save/clear model),osm_helpers.py(fetch_object, optional_name, list_all_as_dicts),skills/__init__.py(auto-discovers all skills)
Two real classes of stdout pollution corrupt MCP JSON-RPC — two-layer defense at startup in server.py::main() before mcp.run().
- Class A — SWIG memleak warnings (interpreter shutdown):
"swig/python detected a memory leak of type 'boost::optional< ... > *'". PyPIopenstudio==3.11.0wheel built WITHOUTSWIG_PYTHON_SILENT_MEMLEAK. Upstream SWIG#2638 / OpenStudio#5421; fix #5422 applied to .deb only, not the wheel (filed as NatLabRockies/OpenStudio#5608). - Class B — OpenStudio Logger Polyhedron/Space (during ops):
[utilities.Polyhedron]/[openstudio.model.Space]warnings on stdout fromSpace::volume()/floorArea()on imperfect geometry. DefaultstandardOutLoggersink runs at Warn level → C stdout. stdout_suppression.py::silence_openstudio_stdout_logger()— primary fix for Class B. Callsopenstudio.Logger.instance().standardOutLogger().setLogLevel(openstudio.Fatal). Uses intended Logger API, no fd manipulation.stdout_suppression.py::redirect_c_stdout_to_stderr()— backstop for Class A + unknowns. Permanently dups fd 1 → stderr; Pythonsys.stdoutgets a private fd to the real MCP client pipe.cbe6399-style claims that FourPipeBeam /add_baseline_systememit stdout do NOT reproduce — per-call wrappers are no-ops now.suppress_openstudio_warnings()retained as no-op for import compat- No action needed for new skills
- Every served skill requires
.claude/skills/<name>/eval.mdwith positive and negative routing cases, or a non-emptyeval-exemptreason inSKILL.mdfrontmatter - Follow the machine-readable table grammar in
tests/llm/README.md#skill-eval-files; malformed or prose-only rows fail deterministic tests - Keep
eval.mdas repository test data; it must not be served to runtime agents throughget_skill - Run
pytest tests/test_skill_docs.py -k eval -vafter changing skill metadata or eval cases; live LLM execution remains opt-in and budgeted
docker build -f docker/Dockerfile -t openstudio-mcp:dev .Run all tests (single container, fastest; same suite CI runs across 5 shards):
docker run --rm \
-v "C:/projects/openstudio-mcp:/repo" \
-v "C:/projects/openstudio-mcp/runs:/runs" \
-e RUN_OPENSTUDIO_INTEGRATION=1 \
-e MCP_SERVER_CMD=openstudio-mcp \
openstudio-mcp:dev bash -lc "cd /repo && pytest -vv tests/test_*.py"Run a specific test file: same command with pytest -vv tests/test_load_save_model.py.
- Lint:
ruff check mcp_server/ - Unit tests (no Docker):
pytest tests/test_skill_registration.py -v
- Integration tests require Docker and OpenStudio
- Use
C:/Windows-style paths for Docker volume mounts (MSYS/c/paths don't resolve dotfile dirs) - Tests create temporary models in
runs/(mounted as/runsin container) - After builds, prune dangling images:
docker image prune -f
When reviewing code, check for:
- Error handling — bare
except Exception: pass, swallowed errors, missing error context - is_initialized() — OpenStudio optionals accessed without checking
.is_initialized() - Dead code — unused imports, unreachable branches, commented-out code
- Type safety — implicit string-to-number coercion, unvalidated inputs
- Security — path traversal in file ops, f-string SQL, unsanitized user input
- Consistency — return shape mismatches between similar tools, naming drift
- Resource leaks — unclosed files, temp dirs not cleaned up
Severity: critical > high > medium > low. Always include a reason — not just "bad practice" but why it matters here.
These SDK behaviors are intentional — do NOT flag them as bugs:
- No transactional rollback. Once an object is created in the model via SWIG bindings, it exists permanently. Create-then-validate is the standard pattern. "Should rollback on failure" is not actionable — the SDK doesn't support it.
.get()on optionals is safe when the preceding API call guarantees a result for valid inputs (e.g.,Space.fromFloorPrint()with a valid polygon). Only flag.get()when the input is user-supplied and could legitimately be invalid.- Plant loops without source equipment are intentional scaffolds. Users add
boilers/chillers afterward via
add_supply_equipment. DOAS and radiant templates create the loop structure; source equipment is a separate step. - OpenStudio setters validate inputs and throw
RuntimeErroron bad values. The outerexcept RuntimeErrorin operations already catches these. Missing pre-validation is LOW severity (nice-to-have), not HIGH. except Exception: passin extractors is often intentional — OpenStudio objects may lack optional attributes depending on version or object type. Flag only when the exception would hide a real bug vs. a legitimately missing field.- DistrictHeating/DistrictCooling fuel options are documented as future work. Silent fallback to default fuel is known, not a current bug.