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.
This commit is contained in:
Bruno Postle
2026-03-23 23:54:17 +00:00
committed by GitHub
parent 29079e8cba
commit db68195310
14 changed files with 2068 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+59
View File
@@ -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
+96
View File
@@ -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
+113
View File
@@ -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)
+106
View File
@@ -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"<svg>FAKE</svg>"
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
+62
View File
@@ -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"
+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