From db681953103779b8f6f642b05e576b3934818f04 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Mon, 23 Mar 2026 23:54:17 +0000 Subject: [PATCH] Add ifcmcp: MCP server for IFC model querying and editing (#7847) ifcmcp is a new Model Context Protocol server that wraps ifcquery and ifcedit, holding an IFC model in memory across tool calls. It is the preferred way to interact with IFC models from AI assistants and MCP-compatible clients. Setup: claude mcp add --transport stdio ifc -- python3 -m ifcmcp Session tools: ifc_load, ifc_save Query tools: ifc_summary, ifc_tree, ifc_info, ifc_select, ifc_relations, ifc_clash, ifc_validate, ifc_schedule, ifc_cost, ifc_schema, ifc_contexts, ifc_materials, ifc_plot, ifc_render, ifc_shape, ifc_shape_list, ifc_shape_docs Edit discovery: ifc_list, ifc_docs Edit execution: ifc_edit, ifc_quantify The model stays in memory between calls - ifc_edit does not auto-save; call ifc_save explicitly when done. Depends on both ifcquery and ifcedit Generated with the assistance of an AI coding tool. --- src/ifcmcp/README.md | 401 +++++++++++++++++ src/ifcmcp/ifcmcp/__init__.py | 20 + src/ifcmcp/ifcmcp/__main__.py | 11 + src/ifcmcp/ifcmcp/core.py | 731 +++++++++++++++++++++++++++++++ src/ifcmcp/ifcmcp/embedded.py | 60 +++ src/ifcmcp/ifcmcp/server.py | 231 ++++++++++ src/ifcmcp/pyproject.toml | 36 ++ src/ifcmcp/tests/__init__.py | 1 + src/ifcmcp/tests/conftest.py | 59 +++ src/ifcmcp/tests/test_edit.py | 96 ++++ src/ifcmcp/tests/test_query.py | 113 +++++ src/ifcmcp/tests/test_server.py | 106 +++++ src/ifcmcp/tests/test_session.py | 62 +++ src/ifcmcp/tests/test_shape.py | 141 ++++++ 14 files changed, 2068 insertions(+) create mode 100644 src/ifcmcp/README.md create mode 100644 src/ifcmcp/ifcmcp/__init__.py create mode 100644 src/ifcmcp/ifcmcp/__main__.py create mode 100644 src/ifcmcp/ifcmcp/core.py create mode 100644 src/ifcmcp/ifcmcp/embedded.py create mode 100644 src/ifcmcp/ifcmcp/server.py create mode 100644 src/ifcmcp/pyproject.toml create mode 100644 src/ifcmcp/tests/__init__.py create mode 100644 src/ifcmcp/tests/conftest.py create mode 100644 src/ifcmcp/tests/test_edit.py create mode 100644 src/ifcmcp/tests/test_query.py create mode 100644 src/ifcmcp/tests/test_server.py create mode 100644 src/ifcmcp/tests/test_session.py create mode 100644 src/ifcmcp/tests/test_shape.py diff --git a/src/ifcmcp/README.md b/src/ifcmcp/README.md new file mode 100644 index 0000000000..3867f7b79c --- /dev/null +++ b/src/ifcmcp/README.md @@ -0,0 +1,401 @@ + +# ifcmcp + +An MCP (Model Context Protocol) server that wraps `ifcquery` and `ifcedit`, +holding the IFC model in memory across tool calls for fast interactive editing +sessions. + +## Installation + +```bash +pip install ifcmcp +``` + +Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately. + +## Running the server + +```bash +python3 -m ifcmcp +``` + +This starts the server on stdio transport, suitable for use with Claude Code +or any MCP client. + +### Claude Code configuration + +Use the `claude mcp add` command: + +```bash +claude mcp add --transport stdio ifc -- python3 -m ifcmcp +``` + +Or create a `.mcp.json` file in your project root: + +```json +{ + "mcpServers": { + "ifc": { + "type": "stdio", + "command": "python3", + "args": ["-m", "ifcmcp"] + } + } +} +``` + +After adding the server, restart Claude Code for the tools to become available. +Then load a model by asking Claude to use `ifc_load`: + +``` +load model.ifc using ifc_load +``` + +## Tools + +### Session + +#### ifc_new + +Create a new empty IFC model in memory, replacing any currently loaded model. + +``` +ifc_new() +ifc_new(schema="IFC4X3") +``` + +Default schema is `IFC4`. + +#### ifc_load + +Open an IFC file into memory. + +``` +ifc_load(path="/path/to/model.ifc") +-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities" +``` + +#### ifc_reset + +Unload the current model from memory, freeing all session state. + +``` +ifc_reset() +``` + +#### ifc_save + +Write the in-memory model to disk. Empty path overwrites the original file. + +``` +ifc_save() +ifc_save(path="/path/to/output.ifc") +``` + +### Query tools + +All query tools require a model to be loaded first via `ifc_load`. + +#### ifc_summary + +Model overview: schema, entity counts, project info. + +```json +{ + "schema": "IFC4", + "total_entities": 1847, + "project": {"id": 1, "name": "Office Building"}, + "types": {"IfcWall": 42, "IfcSlab": 12, "IfcWindow": 36} +} +``` + +#### ifc_tree + +Full spatial hierarchy from IfcProject down through sites, buildings, storeys, +and contained elements. + +```json +{ + "id": 1, + "type": "IfcProject", + "name": "Office Building", + "children": [ + { + "id": 2, + "type": "IfcSite", + "children": [{"id": 3, "type": "IfcBuilding", "children": ["..."]}] + } + ] +} +``` + +#### ifc_info + +Deep inspection of an entity by step ID: attributes, property sets, type, +material, container, and 4x4 placement matrix. + +``` +ifc_info(element_id=10) +``` + +#### ifc_select + +Filter elements using ifcopenshell selector syntax. + +``` +ifc_select(query="IfcWall") +ifc_select(query="IfcWindow") +``` + +Returns a sorted list of `{"id", "type", "name"}` references. + +#### ifc_relations + +Show all relationships for an element: hierarchy, children, type, groups, +systems, material, connections. + +``` +ifc_relations(element_id=10) +ifc_relations(element_id=10, traverse="up") +``` + +With `traverse="up"`, walks the hierarchy from element up to IfcProject. + +#### ifc_contexts + +List all geometric representation contexts and subcontexts in the loaded model. + +``` +ifc_contexts() +``` + +#### ifc_materials + +List all materials and material sets in the loaded model, with their assigned elements. + +``` +ifc_materials() +``` + +#### ifc_clash + +Check an element for geometric intersections and clearance violations. + +``` +ifc_clash(element_id=10) +ifc_clash(element_id=10, clearance=0.5, scope="all") +``` + +Parameters: + +- `clearance` -- minimum clearance distance in meters (0.0 = no clearance check) +- `tolerance` -- intersection tolerance in meters (default: 0.002) +- `scope` -- `"storey"` or `"all"` (default: `"storey"`) + +#### ifc_validate + +Check the model for schema and constraint violations. + +``` +ifc_validate() +ifc_validate(express_rules=True) +``` + +Returns `{"valid": true, "issues": []}` or `{"valid": false, "issues": [{"level": "ERROR", "message": "..."}]}`. + +#### ifc_schedule + +List all work schedules and their nested task trees. + +``` +ifc_schedule() +ifc_schedule(max_depth=1) # top-level phases only +``` + +`max_depth` limits subtask expansion. At the cutoff, `subtasks` is replaced +with `{"truncated": true, "count": N}` so you know children exist without +fetching them all. Omit for unlimited depth. + +#### ifc_cost + +List all cost schedules and their nested cost item trees. + +``` +ifc_cost() +ifc_cost(max_depth=2) # top two levels of the BoQ +``` + +`max_depth` limits cost item expansion, same truncation convention as +`ifc_schedule`. + +#### ifc_schema + +Return IFC class documentation for any entity type, using the loaded model's +schema version. + +``` +ifc_schema(entity_type="IfcWall") +ifc_schema(entity_type="IfcBuildingStorey") +``` + +Returns description, predefined types, spec URL, and attribute descriptions. +Returns `{"error": "Unknown entity: Foo"}` for unrecognised types. + +#### ifc_quantify + +Run quantity take-off (QTO) on the loaded model using an `ifc5d` rule. +Computes physical measurements (volume, area, length, count, weight) and +writes them back as `IfcElementQuantity` property sets. Modifies the model +in-place -- call `ifc_save()` when done. + +``` +ifc_quantify(rule="IFC4QtoBaseQuantities") +ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall") +``` + +Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`. + +`selector` is an optional ifcopenshell selector to restrict which elements +are quantified (default: all `IfcElement`). + +Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`. + +### Drawing and rendering tools + +#### ifc_plot + +Generate a 2D technical drawing of the loaded model and return it as an inline image. + +``` +ifc_plot() +ifc_plot(selector="IfcWall", view="floorplan", scale=0.01, output_path="/tmp/plan.svg") +ifc_plot(element_ids=[10, 11], view="floorplan") +``` + +Parameters: + +- `selector` -- ifcopenshell selector to restrict plotted elements +- `element_ids` -- step IDs of elements to highlight; others are faded +- `view` -- `"floorplan"` (default), `"elevation"`, `"section"`, or `"auto"` +- `width_mm`, `height_mm` -- paper size in mm (default: 297 x 420) +- `scale` -- model-to-paper ratio (default: 0.01 = 1:100) +- `png_width`, `png_height` -- raster output size in pixels (default: 1024 x 1024) +- `output_path` -- optional path to also save to disk (`.svg` for vector, otherwise PNG) + +Returns an inline PNG the LLM can inspect. Requires `ifcopenshell.draw`. + +#### ifc_render + +Render the loaded model to a 3D PNG image. + +``` +ifc_render() +ifc_render(selector="IfcWall", view="iso", output_path="/tmp/model.png") +ifc_render(element_ids=[10, 11], view="south") +``` + +Parameters: + +- `selector` -- ifcopenshell selector to restrict rendered elements +- `element_ids` -- step IDs of elements to highlight; others are shown translucent +- `view` -- `"iso"` (default), `"top"`, `"south"`, `"north"`, `"east"`, or `"west"` +- `output_path` -- optional path to save the PNG to disk + +Returns an inline PNG. Requires `pyvista` and the IfcOpenShell C++ geometry bindings. + +### Shape builder tools + +#### ifc_shape_list + +List all available `ShapeBuilder` methods with brief descriptions. + +``` +ifc_shape_list() +``` + +#### ifc_shape_docs + +Show full documentation for a specific `ShapeBuilder` method. + +``` +ifc_shape_docs(method="extrude") +ifc_shape_docs(method="create_ellipse") +``` + +#### ifc_shape + +Execute a `ShapeBuilder` method on the loaded model. + +``` +ifc_shape(method="extrude", params='{"profile": "42", "magnitude": 3.0}') +``` + +`params` is a JSON string; entity references are resolved by step ID (same coercion as `ifc_edit`). + +### Edit discovery tools + +#### ifc_list + +List all API modules, or functions within a specific module. + +``` +ifc_list() # all modules +ifc_list(module="root") # functions in the root module +``` + +#### ifc_docs + +Show full documentation for an API function including parameters, types, +defaults, and descriptions. + +``` +ifc_docs(function_path="root.create_entity") +``` + +### Edit execution + +#### ifc_edit + +Execute an `ifcopenshell.api` mutation function. Parameters are passed as a +JSON string with string values that get coerced by ifcedit's type system. + +``` +ifc_edit( + function_path="root.create_entity", + params='{"ifc_class": "IfcWall", "name": "My Wall"}' +) +``` + +Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`. + +Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk. + +**Parameter coercion:** + +| Type | JSON value | Python value | +|------|------------|--------------| +| `entity_instance` | `"42"` | resolved from model by step ID | +| `list[entity_instance]` | `"5,6,7"` | list of resolved entities | +| `dict` | `'{"key": "val"}'` | parsed JSON object | +| `bool` | `"true"` | `True` | +| `Optional[X]` | `"none"` | `None` | + +## Typical workflow + +1. **Load** a model: `ifc_load` +2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations` +3. **Validate** if needed: `ifc_validate` +4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects) +5. **Look up IFC classes**: `ifc_schema` +6. **Find** the right API function: `ifc_list`, `ifc_docs` +7. **Edit** the model: `ifc_edit` +8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place) +9. **Verify** changes with query tools +10. **Save** when satisfied: `ifc_save` + +The model stays in memory across all calls, so multi-step editing sessions +are fast -- no file I/O between operations. + +## License + +LGPLv3+ -- see the IfcOpenShell project license. diff --git a/src/ifcmcp/ifcmcp/__init__.py b/src/ifcmcp/ifcmcp/__init__.py new file mode 100644 index 0000000000..92e1c230b6 --- /dev/null +++ b/src/ifcmcp/ifcmcp/__init__.py @@ -0,0 +1,20 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcMCP - MCP server for IFC building models +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcMCP. +# +# IfcMCP is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcMCP is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcMCP. If not, see . + +__version__ = version = "0.0.0" diff --git a/src/ifcmcp/ifcmcp/__main__.py b/src/ifcmcp/ifcmcp/__main__.py new file mode 100644 index 0000000000..0e94699ea0 --- /dev/null +++ b/src/ifcmcp/ifcmcp/__main__.py @@ -0,0 +1,11 @@ +# This file was generated with the assistance of an AI coding tool. +from ifcmcp.server import build_server + + +def main(): + server = build_server() + server.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py new file mode 100644 index 0000000000..ed9f91c3c9 --- /dev/null +++ b/src/ifcmcp/ifcmcp/core.py @@ -0,0 +1,731 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +# inside ifcmcp/core.py +import json +from collections.abc import Callable # noqa: F401 — Callable used in helpers below +from dataclasses import dataclass +from typing import Any + +import ifcopenshell +from ifcedit.discover import function_docs, list_functions, list_modules +from ifcedit.quantify import run_quantify +from ifcedit.run import run_api +from ifcquery import clash as clash_mod +from ifcquery import contexts as contexts_mod +from ifcquery import cost as cost_mod +from ifcquery import ( + info, + relations, + schedule, + schema, + select, + summary, + tree, +) +from ifcquery import ( + materials as materials_mod, +) +from ifcquery import ( + plot as plot_mod, +) +from ifcquery import ( + render as render_mod, +) +from ifcquery import validate as validate_mod + + +def _jsonify(x: Any) -> Any: + """Convert IfcOpenShell objects / iterables into JSON-safe primitives.""" + if x is None or isinstance(x, (str, int, float, bool)): + return x + + # numpy arrays (and any array-like with tolist) + if hasattr(x, "tolist"): + return x.tolist() + + # IfcOpenShell entity instances: normalize + if isinstance(x, ifcopenshell.entity_instance): + return { + "id": int(x.id()), + "type": x.is_a(), + "repr": str(x), + "name": getattr(x, "Name", None), + } + + if isinstance(x, dict): + return {str(k): _jsonify(v) for k, v in x.items()} + + if isinstance(x, (list, tuple, set)): + return [_jsonify(v) for v in x] + + # Try JSON as-is, else fallback to string + try: + json.dumps(x) + return x + except Exception: + return str(x) + + +# --------------------------------------------------------------------------- +# Shape builder helpers +# --------------------------------------------------------------------------- + + +def _list_shape_methods() -> list[dict]: + """Introspect ShapeBuilder and return a summary of all public methods.""" + import inspect + + from ifcedit.discover import _extract_params + from ifcopenshell.util.shape_builder import ShapeBuilder + + results = [] + for name, fn in inspect.getmembers(ShapeBuilder, predicate=inspect.isfunction): + if name.startswith("_"): + continue + doc = fn.__doc__ or "" + description = doc.strip().split("\n")[0] if doc.strip() else "" + results.append({"method": name, "description": description, "params": _extract_params(fn)}) + return results + + +def _shape_method_docs(method_name: str) -> dict: + """Return full documentation for a single ShapeBuilder method.""" + import typing + + from ifcedit.discover import ( + _extract_params, + _format_type_hint, + _parse_docstring_body, + _parse_param_docs, + _parse_return_doc, + ) + from ifcopenshell.util.shape_builder import ShapeBuilder + + if method_name.startswith("_"): + raise ValueError(f"ShapeBuilder has no method '{method_name}'") + fn = getattr(ShapeBuilder, method_name, None) + if fn is None: + raise ValueError(f"ShapeBuilder has no method '{method_name}'") + + doc = fn.__doc__ or "" + description, long_description = _parse_docstring_body(doc) + params = _extract_params(fn) + for param in params: + param_desc = _parse_param_docs(doc) + if param["name"] in param_desc: + param["description"] = param_desc[param["name"]] + + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + result: dict[str, Any] = { + "method": method_name, + "description": description, + "long_description": long_description, + "params": params, + } + return_type = _format_type_hint(hints.get("return")) + if return_type: + result["return_type"] = return_type + return_description = _parse_return_doc(doc) + if return_description: + result["return_description"] = return_description + return result + + +def _coerce_shape_params(fn: Callable, raw_kwargs: dict, model: ifcopenshell.file) -> dict: + """Coerce JSON-parsed kwargs to proper Python types for a ShapeBuilder method.""" + import inspect + import typing + + sig = inspect.signature(fn) + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + return { + key: _coerce_shape_value(value, hints.get(key), model) + for key, value in raw_kwargs.items() + if key in sig.parameters and key != "self" + } + + +def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any: + """Convert a single JSON-parsed value to the correct Python type.""" + import typing + + if hint is None or value is None: + return value + + origin = typing.get_origin(hint) + args = typing.get_args(hint) + + # Optional[X] / Union — try each non-None branch in order + if origin is typing.Union: + if value is None: + return None + for t in (a for a in args if a is not type(None)): + try: + return _coerce_shape_value(value, t, model) + except (ValueError, TypeError): + continue + return value + + # entity_instance: resolve integer or "#N" string step ID + if hint is ifcopenshell.entity_instance or ( + isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance) + ): + entity_id = int(str(value).lstrip("#")) + entity = model.by_id(entity_id) + if entity is None: + raise ValueError(f"Entity #{entity_id} not found in model") + return entity + + # Sequence[entity_instance]: resolve each element in the list + import collections.abc + + if origin is not None and issubclass(origin, collections.abc.Sequence) and not isinstance(value, str): + if args and ( + args[0] is ifcopenshell.entity_instance + or (isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance)) + ): + if isinstance(value, (list, tuple)): + return [_coerce_shape_value(v, args[0], model) for v in value] + + # bool: JSON gives actual bools; also accept string representations + if hint is bool: + if isinstance(value, bool): + return value + return str(value).lower() in ("true", "1", "yes") + + # Everything else (float, int, VectorType lists, dicts, Literals) passes through + return value + + +class IfcSessionError(RuntimeError): + pass + + +@dataclass +class IfcSession: + """In-memory IFC session (no FastMCP dependency). + + Designed to work in: + - FastMCP server (single global session) + - Embedded runtimes like Pyodide (one session per browser tab/worker) + """ + + model: ifcopenshell.file | None = None + model_path: str | None = None + + # ----------------- + # Session lifecycle + # ----------------- + def _require_model(self) -> ifcopenshell.file: + if self.model is None: + raise IfcSessionError("No model loaded. Call ifc_load() or ifc_new() first.") + return self.model + + def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]: + """Create a new empty IFC model in memory.""" + self.model = ifcopenshell.file(schema=schema) + self.model_path = None + return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)} + + def ifc_load(self, path: str) -> str: + """Open an IFC file into memory. Returns confirmation string.""" + self.model = ifcopenshell.open(path) + self.model_path = path + count = sum(1 for _ in self.model) + return f"Loaded {path}: schema {self.model.schema}, {count} entities" + + def ifc_save(self, path: str = "") -> str: + """Write the in-memory model to disk. Empty path overwrites the original file.""" + model = self._require_model() + target = path if path else self.model_path + if not target: + raise IfcSessionError("No path specified and no original path available.") + model.write(target) + return f"Saved to {target}" + + def ifc_reset(self) -> dict[str, Any]: + """Drop the in-memory model.""" + self.model = None + self.model_path = None + return {"ok": True} + + # ------------- + # Query tools + # ------------- + def ifc_summary(self) -> dict[str, Any]: + """Model overview: schema, entity counts, project info.""" + return summary.summary(self._require_model()) + + def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]: + """Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements).""" + return tree.tree(self._require_model()) + + def ifc_info(self, element_id: int) -> dict[str, Any]: + """Deep inspection of an entity by step ID (attributes, psets, placement, type, material).""" + model = self._require_model() + element = model.by_id(element_id) + if element is None: + raise IfcSessionError(f"Element #{element_id} not found.") + return info.info(model, element) + + def ifc_select(self, query: str) -> list[dict[str, Any]]: + """Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow').""" + return select.select(self._require_model(), query) + + def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]: + """Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject.""" + model = self._require_model() + element = model.by_id(element_id) + if element is None: + raise IfcSessionError(f"Element #{element_id} not found.") + return relations.relations(model, element, traverse=traverse if traverse else None) + + def ifc_clash( + self, + element_id: int, + clearance: float = 0.0, + tolerance: float = 0.002, + scope: str = "storey", + ) -> dict[str, Any]: + """Check element for geometric clashes. clearance=0.0 means no clearance check.""" + model = self._require_model() + element = model.by_id(element_id) + if element is None: + raise IfcSessionError(f"Element #{element_id} not found.") + return clash_mod.clash( + model, + element, + clearance=clearance if clearance and clearance > 0.0 else None, + tolerance=tolerance, + scope=scope, + ) + + def ifc_contexts(self) -> list[dict[str, Any]]: + """List all geometric representation contexts and subcontexts with their step IDs.""" + return contexts_mod.contexts(self._require_model()) + + def ifc_materials(self) -> list[dict[str, Any]]: + """List all materials and material sets (layers, constituents, profiles).""" + return materials_mod.materials(self._require_model()) + + # ------------------------ + # Edit discovery + execute + # ------------------------ + def ifc_list(self, module: str = "") -> list[dict]: + """List all API modules, or functions within a module. Empty module = all modules.""" + return list_functions(module) if module else list_modules() + + def ifc_docs(self, function_path: str) -> dict: + """Show full documentation for an API function. Input format: 'module.function'.""" + module, function = function_path.split(".", 1) + return function_docs(module, function) + + def ifc_edit(self, function_path: str, params: Any = "{}") -> dict: + """Execute an ifcopenshell.api mutation. + + params may be: + - JSON string + - dict (from tool calling / JS) + - JsProxy (handled upstream in embedded.py) + """ + model = self._require_model() + module, function = function_path.split(".", 1) + + if isinstance(params, str): + raw_kwargs = json.loads(params) if params.strip() else {} + elif isinstance(params, dict): + raw_kwargs = params + else: + # e.g. list/None/etc + raw_kwargs = dict(params) if params is not None else {} + + res = run_api(model, module, function, raw_kwargs) + return _jsonify(res) + + # ------------------------ + # Extended query + edit tools + # ------------------------ + def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]: + """Validate the loaded model. Returns {'valid': bool, 'issues': [...]}.""" + return validate_mod.validate(self._require_model(), express_rules=express_rules) + + def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]: + """List work schedules and nested tasks from the model. + + max_depth limits subtask expansion (None = unlimited). At the cutoff, + subtasks is replaced with {"truncated": True, "count": N}. + """ + return schedule.schedule(self._require_model(), max_depth=max_depth) + + def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]: + """List cost schedules and nested cost items from the model. + + max_depth limits cost item expansion (None = unlimited). At the cutoff, + subitems is replaced with {"truncated": True, "count": N}. + """ + return cost_mod.cost(self._require_model(), max_depth=max_depth) + + def ifc_schema(self, entity_type: str) -> dict[str, Any]: + """Return IFC class documentation for entity_type using the model's schema version.""" + return schema.schema(self._require_model(), entity_type) + + def ifc_plot( + self, + selector: str = "", + element_ids: list[int] | None = None, + view: str = "floorplan", + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + png_width: int = 1024, + png_height: int = 1024, + output_format: str = "png", + ) -> bytes: + """Generate a 2D technical drawing (floor plan, elevation, or section) and return image bytes. + + Uses ifcopenshell.draw to produce SVG output which is rasterised to PNG via CairoSVG + when output_format is 'png'. + + :param selector: ifcopenshell selector to restrict plotted elements + (e.g. ``'IfcWall'``). Omit to plot the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are faded to 10% opacity so the subject stands out. + :param view: Drawing view — ``floorplan`` (default), ``elevation``, + ``section``, or ``auto``. + :param width_mm: Paper width in mm (default 297 = A4). + :param height_mm: Paper height in mm (default 420 = A4). + :param scale: Model-to-paper scale ratio (default 0.01 = 1:100). + :param png_width: Raster output width in pixels (default 1024). + :param png_height: Raster output height in pixels (default 1024). + :param output_format: ``'svg'`` or ``'png'`` (default ``'png'``). + :return: SVG or PNG bytes depending on output_format. + """ + model = self._require_model() + return plot_mod.plot( + model, + output_format=output_format, + selector=selector if selector else None, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + png_width=png_width, + png_height=png_height, + ) + + def ifc_render( + self, + selector: str = "", + element_ids: list[int] | None = None, + view: str = "iso", + ) -> bytes: + """Render the loaded model to a PNG image and return raw bytes. + + :param selector: ifcopenshell selector to restrict rendered elements + (e.g. ``'IfcWall'``). Omit to render the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are rendered in translucent grey. + :param view: Camera angle: ``iso``, ``top``, ``south``, ``north``, + ``east``, or ``west``. + :return: PNG image as raw bytes. + """ + model = self._require_model() + return render_mod.render( + model, + selector=selector if selector else None, + element_ids=element_ids, + view=view, + ) + + # ------------------------ + # Shape builder tools + # ------------------------ + def ifc_shape_list(self) -> list[dict]: + """List all ShapeBuilder geometry methods with one-line descriptions and parameter names.""" + return _list_shape_methods() + + def ifc_shape_docs(self, method: str) -> dict: + """Full documentation for a ShapeBuilder method: params, types, return value.""" + return _shape_method_docs(method) + + def ifc_shape(self, method: str, params: Any = "{}") -> dict: + """Call a ShapeBuilder method by name. Returns the created entity's step ID. + + params is a JSON string of keyword arguments. Pass entity references as integer + step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]). + """ + model = self._require_model() + + from ifcopenshell.util.shape_builder import ShapeBuilder + + if method.startswith("_"): + raise IfcSessionError(f"Private method '{method}' is not accessible") + fn = getattr(ShapeBuilder, method, None) + if fn is None: + return {"ok": False, "error": f"ShapeBuilder has no method '{method}'"} + + if isinstance(params, str): + raw_kwargs = json.loads(params) if params.strip() else {} + elif isinstance(params, dict): + raw_kwargs = params + else: + raw_kwargs = {} + + try: + coerced = _coerce_shape_params(fn, raw_kwargs, model) + result = fn(ShapeBuilder(model), **coerced) + return {"ok": True, "result": _jsonify(result)} + except Exception as e: + return {"ok": False, "error": f"{type(e).__name__}: {e}"} + + def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]: + """Run quantity take-off on the model using the named rule. + + Modifies the model in-place; call ifc_save() after. + """ + model = self._require_model() + return run_quantify(model, rule, selector=selector if selector else None) + + # ------------------------ + # Generic dispatcher + tool specs for LLMs + # ------------------------ + def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any: + args = args or {} + fn = getattr(self, name, None) + if not callable(fn): + raise IfcSessionError(f"Unknown tool: {name}") + return _jsonify(fn(**args)) + + def openai_tools(self) -> list[dict[str, Any]]: + """Tool schemas in the OpenAI 'Responses API' format (type=function).""" + # Keep schemas tight so the model calls tools correctly. + return [ + { + "type": "function", + "name": "ifc_new", + "description": "Create a new empty IFC model in memory.", + "parameters": { + "type": "object", + "properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}}, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_summary", + "description": "Get a concise overview of the loaded IFC model.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_tree", + "description": "Get the full spatial hierarchy tree.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_select", + "description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_info", + "description": "Inspect an entity by STEP id.", + "parameters": { + "type": "object", + "properties": {"element_id": {"type": "integer"}}, + "required": ["element_id"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_relations", + "description": "Get relationships for an element. traverse='up' walks to IfcProject.", + "parameters": { + "type": "object", + "properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}}, + "required": ["element_id"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_clash", + "description": "Run clash/clearance checks for an element.", + "parameters": { + "type": "object", + "properties": { + "element_id": {"type": "integer"}, + "clearance": {"type": "number"}, + "tolerance": {"type": "number"}, + "scope": {"type": "string", "description": "storey or all"}, + }, + "required": ["element_id"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_contexts", + "description": "List all geometric representation contexts and subcontexts with their step IDs, context type, identifier, and target view. Use this to find the context ID required for geometry-creation API calls.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_materials", + "description": "List all materials and material sets (IfcMaterial, IfcMaterialLayerSet, IfcMaterialConstituentSet, IfcMaterialProfileSet) with their layers, constituents, or profiles.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_list", + "description": "List ifcopenshell.api modules or functions within a module.", + "parameters": { + "type": "object", + "properties": {"module": {"type": "string"}}, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_docs", + "description": "Get documentation for an ifcopenshell.api function, 'module.function'.", + "parameters": { + "type": "object", + "properties": {"function_path": {"type": "string"}}, + "required": ["function_path"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_edit", + "description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.", + "parameters": { + "type": "object", + "properties": {"function_path": {"type": "string"}, "params": {"type": "string"}}, + "required": ["function_path"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_validate", + "description": "Validate the loaded model. Returns valid bool and list of issues.", + "parameters": { + "type": "object", + "properties": { + "express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"} + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_schedule", + "description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.", + "parameters": { + "type": "object", + "properties": { + "max_depth": { + "type": "integer", + "description": "Max levels of subtask expansion (omit for unlimited)", + } + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_cost", + "description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.", + "parameters": { + "type": "object", + "properties": { + "max_depth": { + "type": "integer", + "description": "Max levels of cost item expansion (omit for unlimited)", + } + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_schema", + "description": "Return IFC class documentation for an entity type.", + "parameters": { + "type": "object", + "properties": {"entity_type": {"type": "string", "description": "IFC entity type, e.g. IfcWall"}}, + "required": ["entity_type"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_quantify", + "description": "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.", + "parameters": { + "type": "object", + "properties": { + "rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"}, + "selector": { + "type": "string", + "description": "ifcopenshell selector to restrict elements (default: all IfcElement)", + }, + }, + "required": ["rule"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_render", + "description": ( + "Render the loaded IFC model to a PNG image for visual inspection. " + "Use selector to restrict which elements are rendered (e.g. a single storey). " + "Use element_ids to highlight elements against a greyed-out background. " + "Returns base64-encoded PNG bytes." + ), + "parameters": { + "type": "object", + "properties": { + "selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"}, + "element_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Step IDs of elements to highlight", + }, + "view": { + "type": "string", + "enum": ["iso", "top", "south", "north", "east", "west"], + "description": "Camera angle (default: iso)", + }, + }, + "required": [], + "additionalProperties": False, + }, + }, + ] diff --git a/src/ifcmcp/ifcmcp/embedded.py b/src/ifcmcp/ifcmcp/embedded.py new file mode 100644 index 0000000000..4ad99ba1fa --- /dev/null +++ b/src/ifcmcp/ifcmcp/embedded.py @@ -0,0 +1,60 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ifcmcp.core import IfcSession + +session = IfcSession() + +# Optional imports only available under Pyodide +try: + from pyodide.ffi import JsProxy, to_py # type: ignore +except Exception: # pragma: no cover + JsProxy = None # type: ignore + to_py = None # type: ignore + + +def _coerce_args(args: Any) -> dict[str, Any]: + """Convert JS objects / JsProxy / mappings into a real Python dict.""" + if args is None: + return {} + + # Pyodide: JS object arrives as JsProxy; convert recursively to Python. + if JsProxy is not None and isinstance(args, JsProxy): + # dict_converter=dict ensures JS object -> Python dict (not Map) + return to_py(args, dict_converter=dict) + + # Already a Python dict + if isinstance(args, dict): + return args + + # Any Mapping-like object + if isinstance(args, Mapping): + return dict(args) + + # Last resort: try dict() coercion + try: + return dict(args) + except Exception as e: + raise TypeError(f"Tool args must be a mapping/dict; got {type(args)}") from e + + +def tools_openai() -> list[dict[str, Any]]: + return session.openai_tools() + + +def call_tool(name: str, args: Any = None) -> dict[str, Any]: + """ + Non-throwing tool dispatcher. + Always returns: {"ok": bool, "data": ...} or {"ok": false, "error": "...", "error_type": "...", ...} + """ + try: + py_args = _coerce_args(args) + data = session.dispatch(name, py_args) + return {"ok": True, "data": data} + + except Exception as e: + # Keep it short; avoid full tracebacks in tool output unless debugging. + return {"ok": False, "error_type": type(e).__name__, "error": str(e)} diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py new file mode 100644 index 0000000000..08ba86a2cb --- /dev/null +++ b/src/ifcmcp/ifcmcp/server.py @@ -0,0 +1,231 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import base64 +from typing import Any + +from ifcmcp.core import IfcSession + +try: + from mcp.server.fastmcp import FastMCP # type: ignore + from mcp.types import ImageContent # type: ignore +except Exception: # pragma: no cover + FastMCP = None # type: ignore + ImageContent = None # type: ignore + + +def build_server() -> Any: + """Create the FastMCP server if the dependency is available.""" + if FastMCP is None: + raise ImportError( + "FastMCP is not installed. Install with: pip install ifcmcp[mcp] " "(or add 'mcp' to your environment)." + ) + + session = IfcSession() + + server = FastMCP( + name="ifc-mcp", + instructions=( + "MCP server for querying and editing IFC building models. " + "Load a file first with ifc_load, then use query/edit tools. " + "Save changes with ifc_save." + ), + ) + + # ---- Lifecycle ---- + @server.tool() + def ifc_new(schema: str = "IFC4") -> dict[str, Any]: + return session.ifc_new(schema=schema) + + @server.tool() + def ifc_load(path: str) -> str: + return session.ifc_load(path) + + @server.tool() + def ifc_save(path: str = "") -> str: + return session.ifc_save(path) + + @server.tool() + def ifc_reset() -> dict[str, Any]: + return session.ifc_reset() + + # ---- Query ---- + @server.tool() + def ifc_summary() -> dict[str, Any]: + return session.ifc_summary() + + @server.tool() + def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]: + return session.ifc_tree() + + @server.tool() + def ifc_info(element_id: int) -> dict[str, Any]: + return session.ifc_info(element_id) + + @server.tool() + def ifc_select(query: str) -> list[dict[str, Any]]: + return session.ifc_select(query) + + @server.tool() + def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]: + return session.ifc_relations(element_id, traverse=traverse) + + @server.tool() + def ifc_clash( + element_id: int, + clearance: float = 0.0, + tolerance: float = 0.002, + scope: str = "storey", + ) -> dict[str, Any]: + return session.ifc_clash( + element_id=element_id, + clearance=clearance, + tolerance=tolerance, + scope=scope, + ) + + @server.tool() + def ifc_contexts() -> list[dict[str, Any]]: + return session.ifc_contexts() + + @server.tool() + def ifc_materials() -> list[dict[str, Any]]: + return session.ifc_materials() + + # ---- Edit ---- + @server.tool() + def ifc_list(module: str = "") -> list[dict]: + return session.ifc_list(module=module) + + @server.tool() + def ifc_docs(function_path: str) -> dict: + return session.ifc_docs(function_path=function_path) + + @server.tool() + def ifc_edit(function_path: str, params: str = "{}") -> dict: + return session.ifc_edit(function_path=function_path, params=params) + + # ---- Extended query + edit ---- + @server.tool() + def ifc_validate(express_rules: bool = False) -> dict[str, Any]: + return session.ifc_validate(express_rules=express_rules) + + @server.tool() + def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]: + return session.ifc_schedule(max_depth=max_depth) + + @server.tool() + def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]: + return session.ifc_cost(max_depth=max_depth) + + @server.tool() + def ifc_schema(entity_type: str) -> dict[str, Any]: + return session.ifc_schema(entity_type=entity_type) + + @server.tool() + def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]: + return session.ifc_quantify(rule=rule, selector=selector) + + # ---- Shape builder ---- + @server.tool() + def ifc_shape_list() -> list[dict]: + return session.ifc_shape_list() + + @server.tool() + def ifc_shape_docs(method: str) -> dict: + return session.ifc_shape_docs(method=method) + + @server.tool() + def ifc_shape(method: str, params: str = "{}") -> dict: + return session.ifc_shape(method=method, params=params) + + @server.tool(structured_output=False) + def ifc_plot( + selector: str = "", + element_ids: list[int] | None = None, + view: str = "floorplan", + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + png_width: int = 1024, + png_height: int = 1024, + output_path: str = "", + ) -> list[ImageContent]: + """Generate a 2D technical drawing of the loaded IFC model. + + Returns an inline PNG image (floor plan, elevation, or section) that the + LLM can inspect to understand the 2D layout of the model. If + ``output_path`` is provided the drawing is also saved to disk — as SVG + when the path ends in ``.svg``, otherwise as PNG. + + :param selector: ifcopenshell selector to restrict plotted elements + (e.g. ``'IfcWall'``). Omit to plot the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are faded so the subject stands out. + :param view: Drawing view — ``floorplan`` (default), ``elevation``, + ``section``, or ``auto``. + :param width_mm: Paper width in mm (default 297 = A4 landscape width). + :param height_mm: Paper height in mm (default 420 = A4 landscape height). + :param scale: Model-to-paper scale ratio (default 0.01 = 1:100). + :param png_width: Raster output width in pixels (default 1024). + :param png_height: Raster output height in pixels (default 1024). + :param output_path: Optional file path to save the drawing to disk. + """ + png_bytes = session.ifc_plot( + selector=selector, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + png_width=png_width, + png_height=png_height, + output_format="png", + ) + if output_path: + if output_path.endswith(".svg"): + svg_bytes = session.ifc_plot( + selector=selector, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + output_format="svg", + ) + with open(output_path, "wb") as f: + f.write(svg_bytes) + else: + with open(output_path, "wb") as f: + f.write(png_bytes) + return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")] + + @server.tool(structured_output=False) + def ifc_render( + selector: str = "", + element_ids: list[int] | None = None, + view: str = "iso", + output_path: str = "", + ) -> list[ImageContent]: + """Render the loaded IFC model to a PNG image. + + Returns an inline image the LLM can inspect to understand the spatial + layout of the model or a specific element in context. If + ``output_path`` is provided the PNG is also saved to that file path. + + :param selector: ifcopenshell selector to restrict rendered elements + (e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``). + Omit to render the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are rendered in translucent grey so the subject stands out. + :param view: Camera angle — ``iso`` (default), ``top``, ``south``, + ``north``, ``east``, or ``west``. + :param output_path: Optional file path to save the PNG to disk. + """ + png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view) + if output_path: + with open(output_path, "wb") as f: + f.write(png_bytes) + return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")] + + return server diff --git a/src/ifcmcp/pyproject.toml b/src/ifcmcp/pyproject.toml new file mode 100644 index 0000000000..1cfe734e4b --- /dev/null +++ b/src/ifcmcp/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcmcp" +version = "0.0.0" +authors = [ + { name="Bruno Postle", email="bruno@postle.net" }, +] +description = "MCP server for querying and editing IFC building models" +readme = "README.md" +keywords = ["IFC", "BIM", "MCP"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", +] +dependencies = ["ifcopenshell", "ifcquery", "ifcedit"] + +[project.optional-dependencies] +mcp = ["mcp"] + +[project.scripts] +ifcmcp = "ifcmcp.__main__:main" + +[project.urls] +Homepage = "http://ifcopenshell.org" +Documentation = "https://docs.ifcopenshell.org" +Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues" + +[tool.setuptools.packages.find] +include = ["ifcmcp*"] +exclude = ["test*"] + +[tool.ruff] +extend = "../../pyproject.toml" diff --git a/src/ifcmcp/tests/__init__.py b/src/ifcmcp/tests/__init__.py new file mode 100644 index 0000000000..0a3bc271a0 --- /dev/null +++ b/src/ifcmcp/tests/__init__.py @@ -0,0 +1 @@ +# This file was generated with the assistance of an AI coding tool. diff --git a/src/ifcmcp/tests/conftest.py b/src/ifcmcp/tests/conftest.py new file mode 100644 index 0000000000..9fae4aef92 --- /dev/null +++ b/src/ifcmcp/tests/conftest.py @@ -0,0 +1,59 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcmcp.core import IfcSession + + +@pytest.fixture +def session(): + return IfcSession() + + +@pytest.fixture +def model(): + """IFC4 model with a spatial hierarchy, a wall, and a slab.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001") + ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey) + + return f + + +@pytest.fixture +def model_file(model, tmp_path): + """Write the model fixture to a temp file and return the path.""" + path = tmp_path / "test.ifc" + model.write(str(path)) + return str(path) + + +@pytest.fixture +def loaded_session(model): + """An IfcSession with an in-memory model already loaded (no file path).""" + s = IfcSession() + s.model = model + return s diff --git a/src/ifcmcp/tests/test_edit.py b/src/ifcmcp/tests/test_edit.py new file mode 100644 index 0000000000..778b232249 --- /dev/null +++ b/src/ifcmcp/tests/test_edit.py @@ -0,0 +1,96 @@ +# This file was generated with the assistance of an AI coding tool. +import json + +import ifcopenshell +import pytest + +from ifcmcp.core import IfcSession, IfcSessionError + + +class TestNoModel: + def test_edit_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_edit("root.create_entity") + + +class TestList: + def test_list_all_modules(self, loaded_session): + result = loaded_session.ifc_list() + assert isinstance(result, list) + assert len(result) > 0 + modules = [m["module"] for m in result] + assert "root" in modules + assert "spatial" in modules + + def test_list_module_functions(self, loaded_session): + result = loaded_session.ifc_list(module="root") + assert isinstance(result, list) + names = [f["name"] for f in result] + assert "create_entity" in names + + def test_list_empty_string_returns_modules(self, loaded_session): + result = loaded_session.ifc_list(module="") + assert isinstance(result, list) + assert any(m["module"] == "root" for m in result) + + +class TestDocs: + def test_docs_create_entity(self, loaded_session): + result = loaded_session.ifc_docs("root.create_entity") + assert result["module"] == "root" + assert result["function"] == "create_entity" + assert "params" in result + + def test_docs_bad_format(self, loaded_session): + with pytest.raises(ValueError): + loaded_session.ifc_docs("no_dot_here") + + +class TestEdit: + def test_create_entity(self, loaded_session): + result = loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "NewWall"})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcWall" + assert result["result"]["name"] == "NewWall" + + def test_create_entity_default_params(self, loaded_session): + result = loaded_session.ifc_edit("root.create_entity", "{}") + assert result["ok"] is True + + def test_unknown_function(self, loaded_session): + result = loaded_session.ifc_edit("root.nonexistent", "{}") + assert result["ok"] is False + assert "Cannot find" in result["error"] + + def test_unknown_parameter(self, loaded_session): + result = loaded_session.ifc_edit("root.create_entity", json.dumps({"bogus": "value"})) + assert result["ok"] is False + assert "Unknown parameter" in result["error"] + + def test_bad_json(self, loaded_session): + with pytest.raises(json.JSONDecodeError): + loaded_session.ifc_edit("root.create_entity", "not json") + + def test_edit_does_not_save(self, loaded_session, tmp_path): + """Verify that ifc_edit mutates the in-memory model but does not write to disk.""" + path = str(tmp_path / "test.ifc") + loaded_session.model.write(path) + loaded_session.model_path = path + + before_count = sum(1 for _ in loaded_session.model) + loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"})) + after_count = sum(1 for _ in loaded_session.model) + assert after_count == before_count + 1 + + on_disk = ifcopenshell.open(path) + disk_count = sum(1 for _ in on_disk) + assert disk_count == before_count + + def test_assign_container(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + storey = loaded_session.model.by_type("IfcBuildingStorey")[0] + result = loaded_session.ifc_edit( + "spatial.assign_container", + json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}), + ) + assert result["ok"] is True diff --git a/src/ifcmcp/tests/test_query.py b/src/ifcmcp/tests/test_query.py new file mode 100644 index 0000000000..e0a28ae1c0 --- /dev/null +++ b/src/ifcmcp/tests/test_query.py @@ -0,0 +1,113 @@ +# This file was generated with the assistance of an AI coding tool. +import pytest + +from ifcmcp.core import IfcSessionError + + +class TestNoModel: + """All query tools should fail when no model is loaded.""" + + def test_summary_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_summary() + + def test_tree_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_tree() + + def test_info_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_info(1) + + def test_select_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_select("IfcWall") + + def test_relations_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_relations(1) + + +class TestSummary: + def test_schema(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["schema"] == "IFC4" + + def test_total_entities(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["total_entities"] > 0 + + def test_project_name(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["project"]["name"] == "TestProject" + + def test_type_counts(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["types"]["IfcWall"] == 1 + assert result["types"]["IfcSlab"] == 1 + + +class TestTree: + def test_root_is_project(self, loaded_session): + result = loaded_session.ifc_tree() + assert result["type"] == "IfcProject" + assert result["name"] == "TestProject" + + def test_hierarchy_depth(self, loaded_session): + result = loaded_session.ifc_tree() + site = result["children"][0] + assert site["type"] == "IfcSite" + building = site["children"][0] + assert building["type"] == "IfcBuilding" + storey = building["children"][0] + assert storey["type"] == "IfcBuildingStorey" + + +class TestInfo: + def test_wall_info(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_info(wall.id()) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + + def test_invalid_id(self, loaded_session): + with pytest.raises(Exception): + loaded_session.ifc_info(999999) + + +class TestSelect: + def test_select_walls(self, loaded_session): + result = loaded_session.ifc_select("IfcWall") + assert len(result) == 1 + assert result[0]["type"] == "IfcWall" + assert result[0]["name"] == "Wall001" + + def test_select_slabs(self, loaded_session): + result = loaded_session.ifc_select("IfcSlab") + assert len(result) == 1 + assert result[0]["name"] == "Slab001" + + def test_select_no_match(self, loaded_session): + result = loaded_session.ifc_select("IfcWindow") + assert result == [] + + +class TestRelations: + def test_wall_relations(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_relations(wall.id()) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert "hierarchy" in result + + def test_traverse_up(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_relations(wall.id(), traverse="up") + assert isinstance(result, list) + assert result[0]["type"] == "IfcWall" + assert result[-1]["type"] == "IfcProject" + + def test_traverse_empty_string_means_no_traverse(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_relations(wall.id(), traverse="") + assert isinstance(result, dict) diff --git a/src/ifcmcp/tests/test_server.py b/src/ifcmcp/tests/test_server.py new file mode 100644 index 0000000000..ed41434df7 --- /dev/null +++ b/src/ifcmcp/tests/test_server.py @@ -0,0 +1,106 @@ +# This file was generated with the assistance of an AI coding tool. +from unittest.mock import patch + +import pytest + +from ifcmcp.server import build_server + + +class TestServerRegistration: + def test_server_name(self): + server = build_server() + assert server.name == "ifc-mcp" + + def test_all_tools_registered(self): + server = build_server() + tools = [t.name for t in server._tool_manager.list_tools()] + expected = [ + "ifc_load", + "ifc_save", + "ifc_summary", + "ifc_tree", + "ifc_info", + "ifc_select", + "ifc_relations", + "ifc_clash", + "ifc_list", + "ifc_docs", + "ifc_edit", + ] + for name in expected: + assert name in tools, f"Tool {name} not registered" + + +@pytest.fixture +def tool_fns(): + """Return a dict of tool name → raw function from a freshly built server.""" + server = build_server() + return {t.name: t.fn for t in server._tool_manager.list_tools()} + + +PNG_FAKE = b"\x89PNG\r\n\x1a\nFAKE" +SVG_FAKE = b"FAKE" + + +class TestRenderOutputPath: + def test_no_output_path_no_file_written(self, tool_fns, tmp_path): + with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE): + tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path="") + assert list(tmp_path.iterdir()) == [] + + def test_png_output_path_writes_file(self, tool_fns, tmp_path): + out = str(tmp_path / "render.png") + with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE): + tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path=out) + assert open(out, "rb").read() == PNG_FAKE + + +class TestPlotOutputPath: + def test_no_output_path_no_file_written(self, tool_fns, tmp_path): + with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE): + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path="", + ) + assert list(tmp_path.iterdir()) == [] + + def test_png_output_path_writes_png(self, tool_fns, tmp_path): + out = str(tmp_path / "plot.png") + with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE): + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path=out, + ) + assert open(out, "rb").read() == PNG_FAKE + + def test_svg_output_path_writes_svg(self, tool_fns, tmp_path): + out = str(tmp_path / "plot.svg") + # ifc_plot is called twice: once with "png" for the inline image, + # once with "svg" for the file. + with patch("ifcmcp.core.IfcSession.ifc_plot", side_effect=[PNG_FAKE, SVG_FAKE]): + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path=out, + ) + assert open(out, "rb").read() == SVG_FAKE diff --git a/src/ifcmcp/tests/test_session.py b/src/ifcmcp/tests/test_session.py new file mode 100644 index 0000000000..4fa59120c0 --- /dev/null +++ b/src/ifcmcp/tests/test_session.py @@ -0,0 +1,62 @@ +# This file was generated with the assistance of an AI coding tool. +from unittest.mock import patch + +import ifcopenshell +import pytest + +from ifcmcp.core import IfcSession, IfcSessionError + + +class TestLoad: + def test_load_file(self, session, model_file): + result = session.ifc_load(model_file) + assert "IFC4" in result + assert session.model is not None + assert session.model_path == model_file + + def test_load_sets_entity_count(self, session, model_file): + result = session.ifc_load(model_file) + assert "entities" in result + + def test_load_nonexistent_file(self, session): + with pytest.raises(Exception): + session.ifc_load("/nonexistent/path/model.ifc") + + +class TestSave: + def test_save_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_save() + + def test_save_overwrites_original(self, session, model_file): + session.ifc_load(model_file) + result = session.ifc_save() + assert model_file in result + + def test_save_to_new_path(self, session, model_file, tmp_path): + session.ifc_load(model_file) + new_path = str(tmp_path / "output.ifc") + result = session.ifc_save(new_path) + assert new_path in result + reloaded = ifcopenshell.open(new_path) + assert reloaded.schema == "IFC4" + + def test_save_no_path_no_original(self, loaded_session): + with pytest.raises(IfcSessionError, match="No path specified"): + loaded_session.ifc_save() + + +class TestIfcPlotOutputFormat: + """ifc_plot should pass output_format through to the underlying plot function.""" + + def test_default_output_format_is_png(self, loaded_session): + with patch("ifcmcp.core.plot_mod.plot", return_value=b"PNG_FAKE") as mock_plot: + loaded_session.ifc_plot() + mock_plot.assert_called_once() + assert mock_plot.call_args.kwargs["output_format"] == "png" + + def test_svg_output_format(self, loaded_session): + with patch("ifcmcp.core.plot_mod.plot", return_value=b"SVG_FAKE") as mock_plot: + result = loaded_session.ifc_plot(output_format="svg") + assert result == b"SVG_FAKE" + assert mock_plot.call_args.kwargs["output_format"] == "svg" diff --git a/src/ifcmcp/tests/test_shape.py b/src/ifcmcp/tests/test_shape.py new file mode 100644 index 0000000000..1b657d5730 --- /dev/null +++ b/src/ifcmcp/tests/test_shape.py @@ -0,0 +1,141 @@ +# This file was generated with the assistance of an AI coding tool. +import json + +import pytest + +from ifcmcp.core import IfcSessionError + + +class TestShapeList: + def test_returns_list(self, loaded_session): + result = loaded_session.ifc_shape_list() + assert isinstance(result, list) + assert len(result) > 0 + + def test_has_expected_methods(self, loaded_session): + result = loaded_session.ifc_shape_list() + names = [m["method"] for m in result] + assert "polyline" in names + assert "rectangle" in names + assert "extrude" in names + assert "profile" in names + assert "get_representation" in names + + def test_well_documented_methods_have_descriptions(self, loaded_session): + result = loaded_session.ifc_shape_list() + by_name = {m["method"]: m for m in result} + # These methods have detailed docstrings + for name in ("polyline", "extrude", "rectangle", "profile", "get_representation"): + assert by_name[name]["description"], f"'{name}' has no description" + + def test_no_private_methods(self, loaded_session): + result = loaded_session.ifc_shape_list() + assert not any(m["method"].startswith("_") for m in result) + + def test_does_not_require_model(self, session): + # ifc_shape_list is pure introspection — no model needed + result = session.ifc_shape_list() + assert isinstance(result, list) + + +class TestShapeDocs: + def test_extrude_docs(self, loaded_session): + result = loaded_session.ifc_shape_docs("extrude") + assert result["method"] == "extrude" + assert result["description"] + assert "params" in result + param_names = [p["name"] for p in result["params"]] + assert "profile_or_curve" in param_names + assert "magnitude" in param_names + + def test_has_return_type(self, loaded_session): + result = loaded_session.ifc_shape_docs("rectangle") + assert "return_type" in result + + def test_has_param_descriptions(self, loaded_session): + result = loaded_session.ifc_shape_docs("polyline") + params_with_desc = [p for p in result["params"] if "description" in p] + assert len(params_with_desc) > 0 + + def test_unknown_method(self, loaded_session): + with pytest.raises(ValueError, match="no method"): + loaded_session.ifc_shape_docs("nonexistent_method") + + def test_private_method_rejected(self, loaded_session): + with pytest.raises(ValueError): + loaded_session.ifc_shape_docs("__init__") + + def test_does_not_require_model(self, session): + result = session.ifc_shape_docs("circle") + assert result["method"] == "circle" + + +class TestShapeExecute: + def test_rectangle(self, loaded_session): + result = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcIndexedPolyCurve" + + def test_circle(self, loaded_session): + result = loaded_session.ifc_shape("circle", json.dumps({"center": [0.0, 0.0], "radius": 0.5})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcCircle" + + def test_extrude_chained_from_rectangle(self, loaded_session): + rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]})) + rect_id = rect["result"]["id"] + result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 3.0})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcExtrudedAreaSolid" + + def test_entity_id_as_integer(self, loaded_session): + """Entity IDs should be accepted as plain integers (from JSON).""" + rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [1.0, 1.0]})) + rect_id = rect["result"]["id"] + # Pass as int, not string + result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 1.0})) + assert result["ok"] is True + + def test_rotate_2d_point_returns_list(self, loaded_session): + """Methods returning numpy arrays should give back plain lists.""" + result = loaded_session.ifc_shape( + "rotate_2d_point", json.dumps({"point_2d": [1.0, 0.0], "angle": 90.0, "counter_clockwise": True}) + ) + assert result["ok"] is True + assert isinstance(result["result"], list) + assert len(result["result"]) == 2 + + def test_set_polyline_coords_returns_none(self, loaded_session): + """In-place methods that return None should give ok=True, result=None.""" + rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [2.0, 2.0]})) + rect_id = rect["result"]["id"] + result = loaded_session.ifc_shape( + "set_polyline_coords", + json.dumps({"polyline": rect_id, "coords": [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 3.0]]}), + ) + assert result["ok"] is True + assert result["result"] is None + + def test_unknown_method(self, loaded_session): + result = loaded_session.ifc_shape("nonexistent_method", "{}") + assert result["ok"] is False + assert "error" in result + + def test_private_method_rejected(self, loaded_session): + with pytest.raises(IfcSessionError): + loaded_session.ifc_shape("__init__", "{}") + + def test_no_model_raises(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_shape("rectangle", "{}") + + def test_params_as_dict(self, loaded_session): + """params can be passed as a dict (not just a JSON string).""" + result = loaded_session.ifc_shape("rectangle", {"size": [2.0, 1.0]}) + assert result["ok"] is True + + def test_error_on_bad_params(self, loaded_session): + """Bad parameters should give ok=False with an error message.""" + result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": 999999, "magnitude": 1.0})) + assert result["ok"] is False + assert "error" in result