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 + + + + + +
+
+ + IfcOpenShell AI Assistant + +
+
+ + Booting… +
+
+ +
+
+
+ + +
+ +
+ +
+ + +
+ + + +
+ + +
+ +
+ +
+ +
+ +
+ +
+ • 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