diff --git a/src/chat/README.md b/src/chat/README.md
new file mode 100644
index 0000000000..355bce73e5
--- /dev/null
+++ b/src/chat/README.md
@@ -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
+```
\ No newline at end of file
diff --git a/src/chat/app.js b/src/chat/app.js
new file mode 100644
index 0000000000..302fe9496d
--- /dev/null
+++ b/src/chat/app.js
@@ -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 = `
+
${role}
+ `;
+ 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}`);
+ }
+})();
\ No newline at end of file
diff --git a/src/chat/ifc_worker.js b/src/chat/ifc_worker.js
new file mode 100644
index 0000000000..903dcd1fe0
--- /dev/null
+++ b/src/chat/ifc_worker.js
@@ -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);
+ }
+};
\ No newline at end of file
diff --git a/src/chat/index.html b/src/chat/index.html
new file mode 100644
index 0000000000..3d59ac1371
--- /dev/null
+++ b/src/chat/index.html
@@ -0,0 +1,294 @@
+
+
+
+
+
+
+ IfcOpenShell AI Assistant
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ • Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.
+ • Try “Add a new site and building named X” (will use ifc_edit).
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ifcedit/ifcedit/coerce.py b/src/ifcedit/ifcedit/coerce.py
index 7e53efc467..0e92bded6d 100644
--- a/src/ifcedit/ifcedit/coerce.py
+++ b/src/ifcedit/ifcedit/coerce.py
@@ -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:
diff --git a/src/ifcmcp/ifcmcp/__main__.py b/src/ifcmcp/ifcmcp/__main__.py
index a5ec96475d..e4990aaf95 100644
--- a/src/ifcmcp/ifcmcp/__main__.py
+++ b/src/ifcmcp/ifcmcp/__main__.py
@@ -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")
\ No newline at end of file
diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py
new file mode 100644
index 0000000000..d9df299d6f
--- /dev/null
+++ b/src/ifcmcp/ifcmcp/core.py
@@ -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,
+ },
+ },
+ ]
\ No newline at end of file
diff --git a/src/ifcmcp/ifcmcp/embedded.py b/src/ifcmcp/ifcmcp/embedded.py
new file mode 100644
index 0000000000..1312aedf36
--- /dev/null
+++ b/src/ifcmcp/ifcmcp/embedded.py
@@ -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)}
diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py
index 931702e913..6ee66d535d 100644
--- a/src/ifcmcp/ifcmcp/server.py
+++ b/src/ifcmcp/ifcmcp/server.py
@@ -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
\ No newline at end of file
diff --git a/src/ifcmcp/pyproject.toml b/src/ifcmcp/pyproject.toml
index c3729db421..07366056d1 100644
--- a/src/ifcmcp/pyproject.toml
+++ b/src/ifcmcp/pyproject.toml
@@ -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"
diff --git a/src/ifcquery/ifcquery/select.py b/src/ifcquery/ifcquery/select.py
index 972af538b8..c28a159b90 100644
--- a/src/ifcquery/ifcquery/select.py
+++ b/src/ifcquery/ifcquery/select.py
@@ -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