diff --git a/src/ifcmcp/README.md b/src/ifcmcp/README.md new file mode 100644 index 0000000000..d930820e70 --- /dev/null +++ b/src/ifcmcp/README.md @@ -0,0 +1,221 @@ +# 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"`) + +### 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. **Find** the right API function: `ifc_list`, `ifc_docs` +4. **Edit** the model: `ifc_edit` +5. **Verify** changes with query tools +6. **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..9d42a08c1d --- /dev/null +++ b/src/ifcmcp/ifcmcp/__init__.py @@ -0,0 +1,19 @@ +# IfcMCP - MCP server for IFC building models +# Copyright (C) 2025 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..a6ea60e469 --- /dev/null +++ b/src/ifcmcp/ifcmcp/__main__.py @@ -0,0 +1,3 @@ +from ifcmcp.server import server + +server.run(transport="stdio") diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py new file mode 100644 index 0000000000..834d63eed0 --- /dev/null +++ b/src/ifcmcp/ifcmcp/server.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +from typing import Any + +import ifcopenshell + +from ifcquery import clash as clash_mod +from ifcquery import info, relations, select, summary, tree +from ifcedit.discover import function_docs, list_functions, list_modules +from ifcedit.run import run_api +from mcp.server.fastmcp import FastMCP + +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.", +) + +_model: ifcopenshell.file | None = None +_model_path: str | None = None + + +def _require_model() -> ifcopenshell.file: + if _model is None: + raise ValueError("No model loaded. Call ifc_load first.") + return _model + + +# -- Session tools -- + + +@server.tool() +def ifc_load(path: str) -> str: + """Open an IFC file into memory. Returns confirmation with schema and entity count.""" + global _model, _model_path + _model = ifcopenshell.open(path) + _model_path = path + count = sum(1 for _ in _model) + return f"Loaded {path}: schema {_model.schema}, {count} entities" + + +@server.tool() +def ifc_save(path: str = "") -> str: + """Write the in-memory model to disk. Empty path overwrites the original file.""" + model = _require_model() + target = path if path else _model_path + if not target: + raise ValueError("No path specified and no original path available.") + model.write(target) + return f"Saved to {target}" + + +# -- Query tools -- + + +@server.tool() +def ifc_summary() -> dict[str, Any]: + """Model overview: schema, entity counts, project info.""" + return summary.summary(_require_model()) + + +@server.tool() +def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]: + """Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements).""" + return tree.tree(_require_model()) + + +@server.tool() +def ifc_info(element_id: int) -> dict[str, Any]: + """Deep inspection of an entity by step ID (attributes, psets, placement, type, material).""" + model = _require_model() + element = model.by_id(element_id) + return info.info(model, element) + + +@server.tool() +def ifc_select(query: str) -> list[dict[str, Any]]: + """Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow').""" + return select.select(_require_model(), query) + + +@server.tool() +def ifc_relations(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 = _require_model() + element = model.by_id(element_id) + return relations.relations(model, element, traverse=traverse if traverse else None) + + +@server.tool() +def ifc_clash( + 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 = _require_model() + element = model.by_id(element_id) + return clash_mod.clash( + model, + element, + clearance=clearance if clearance > 0.0 else None, + tolerance=tolerance, + scope=scope, + ) + + +# -- Edit discovery tools -- + + +@server.tool() +def ifc_list(module: str = "") -> list[dict]: + """List all API modules, or functions within a module. Empty module = all modules.""" + if module: + return list_functions(module) + return list_modules() + + +@server.tool() +def ifc_docs(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) + + +# -- Edit execution tool -- + + +@server.tool() +def ifc_edit(function_path: str, params: str = "{}") -> dict: + """Execute an ifcopenshell.api mutation. params is a JSON string of {"param": "value"} pairs. + + Values are strings coerced by ifcedit's type system (entity IDs as "123", + dicts as JSON strings, etc). Does NOT auto-save; call ifc_save() after edits. + """ + model = _require_model() + module, function = function_path.split(".", 1) + raw_kwargs = json.loads(params) + return run_api(model, module, function, raw_kwargs) diff --git a/src/ifcmcp/pyproject.toml b/src/ifcmcp/pyproject.toml new file mode 100644 index 0000000000..c3729db421 --- /dev/null +++ b/src/ifcmcp/pyproject.toml @@ -0,0 +1,29 @@ +[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", "mcp"] + +[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..e69de29bb2 diff --git a/src/ifcmcp/tests/conftest.py b/src/ifcmcp/tests/conftest.py new file mode 100644 index 0000000000..095a43d587 --- /dev/null +++ b/src/ifcmcp/tests/conftest.py @@ -0,0 +1,64 @@ +import pytest + +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 ifcmcp.server as server_mod + + +@pytest.fixture +def model(): + """Create an 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(autouse=True) +def reset_server_state(): + """Reset module-level state before each test.""" + server_mod._model = None + server_mod._model_path = None + yield + server_mod._model = None + server_mod._model_path = None + + +@pytest.fixture +def loaded_model(model): + """Set the server module state to an in-memory model (no file path).""" + server_mod._model = model + server_mod._model_path = None + return model diff --git a/src/ifcmcp/tests/test_edit.py b/src/ifcmcp/tests/test_edit.py new file mode 100644 index 0000000000..69ff74ec9d --- /dev/null +++ b/src/ifcmcp/tests/test_edit.py @@ -0,0 +1,99 @@ +import json + +import pytest + +from ifcmcp.server import ifc_docs, ifc_edit, ifc_list + + +class TestNoModel: + def test_edit_no_model(self): + with pytest.raises(ValueError, match="No model loaded"): + ifc_edit("root.create_entity") + + +class TestList: + def test_list_all_modules(self, loaded_model): + result = 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_model): + result = 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_model): + result = 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_model): + result = 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_model): + with pytest.raises(ValueError): + ifc_docs("no_dot_here") + + +class TestEdit: + def test_create_entity(self, loaded_model): + result = 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_model): + result = ifc_edit("root.create_entity", "{}") + assert result["ok"] is True + + def test_unknown_function(self, loaded_model): + result = ifc_edit("root.nonexistent", "{}") + assert result["ok"] is False + assert "Cannot find" in result["error"] + + def test_unknown_parameter(self, loaded_model): + result = 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_model): + with pytest.raises(json.JSONDecodeError): + ifc_edit("root.create_entity", "not json") + + def test_edit_does_not_save(self, loaded_model, tmp_path): + """Verify that ifc_edit mutates the in-memory model but does not write to disk.""" + import ifcmcp.server as server_mod + + path = str(tmp_path / "test.ifc") + loaded_model.write(path) + server_mod._model_path = path + + before_count = sum(1 for _ in loaded_model) + ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"})) + after_count = sum(1 for _ in loaded_model) + assert after_count == before_count + 1 + + # Re-read the file — it should not have the new entity + import ifcopenshell + + on_disk = ifcopenshell.open(path) + disk_count = sum(1 for _ in on_disk) + assert disk_count == before_count + + def test_assign_container(self, loaded_model): + wall = loaded_model.by_type("IfcWall")[0] + storey = loaded_model.by_type("IfcBuildingStorey")[0] + result = 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..515528cffc --- /dev/null +++ b/src/ifcmcp/tests/test_query.py @@ -0,0 +1,112 @@ +import pytest + +from ifcmcp.server import ifc_info, ifc_relations, ifc_select, ifc_summary, ifc_tree + + +class TestNoModel: + """All query tools should fail when no model is loaded.""" + + def test_summary_no_model(self): + with pytest.raises(ValueError, match="No model loaded"): + ifc_summary() + + def test_tree_no_model(self): + with pytest.raises(ValueError, match="No model loaded"): + ifc_tree() + + def test_info_no_model(self): + with pytest.raises(ValueError, match="No model loaded"): + ifc_info(1) + + def test_select_no_model(self): + with pytest.raises(ValueError, match="No model loaded"): + ifc_select("IfcWall") + + def test_relations_no_model(self): + with pytest.raises(ValueError, match="No model loaded"): + ifc_relations(1) + + +class TestSummary: + def test_schema(self, loaded_model): + result = ifc_summary() + assert result["schema"] == "IFC4" + + def test_total_entities(self, loaded_model): + result = ifc_summary() + assert result["total_entities"] > 0 + + def test_project_name(self, loaded_model): + result = ifc_summary() + assert result["project"]["name"] == "TestProject" + + def test_type_counts(self, loaded_model): + result = ifc_summary() + assert result["types"]["IfcWall"] == 1 + assert result["types"]["IfcSlab"] == 1 + + +class TestTree: + def test_root_is_project(self, loaded_model): + result = ifc_tree() + assert result["type"] == "IfcProject" + assert result["name"] == "TestProject" + + def test_hierarchy_depth(self, loaded_model): + result = 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_model): + wall = loaded_model.by_type("IfcWall")[0] + result = ifc_info(wall.id()) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + + def test_invalid_id(self, loaded_model): + with pytest.raises(Exception): + ifc_info(999999) + + +class TestSelect: + def test_select_walls(self, loaded_model): + result = ifc_select("IfcWall") + assert len(result) == 1 + assert result[0]["type"] == "IfcWall" + assert result[0]["name"] == "Wall001" + + def test_select_slabs(self, loaded_model): + result = ifc_select("IfcSlab") + assert len(result) == 1 + assert result[0]["name"] == "Slab001" + + def test_select_no_match(self, loaded_model): + result = ifc_select("IfcWindow") + assert result == [] + + +class TestRelations: + def test_wall_relations(self, loaded_model): + wall = loaded_model.by_type("IfcWall")[0] + result = ifc_relations(wall.id()) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert "hierarchy" in result + + def test_traverse_up(self, loaded_model): + wall = loaded_model.by_type("IfcWall")[0] + result = 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_model): + wall = loaded_model.by_type("IfcWall")[0] + result = 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..38ddd4260c --- /dev/null +++ b/src/ifcmcp/tests/test_server.py @@ -0,0 +1,28 @@ +from ifcmcp.server import server + + +class TestServerRegistration: + def test_server_name(self): + assert server.name == "ifc-mcp" + + def test_all_tools_registered(self): + 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" + + def test_tool_count(self): + tools = server._tool_manager.list_tools() + assert len(tools) == 11 diff --git a/src/ifcmcp/tests/test_session.py b/src/ifcmcp/tests/test_session.py new file mode 100644 index 0000000000..0bb5a09d92 --- /dev/null +++ b/src/ifcmcp/tests/test_session.py @@ -0,0 +1,45 @@ +import pytest + +import ifcmcp.server as server_mod +from ifcmcp.server import ifc_load, ifc_save + + +class TestLoad: + def test_load_file(self, model_file): + result = ifc_load(model_file) + assert "IFC4" in result + assert server_mod._model is not None + assert server_mod._model_path == model_file + + def test_load_sets_entity_count(self, model_file): + result = ifc_load(model_file) + assert "entities" in result + + def test_load_nonexistent_file(self): + with pytest.raises(Exception): + ifc_load("/nonexistent/path/model.ifc") + + +class TestSave: + def test_save_no_model(self): + with pytest.raises(ValueError, match="No model loaded"): + ifc_save() + + def test_save_overwrites_original(self, model_file): + ifc_load(model_file) + result = ifc_save() + assert model_file in result + + def test_save_to_new_path(self, model_file, tmp_path): + ifc_load(model_file) + new_path = str(tmp_path / "output.ifc") + result = ifc_save(new_path) + assert new_path in result + import ifcopenshell + + reloaded = ifcopenshell.open(new_path) + assert reloaded.schema == "IFC4" + + def test_save_no_path_no_original(self, loaded_model): + with pytest.raises(ValueError, match="No path specified"): + ifc_save()