ifcmcp: add ifc_shape_list, ifc_shape_docs, ifc_shape tools

Exposes ShapeBuilder geometry methods via the MCP server: discovery
(list), documentation (docs), and execution (shape) with entity ID
coercion and numpy serialisation.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle
2026-03-04 23:50:56 +00:00
parent b02321e3ed
commit 06548c5db2
3 changed files with 336 additions and 1 deletions
+182 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Callable
from typing import Any, Callable # noqa: F401 — Callable used in helpers below
import ifcopenshell
@@ -26,6 +26,10 @@ def _jsonify(x: Any) -> Any:
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 {
@@ -48,6 +52,142 @@ def _jsonify(x: Any) -> Any:
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
@@ -244,6 +384,47 @@ class IfcSession:
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.
+13
View File
@@ -127,6 +127,19 @@ def build_server() -> Any:
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_render(
selector: str = "",
+141
View File
@@ -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