Package ifcmcp as a HTML client-side chat app

This commit is contained in:
Thomas Krijnen
2026-02-20 21:23:58 +01:00
parent d4ac3c36ce
commit bab00dffc3
11 changed files with 1176 additions and 127 deletions
+21
View File
@@ -0,0 +1,21 @@
IfcOpenShell AI Assistant
=========================
A web-based client-side (pyodide + OpenAI API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp packaged in a HTML+JS application.
### Setup instructions
```
mkdir ./src/chat/dist
cd ./src/ifcquery/
python -m build
cp ./dist/ifcquery-0.0.0-py3-none-any.whl ../chat/dist/
cd ../../src/ifcedit
python -m build
cp ./dist/ifcedit-0.0.0-py3-none-any.whl ../chat/dist/
cd ../../src/ifcmcp
python -m build
cp ./dist/ifcmcp-0.0.0-py3-none-any.whl ../chat/dist/
cd ../chat/dist/
wget https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl
```
+300
View File
@@ -0,0 +1,300 @@
// app.js
const $ = (id) => document.getElementById(id);
const statusEl = $("status");
const msgsEl = $("msgs");
const sendBtn = $("send");
const inputEl = $("input");
const apiKeyEl = $("apiKey");
const modelEl = $("model");
const ifcFileEl = $("ifcFile");
const newBtn = $("newModel");
const downloadBtn = $("downloadIfc");
function setBusy(isBusy, reason = "") {
const controls = [
$("send"),
$("newModel"),
$("downloadIfc"),
$("ifcFile"),
];
for (const el of controls) el.disabled = isBusy;
$("input").disabled = isBusy;
const browseBtn = $("browseBtn");
if (browseBtn) {
browseBtn.classList.toggle("disabled", isBusy);
browseBtn.setAttribute("aria-disabled", isBusy ? "true" : "false");
browseBtn.tabIndex = isBusy ? -1 : 0;
}
setStatus(isBusy ? (reason || "Working…") : "Ready");
}
function addMessage(role, text) {
if (text.ok) {
text = text.data;
}
const wrap = document.createElement("div");
wrap.className = `msg ${role}`;
wrap.innerHTML = `
<div class="role">${role}</div>
<div class="bubble"></div>`;
const bubble = wrap.querySelector(".bubble");
bubble.textContent = text;
bubble.onclick = function () {
if (bubble.scrollHeight > 100 && role === "tool") {
bubble.style.maxHeight = bubble.style.maxHeight == 'none' ? '' : 'none';
bubble.style.borderBottom = bubble.style.borderBottom == '' ? 'dotted 2px gray' : '';
}
}
msgsEl.appendChild(wrap);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function setStatus(text) {
statusEl.textContent = text;
}
const worker = new Worker("./ifc_worker.js", { type: "module" });
function callWorker(type, payload = {}) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const onMsg = (ev) => {
const msg = ev.data;
if (!msg || msg.id !== id) return;
worker.removeEventListener("message", onMsg);
if (msg.ok) resolve(msg);
else reject(new Error(msg.error || "Worker error"));
};
worker.addEventListener("message", onMsg);
worker.postMessage({ id, type, payload });
});
}
// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ----
// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4}
const tools = [
{
type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.",
parameters: { type: "object", properties: { schema: { type: "string" } }, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.",
parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_tree", description: "Get the full spatial hierarchy tree.",
parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false }
},
{
type: "function", name: "ifc_info", description: "Inspect an entity by STEP id.",
parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false }
},
{
type: "function", name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.",
parameters: {
type: "object", properties: { element_id: { type: "integer" }, traverse: { type: "string" } },
required: ["element_id"], additionalProperties: false
}
},
{
type: "function", name: "ifc_clash", description: "Run clash/clearance checks for an element.",
parameters: {
type: "object", properties: { element_id: { type: "integer" }, clearance: { type: "number" }, tolerance: { type: "number" }, scope: { type: "string" } },
required: ["element_id"], additionalProperties: false
}
},
{
type: "function", name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.",
parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.",
parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false }
},
{
type: "function", name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], additionalProperties: false }
},
];
const SYSTEM_INSTRUCTIONS = `
You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model.
Rules:
- If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree.
- If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit.
- If there is no model and the user wants to create one, call ifc_new.
- After edits, explain what changed and suggest downloading the IFC.
Be concise. Avoid dumping huge trees unless asked.
`;
let inputItems = []; // running conversation state (Responses API style)
async function openAIResponsesCreate({ apiKey, model, input, tools }) {
const res = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
instructions: SYSTEM_INSTRUCTIONS,
tools,
input,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`OpenAI error ${res.status}: ${text}`);
}
return await res.json();
}
function extractAssistantText(response) {
const out = [];
for (const item of response.output ?? []) {
if (item.type === "message" && item.role === "assistant") {
for (const c of item.content ?? []) {
if (c.type === "output_text") out.push(c.text);
}
}
}
return out.join("\n").trim();
}
async function runAgentTurn(userText) {
const apiKey = apiKeyEl.value.trim();
if (!apiKey) throw new Error("Missing API key");
// Add user message
inputItems.push({ role: "user", content: userText });
// Tool-calling loop (Responses API): append response.output, execute function_call items, append function_call_output.
for (let i = 0; i < 64; i++) {
const response = await openAIResponsesCreate({
apiKey,
model: modelEl.value,
input: inputItems,
tools,
});
// Keep ALL output items (incl reasoning/tool calls) in the running state.
inputItems.push(...(response.output ?? []));
// Show any assistant text immediately
const text = extractAssistantText(response);
if (text) addMessage("assistant", text);
const calls = (response.output ?? []).filter((x) => x.type === "function_call");
if (calls.length === 0) return;
for (const call of calls) {
let args = {};
try { args = call.arguments ? JSON.parse(call.arguments) : {}; }
catch { args = {}; }
addMessage("tool", `${call.name}(${JSON.stringify(args)})`);
const toolRes = await callWorker("toolCall", { name: call.name, args });
// Feed tool result back to the model
inputItems.push({
type: "function_call_output",
call_id: call.call_id,
output: JSON.stringify(toolRes.result),
});
addMessage("tool", `${call.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
}
}
addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
}
sendBtn.onclick = async () => {
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = "";
addMessage("user", text);
try {
setBusy(true, "Thinking…");
await runAgentTurn(text);
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Error: ${e.message}`);
}
};
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendBtn.click();
}
});
ifcFileEl.onchange = async () => {
const f = ifcFileEl.files?.[0];
if (!f) return;
setBusy(true, "Loading IFC into Pyodide…");
const buf = await f.arrayBuffer();
try {
const r = await callWorker("loadIfc", { filename: f.name, bytes: buf }, [buf]);
addMessage("assistant", r.result);
setBusy(false, "Ready");
} catch (e) {
setStatus(true, "Error");
addMessage("assistant", `Load error: ${e.message}`);
}
};
newBtn.onclick = async () => {
try {
setBusy(true, "Creating new model…");
const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4" } });
addMessage("assistant", `New model: ${JSON.stringify(r.result)}`);
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Error: ${e.message}`);
}
};
downloadBtn.onclick = async () => {
try {
setBusy(true, "Exporting IFC…");
const r = await callWorker("exportIfc", {});
const blob = new Blob([r.bytes], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = r.filename || "model.ifc";
a.click();
URL.revokeObjectURL(url);
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Export error: ${e.message}`);
}
};
(async () => {
try {
setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…");
await callWorker("init", {});
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Worker init failed: ${e.message}`);
}
})();
+108
View File
@@ -0,0 +1,108 @@
// ifc_worker.js (MODULE WORKER)
import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.mjs";
let pyodide = null;
let callToolPy = null;
let initPromise = null;
function ok(id, extra = {}, transfer = []) {
self.postMessage({ id, ok: true, ...extra }, transfer);
}
function fail(id, error) {
self.postMessage({ id, ok: false, error: String(error?.message || error) });
}
async function ensurePyodide() {
if (initPromise) return initPromise;
initPromise = (async () => {
// Passing indexURL avoids some environments failing to infer it from the module URL. :contentReference[oaicite:3]{index=3}
pyodide = await loadPyodide({
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/",
});
await pyodide.loadPackage("micropip");
await pyodide.loadPackage("numpy");
await pyodide.loadPackage("shapely");
await pyodide.loadPackage("typing-extensions");
const micropip = pyodide.pyimport("micropip");
// Detect python minor version (3.12 vs 3.13) and pick a matching wheel.
const pyVer = pyodide.runPython(`
import sys
f"{sys.version_info.major}.{sys.version_info.minor}"
`);
const wheelUrl =
pyVer === "3.13"
? "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.3+34a1bc6-cp313-cp313-emscripten_4_0_9_wasm32.whl"
: "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl";
await micropip.install(wheelUrl);
await micropip.install([
"./dist/ifcquery-0.0.0-py3-none-any.whl",
"./dist/ifcedit-0.0.0-py3-none-any.whl",
"./dist/ifcmcp-0.0.0-py3-none-any.whl",
"./dist/lark-1.3.1-py3-none-any.whl",
])
await pyodide.runPythonAsync(`
from ifcmcp.embedded import call_tool as _call_tool
`);
callToolPy = pyodide.globals.get("_call_tool");
})();
return initPromise;
}
function callTool(name, args) {
const pyArgs = pyodide.toPy(args);
const res = callToolPy(name, pyArgs);
pyArgs.destroy();
const resJs = res.toJs({ dict_converter: Object.fromEntries });
res.destroy();
return resJs;
}
self.onmessage = async (ev) => {
const { id, type, payload } = ev.data || {};
try {
if (type === "init") {
await ensurePyodide();
ok(id, { result: "ok" });
return;
}
await ensurePyodide();
if (type === "loadIfc") {
const { filename, bytes } = payload;
const path = `/tmp/${filename || "model.ifc"}`;
pyodide.FS.mkdirTree("/tmp");
pyodide.FS.writeFile(path, new Uint8Array(bytes));
const result = callTool("ifc_load", { path });
ok(id, { result });
return;
}
if (type === "exportIfc") {
const path = "/tmp/export.ifc";
const result = callTool("ifc_save", { path });
const data = pyodide.FS.readFile(path);
ok(id, { result, filename: "export.ifc", bytes: data }, [data.buffer]);
return;
}
if (type === "toolCall") {
const { name, args } = payload;
const result = callTool(name, args || {});
ok(id, { result });
return;
}
throw new Error(`Unknown message type: ${type}`);
} catch (e) {
fail(id, e);
}
};
+294
View File
@@ -0,0 +1,294 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IfcOpenShell AI Assistant</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
header {
padding: 12px 16px;
border-bottom: 1px solid #ddd;
}
header input,
header select {
padding: 8px;
}
main {
display: grid;
grid-template-columns: 320px 1fr;
height: calc(100vh - 84px);
}
.side {
border-right: 1px solid #ddd;
padding: 12px;
overflow: auto;
}
.chat {
display: flex;
flex-direction: column;
height: 100%;
}
.msgs {
flex: 1;
overflow: auto;
padding: 16px;
}
.msg {
margin: 10px 0;
}
.msg .role {
font-size: 12px;
opacity: 0.7;
margin-bottom: 4px;
}
.msg .bubble {
padding: 10px 12px;
border-radius: 10px;
white-space: pre-wrap;
}
.msg.user .bubble {
background: #e8f0ff;
align-self: flex-end;
}
.msg.assistant .bubble {
background: #f4f4f4;
}
.msg.tool .bubble {
background: #fff6db;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.composer {
display: flex;
gap: 8px;
padding: 12px;
justify-content: center;
}
.composer textarea {
flex: 1;
resize: none;
height: 88px;
border: none;
}
.composer button {
align-self: center;
}
.composer .inner {
border: solid 1px #ddd;
border-radius: 20px;
padding: 10px;
display: flex;
width: 50%;
}
.status {
font-size: 12px;
opacity: 0.7;
}
section>.row>label {
display: block;
font-size: 12px;
opacity: 0.75;
margin-bottom: 6px;
}
.side .row {
margin-bottom: 10px;
}
.row button {
padding: 8px 10px;
}
#model {
background: white;
color: gray;
border: solid 1px #eee;
border-radius: 6px;
}
hr {
border: dashed 1px #ddd;
}
.btn-row {
display: flex;
gap: 10px;
}
.btn-row>.btn,
.btn-row>button.btn {
flex: 1 1 0;
min-width: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #d0d0d0;
background: #eee;
cursor: pointer;
user-select: none;
text-decoration: none;
}
.btn:hover {
background: #ddd;
}
.btn:active {
transform: translateY(1px);
}
.btn-wide {
width: 100%;
}
.material-icons {
font-size: 18px;
line-height: 1;
}
/* Disabled state for label-button + normal buttons */
.btn.disabled,
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
pointer-events: none;
}
.msg.tool .bubble {
max-height: 100px;
overflow: hidden;
}
#input {
border: none;
outline: none;
}
#input:focus,
#input:focus-visible {
outline: none;
box-shadow: none;
}
</style>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<header>
<div class="row">
<strong>IfcOpenShell AI Assistant</strong>
<select id="model">
<option value="gpt-5">gpt-5</option>
<option value="gpt-4.1">gpt-4.1</option>
</select>
</div>
<div class="row">
<span class="status" id="status">Booting…</span>
</div>
</header>
<main>
<section class="side">
<div class="row">
<label>OpenAI API key (stored only in memory)</label>
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
</div>
<hr />
<div class="row">
<label>IFC model (stored only in memory)</label>
<div class="btn-row">
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
<span class="material-icons">folder_open</span>
Browse
</label>
<button class="btn" id="newModel" type="button">
<span class="material-icons">add_box</span>
New IFC
</button>
</div>
<input id="ifcFile" type="file" accept=".ifc,.ifczip,.ifcxml,.zip" hidden />
</div>
<div class="row">
<button class="btn btn-wide" id="downloadIfc" type="button">
<span class="material-icons">download</span>
Download IFC
</button>
</div>
<hr />
<div class="row">
<label>Tips</label>
<div class="status">
• Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.<br />
• Try “Add a new site and building named X” (will use ifc_edit).
</div>
</div>
</section>
<section class="chat">
<div class="msgs" id="msgs"></div>
<div class="composer">
<div class="inner">
<textarea id="input" placeholder="Ask or instruct about the IFC model…"></textarea>
<button id="send" class="btn">Send <span class="material-icons">send</span></button>
</div>
</div>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+5 -2
View File
@@ -113,11 +113,14 @@ def _is_entity_type(hint) -> bool:
return False
def _coerce_entity(value_str: str, model: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
def _coerce_entity(value_str: str | int, model: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
"""Resolve a step ID string like '123' or '#123' to an entity instance."""
if model is None:
raise ValueError("Cannot resolve entity reference without an IFC model")
entity_id = int(value_str.strip().lstrip("#"))
if isinstance(value_str, int):
entity_id = value_str
else:
entity_id = int(value_str.strip().lstrip("#"))
try:
return model.by_id(entity_id)
except RuntimeError:
+4 -3
View File
@@ -1,4 +1,5 @@
# This file was generated with the assistance of an AI coding tool.
from ifcmcp.server import server
# ifc_cli/src/ifcmcp/ifcmcp/__main__.py
from ifcmcp.server import build_server
server.run(transport="stdio")
server = build_server()
server.run(transport="stdio")
+303
View File
@@ -0,0 +1,303 @@
# ifc_cli/src/ifcmcp/ifcmcp/core.py
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Callable
import ifcopenshell
from ifcedit.discover import function_docs, list_functions, list_modules
from ifcedit.run import run_api
from ifcquery import clash as clash_mod
from ifcquery import info, relations, select, summary, tree
# inside ifcmcp/core.py
import json
from typing import Any
def _jsonify(x: Any) -> Any:
"""Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
if x is None or isinstance(x, (str, int, float, bool)):
return x
# IfcOpenShell entity instances: normalize
if isinstance(x, ifcopenshell.entity_instance):
return {
"id": int(x.id()),
"type": x.is_a(),
"repr": str(x),
"name": getattr(x, "Name", None),
}
if isinstance(x, dict):
return {str(k): _jsonify(v) for k, v in x.items()}
if isinstance(x, (list, tuple, set)):
return [_jsonify(v) for v in x]
# Try JSON as-is, else fallback to string
try:
json.dumps(x)
return x
except Exception:
return str(x)
class IfcSessionError(RuntimeError):
pass
@dataclass
class IfcSession:
"""In-memory IFC session (no FastMCP dependency).
Designed to work in:
- FastMCP server (single global session)
- Embedded runtimes like Pyodide (one session per browser tab/worker)
"""
model: ifcopenshell.file | None = None
model_path: str | None = None
# -----------------
# Session lifecycle
# -----------------
def _require_model(self) -> ifcopenshell.file:
if self.model is None:
raise IfcSessionError("No model loaded. Call ifc_load() or ifc_new() first.")
return self.model
def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
"""Create a new empty IFC model in memory."""
self.model = ifcopenshell.file(schema=schema)
self.model_path = None
return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)}
def ifc_load(self, path: str) -> str:
"""Open an IFC file into memory. Returns confirmation string."""
self.model = ifcopenshell.open(path)
self.model_path = path
count = sum(1 for _ in self.model)
return f"Loaded {path}: schema {self.model.schema}, {count} entities"
def ifc_save(self, path: str = "") -> str:
"""Write the in-memory model to disk. Empty path overwrites the original file."""
model = self._require_model()
target = path if path else self.model_path
if not target:
raise IfcSessionError("No path specified and no original path available.")
model.write(target)
return f"Saved to {target}"
def ifc_reset(self) -> dict[str, Any]:
"""Drop the in-memory model."""
self.model = None
self.model_path = None
return {"ok": True}
# -------------
# Query tools
# -------------
def ifc_summary(self) -> dict[str, Any]:
"""Model overview: schema, entity counts, project info."""
return summary.summary(self._require_model())
def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]:
"""Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements)."""
return tree.tree(self._require_model())
def ifc_info(self, element_id: int) -> dict[str, Any]:
"""Deep inspection of an entity by step ID (attributes, psets, placement, type, material)."""
model = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return info.info(model, element)
def ifc_select(self, query: str) -> list[dict[str, Any]]:
"""Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow')."""
return select.select(self._require_model(), query)
def ifc_relations(self, 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 = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return relations.relations(model, element, traverse=traverse if traverse else None)
def ifc_clash(
self,
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 = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return clash_mod.clash(
model,
element,
clearance=clearance if clearance and clearance > 0.0 else None,
tolerance=tolerance,
scope=scope,
)
# ------------------------
# Edit discovery + execute
# ------------------------
def ifc_list(self, module: str = "") -> list[dict]:
"""List all API modules, or functions within a module. Empty module = all modules."""
return list_functions(module) if module else list_modules()
def ifc_docs(self, 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)
def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
"""Execute an ifcopenshell.api mutation.
params may be:
- JSON string
- dict (from tool calling / JS)
- JsProxy (handled upstream in embedded.py)
"""
model = self._require_model()
module, function = function_path.split(".", 1)
if isinstance(params, str):
raw_kwargs = json.loads(params) if params.strip() else {}
elif isinstance(params, dict):
raw_kwargs = params
else:
# e.g. list/None/etc
raw_kwargs = dict(params) if params is not None else {}
res = run_api(model, module, function, raw_kwargs)
return _jsonify(res)
# ------------------------
# Generic dispatcher + tool specs for LLMs
# ------------------------
def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any:
args = args or {}
fn = getattr(self, name, None)
if not callable(fn):
raise IfcSessionError(f"Unknown tool: {name}")
return _jsonify(fn(**args))
def openai_tools(self) -> list[dict[str, Any]]:
"""Tool schemas in the OpenAI 'Responses API' format (type=function)."""
# Keep schemas tight so the model calls tools correctly.
return [
{
"type": "function",
"name": "ifc_new",
"description": "Create a new empty IFC model in memory.",
"parameters": {
"type": "object",
"properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_summary",
"description": "Get a concise overview of the loaded IFC model.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_tree",
"description": "Get the full spatial hierarchy tree.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_select",
"description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_info",
"description": "Inspect an entity by STEP id.",
"parameters": {
"type": "object",
"properties": {"element_id": {"type": "integer"}},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_relations",
"description": "Get relationships for an element. traverse='up' walks to IfcProject.",
"parameters": {
"type": "object",
"properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_clash",
"description": "Run clash/clearance checks for an element.",
"parameters": {
"type": "object",
"properties": {
"element_id": {"type": "integer"},
"clearance": {"type": "number"},
"tolerance": {"type": "number"},
"scope": {"type": "string", "description": "storey or all"},
},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_list",
"description": "List ifcopenshell.api modules or functions within a module.",
"parameters": {
"type": "object",
"properties": {"module": {"type": "string"}},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_docs",
"description": "Get documentation for an ifcopenshell.api function, 'module.function'.",
"parameters": {
"type": "object",
"properties": {"function_path": {"type": "string"}},
"required": ["function_path"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_edit",
"description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
"parameters": {
"type": "object",
"properties": {"function_path": {"type": "string"}, "params": {"type": "string"}},
"required": ["function_path"],
"additionalProperties": False,
},
},
]
+59
View File
@@ -0,0 +1,59 @@
# ifc_cli/src/ifcmcp/ifcmcp/embedded.py
from __future__ import annotations
from typing import Any, Mapping
from ifcmcp.core import IfcSession
session = IfcSession()
# Optional imports only available under Pyodide
try:
from pyodide.ffi import JsProxy, to_py # type: ignore
except Exception: # pragma: no cover
JsProxy = None # type: ignore
to_py = None # type: ignore
def _coerce_args(args: Any) -> dict[str, Any]:
"""Convert JS objects / JsProxy / mappings into a real Python dict."""
if args is None:
return {}
# Pyodide: JS object arrives as JsProxy; convert recursively to Python.
if JsProxy is not None and isinstance(args, JsProxy):
# dict_converter=dict ensures JS object -> Python dict (not Map)
return to_py(args, dict_converter=dict)
# Already a Python dict
if isinstance(args, dict):
return args
# Any Mapping-like object
if isinstance(args, Mapping):
return dict(args)
# Last resort: try dict() coercion
try:
return dict(args)
except Exception as e:
raise TypeError(f"Tool args must be a mapping/dict; got {type(args)}") from e
def tools_openai() -> list[dict[str, Any]]:
return session.openai_tools()
def call_tool(name: str, args: Any = None) -> dict[str, Any]:
"""
Non-throwing tool dispatcher.
Always returns: {"ok": bool, "data": ...} or {"ok": false, "error": "...", "error_type": "...", ...}
"""
try:
py_args = _coerce_args(args)
data = session.dispatch(name, py_args)
return {"ok": True, "data": data}
except Exception as e:
# Keep it short; avoid full tracebacks in tool output unless debugging.
return {"ok": False, "error_type": type(e).__name__, "error": str(e)}
+77 -121
View File
@@ -1,142 +1,98 @@
# This file was generated with the assistance of an AI coding tool.
# ifc_cli/src/ifcmcp/ifcmcp/server.py
from __future__ import annotations
import json
from typing import Any
import ifcopenshell
from ifcedit.discover import function_docs, list_functions, list_modules
from ifcedit.run import run_api
from ifcquery import clash as clash_mod
from ifcquery import info, relations, select, summary, tree
from mcp.server.fastmcp import FastMCP
from ifcmcp.core import IfcSession
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
try:
from mcp.server.fastmcp import FastMCP # type: ignore
except Exception: # pragma: no cover
FastMCP = None # type: ignore
def _require_model() -> ifcopenshell.file:
if _model is None:
raise ValueError("No model loaded. Call ifc_load first.")
return _model
def build_server() -> Any:
"""Create the FastMCP server if the dependency is available."""
if FastMCP is None:
raise ImportError(
"FastMCP is not installed. Install with: pip install ifcmcp[mcp] "
"(or add 'mcp' to your environment)."
)
session = IfcSession()
# -- 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,
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."
),
)
# ---- Lifecycle ----
@server.tool()
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
return session.ifc_new(schema=schema)
# -- Edit discovery tools --
@server.tool()
def ifc_load(path: str) -> str:
return session.ifc_load(path)
@server.tool()
def ifc_save(path: str = "") -> str:
return session.ifc_save(path)
@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_reset() -> dict[str, Any]:
return session.ifc_reset()
# ---- Query ----
@server.tool()
def ifc_summary() -> dict[str, Any]:
return session.ifc_summary()
@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)
@server.tool()
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_tree()
@server.tool()
def ifc_info(element_id: int) -> dict[str, Any]:
return session.ifc_info(element_id)
# -- Edit execution tool --
@server.tool()
def ifc_select(query: str) -> list[dict[str, Any]]:
return session.ifc_select(query)
@server.tool()
def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_relations(element_id, traverse=traverse)
@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.
@server.tool()
def ifc_clash(
element_id: int,
clearance: float = 0.0,
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
return session.ifc_clash(
element_id=element_id,
clearance=clearance,
tolerance=tolerance,
scope=scope,
)
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)
# ---- Edit ----
@server.tool()
def ifc_list(module: str = "") -> list[dict]:
return session.ifc_list(module=module)
@server.tool()
def ifc_docs(function_path: str) -> dict:
return session.ifc_docs(function_path=function_path)
@server.tool()
def ifc_edit(function_path: str, params: str = "{}") -> dict:
return session.ifc_edit(function_path=function_path, params=params)
return server
+4 -1
View File
@@ -14,7 +14,10 @@ classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
]
dependencies = ["ifcopenshell", "ifcquery", "ifcedit", "mcp"]
dependencies = ["ifcopenshell", "ifcquery", "ifcedit"]
[project.optional-dependencies]
mcp = ["mcp"]
[project.urls]
Homepage = "http://ifcopenshell.org"
+1
View File
@@ -33,6 +33,7 @@ def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]:
entry: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
"repr": str(element),
}
if hasattr(element, "Name"):
entry["name"] = element.Name