mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-24 23:19:57 +00:00
MCP wrapper for ifcquery and ifcedit
Model Context Protocol server implementation for AI agent access to IFC projects
This commit is contained in:
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from ifcmcp.server import server
|
||||||
|
|
||||||
|
server.run(transport="stdio")
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user