MCP wrapper for ifcquery and ifcedit

Model Context Protocol server implementation for AI agent access to IFC
projects
This commit is contained in:
Bruno Postle
2026-02-10 22:12:44 +00:00
parent b1f1de954d
commit 060afb94b5
11 changed files with 762 additions and 0 deletions
+221
View File
@@ -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.
+19
View File
@@ -0,0 +1,19 @@
# IfcMCP - MCP server for IFC building models
# Copyright (C) 2025 Bruno Postle <bruno@postle.net>
#
# 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 <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+3
View File
@@ -0,0 +1,3 @@
from ifcmcp.server import server
server.run(transport="stdio")
+142
View File
@@ -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)
+29
View File
@@ -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"
View File
+64
View File
@@ -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
+99
View File
@@ -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
+112
View File
@@ -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)
+28
View File
@@ -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
+45
View File
@@ -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()