mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 18:16:40 +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