From 9fd4c5a6a8bded2e9d2c09689c04afe1867d269f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Mon, 23 Mar 2026 23:23:49 +0000 Subject: [PATCH] Add ifcmcp MCP server for IFC model querying and editing --- src/ifcmcp/README.md | 294 +++++++++++++ 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 | 35 ++ 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, 1960 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..94602a1d80 --- /dev/null +++ b/src/ifcmcp/README.md @@ -0,0 +1,294 @@ + +# 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`, `ifcedit`, and `mcp`. + +## 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_load + +Open an IFC file into memory. + +``` +ifc_load(path="/path/to/model.ifc") +-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities" +``` + +#### 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_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}`. + +### 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..31e5a17408 --- /dev/null +++ b/src/ifcmcp/pyproject.toml @@ -0,0 +1,35 @@ +[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" +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