From 8b748573dcb18072e59f8e48034a9fa4f3cc7d7d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 05:49:25 +0000 Subject: [PATCH 01/10] chore: bump version to 0.2.0, update SDK and zod deps, add esbuild build script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @modelcontextprotocol/sdk ^1.12.1 → ^1.29.0 (registerTool API, annotations) - zod ^3.23.0 → ^3.25.0 (SDK peer dep requirement) - Add build:mcp script for esbuild bundling (.mcpb packaging) - Keep existing pkg bundle script unchanged https://claude.ai/code/session_01XEvUykWcLc452uxGWPb1mb --- package.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 0b25768..a8ecdc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clashcontrol/mcp-server", - "version": "0.1.5", + "version": "0.2.0", "description": "ClashControl Smart Bridge — LLM bridge connecting Claude, ChatGPT, or any AI assistant to BIM clash detection", "main": "smart-bridge.js", "bin": { @@ -9,12 +9,14 @@ "scripts": { "start": "node smart-bridge.js", "mcp": "node smart-bridge.js --mcp", + "build:mcp": "npx esbuild index.js --bundle --platform=node --target=node18 --format=cjs --outfile=dist/mcp-bundle.cjs", + "build:pkg": "npx pkg smart-bridge.js --targets node18-win-x64,node18-macos-x64,node18-linux-x64 --output dist/clashcontrol-smart-bridge", "bundle": "npx pkg smart-bridge.js --targets node18-win-x64,node18-macos-x64,node18-linux-x64 --output dist/clashcontrol-smart-bridge" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "ws": "^8.18.0", - "zod": "^3.23.0" + "zod": "^3.25.0" }, "pkg": { "assets": [ From 11a519cfbc994df3acabbeaa9d4a33bd4033b1b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 05:50:03 +0000 Subject: [PATCH 02/10] feat: add .mcpb Desktop Extension packaging files - manifest.json (v0.4 schema) for one-click Claude Desktop install - .mcpbignore to exclude source files from the bundle - Extension uses Claude Desktop's built-in Node.js runtime (server.type: "node") - Entry point is esbuild-bundled dist/mcp-bundle.cjs https://claude.ai/code/session_01XEvUykWcLc452uxGWPb1mb --- .mcpbignore | 10 ++++++++++ manifest.json | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 .mcpbignore create mode 100644 manifest.json diff --git a/.mcpbignore b/.mcpbignore new file mode 100644 index 0000000..14ccfb1 --- /dev/null +++ b/.mcpbignore @@ -0,0 +1,10 @@ +node_modules/ +.git/ +.github/ +.gitignore +rest-bridge.js +smart-bridge.js +index.js +tools.js +package-lock.json +*.md diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..a86ddc1 --- /dev/null +++ b/manifest.json @@ -0,0 +1,48 @@ +{ + "manifest_version": "0.4", + "type": "mcp_server", + "name": "clashcontrol-mcp", + "display_name": "ClashControl — BIM Clash Detection", + "version": "0.2.0", + "description": "Control ClashControl BIM clash detection directly from Claude.", + "long_description": "Connect Claude to ClashControl, a browser-based IFC clash detection tool. Load IFC models, run clash detection between disciplines, review and triage clash pairs, navigate the 3D view, and export BCF reports — all through natural conversation. Requires ClashControl to be open in your browser with the Smart Bridge addon enabled.", + "authors": [ + { "name": "ClashControl", "url": "https://clashcontrol.io" } + ], + "repository": "https://github.com/clashcontrol-io/ClashControlSmartBridge", + "server": { + "type": "node", + "entry_point": "dist/mcp-bundle.cjs" + }, + "compatibility": { + "platforms": ["darwin", "win32"], + "runtimes": { "node": ">=18.0.0" } + }, + "tools": [ + { "name": "get_status", "description": "Get loaded models, clash count, project, detection rules" }, + { "name": "get_clashes", "description": "Get clash list with details" }, + { "name": "get_issues", "description": "Get issues list" }, + { "name": "run_detection", "description": "Run clash detection between model groups" }, + { "name": "set_detection_rules", "description": "Update detection settings" }, + { "name": "update_clash", "description": "Update a specific clash" }, + { "name": "batch_update_clashes", "description": "Bulk update clashes by filter" }, + { "name": "set_view", "description": "Set 3D camera preset" }, + { "name": "set_render_style", "description": "Change rendering style" }, + { "name": "set_section", "description": "Add or clear section cut plane" }, + { "name": "color_by", "description": "Color elements by property" }, + { "name": "set_theme", "description": "Switch UI theme" }, + { "name": "set_visibility", "description": "Show or hide UI overlays" }, + { "name": "restore_visibility", "description": "Restore all hidden elements" }, + { "name": "fly_to_clash", "description": "Fly camera to a clash" }, + { "name": "navigate_tab", "description": "Switch UI tab" }, + { "name": "filter_clashes", "description": "Filter clash list" }, + { "name": "sort_clashes", "description": "Sort clash list" }, + { "name": "group_clashes", "description": "Group clashes by category" }, + { "name": "export_bcf", "description": "Export clashes as BCF" }, + { "name": "create_project", "description": "Create a new project" }, + { "name": "switch_project", "description": "Switch to a project" }, + { "name": "measure", "description": "Start or stop measurement mode" }, + { "name": "walk_mode", "description": "Enter or exit walk mode" } + ], + "tools_generated": false +} From 4e73de84d08084f325ef675dc7cf30b970817fea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 05:52:51 +0000 Subject: [PATCH 03/10] feat: enrich MCP tools with BIM descriptions, annotations, resources, and prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smart-bridge.js changes: - Enrich all 24 TOOLS with mcpDesc (rich BIM-domain descriptions for Claude) and per-param md (detailed parameter descriptions). REST uses existing terse desc/d fields — fully backward compatible. - Add safety annotations to every tool (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) so Claude Desktop can auto-approve reads. - Add MCP server instructions explaining BIM context and typical workflow. - Migrate mcp.tool() → mcp.registerTool() (deprecated API in SDK v1.29+). - Add 2 MCP Resources: clashcontrol://status and clashcontrol://clash-summary. - Add 4 MCP Prompts: analyze-clash-report, investigate-clash, coordination-review, compare-clash-runs. https://claude.ai/code/session_01XEvUykWcLc452uxGWPb1mb --- smart-bridge.js | 314 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 286 insertions(+), 28 deletions(-) diff --git a/smart-bridge.js b/smart-bridge.js index 918029c..1d993c0 100644 --- a/smart-bridge.js +++ b/smart-bridge.js @@ -259,32 +259,184 @@ function log(msg) { } // ── Tool definitions (shared by REST and MCP) ───────────────────── +// Fields: desc (REST/OpenAPI), mcpDesc (richer Claude description), annotations (MCP safety hints) +// Param fields: t (type), e (enum), r (required), d (REST desc), md (MCP desc) const TOOLS = { - get_status: { desc: 'Get current state: loaded models, clash count, active project, detection rules.', params: {} }, - get_clashes: { desc: 'Get the clash list with details.', params: { status: {t:'string',e:['open','resolved','all']}, limit: {t:'number'} } }, - get_issues: { desc: 'Get the issues list.', params: { limit: {t:'number'} } }, - run_detection: { desc: 'Run clash detection between model groups.', params: { modelA: {t:'string',r:1,d:'First side: model name, discipline, or "all". Use "+" for groups.'}, modelB: {t:'string',r:1,d:'Second side'}, maxGap: {t:'number',d:'Gap mm'}, hard: {t:'boolean'}, excludeSelf: {t:'boolean'} } }, - set_detection_rules: { desc: 'Update detection settings without running.', params: { maxGap: {t:'number'}, hard: {t:'boolean'}, excludeSelf: {t:'boolean'}, duplicates: {t:'boolean'} } }, - update_clash: { desc: 'Update a specific clash.', params: { clashIndex: {t:'number',r:1}, status: {t:'string',e:['open','resolved']}, priority: {t:'string',e:['critical','high','normal','low']}, assignee: {t:'string'}, title: {t:'string'} } }, - batch_update_clashes:{ desc: 'Bulk update clashes.', params: { action: {t:'string',e:['resolve','set_priority','set_status'],r:1}, filter: {t:'string',e:['duplicates','soft','hard','all'],r:1}, value: {t:'string'} } }, - set_view: { desc: 'Set camera to a preset angle.', params: { view: {t:'string',e:['top','front','back','left','right','isometric','reset'],r:1} } }, - set_render_style: { desc: 'Change 3D rendering style.', params: { style: {t:'string',e:['wireframe','shaded','rendered','standard'],r:1} } }, - set_section: { desc: 'Add or clear section cut plane.', params: { axis: {t:'string',e:['x','y','z','none'],r:1} } }, - color_by: { desc: 'Color elements by property.', params: { by: {t:'string',e:['type','storey','discipline','material','none'],r:1} } }, - set_theme: { desc: 'Switch UI theme.', params: { theme: {t:'string',e:['dark','light'],r:1} } }, - set_visibility: { desc: 'Show or hide UI overlays.', params: { option: {t:'string',e:['grid','axes','markers'],r:1}, visible: {t:'boolean',r:1} } }, - restore_visibility: { desc: 'Restore all hidden/ghosted elements.', params: {} }, - fly_to_clash: { desc: 'Fly camera to a clash.', params: { clashIndex: {t:'number',r:1} } }, - navigate_tab: { desc: 'Switch to a UI tab.', params: { tab: {t:'string',e:['models','clashes','issues','navigator','ai'],r:1} } }, - filter_clashes: { desc: 'Filter the clash list.', params: { status: {t:'string',e:['open','resolved','all']}, priority: {t:'string',e:['critical','high','normal','low','all']} } }, - sort_clashes: { desc: 'Sort the clash list.', params: { sortBy: {t:'string',e:['priority','status','type','storey','date','distance'],r:1} } }, - group_clashes: { desc: 'Group clashes by category.', params: { groupBy: {t:'string',e:['storey','discipline','status','type','none'],r:1} } }, - export_bcf: { desc: 'Export clashes/issues as BCF.', params: { version: {t:'string',e:['2.1','3.0']} } }, - create_project: { desc: 'Create a new project.', params: { name: {t:'string',r:1} } }, - switch_project: { desc: 'Switch to a project by name.', params: { name: {t:'string',r:1} } }, - measure: { desc: 'Start or stop measurement mode.', params: { mode: {t:'string',e:['length','angle','area','stop','clear'],r:1} } }, - walk_mode: { desc: 'Enter or exit walk mode.', params: { enabled: {t:'boolean',r:1} } }, + get_status: { + desc: 'Get current state: loaded models, clash count, active project, detection rules.', + mcpDesc: 'Retrieve the current state of ClashControl: which IFC models are loaded, total clash count, active project name, and detection rule settings (gap tolerance, hard/soft mode). Call this first to confirm the browser is connected and models are loaded before running other tools.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: {} + }, + get_clashes: { + desc: 'Get the clash list with details.', + mcpDesc: 'Retrieve detected clash pairs between IFC model elements. Each clash includes the two colliding elements (with IFC type, discipline, storey), clash type (hard intersection or soft clearance violation), distance in mm, status (open/resolved), and priority level. Use after run_detection or to inspect existing results.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + status: { t:'string', e:['open','resolved','all'], d:'Filter by status', md:'Filter clashes by resolution status: "open" = unresolved conflicts needing attention, "resolved" = already addressed, "all" = both' }, + limit: { t:'number', d:'Max clashes to return', md:'Maximum number of clash pairs to return (default 50). Use lower values for overview, higher for full export.' } + } + }, + get_issues: { + desc: 'Get the issues list.', + mcpDesc: 'Retrieve the list of manually created issues (distinct from auto-detected clashes). Issues are user-authored coordination notes attached to the project.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { limit: { t:'number', d:'Max issues to return', md:'Maximum number of issues to return (default 50).' } } + }, + run_detection: { + desc: 'Run clash detection between model groups.', + mcpDesc: 'Execute clash detection between two sets of IFC model elements. Specify model names, discipline labels, or "all". Use "+" to combine groups (e.g. "structural + architectural" vs "MEP"). Hard mode detects physical intersections; soft mode detects clearance violations within the gap tolerance. Results replace the current clash list.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + modelA: { t:'string', r:1, d:'First side: model name, discipline, or "all". Use "+" for groups.', md:'First side of detection: model name, discipline label, or "all". Combine with "+" (e.g. "structural + architectural").' }, + modelB: { t:'string', r:1, d:'Second side', md:'Second side of detection: model name, discipline label, or "all".' }, + maxGap: { t:'number', d:'Gap mm', md:'Gap tolerance in millimeters (default 10). Elements closer than this trigger a soft clash.' }, + hard: { t:'boolean', md:'true = detect hard clashes (physical intersections only), false = detect soft clashes (clearance violations within gap tolerance).' }, + excludeSelf: { t:'boolean', md:'true = skip clashes between elements within the same model file.' } + } + }, + set_detection_rules: { + desc: 'Update detection settings without running.', + mcpDesc: 'Update clash detection configuration (gap tolerance, hard/soft mode, self-clash filtering, duplicate handling) without triggering a new detection run. Settings take effect on the next run_detection call.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + maxGap: { t:'number', d:'Gap mm', md:'Gap tolerance in millimeters.' }, + hard: { t:'boolean', md:'true for hard/intersection mode, false for soft/clearance mode.' }, + excludeSelf: { t:'boolean', md:'Exclude self-clashes within same model.' }, + duplicates: { t:'boolean', md:'Include duplicate clash pairs in results.' } + } + }, + update_clash: { + desc: 'Update a specific clash.', + mcpDesc: 'Modify a single clash entry: change its resolution status, priority level, assigned reviewer, or descriptive title. Use clashIndex from the current clash list (0-based).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + clashIndex: { t:'number', r:1, d:'Clash index', md:'Zero-based index of the clash in the current list.' }, + status: { t:'string', e:['open','resolved'], md:'Set resolution status.' }, + priority: { t:'string', e:['critical','high','normal','low'], md:'Set priority level for triage.' }, + assignee: { t:'string', md:'Name of the person or team responsible for resolving this clash.' }, + title: { t:'string', md:'Short descriptive label for the clash.' } + } + }, + batch_update_clashes: { + desc: 'Bulk update clashes.', + mcpDesc: 'Bulk update multiple clashes at once by filter category. Can mass-resolve duplicates, set priority on all hard clashes, etc. Use with caution — affects many clashes at once.', + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + params: { + action: { t:'string', e:['resolve','set_priority','set_status'], r:1, d:'Action to perform', md:'Bulk action: "resolve" marks matching clashes as resolved, "set_priority" changes their priority, "set_status" changes their status.' }, + filter: { t:'string', e:['duplicates','soft','hard','all'], r:1, d:'Which clashes to target', md:'Filter: "duplicates" = repeated clash pairs, "soft" = clearance violations, "hard" = physical intersections, "all" = every clash.' }, + value: { t:'string', d:'New value for the action', md:'Value for the action (e.g. priority level for set_priority, status for set_status).' } + } + }, + set_view: { + desc: 'Set camera to a preset angle.', + mcpDesc: 'Set the 3D camera to a preset viewing angle. Useful for inspecting clashes from different perspectives or resetting the view.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { view: { t:'string', e:['top','front','back','left','right','isometric','reset'], r:1, md:'Camera preset angle. "reset" returns to the default view.' } } + }, + set_render_style: { + desc: 'Change 3D rendering style.', + mcpDesc: 'Change how the 3D model is rendered. Wireframe is useful for seeing through elements to inspect internal clashes. Shaded/rendered modes show solid surfaces.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { style: { t:'string', e:['wireframe','shaded','rendered','standard'], r:1, md:'Rendering mode: wireframe (see-through), shaded (basic lighting), rendered (full materials), standard (default).' } } + }, + set_section: { + desc: 'Add or clear section cut plane.', + mcpDesc: 'Apply a section cut plane to slice through the model along an axis, revealing internal geometry and hidden clashes. Use "none" to remove the cut.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { axis: { t:'string', e:['x','y','z','none'], r:1, md:'Cut axis (x/y/z), or "none" to remove the section plane.' } } + }, + color_by: { + desc: 'Color elements by property.', + mcpDesc: 'Color-code all model elements by a grouping property. Discipline coloring helps visualize which teams own which elements; storey coloring shows vertical distribution; type coloring distinguishes element categories (beams, ducts, pipes, etc.).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { by: { t:'string', e:['type','storey','discipline','material','none'], r:1, md:'Color grouping: type (IFC class), storey (building level), discipline (MEP/structural/architectural), material, or none (reset).' } } + }, + set_theme: { + desc: 'Switch UI theme.', + mcpDesc: 'Switch the ClashControl UI between dark and light theme.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { theme: { t:'string', e:['dark','light'], r:1 } } + }, + set_visibility: { + desc: 'Show or hide UI overlays.', + mcpDesc: 'Toggle visibility of 3D viewport overlays: grid lines, coordinate axes, or clash markers.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + option: { t:'string', e:['grid','axes','markers'], r:1, md:'Overlay to toggle.' }, + visible: { t:'boolean', r:1, md:'true to show, false to hide.' } + } + }, + restore_visibility: { + desc: 'Restore all hidden/ghosted elements.', + mcpDesc: 'Restore all hidden, ghosted, or isolated elements back to full visibility. Resets any per-element visibility overrides.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: {} + }, + fly_to_clash: { + desc: 'Fly camera to a clash.', + mcpDesc: 'Animate the 3D camera to focus on a specific clash, centering the view on the collision point between the two elements. Use to visually inspect individual clashes.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { clashIndex: { t:'number', r:1, md:'Zero-based index of the clash to navigate to.' } } + }, + navigate_tab: { + desc: 'Switch to a UI tab.', + mcpDesc: 'Switch the ClashControl sidebar to a specific tab: models (loaded IFC files), clashes (detection results), issues (manual notes), navigator (spatial tree), or ai (chat panel).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { tab: { t:'string', e:['models','clashes','issues','navigator','ai'], r:1 } } + }, + filter_clashes: { + desc: 'Filter the clash list.', + mcpDesc: 'Apply filters to the displayed clash list by status and/or priority level. Does not modify clashes, only changes which ones are shown in the UI.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + status: { t:'string', e:['open','resolved','all'], md:'Filter by resolution status.' }, + priority: { t:'string', e:['critical','high','normal','low','all'], md:'Filter by priority level.' } + } + }, + sort_clashes: { + desc: 'Sort the clash list.', + mcpDesc: 'Sort the displayed clash list by a given property. Sorting by priority or distance helps identify the most critical or closest clashes first.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { sortBy: { t:'string', e:['priority','status','type','storey','date','distance'], r:1, md:'Property to sort by.' } } + }, + group_clashes: { + desc: 'Group clashes by category.', + mcpDesc: 'Group the clash list by a category to identify patterns. Grouping by discipline shows which team pairs have the most conflicts; by storey shows which floors are most problematic.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { groupBy: { t:'string', e:['storey','discipline','status','type','none'], r:1, md:'Grouping category, or "none" to flatten.' } } + }, + export_bcf: { + desc: 'Export clashes/issues as BCF.', + mcpDesc: 'Export all clashes and issues as a BCF (BIM Collaboration Format) file, triggering a download in the browser. BCF files can be imported into Revit, Navisworks, Solibri, and other BIM tools for coordination workflows.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { version: { t:'string', e:['2.1','3.0'], md:'BCF version: 2.1 (widest compatibility) or 3.0 (latest spec).' } } + }, + create_project: { + desc: 'Create a new project.', + mcpDesc: 'Create a new ClashControl project. Projects organize clash detection sessions, allowing separate tracking for different buildings or coordination phases.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + params: { name: { t:'string', r:1, md:'Name for the new project.' } } + }, + switch_project: { + desc: 'Switch to a project by name.', + mcpDesc: 'Switch to an existing project by name. Loads that project\'s models, clash results, and settings.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { name: { t:'string', r:1, md:'Project name or substring to match.' } } + }, + measure: { + desc: 'Start or stop measurement mode.', + mcpDesc: 'Activate measurement mode in the 3D viewport: measure distances (length), angles between surfaces, or areas. Use "stop" to exit measurement mode, "clear" to remove measurement annotations.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { mode: { t:'string', e:['length','angle','area','stop','clear'], r:1, md:'Measurement type, or "stop"/"clear" to exit/reset.' } } + }, + walk_mode: { + desc: 'Enter or exit walk mode.', + mcpDesc: 'Enter or exit first-person walk mode for navigating through the building model at human scale. Useful for understanding spatial relationships and clash locations in context.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { enabled: { t:'boolean', r:1, md:'true to enter walk mode, false to exit.' } } + }, }; // ── REST API ────────────────────────────────────────────────────── @@ -402,9 +554,19 @@ async function startMcpServer() { const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); const { z } = require('zod'); - const mcp = new McpServer({ name: 'ClashControl', version: VERSION }); + const MCP_INSTRUCTIONS = + 'ClashControl is a BIM (Building Information Modeling) clash detection tool running in the browser. ' + + 'Every tool call is relayed to the ClashControl web app via WebSocket — the browser must be open with the Smart Bridge addon enabled. ' + + 'Typical workflow: (1) get_status to confirm connection and see loaded IFC models, ' + + '(2) run_detection to find clashes between discipline groups (e.g. structural vs MEP), ' + + '(3) get_clashes to review results, (4) fly_to_clash to inspect individual collisions, ' + + '(5) update_clash or batch_update_clashes to triage. ' + + 'Hard clashes = physical intersections. Soft clashes = clearance violations within a gap tolerance (mm). ' + + 'Always start with get_status to verify the browser is connected and models are loaded.'; - // Register all tools from the shared TOOLS definition + const mcp = new McpServer({ name: 'ClashControl', version: VERSION }, { instructions: MCP_INSTRUCTIONS }); + + // ── Register tools from shared TOOLS definition ── for (const [name, tool] of Object.entries(TOOLS)) { const schema = {}; for (const [pn, pd] of Object.entries(tool.params)) { @@ -412,10 +574,15 @@ async function startMcpServer() { else if (pd.t === 'number') schema[pn] = pd.r ? z.number() : z.number().optional(); else if (pd.t === 'boolean') schema[pn] = pd.r ? z.boolean() : z.boolean().optional(); else schema[pn] = pd.r ? z.string() : z.string().optional(); - if (pd.d && schema[pn].describe) schema[pn] = schema[pn].describe(pd.d); + const paramDesc = pd.md || pd.d; + if (paramDesc && schema[pn].describe) schema[pn] = schema[pn].describe(paramDesc); } - mcp.tool(name, tool.desc, schema, async (params) => { + mcp.registerTool(name, { + description: tool.mcpDesc || tool.desc, + inputSchema: Object.keys(schema).length > 0 ? schema : undefined, + annotations: tool.annotations + }, async (params) => { try { const result = await sendToBrowser(name, params); return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; @@ -425,6 +592,97 @@ async function startMcpServer() { }); } + // ── MCP Resources ── + mcp.registerResource('status', 'clashcontrol://status', { + description: 'Current ClashControl state: loaded IFC models, clash count, active project, detection rules, browser connection status.', + mimeType: 'application/json' + }, async (uri) => { + try { + const result = await sendToBrowser('get_status', {}); + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: e.message, connected: false }) }] }; + } + }); + + mcp.registerResource('clash-summary', 'clashcontrol://clash-summary', { + description: 'High-level clash summary: total counts by status, type, and priority.', + mimeType: 'application/json' + }, async (uri) => { + try { + const result = await sendToBrowser('get_clashes', { status: 'all', limit: 500 }); + const clashes = Array.isArray(result) ? result : (result && result.clashes) || []; + const summary = { total: clashes.length, byStatus: {}, byType: {}, byPriority: {} }; + for (const c of clashes) { + if (c.status) summary.byStatus[c.status] = (summary.byStatus[c.status] || 0) + 1; + if (c.type) summary.byType[c.type] = (summary.byType[c.type] || 0) + 1; + if (c.priority) summary.byPriority[c.priority] = (summary.byPriority[c.priority] || 0) + 1; + } + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(summary, null, 2) }] }; + } catch (e) { + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: e.message }) }] }; + } + }); + + // ── MCP Prompts ── + mcp.registerPrompt('analyze-clash-report', { + description: 'Systematic analysis of current clash detection results with triage recommendations.' + }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Analyze the current BIM clash detection results in ClashControl:\n\n' + + '1. Call get_status to confirm which IFC models are loaded and current detection settings.\n' + + '2. Call get_clashes with status "all" to retrieve the full clash list.\n' + + '3. Summarize: total clashes, breakdown by status (open vs resolved), by type (hard vs soft), by priority, and by discipline pairs.\n' + + '4. Identify patterns: clashes concentrated on specific storeys? Between specific disciplines? Clusters of duplicates?\n' + + '5. Recommend a triage strategy: which clashes to address first, which might be false positives, and which discipline teams should coordinate.\n' + + '6. If there are many duplicates, suggest using batch_update_clashes to resolve them before manual review.' + }}] + })); + + mcp.registerPrompt('investigate-clash', { + description: 'Deep-dive investigation of a specific clash with visual inspection and resolution advice.', + argsSchema: { clashIndex: z.string().describe('Zero-based index of the clash to investigate') } + }, async ({ clashIndex }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Investigate clash #' + clashIndex + ' in ClashControl:\n\n' + + '1. Call fly_to_clash with clashIndex ' + clashIndex + ' to navigate the 3D view to this clash.\n' + + '2. Call get_clashes to get details about this clash (elements involved, type, distance, storey).\n' + + '3. Try different viewing angles: set_view with "front", "top", and "isometric".\n' + + '4. If elements are hard to see, try set_render_style with "wireframe" or color_by with "discipline".\n' + + '5. Explain what the two clashing elements are, why they might be colliding, and whether this is a real coordination issue or a modeling artifact.\n' + + '6. Suggest a resolution: flag as critical, assign to a discipline team, or resolve as false positive.' + }}] + })); + + mcp.registerPrompt('coordination-review', { + description: 'Discipline coordination checklist for BIM review meetings.', + argsSchema: { discipline: z.string().describe('Primary discipline to review, e.g. "structural", "mechanical", "electrical", "plumbing"') } + }, async ({ discipline }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Run a coordination review for the ' + discipline + ' discipline:\n\n' + + '1. Call get_status to see which models and disciplines are loaded.\n' + + '2. Call get_clashes to find all clashes involving ' + discipline + ' elements.\n' + + '3. Use group_clashes with "discipline" to see which other disciplines clash most with ' + discipline + '.\n' + + '4. For each discipline pair, summarize: clash count, types (hard vs soft), affected storeys, severity.\n' + + '5. Identify the top 3 most critical coordination issues needing team discussion.\n' + + '6. Provide a checklist of action items for the ' + discipline + ' coordination lead.' + }}] + })); + + mcp.registerPrompt('compare-clash-runs', { + description: 'Compare current clash results to track coordination progress over time.' + }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Compare clash detection results to track coordination progress:\n\n' + + '1. Call get_status to see the current project and detection settings.\n' + + '2. Call get_clashes with status "all" to get the complete clash list.\n' + + '3. Summarize: total clashes, open vs resolved, by type and priority.\n' + + '4. Compute the resolution rate: what percentage of clashes are resolved?\n' + + '5. Identify new open clashes with high/critical priority — these need immediate attention.\n' + + '6. Produce a progress report for a BIM coordination meeting: overall trend, improvements, remaining problem areas, recommended next steps.' + }}] + })); + log('MCP server starting on stdio...'); const transport = new StdioServerTransport(); await mcp.connect(transport); From 3de81e8bd264ed48d07e9cd90e1d5a5447f78e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 06:13:22 +0000 Subject: [PATCH 04/10] feat: rewrite index.js with enriched TOOLS, registerTool, resources, and prompts Replace 24 hand-coded server.tool() registrations with the same dynamic registerTool() pattern used in smart-bridge.js. Adds: - Enriched TOOLS object with mcpDesc, annotations, and md param descriptions - MCP server instructions for BIM workflow context - 2 MCP Resources (clashcontrol://status, clashcontrol://clash-summary) - 4 MCP Prompts (analyze-clash-report, investigate-clash, coordination-review, compare-clash-runs) - Version read from package.json instead of hardcoded https://claude.ai/code/session_01XEvUykWcLc452uxGWPb1mb --- index.js | 500 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 291 insertions(+), 209 deletions(-) diff --git a/index.js b/index.js index 2eb8cb7..44450b8 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,7 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio const { z } = require('zod'); const WebSocket = require('ws'); +const VERSION = require('./package.json').version; const WS_PORT = 19802; const REQUEST_TIMEOUT = 15000; // 15s for browser to respond @@ -70,19 +71,220 @@ function sendToBrowser(action, params) { }); } +// ── Tool definitions ───────────────────────────────────────────── +// Same enriched TOOLS as smart-bridge.js: desc (REST), mcpDesc (Claude), annotations, md (param descriptions) + +const TOOLS = { + get_status: { + desc: 'Get current state: loaded models, clash count, active project, detection rules.', + mcpDesc: 'Retrieve the current state of ClashControl: which IFC models are loaded, total clash count, active project name, and detection rule settings (gap tolerance, hard/soft mode). Call this first to confirm the browser is connected and models are loaded before running other tools.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: {} + }, + get_clashes: { + desc: 'Get the clash list with details.', + mcpDesc: 'Retrieve detected clash pairs between IFC model elements. Each clash includes the two colliding elements (with IFC type, discipline, storey), clash type (hard intersection or soft clearance violation), distance in mm, status (open/resolved), and priority level. Use after run_detection or to inspect existing results.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + status: { t:'string', e:['open','resolved','all'], md:'Filter clashes by resolution status: "open" = unresolved conflicts needing attention, "resolved" = already addressed, "all" = both' }, + limit: { t:'number', md:'Maximum number of clash pairs to return (default 50). Use lower values for overview, higher for full export.' } + } + }, + get_issues: { + desc: 'Get the issues list.', + mcpDesc: 'Retrieve the list of manually created issues (distinct from auto-detected clashes). Issues are user-authored coordination notes attached to the project.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { limit: { t:'number', md:'Maximum number of issues to return (default 50).' } } + }, + run_detection: { + desc: 'Run clash detection between model groups.', + mcpDesc: 'Execute clash detection between two sets of IFC model elements. Specify model names, discipline labels, or "all". Use "+" to combine groups (e.g. "structural + architectural" vs "MEP"). Hard mode detects physical intersections; soft mode detects clearance violations within the gap tolerance. Results replace the current clash list.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + modelA: { t:'string', r:1, md:'First side of detection: model name, discipline label, or "all". Combine with "+" (e.g. "structural + architectural").' }, + modelB: { t:'string', r:1, md:'Second side of detection: model name, discipline label, or "all".' }, + maxGap: { t:'number', md:'Gap tolerance in millimeters (default 10). Elements closer than this trigger a soft clash.' }, + hard: { t:'boolean', md:'true = detect hard clashes (physical intersections only), false = detect soft clashes (clearance violations within gap tolerance).' }, + excludeSelf: { t:'boolean', md:'true = skip clashes between elements within the same model file.' } + } + }, + set_detection_rules: { + desc: 'Update detection settings without running.', + mcpDesc: 'Update clash detection configuration (gap tolerance, hard/soft mode, self-clash filtering, duplicate handling) without triggering a new detection run. Settings take effect on the next run_detection call.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + maxGap: { t:'number', md:'Gap tolerance in millimeters.' }, + hard: { t:'boolean', md:'true for hard/intersection mode, false for soft/clearance mode.' }, + excludeSelf: { t:'boolean', md:'Exclude self-clashes within same model.' }, + duplicates: { t:'boolean', md:'Include duplicate clash pairs in results.' } + } + }, + update_clash: { + desc: 'Update a specific clash.', + mcpDesc: 'Modify a single clash entry: change its resolution status, priority level, assigned reviewer, or descriptive title. Use clashIndex from the current clash list (0-based).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + clashIndex: { t:'number', r:1, md:'Zero-based index of the clash in the current list.' }, + status: { t:'string', e:['open','resolved'], md:'Set resolution status.' }, + priority: { t:'string', e:['critical','high','normal','low'], md:'Set priority level for triage.' }, + assignee: { t:'string', md:'Name of the person or team responsible for resolving this clash.' }, + title: { t:'string', md:'Short descriptive label for the clash.' } + } + }, + batch_update_clashes: { + desc: 'Bulk update clashes.', + mcpDesc: 'Bulk update multiple clashes at once by filter category. Can mass-resolve duplicates, set priority on all hard clashes, etc. Use with caution — affects many clashes at once.', + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + params: { + action: { t:'string', e:['resolve','set_priority','set_status'], r:1, md:'Bulk action: "resolve" marks matching clashes as resolved, "set_priority" changes their priority, "set_status" changes their status.' }, + filter: { t:'string', e:['duplicates','soft','hard','all'], r:1, md:'Filter: "duplicates" = repeated clash pairs, "soft" = clearance violations, "hard" = physical intersections, "all" = every clash.' }, + value: { t:'string', md:'Value for the action (e.g. priority level for set_priority, status for set_status).' } + } + }, + set_view: { + desc: 'Set camera to a preset angle.', + mcpDesc: 'Set the 3D camera to a preset viewing angle. Useful for inspecting clashes from different perspectives or resetting the view.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { view: { t:'string', e:['top','front','back','left','right','isometric','reset'], r:1, md:'Camera preset angle. "reset" returns to the default view.' } } + }, + set_render_style: { + desc: 'Change 3D rendering style.', + mcpDesc: 'Change how the 3D model is rendered. Wireframe is useful for seeing through elements to inspect internal clashes. Shaded/rendered modes show solid surfaces.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { style: { t:'string', e:['wireframe','shaded','rendered','standard'], r:1, md:'Rendering mode: wireframe (see-through), shaded (basic lighting), rendered (full materials), standard (default).' } } + }, + set_section: { + desc: 'Add or clear section cut plane.', + mcpDesc: 'Apply a section cut plane to slice through the model along an axis, revealing internal geometry and hidden clashes. Use "none" to remove the cut.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { axis: { t:'string', e:['x','y','z','none'], r:1, md:'Cut axis (x/y/z), or "none" to remove the section plane.' } } + }, + color_by: { + desc: 'Color elements by property.', + mcpDesc: 'Color-code all model elements by a grouping property. Discipline coloring helps visualize which teams own which elements; storey coloring shows vertical distribution; type coloring distinguishes element categories (beams, ducts, pipes, etc.).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { by: { t:'string', e:['type','storey','discipline','material','none'], r:1, md:'Color grouping: type (IFC class), storey (building level), discipline (MEP/structural/architectural), material, or none (reset).' } } + }, + set_theme: { + desc: 'Switch UI theme.', + mcpDesc: 'Switch the ClashControl UI between dark and light theme.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { theme: { t:'string', e:['dark','light'], r:1 } } + }, + set_visibility: { + desc: 'Show or hide UI overlays.', + mcpDesc: 'Toggle visibility of 3D viewport overlays: grid lines, coordinate axes, or clash markers.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + option: { t:'string', e:['grid','axes','markers'], r:1, md:'Overlay to toggle.' }, + visible: { t:'boolean', r:1, md:'true to show, false to hide.' } + } + }, + restore_visibility: { + desc: 'Restore all hidden/ghosted elements.', + mcpDesc: 'Restore all hidden, ghosted, or isolated elements back to full visibility. Resets any per-element visibility overrides.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: {} + }, + fly_to_clash: { + desc: 'Fly camera to a clash.', + mcpDesc: 'Animate the 3D camera to focus on a specific clash, centering the view on the collision point between the two elements. Use to visually inspect individual clashes.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { clashIndex: { t:'number', r:1, md:'Zero-based index of the clash to navigate to.' } } + }, + navigate_tab: { + desc: 'Switch to a UI tab.', + mcpDesc: 'Switch the ClashControl sidebar to a specific tab: models (loaded IFC files), clashes (detection results), issues (manual notes), navigator (spatial tree), or ai (chat panel).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { tab: { t:'string', e:['models','clashes','issues','navigator','ai'], r:1 } } + }, + filter_clashes: { + desc: 'Filter the clash list.', + mcpDesc: 'Apply filters to the displayed clash list by status and/or priority level. Does not modify clashes, only changes which ones are shown in the UI.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + status: { t:'string', e:['open','resolved','all'], md:'Filter by resolution status.' }, + priority: { t:'string', e:['critical','high','normal','low','all'], md:'Filter by priority level.' } + } + }, + sort_clashes: { + desc: 'Sort the clash list.', + mcpDesc: 'Sort the displayed clash list by a given property. Sorting by priority or distance helps identify the most critical or closest clashes first.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { sortBy: { t:'string', e:['priority','status','type','storey','date','distance'], r:1, md:'Property to sort by.' } } + }, + group_clashes: { + desc: 'Group clashes by category.', + mcpDesc: 'Group the clash list by a category to identify patterns. Grouping by discipline shows which team pairs have the most conflicts; by storey shows which floors are most problematic.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { groupBy: { t:'string', e:['storey','discipline','status','type','none'], r:1, md:'Grouping category, or "none" to flatten.' } } + }, + export_bcf: { + desc: 'Export clashes/issues as BCF.', + mcpDesc: 'Export all clashes and issues as a BCF (BIM Collaboration Format) file, triggering a download in the browser. BCF files can be imported into Revit, Navisworks, Solibri, and other BIM tools for coordination workflows.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { version: { t:'string', e:['2.1','3.0'], md:'BCF version: 2.1 (widest compatibility) or 3.0 (latest spec).' } } + }, + create_project: { + desc: 'Create a new project.', + mcpDesc: 'Create a new ClashControl project. Projects organize clash detection sessions, allowing separate tracking for different buildings or coordination phases.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + params: { name: { t:'string', r:1, md:'Name for the new project.' } } + }, + switch_project: { + desc: 'Switch to a project by name.', + mcpDesc: 'Switch to an existing project by name. Loads that project\'s models, clash results, and settings.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { name: { t:'string', r:1, md:'Project name or substring to match.' } } + }, + measure: { + desc: 'Start or stop measurement mode.', + mcpDesc: 'Activate measurement mode in the 3D viewport: measure distances (length), angles between surfaces, or areas. Use "stop" to exit measurement mode, "clear" to remove measurement annotations.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { mode: { t:'string', e:['length','angle','area','stop','clear'], r:1, md:'Measurement type, or "stop"/"clear" to exit/reset.' } } + }, + walk_mode: { + desc: 'Enter or exit walk mode.', + mcpDesc: 'Enter or exit first-person walk mode for navigating through the building model at human scale. Useful for understanding spatial relationships and clash locations in context.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { enabled: { t:'boolean', r:1, md:'true to enter walk mode, false to exit.' } } + }, +}; + // ── MCP Server ──────────────────────────────────────────────────── -const server = new McpServer({ - name: 'ClashControl', - version: '0.1.0', -}); +const MCP_INSTRUCTIONS = + 'ClashControl is a BIM (Building Information Modeling) clash detection tool running in the browser. ' + + 'Every tool call is relayed to the ClashControl web app via WebSocket — the browser must be open with the Smart Bridge addon enabled. ' + + 'Typical workflow: (1) get_status to confirm connection and see loaded IFC models, ' + + '(2) run_detection to find clashes between discipline groups (e.g. structural vs MEP), ' + + '(3) get_clashes to review results, (4) fly_to_clash to inspect individual collisions, ' + + '(5) update_clash or batch_update_clashes to triage. ' + + 'Hard clashes = physical intersections. Soft clashes = clearance violations within a gap tolerance (mm). ' + + 'Always start with get_status to verify the browser is connected and models are loaded.'; + +const server = new McpServer({ name: 'ClashControl', version: VERSION }, { instructions: MCP_INSTRUCTIONS }); + +// ── Register tools from shared TOOLS definition ── + +for (const [name, tool] of Object.entries(TOOLS)) { + const schema = {}; + for (const [pn, pd] of Object.entries(tool.params)) { + if (pd.e) schema[pn] = pd.r ? z.enum(pd.e) : z.enum(pd.e).optional(); + else if (pd.t === 'number') schema[pn] = pd.r ? z.number() : z.number().optional(); + else if (pd.t === 'boolean') schema[pn] = pd.r ? z.boolean() : z.boolean().optional(); + else schema[pn] = pd.r ? z.string() : z.string().optional(); + const paramDesc = pd.md || pd.d; + if (paramDesc && schema[pn].describe) schema[pn] = schema[pn].describe(paramDesc); + } -// Helper: wrap a browser action as an MCP tool -function browserTool(name, description, schema, paramMapper) { - server.tool(name, description, schema, async (params) => { + server.registerTool(name, { + description: tool.mcpDesc || tool.desc, + inputSchema: Object.keys(schema).length > 0 ? schema : undefined, + annotations: tool.annotations + }, async (params) => { try { - const mapped = paramMapper ? paramMapper(params) : params; - const result = await sendToBrowser(name, mapped); + const result = await sendToBrowser(name, params); return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; } catch (e) { return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true }; @@ -90,218 +292,98 @@ function browserTool(name, description, schema, paramMapper) { }); } -// ── Tool definitions ────────────────────────────────────────────── +// ── MCP Resources ── -// State queries (read-only) -server.tool('get_status', 'Get the current state of ClashControl: loaded models, clash count, active project, detection rules.', {}, async () => { +server.registerResource('status', 'clashcontrol://status', { + description: 'Current ClashControl state: loaded IFC models, clash count, active project, detection rules, browser connection status.', + mimeType: 'application/json' +}, async (uri) => { try { const result = await sendToBrowser('get_status', {}); - return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; } catch (e) { - return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true }; + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: e.message, connected: false }) }] }; } }); -server.tool('get_clashes', 'Get the current clash list with details (type, storey, status, elements involved). Returns up to 50 clashes.', { - status: z.enum(['open', 'resolved', 'all']).optional().describe('Filter by status'), - limit: z.number().optional().describe('Max clashes to return (default 50)'), -}, async (params) => { +server.registerResource('clash-summary', 'clashcontrol://clash-summary', { + description: 'High-level clash summary: total counts by status, type, and priority.', + mimeType: 'application/json' +}, async (uri) => { try { - const result = await sendToBrowser('get_clashes', params); - return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; - } catch (e) { - return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true }; - } -}); - -server.tool('get_issues', 'Get the current issues list with details.', { - limit: z.number().optional().describe('Max issues to return (default 50)'), -}, async (params) => { - try { - const result = await sendToBrowser('get_issues', params); - return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; + const result = await sendToBrowser('get_clashes', { status: 'all', limit: 500 }); + const clashes = Array.isArray(result) ? result : (result && result.clashes) || []; + const summary = { total: clashes.length, byStatus: {}, byType: {}, byPriority: {} }; + for (const c of clashes) { + if (c.status) summary.byStatus[c.status] = (summary.byStatus[c.status] || 0) + 1; + if (c.type) summary.byType[c.type] = (summary.byType[c.type] || 0) + 1; + if (c.priority) summary.byPriority[c.priority] = (summary.byPriority[c.priority] || 0) + 1; + } + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(summary, null, 2) }] }; } catch (e) { - return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true }; + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: e.message }) }] }; } }); -// Detection -browserTool( - 'run_detection', - 'Run clash detection between two model groups. Pass model names, disciplines, or "all".', - { - modelA: z.string().describe('First side: model name, discipline, or "all". Use "+" to combine: "structural + architectural"'), - modelB: z.string().describe('Second side: model name, discipline, or "all"'), - maxGap: z.number().optional().describe('Gap tolerance in mm (default 10)'), - hard: z.boolean().optional().describe('true for hard/intersection clashes, false for soft/clearance'), - excludeSelf: z.boolean().optional().describe('Exclude self-clashes within same model'), - } -); - -browserTool( - 'set_detection_rules', - 'Update clash detection settings without running detection.', - { - maxGap: z.number().optional().describe('Gap tolerance in mm'), - hard: z.boolean().optional().describe('Hard clash mode'), - excludeSelf: z.boolean().optional().describe('Exclude self-clashes'), - duplicates: z.boolean().optional().describe('Include duplicates'), - } -); - -// Clash management -browserTool( - 'update_clash', - 'Update a specific clash: change status, priority, assignee, or title.', - { - clashIndex: z.number().describe('Clash index (0-based) in the current list'), - status: z.enum(['open', 'resolved']).optional(), - priority: z.enum(['critical', 'high', 'normal', 'low']).optional(), - assignee: z.string().optional(), - title: z.string().optional(), - } -); - -browserTool( - 'batch_update_clashes', - 'Bulk update multiple clashes by filter.', - { - action: z.enum(['resolve', 'set_priority', 'set_status']).describe('Action to perform'), - filter: z.enum(['duplicates', 'soft', 'hard', 'all']).describe('Which clashes to target'), - value: z.string().optional().describe('New value for the action'), - } -); - -// View controls -browserTool( - 'set_view', - 'Set the 3D camera to a preset angle.', - { - view: z.enum(['top', 'front', 'back', 'left', 'right', 'isometric', 'reset']).describe('Camera preset'), - } -); - -browserTool( - 'set_render_style', - 'Change the 3D rendering style.', - { - style: z.enum(['wireframe', 'shaded', 'rendered', 'standard']).describe('Render style'), - } -); - -browserTool( - 'set_section', - 'Add or clear a section cut plane.', - { - axis: z.enum(['x', 'y', 'z', 'none']).describe('Cut axis, or "none" to clear'), - } -); - -browserTool( - 'color_by', - 'Color model elements by a property.', - { - by: z.enum(['type', 'storey', 'discipline', 'material', 'none']).describe('Color grouping'), - } -); - -browserTool( - 'set_theme', - 'Switch UI theme.', - { theme: z.enum(['dark', 'light']) } -); - -browserTool( - 'set_visibility', - 'Show or hide UI overlays.', - { - option: z.enum(['grid', 'axes', 'markers']).describe('What to toggle'), - visible: z.boolean().describe('true to show, false to hide'), - } -); - -browserTool( - 'restore_visibility', - 'Restore all hidden/ghosted/isolated elements to full visibility.', - {} -); - -// Navigation -browserTool( - 'fly_to_clash', - 'Fly the camera to a specific clash by index.', - { clashIndex: z.number().describe('Clash index (0-based)') } -); - -browserTool( - 'navigate_tab', - 'Switch to a UI tab.', - { tab: z.enum(['models', 'clashes', 'issues', 'navigator', 'ai']) } -); - -// Filtering & sorting -browserTool( - 'filter_clashes', - 'Filter the clash list.', - { - status: z.enum(['open', 'resolved', 'all']).optional(), - priority: z.enum(['critical', 'high', 'normal', 'low', 'all']).optional(), - } -); - -browserTool( - 'sort_clashes', - 'Sort the clash list.', - { - sortBy: z.enum(['priority', 'status', 'type', 'storey', 'date', 'distance']), - } -); - -browserTool( - 'group_clashes', - 'Group clashes by a category.', - { - groupBy: z.enum(['storey', 'discipline', 'status', 'type', 'none']), - } -); - -// Export -browserTool( - 'export_bcf', - 'Export clashes/issues as a BCF file (triggers download in browser).', - { - version: z.enum(['2.1', '3.0']).optional().describe('BCF version (default 2.1)'), - } -); - -// Projects -browserTool( - 'create_project', - 'Create a new project.', - { name: z.string().describe('Project name') } -); - -browserTool( - 'switch_project', - 'Switch to an existing project by name.', - { name: z.string().describe('Project name or substring') } -); - -// Measurement -browserTool( - 'measure', - 'Start or stop measurement mode.', - { - mode: z.enum(['length', 'angle', 'area', 'stop', 'clear']).describe('Measurement mode'), - } -); - -// Walk mode -browserTool( - 'walk_mode', - 'Enter or exit first-person walk mode.', - { enabled: z.boolean().describe('true to enter, false to exit') } -); +// ── MCP Prompts ── + +server.registerPrompt('analyze-clash-report', { + description: 'Systematic analysis of current clash detection results with triage recommendations.' +}, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Analyze the current BIM clash detection results in ClashControl:\n\n' + + '1. Call get_status to confirm which IFC models are loaded and current detection settings.\n' + + '2. Call get_clashes with status "all" to retrieve the full clash list.\n' + + '3. Summarize: total clashes, breakdown by status (open vs resolved), by type (hard vs soft), by priority, and by discipline pairs.\n' + + '4. Identify patterns: clashes concentrated on specific storeys? Between specific disciplines? Clusters of duplicates?\n' + + '5. Recommend a triage strategy: which clashes to address first, which might be false positives, and which discipline teams should coordinate.\n' + + '6. If there are many duplicates, suggest using batch_update_clashes to resolve them before manual review.' + }}] +})); + +server.registerPrompt('investigate-clash', { + description: 'Deep-dive investigation of a specific clash with visual inspection and resolution advice.', + argsSchema: { clashIndex: z.string().describe('Zero-based index of the clash to investigate') } +}, async ({ clashIndex }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Investigate clash #' + clashIndex + ' in ClashControl:\n\n' + + '1. Call fly_to_clash with clashIndex ' + clashIndex + ' to navigate the 3D view to this clash.\n' + + '2. Call get_clashes to get details about this clash (elements involved, type, distance, storey).\n' + + '3. Try different viewing angles: set_view with "front", "top", and "isometric".\n' + + '4. If elements are hard to see, try set_render_style with "wireframe" or color_by with "discipline".\n' + + '5. Explain what the two clashing elements are, why they might be colliding, and whether this is a real coordination issue or a modeling artifact.\n' + + '6. Suggest a resolution: flag as critical, assign to a discipline team, or resolve as false positive.' + }}] +})); + +server.registerPrompt('coordination-review', { + description: 'Discipline coordination checklist for BIM review meetings.', + argsSchema: { discipline: z.string().describe('Primary discipline to review, e.g. "structural", "mechanical", "electrical", "plumbing"') } +}, async ({ discipline }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Run a coordination review for the ' + discipline + ' discipline:\n\n' + + '1. Call get_status to see which models and disciplines are loaded.\n' + + '2. Call get_clashes to find all clashes involving ' + discipline + ' elements.\n' + + '3. Use group_clashes with "discipline" to see which other disciplines clash most with ' + discipline + '.\n' + + '4. For each discipline pair, summarize: clash count, types (hard vs soft), affected storeys, severity.\n' + + '5. Identify the top 3 most critical coordination issues needing team discussion.\n' + + '6. Provide a checklist of action items for the ' + discipline + ' coordination lead.' + }}] +})); + +server.registerPrompt('compare-clash-runs', { + description: 'Compare current clash results to track coordination progress over time.' +}, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Compare clash detection results to track coordination progress:\n\n' + + '1. Call get_status to see the current project and detection settings.\n' + + '2. Call get_clashes with status "all" to get the complete clash list.\n' + + '3. Summarize: total clashes, open vs resolved, by type and priority.\n' + + '4. Compute the resolution rate: what percentage of clashes are resolved?\n' + + '5. Identify new open clashes with high/critical priority — these need immediate attention.\n' + + '6. Produce a progress report for a BIM coordination meeting: overall trend, improvements, remaining problem areas, recommended next steps.' + }}] +})); // ── Start ───────────────────────────────────────────────────────── From 8d9e8036827f5a8eb0b8bf198c7aea6661e4f8af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 06:15:01 +0000 Subject: [PATCH 05/10] feat: enrich rest-bridge.js tool descriptions for better OpenAPI/ChatGPT integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve all 24 tool descriptions and parameter descriptions with BIM-domain context. Descriptions now mention IFC elements, discipline pairs, hard vs soft clashes, BCF compatibility, etc. Backward compatible — same field structure, just richer text. https://claude.ai/code/session_01XEvUykWcLc452uxGWPb1mb --- rest-bridge.js | 112 ++++++++++++++++++++++++------------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/rest-bridge.js b/rest-bridge.js index ba8ab94..0271259 100644 --- a/rest-bridge.js +++ b/rest-bridge.js @@ -107,132 +107,132 @@ function sendToBrowser(action, params) { const TOOLS = { get_status: { - description: 'Get current state: loaded models, clash count, active project, detection rules.', + description: 'Get current state of ClashControl: loaded IFC models, clash count, active project, and detection rules.', params: {} }, get_clashes: { - description: 'Get the clash list with details.', + description: 'Get detected clash pairs between IFC elements with type, storey, status, priority, and element details.', params: { - status: { type: 'string', enum: ['open', 'resolved', 'all'], description: 'Filter by status' }, + status: { type: 'string', enum: ['open', 'resolved', 'all'], description: 'Filter by resolution status (default: open)' }, limit: { type: 'number', description: 'Max clashes to return (default 50)' } } }, get_issues: { - description: 'Get the issues list.', + description: 'Get manually created coordination issues (distinct from auto-detected clashes).', params: { limit: { type: 'number', description: 'Max issues to return (default 50)' } } }, run_detection: { - description: 'Run clash detection between model groups.', + description: 'Run clash detection between two sets of IFC model elements. Hard mode = physical intersections, soft mode = clearance violations.', params: { - modelA: { type: 'string', description: 'First side: model name, discipline, or "all". Use "+" for groups.', required: true }, + modelA: { type: 'string', description: 'First side: model name, discipline, or "all". Use "+" for groups (e.g. "structural + architectural").', required: true }, modelB: { type: 'string', description: 'Second side: model name, discipline, or "all".', required: true }, - maxGap: { type: 'number', description: 'Gap tolerance in mm (default 10)' }, - hard: { type: 'boolean', description: 'Hard/intersection clashes' }, - excludeSelf: { type: 'boolean', description: 'Exclude self-clashes' } + maxGap: { type: 'number', description: 'Gap tolerance in mm (default 10). Elements closer than this trigger a soft clash.' }, + hard: { type: 'boolean', description: 'true = hard/intersection clashes only, false = soft/clearance violations' }, + excludeSelf: { type: 'boolean', description: 'Skip clashes between elements within the same model' } } }, set_detection_rules: { - description: 'Update detection settings without running.', + description: 'Update clash detection settings (gap tolerance, hard/soft mode) without running detection.', params: { maxGap: { type: 'number', description: 'Gap tolerance in mm' }, - hard: { type: 'boolean', description: 'Hard clash mode' }, - excludeSelf: { type: 'boolean', description: 'Exclude self-clashes' }, - duplicates: { type: 'boolean', description: 'Include duplicates' } + hard: { type: 'boolean', description: 'true for hard/intersection mode, false for soft/clearance mode' }, + excludeSelf: { type: 'boolean', description: 'Exclude self-clashes within same model' }, + duplicates: { type: 'boolean', description: 'Include duplicate clash pairs' } } }, update_clash: { - description: 'Update a specific clash.', + description: 'Update a single clash: change status, priority, assignee, or title.', params: { - clashIndex: { type: 'number', description: 'Clash index (0-based)', required: true }, - status: { type: 'string', enum: ['open', 'resolved'] }, - priority: { type: 'string', enum: ['critical', 'high', 'normal', 'low'] }, - assignee: { type: 'string' }, - title: { type: 'string' } + clashIndex: { type: 'number', description: 'Zero-based index of the clash in the current list', required: true }, + status: { type: 'string', enum: ['open', 'resolved'], description: 'Resolution status' }, + priority: { type: 'string', enum: ['critical', 'high', 'normal', 'low'], description: 'Priority level' }, + assignee: { type: 'string', description: 'Person or team responsible' }, + title: { type: 'string', description: 'Short descriptive label' } } }, batch_update_clashes: { - description: 'Bulk update clashes.', + description: 'Bulk update multiple clashes by filter. Can mass-resolve duplicates, set priority on all hard clashes, etc.', params: { - action: { type: 'string', enum: ['resolve', 'set_priority', 'set_status'], required: true }, - filter: { type: 'string', enum: ['duplicates', 'soft', 'hard', 'all'], required: true }, - value: { type: 'string' } + action: { type: 'string', enum: ['resolve', 'set_priority', 'set_status'], description: 'Bulk action to perform', required: true }, + filter: { type: 'string', enum: ['duplicates', 'soft', 'hard', 'all'], description: 'Which clashes to target', required: true }, + value: { type: 'string', description: 'Value for the action (e.g. priority level)' } } }, set_view: { - description: 'Set camera to a preset angle.', - params: { view: { type: 'string', enum: ['top', 'front', 'back', 'left', 'right', 'isometric', 'reset'], required: true } } + description: 'Set the 3D camera to a preset viewing angle.', + params: { view: { type: 'string', enum: ['top', 'front', 'back', 'left', 'right', 'isometric', 'reset'], description: 'Camera preset', required: true } } }, set_render_style: { - description: 'Change 3D rendering style.', - params: { style: { type: 'string', enum: ['wireframe', 'shaded', 'rendered', 'standard'], required: true } } + description: 'Change 3D rendering: wireframe (see-through), shaded, rendered (full materials), or standard.', + params: { style: { type: 'string', enum: ['wireframe', 'shaded', 'rendered', 'standard'], description: 'Rendering mode', required: true } } }, set_section: { - description: 'Add or clear a section cut plane.', - params: { axis: { type: 'string', enum: ['x', 'y', 'z', 'none'], required: true } } + description: 'Apply a section cut plane to slice through the model, or remove it with "none".', + params: { axis: { type: 'string', enum: ['x', 'y', 'z', 'none'], description: 'Cut axis or "none" to clear', required: true } } }, color_by: { - description: 'Color elements by property.', - params: { by: { type: 'string', enum: ['type', 'storey', 'discipline', 'material', 'none'], required: true } } + description: 'Color-code model elements by type (IFC class), storey, discipline, material, or reset with "none".', + params: { by: { type: 'string', enum: ['type', 'storey', 'discipline', 'material', 'none'], description: 'Color grouping', required: true } } }, set_theme: { - description: 'Switch UI theme.', + description: 'Switch ClashControl UI between dark and light theme.', params: { theme: { type: 'string', enum: ['dark', 'light'], required: true } } }, set_visibility: { - description: 'Show or hide UI overlays.', + description: 'Toggle visibility of 3D viewport overlays: grid, axes, or clash markers.', params: { - option: { type: 'string', enum: ['grid', 'axes', 'markers'], required: true }, - visible: { type: 'boolean', required: true } + option: { type: 'string', enum: ['grid', 'axes', 'markers'], description: 'Overlay to toggle', required: true }, + visible: { type: 'boolean', description: 'true to show, false to hide', required: true } } }, restore_visibility: { - description: 'Restore all hidden/ghosted/isolated elements.', + description: 'Restore all hidden, ghosted, or isolated elements back to full visibility.', params: {} }, fly_to_clash: { - description: 'Fly camera to a clash.', - params: { clashIndex: { type: 'number', required: true } } + description: 'Fly the 3D camera to focus on a specific clash by index.', + params: { clashIndex: { type: 'number', description: 'Zero-based clash index', required: true } } }, navigate_tab: { - description: 'Switch to a UI tab.', + description: 'Switch the ClashControl sidebar to a tab: models, clashes, issues, navigator, or ai.', params: { tab: { type: 'string', enum: ['models', 'clashes', 'issues', 'navigator', 'ai'], required: true } } }, filter_clashes: { - description: 'Filter the clash list.', + description: 'Filter the displayed clash list by status and/or priority (UI only, does not modify data).', params: { - status: { type: 'string', enum: ['open', 'resolved', 'all'] }, - priority: { type: 'string', enum: ['critical', 'high', 'normal', 'low', 'all'] } + status: { type: 'string', enum: ['open', 'resolved', 'all'], description: 'Filter by resolution status' }, + priority: { type: 'string', enum: ['critical', 'high', 'normal', 'low', 'all'], description: 'Filter by priority level' } } }, sort_clashes: { - description: 'Sort the clash list.', - params: { sortBy: { type: 'string', enum: ['priority', 'status', 'type', 'storey', 'date', 'distance'], required: true } } + description: 'Sort the clash list by priority, status, type, storey, date, or distance.', + params: { sortBy: { type: 'string', enum: ['priority', 'status', 'type', 'storey', 'date', 'distance'], description: 'Sort property', required: true } } }, group_clashes: { - description: 'Group clashes by category.', - params: { groupBy: { type: 'string', enum: ['storey', 'discipline', 'status', 'type', 'none'], required: true } } + description: 'Group clashes by category to identify patterns (e.g. discipline pairs with most conflicts).', + params: { groupBy: { type: 'string', enum: ['storey', 'discipline', 'status', 'type', 'none'], description: 'Grouping category', required: true } } }, export_bcf: { - description: 'Export clashes/issues as BCF (triggers download in browser).', - params: { version: { type: 'string', enum: ['2.1', '3.0'] } } + description: 'Export clashes/issues as a BCF file for import into Revit, Navisworks, Solibri, etc.', + params: { version: { type: 'string', enum: ['2.1', '3.0'], description: 'BCF version (2.1 = widest compatibility)' } } }, create_project: { - description: 'Create a new project.', - params: { name: { type: 'string', required: true } } + description: 'Create a new ClashControl project for organizing clash detection sessions.', + params: { name: { type: 'string', description: 'Project name', required: true } } }, switch_project: { - description: 'Switch to a project by name.', - params: { name: { type: 'string', required: true } } + description: 'Switch to an existing project, loading its models, clashes, and settings.', + params: { name: { type: 'string', description: 'Project name or substring to match', required: true } } }, measure: { - description: 'Start or stop measurement mode.', - params: { mode: { type: 'string', enum: ['length', 'angle', 'area', 'stop', 'clear'], required: true } } + description: 'Activate measurement mode: length, angle, or area. Use "stop" to exit, "clear" to remove annotations.', + params: { mode: { type: 'string', enum: ['length', 'angle', 'area', 'stop', 'clear'], description: 'Measurement type or control', required: true } } }, walk_mode: { - description: 'Enter or exit first-person walk mode.', - params: { enabled: { type: 'boolean', required: true } } + description: 'Enter or exit first-person walk mode for human-scale building navigation.', + params: { enabled: { type: 'boolean', description: 'true to enter, false to exit', required: true } } } }; From 6aae25e02865d4b7b559502f06a9b7c49be1deba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 06:20:09 +0000 Subject: [PATCH 06/10] feat: extract shared TOOLS object into tools.js Single source of truth for all 24 tool definitions with enriched descriptions, annotations, and parameter metadata. Will be consumed by smart-bridge.js, index.js, and the esbuild .mcpb bundle. https://claude.ai/code/session_01XEvUykWcLc452uxGWPb1mb --- tools.js | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tools.js diff --git a/tools.js b/tools.js new file mode 100644 index 0000000..7b91413 --- /dev/null +++ b/tools.js @@ -0,0 +1,183 @@ +// ── ClashControl Tool Definitions ───────────────────────────────── +// Shared by smart-bridge.js (REST + MCP) and index.js (MCP standalone). +// Fields: desc (REST/OpenAPI), mcpDesc (richer Claude description), annotations (MCP safety hints) +// Param fields: t (type), e (enum), r (required), d (REST desc), md (MCP desc) + +const TOOLS = { + get_status: { + desc: 'Get current state: loaded models, clash count, active project, detection rules.', + mcpDesc: 'Retrieve the current state of ClashControl: which IFC models are loaded, total clash count, active project name, and detection rule settings (gap tolerance, hard/soft mode). Call this first to confirm the browser is connected and models are loaded before running other tools.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: {} + }, + get_clashes: { + desc: 'Get the clash list with details.', + mcpDesc: 'Retrieve detected clash pairs between IFC model elements. Each clash includes the two colliding elements (with IFC type, discipline, storey), clash type (hard intersection or soft clearance violation), distance in mm, status (open/resolved), and priority level. Use after run_detection or to inspect existing results.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + status: { t:'string', e:['open','resolved','all'], d:'Filter by status', md:'Filter clashes by resolution status: "open" = unresolved conflicts needing attention, "resolved" = already addressed, "all" = both' }, + limit: { t:'number', d:'Max clashes to return', md:'Maximum number of clash pairs to return (default 50). Use lower values for overview, higher for full export.' } + } + }, + get_issues: { + desc: 'Get the issues list.', + mcpDesc: 'Retrieve the list of manually created issues (distinct from auto-detected clashes). Issues are user-authored coordination notes attached to the project.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { limit: { t:'number', d:'Max issues to return', md:'Maximum number of issues to return (default 50).' } } + }, + run_detection: { + desc: 'Run clash detection between model groups.', + mcpDesc: 'Execute clash detection between two sets of IFC model elements. Specify model names, discipline labels, or "all". Use "+" to combine groups (e.g. "structural + architectural" vs "MEP"). Hard mode detects physical intersections; soft mode detects clearance violations within the gap tolerance. Results replace the current clash list.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + modelA: { t:'string', r:1, d:'First side: model name, discipline, or "all". Use "+" for groups.', md:'First side of detection: model name, discipline label, or "all". Combine with "+" (e.g. "structural + architectural").' }, + modelB: { t:'string', r:1, d:'Second side', md:'Second side of detection: model name, discipline label, or "all".' }, + maxGap: { t:'number', d:'Gap mm', md:'Gap tolerance in millimeters (default 10). Elements closer than this trigger a soft clash.' }, + hard: { t:'boolean', md:'true = detect hard clashes (physical intersections only), false = detect soft clashes (clearance violations within gap tolerance).' }, + excludeSelf: { t:'boolean', md:'true = skip clashes between elements within the same model file.' } + } + }, + set_detection_rules: { + desc: 'Update detection settings without running.', + mcpDesc: 'Update clash detection configuration (gap tolerance, hard/soft mode, self-clash filtering, duplicate handling) without triggering a new detection run. Settings take effect on the next run_detection call.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + maxGap: { t:'number', d:'Gap mm', md:'Gap tolerance in millimeters.' }, + hard: { t:'boolean', md:'true for hard/intersection mode, false for soft/clearance mode.' }, + excludeSelf: { t:'boolean', md:'Exclude self-clashes within same model.' }, + duplicates: { t:'boolean', md:'Include duplicate clash pairs in results.' } + } + }, + update_clash: { + desc: 'Update a specific clash.', + mcpDesc: 'Modify a single clash entry: change its resolution status, priority level, assigned reviewer, or descriptive title. Use clashIndex from the current clash list (0-based).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + clashIndex: { t:'number', r:1, d:'Clash index', md:'Zero-based index of the clash in the current list.' }, + status: { t:'string', e:['open','resolved'], md:'Set resolution status.' }, + priority: { t:'string', e:['critical','high','normal','low'], md:'Set priority level for triage.' }, + assignee: { t:'string', md:'Name of the person or team responsible for resolving this clash.' }, + title: { t:'string', md:'Short descriptive label for the clash.' } + } + }, + batch_update_clashes: { + desc: 'Bulk update clashes.', + mcpDesc: 'Bulk update multiple clashes at once by filter category. Can mass-resolve duplicates, set priority on all hard clashes, etc. Use with caution — affects many clashes at once.', + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + params: { + action: { t:'string', e:['resolve','set_priority','set_status'], r:1, d:'Action to perform', md:'Bulk action: "resolve" marks matching clashes as resolved, "set_priority" changes their priority, "set_status" changes their status.' }, + filter: { t:'string', e:['duplicates','soft','hard','all'], r:1, d:'Which clashes to target', md:'Filter: "duplicates" = repeated clash pairs, "soft" = clearance violations, "hard" = physical intersections, "all" = every clash.' }, + value: { t:'string', d:'New value for the action', md:'Value for the action (e.g. priority level for set_priority, status for set_status).' } + } + }, + set_view: { + desc: 'Set camera to a preset angle.', + mcpDesc: 'Set the 3D camera to a preset viewing angle. Useful for inspecting clashes from different perspectives or resetting the view.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { view: { t:'string', e:['top','front','back','left','right','isometric','reset'], r:1, md:'Camera preset angle. "reset" returns to the default view.' } } + }, + set_render_style: { + desc: 'Change 3D rendering style.', + mcpDesc: 'Change how the 3D model is rendered. Wireframe is useful for seeing through elements to inspect internal clashes. Shaded/rendered modes show solid surfaces.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { style: { t:'string', e:['wireframe','shaded','rendered','standard'], r:1, md:'Rendering mode: wireframe (see-through), shaded (basic lighting), rendered (full materials), standard (default).' } } + }, + set_section: { + desc: 'Add or clear section cut plane.', + mcpDesc: 'Apply a section cut plane to slice through the model along an axis, revealing internal geometry and hidden clashes. Use "none" to remove the cut.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { axis: { t:'string', e:['x','y','z','none'], r:1, md:'Cut axis (x/y/z), or "none" to remove the section plane.' } } + }, + color_by: { + desc: 'Color elements by property.', + mcpDesc: 'Color-code all model elements by a grouping property. Discipline coloring helps visualize which teams own which elements; storey coloring shows vertical distribution; type coloring distinguishes element categories (beams, ducts, pipes, etc.).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { by: { t:'string', e:['type','storey','discipline','material','none'], r:1, md:'Color grouping: type (IFC class), storey (building level), discipline (MEP/structural/architectural), material, or none (reset).' } } + }, + set_theme: { + desc: 'Switch UI theme.', + mcpDesc: 'Switch the ClashControl UI between dark and light theme.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { theme: { t:'string', e:['dark','light'], r:1 } } + }, + set_visibility: { + desc: 'Show or hide UI overlays.', + mcpDesc: 'Toggle visibility of 3D viewport overlays: grid lines, coordinate axes, or clash markers.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + option: { t:'string', e:['grid','axes','markers'], r:1, md:'Overlay to toggle.' }, + visible: { t:'boolean', r:1, md:'true to show, false to hide.' } + } + }, + restore_visibility: { + desc: 'Restore all hidden/ghosted elements.', + mcpDesc: 'Restore all hidden, ghosted, or isolated elements back to full visibility. Resets any per-element visibility overrides.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: {} + }, + fly_to_clash: { + desc: 'Fly camera to a clash.', + mcpDesc: 'Animate the 3D camera to focus on a specific clash, centering the view on the collision point between the two elements. Use to visually inspect individual clashes.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { clashIndex: { t:'number', r:1, md:'Zero-based index of the clash to navigate to.' } } + }, + navigate_tab: { + desc: 'Switch to a UI tab.', + mcpDesc: 'Switch the ClashControl sidebar to a specific tab: models (loaded IFC files), clashes (detection results), issues (manual notes), navigator (spatial tree), or ai (chat panel).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { tab: { t:'string', e:['models','clashes','issues','navigator','ai'], r:1 } } + }, + filter_clashes: { + desc: 'Filter the clash list.', + mcpDesc: 'Apply filters to the displayed clash list by status and/or priority level. Does not modify clashes, only changes which ones are shown in the UI.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { + status: { t:'string', e:['open','resolved','all'], md:'Filter by resolution status.' }, + priority: { t:'string', e:['critical','high','normal','low','all'], md:'Filter by priority level.' } + } + }, + sort_clashes: { + desc: 'Sort the clash list.', + mcpDesc: 'Sort the displayed clash list by a given property. Sorting by priority or distance helps identify the most critical or closest clashes first.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { sortBy: { t:'string', e:['priority','status','type','storey','date','distance'], r:1, md:'Property to sort by.' } } + }, + group_clashes: { + desc: 'Group clashes by category.', + mcpDesc: 'Group the clash list by a category to identify patterns. Grouping by discipline shows which team pairs have the most conflicts; by storey shows which floors are most problematic.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { groupBy: { t:'string', e:['storey','discipline','status','type','none'], r:1, md:'Grouping category, or "none" to flatten.' } } + }, + export_bcf: { + desc: 'Export clashes/issues as BCF.', + mcpDesc: 'Export all clashes and issues as a BCF (BIM Collaboration Format) file, triggering a download in the browser. BCF files can be imported into Revit, Navisworks, Solibri, and other BIM tools for coordination workflows.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { version: { t:'string', e:['2.1','3.0'], md:'BCF version: 2.1 (widest compatibility) or 3.0 (latest spec).' } } + }, + create_project: { + desc: 'Create a new project.', + mcpDesc: 'Create a new ClashControl project. Projects organize clash detection sessions, allowing separate tracking for different buildings or coordination phases.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + params: { name: { t:'string', r:1, md:'Name for the new project.' } } + }, + switch_project: { + desc: 'Switch to a project by name.', + mcpDesc: 'Switch to an existing project by name. Loads that project\'s models, clash results, and settings.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { name: { t:'string', r:1, md:'Project name or substring to match.' } } + }, + measure: { + desc: 'Start or stop measurement mode.', + mcpDesc: 'Activate measurement mode in the 3D viewport: measure distances (length), angles between surfaces, or areas. Use "stop" to exit measurement mode, "clear" to remove measurement annotations.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { mode: { t:'string', e:['length','angle','area','stop','clear'], r:1, md:'Measurement type, or "stop"/"clear" to exit/reset.' } } + }, + walk_mode: { + desc: 'Enter or exit walk mode.', + mcpDesc: 'Enter or exit first-person walk mode for navigating through the building model at human scale. Useful for understanding spatial relationships and clash locations in context.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + params: { enabled: { t:'boolean', r:1, md:'true to enter walk mode, false to exit.' } } + }, +}; + +module.exports = { TOOLS }; From 0dc9663752ad0b610465fd42985f31be5d331728 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 06:20:39 +0000 Subject: [PATCH 07/10] feat: add registerMcpTools helper and MCP_INSTRUCTIONS to tools.js Shared function that registers all 24 tools on an McpServer instance using registerTool() with enriched descriptions and safety annotations. Both smart-bridge.js and index.js will use this instead of inline loops. https://claude.ai/code/session_01XEvUykWcLc452uxGWPb1mb --- tools.js | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tools.js b/tools.js index 7b91413..cb4b1c7 100644 --- a/tools.js +++ b/tools.js @@ -180,4 +180,43 @@ const TOOLS = { }, }; -module.exports = { TOOLS }; +// ── MCP Registration Helper ────────────────────────────────────── + +const MCP_INSTRUCTIONS = + 'ClashControl is a BIM (Building Information Modeling) clash detection tool running in the browser. ' + + 'Every tool call is relayed to the ClashControl web app via WebSocket — the browser must be open with the Smart Bridge addon enabled. ' + + 'Typical workflow: (1) get_status to confirm connection and see loaded IFC models, ' + + '(2) run_detection to find clashes between discipline groups (e.g. structural vs MEP), ' + + '(3) get_clashes to review results, (4) fly_to_clash to inspect individual collisions, ' + + '(5) update_clash or batch_update_clashes to triage. ' + + 'Hard clashes = physical intersections. Soft clashes = clearance violations within a gap tolerance (mm). ' + + 'Always start with get_status to verify the browser is connected and models are loaded.'; + +function registerMcpTools(mcp, z, sendToBrowser) { + for (const [name, tool] of Object.entries(TOOLS)) { + const schema = {}; + for (const [pn, pd] of Object.entries(tool.params)) { + if (pd.e) schema[pn] = pd.r ? z.enum(pd.e) : z.enum(pd.e).optional(); + else if (pd.t === 'number') schema[pn] = pd.r ? z.number() : z.number().optional(); + else if (pd.t === 'boolean') schema[pn] = pd.r ? z.boolean() : z.boolean().optional(); + else schema[pn] = pd.r ? z.string() : z.string().optional(); + const paramDesc = pd.md || pd.d; + if (paramDesc && schema[pn].describe) schema[pn] = schema[pn].describe(paramDesc); + } + + mcp.registerTool(name, { + description: tool.mcpDesc || tool.desc, + inputSchema: Object.keys(schema).length > 0 ? schema : undefined, + annotations: tool.annotations + }, async (params) => { + try { + const result = await sendToBrowser(name, params); + return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true }; + } + }); + } +} + +module.exports = { TOOLS, MCP_INSTRUCTIONS, registerMcpTools }; From c57ad9cf539615dd66a204ff31d9c8225be500c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 06:21:41 +0000 Subject: [PATCH 08/10] feat: add registerMcpResources and registerMcpPrompts to tools.js - 2 Resources: clashcontrol://status, clashcontrol://clash-summary - 4 Prompts: analyze-clash-report, investigate-clash, coordination-review, compare-clash-runs - Complete shared module now exports: TOOLS, MCP_INSTRUCTIONS, registerMcpTools, registerMcpResources, registerMcpPrompts https://claude.ai/code/session_01XEvUykWcLc452uxGWPb1mb --- tools.js | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/tools.js b/tools.js index cb4b1c7..4b97a5f 100644 --- a/tools.js +++ b/tools.js @@ -219,4 +219,101 @@ function registerMcpTools(mcp, z, sendToBrowser) { } } -module.exports = { TOOLS, MCP_INSTRUCTIONS, registerMcpTools }; +// ── MCP Resources ──────────────────────────────────────────────── + +function registerMcpResources(mcp, sendToBrowser) { + mcp.registerResource('status', 'clashcontrol://status', { + description: 'Current ClashControl state: loaded IFC models, clash count, active project, detection rules, browser connection status.', + mimeType: 'application/json' + }, async (uri) => { + try { + const result = await sendToBrowser('get_status', {}); + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; + } catch (e) { + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: e.message, connected: false }) }] }; + } + }); + + mcp.registerResource('clash-summary', 'clashcontrol://clash-summary', { + description: 'High-level clash summary: total counts by status, type, and priority.', + mimeType: 'application/json' + }, async (uri) => { + try { + const result = await sendToBrowser('get_clashes', { status: 'all', limit: 500 }); + const clashes = Array.isArray(result) ? result : (result && result.clashes) || []; + const summary = { total: clashes.length, byStatus: {}, byType: {}, byPriority: {} }; + for (const c of clashes) { + if (c.status) summary.byStatus[c.status] = (summary.byStatus[c.status] || 0) + 1; + if (c.type) summary.byType[c.type] = (summary.byType[c.type] || 0) + 1; + if (c.priority) summary.byPriority[c.priority] = (summary.byPriority[c.priority] || 0) + 1; + } + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(summary, null, 2) }] }; + } catch (e) { + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: e.message }) }] }; + } + }); +} + +// ── MCP Prompts ────────────────────────────────────────────────── + +function registerMcpPrompts(mcp, z) { + mcp.registerPrompt('analyze-clash-report', { + description: 'Systematic analysis of current clash detection results with triage recommendations.' + }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Analyze the current BIM clash detection results in ClashControl:\n\n' + + '1. Call get_status to confirm which IFC models are loaded and current detection settings.\n' + + '2. Call get_clashes with status "all" to retrieve the full clash list.\n' + + '3. Summarize: total clashes, breakdown by status (open vs resolved), by type (hard vs soft), by priority, and by discipline pairs.\n' + + '4. Identify patterns: clashes concentrated on specific storeys? Between specific disciplines? Clusters of duplicates?\n' + + '5. Recommend a triage strategy: which clashes to address first, which might be false positives, and which discipline teams should coordinate.\n' + + '6. If there are many duplicates, suggest using batch_update_clashes to resolve them before manual review.' + }}] + })); + + mcp.registerPrompt('investigate-clash', { + description: 'Deep-dive investigation of a specific clash with visual inspection and resolution advice.', + argsSchema: { clashIndex: z.string().describe('Zero-based index of the clash to investigate') } + }, async ({ clashIndex }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Investigate clash #' + clashIndex + ' in ClashControl:\n\n' + + '1. Call fly_to_clash with clashIndex ' + clashIndex + ' to navigate the 3D view to this clash.\n' + + '2. Call get_clashes to get details about this clash (elements involved, type, distance, storey).\n' + + '3. Try different viewing angles: set_view with "front", "top", and "isometric".\n' + + '4. If elements are hard to see, try set_render_style with "wireframe" or color_by with "discipline".\n' + + '5. Explain what the two clashing elements are, why they might be colliding, and whether this is a real coordination issue or a modeling artifact.\n' + + '6. Suggest a resolution: flag as critical, assign to a discipline team, or resolve as false positive.' + }}] + })); + + mcp.registerPrompt('coordination-review', { + description: 'Discipline coordination checklist for BIM review meetings.', + argsSchema: { discipline: z.string().describe('Primary discipline to review, e.g. "structural", "mechanical", "electrical", "plumbing"') } + }, async ({ discipline }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Run a coordination review for the ' + discipline + ' discipline:\n\n' + + '1. Call get_status to see which models and disciplines are loaded.\n' + + '2. Call get_clashes to find all clashes involving ' + discipline + ' elements.\n' + + '3. Use group_clashes with "discipline" to see which other disciplines clash most with ' + discipline + '.\n' + + '4. For each discipline pair, summarize: clash count, types (hard vs soft), affected storeys, severity.\n' + + '5. Identify the top 3 most critical coordination issues needing team discussion.\n' + + '6. Provide a checklist of action items for the ' + discipline + ' coordination lead.' + }}] + })); + + mcp.registerPrompt('compare-clash-runs', { + description: 'Compare current clash results to track coordination progress over time.' + }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: + 'Compare clash detection results to track coordination progress:\n\n' + + '1. Call get_status to see the current project and detection settings.\n' + + '2. Call get_clashes with status "all" to get the complete clash list.\n' + + '3. Summarize: total clashes, open vs resolved, by type and priority.\n' + + '4. Compute the resolution rate: what percentage of clashes are resolved?\n' + + '5. Identify new open clashes with high/critical priority — these need immediate attention.\n' + + '6. Produce a progress report for a BIM coordination meeting: overall trend, improvements, remaining problem areas, recommended next steps.' + }}] + })); +} + +module.exports = { TOOLS, MCP_INSTRUCTIONS, registerMcpTools, registerMcpResources, registerMcpPrompts }; From 0511a38abcd30bff1772ef1ac0d20d4410854ada Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 06:24:13 +0000 Subject: [PATCH 09/10] refactor: smart-bridge.js imports from shared tools.js Replace inline TOOLS object (~175 lines) and inline MCP registration (~130 lines) with require('./tools.js'). startMcpServer() now calls registerMcpTools, registerMcpResources, registerMcpPrompts from the shared module. REST API continues working via the imported TOOLS object. https://claude.ai/code/session_01XEvUykWcLc452uxGWPb1mb --- smart-bridge.js | 309 +----------------------------------------------- 1 file changed, 4 insertions(+), 305 deletions(-) diff --git a/smart-bridge.js b/smart-bridge.js index 1d993c0..3ea7909 100644 --- a/smart-bridge.js +++ b/smart-bridge.js @@ -259,185 +259,7 @@ function log(msg) { } // ── Tool definitions (shared by REST and MCP) ───────────────────── -// Fields: desc (REST/OpenAPI), mcpDesc (richer Claude description), annotations (MCP safety hints) -// Param fields: t (type), e (enum), r (required), d (REST desc), md (MCP desc) - -const TOOLS = { - get_status: { - desc: 'Get current state: loaded models, clash count, active project, detection rules.', - mcpDesc: 'Retrieve the current state of ClashControl: which IFC models are loaded, total clash count, active project name, and detection rule settings (gap tolerance, hard/soft mode). Call this first to confirm the browser is connected and models are loaded before running other tools.', - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: {} - }, - get_clashes: { - desc: 'Get the clash list with details.', - mcpDesc: 'Retrieve detected clash pairs between IFC model elements. Each clash includes the two colliding elements (with IFC type, discipline, storey), clash type (hard intersection or soft clearance violation), distance in mm, status (open/resolved), and priority level. Use after run_detection or to inspect existing results.', - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - status: { t:'string', e:['open','resolved','all'], d:'Filter by status', md:'Filter clashes by resolution status: "open" = unresolved conflicts needing attention, "resolved" = already addressed, "all" = both' }, - limit: { t:'number', d:'Max clashes to return', md:'Maximum number of clash pairs to return (default 50). Use lower values for overview, higher for full export.' } - } - }, - get_issues: { - desc: 'Get the issues list.', - mcpDesc: 'Retrieve the list of manually created issues (distinct from auto-detected clashes). Issues are user-authored coordination notes attached to the project.', - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { limit: { t:'number', d:'Max issues to return', md:'Maximum number of issues to return (default 50).' } } - }, - run_detection: { - desc: 'Run clash detection between model groups.', - mcpDesc: 'Execute clash detection between two sets of IFC model elements. Specify model names, discipline labels, or "all". Use "+" to combine groups (e.g. "structural + architectural" vs "MEP"). Hard mode detects physical intersections; soft mode detects clearance violations within the gap tolerance. Results replace the current clash list.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - modelA: { t:'string', r:1, d:'First side: model name, discipline, or "all". Use "+" for groups.', md:'First side of detection: model name, discipline label, or "all". Combine with "+" (e.g. "structural + architectural").' }, - modelB: { t:'string', r:1, d:'Second side', md:'Second side of detection: model name, discipline label, or "all".' }, - maxGap: { t:'number', d:'Gap mm', md:'Gap tolerance in millimeters (default 10). Elements closer than this trigger a soft clash.' }, - hard: { t:'boolean', md:'true = detect hard clashes (physical intersections only), false = detect soft clashes (clearance violations within gap tolerance).' }, - excludeSelf: { t:'boolean', md:'true = skip clashes between elements within the same model file.' } - } - }, - set_detection_rules: { - desc: 'Update detection settings without running.', - mcpDesc: 'Update clash detection configuration (gap tolerance, hard/soft mode, self-clash filtering, duplicate handling) without triggering a new detection run. Settings take effect on the next run_detection call.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - maxGap: { t:'number', d:'Gap mm', md:'Gap tolerance in millimeters.' }, - hard: { t:'boolean', md:'true for hard/intersection mode, false for soft/clearance mode.' }, - excludeSelf: { t:'boolean', md:'Exclude self-clashes within same model.' }, - duplicates: { t:'boolean', md:'Include duplicate clash pairs in results.' } - } - }, - update_clash: { - desc: 'Update a specific clash.', - mcpDesc: 'Modify a single clash entry: change its resolution status, priority level, assigned reviewer, or descriptive title. Use clashIndex from the current clash list (0-based).', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - clashIndex: { t:'number', r:1, d:'Clash index', md:'Zero-based index of the clash in the current list.' }, - status: { t:'string', e:['open','resolved'], md:'Set resolution status.' }, - priority: { t:'string', e:['critical','high','normal','low'], md:'Set priority level for triage.' }, - assignee: { t:'string', md:'Name of the person or team responsible for resolving this clash.' }, - title: { t:'string', md:'Short descriptive label for the clash.' } - } - }, - batch_update_clashes: { - desc: 'Bulk update clashes.', - mcpDesc: 'Bulk update multiple clashes at once by filter category. Can mass-resolve duplicates, set priority on all hard clashes, etc. Use with caution — affects many clashes at once.', - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, - params: { - action: { t:'string', e:['resolve','set_priority','set_status'], r:1, d:'Action to perform', md:'Bulk action: "resolve" marks matching clashes as resolved, "set_priority" changes their priority, "set_status" changes their status.' }, - filter: { t:'string', e:['duplicates','soft','hard','all'], r:1, d:'Which clashes to target', md:'Filter: "duplicates" = repeated clash pairs, "soft" = clearance violations, "hard" = physical intersections, "all" = every clash.' }, - value: { t:'string', d:'New value for the action', md:'Value for the action (e.g. priority level for set_priority, status for set_status).' } - } - }, - set_view: { - desc: 'Set camera to a preset angle.', - mcpDesc: 'Set the 3D camera to a preset viewing angle. Useful for inspecting clashes from different perspectives or resetting the view.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { view: { t:'string', e:['top','front','back','left','right','isometric','reset'], r:1, md:'Camera preset angle. "reset" returns to the default view.' } } - }, - set_render_style: { - desc: 'Change 3D rendering style.', - mcpDesc: 'Change how the 3D model is rendered. Wireframe is useful for seeing through elements to inspect internal clashes. Shaded/rendered modes show solid surfaces.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { style: { t:'string', e:['wireframe','shaded','rendered','standard'], r:1, md:'Rendering mode: wireframe (see-through), shaded (basic lighting), rendered (full materials), standard (default).' } } - }, - set_section: { - desc: 'Add or clear section cut plane.', - mcpDesc: 'Apply a section cut plane to slice through the model along an axis, revealing internal geometry and hidden clashes. Use "none" to remove the cut.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { axis: { t:'string', e:['x','y','z','none'], r:1, md:'Cut axis (x/y/z), or "none" to remove the section plane.' } } - }, - color_by: { - desc: 'Color elements by property.', - mcpDesc: 'Color-code all model elements by a grouping property. Discipline coloring helps visualize which teams own which elements; storey coloring shows vertical distribution; type coloring distinguishes element categories (beams, ducts, pipes, etc.).', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { by: { t:'string', e:['type','storey','discipline','material','none'], r:1, md:'Color grouping: type (IFC class), storey (building level), discipline (MEP/structural/architectural), material, or none (reset).' } } - }, - set_theme: { - desc: 'Switch UI theme.', - mcpDesc: 'Switch the ClashControl UI between dark and light theme.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { theme: { t:'string', e:['dark','light'], r:1 } } - }, - set_visibility: { - desc: 'Show or hide UI overlays.', - mcpDesc: 'Toggle visibility of 3D viewport overlays: grid lines, coordinate axes, or clash markers.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - option: { t:'string', e:['grid','axes','markers'], r:1, md:'Overlay to toggle.' }, - visible: { t:'boolean', r:1, md:'true to show, false to hide.' } - } - }, - restore_visibility: { - desc: 'Restore all hidden/ghosted elements.', - mcpDesc: 'Restore all hidden, ghosted, or isolated elements back to full visibility. Resets any per-element visibility overrides.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: {} - }, - fly_to_clash: { - desc: 'Fly camera to a clash.', - mcpDesc: 'Animate the 3D camera to focus on a specific clash, centering the view on the collision point between the two elements. Use to visually inspect individual clashes.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { clashIndex: { t:'number', r:1, md:'Zero-based index of the clash to navigate to.' } } - }, - navigate_tab: { - desc: 'Switch to a UI tab.', - mcpDesc: 'Switch the ClashControl sidebar to a specific tab: models (loaded IFC files), clashes (detection results), issues (manual notes), navigator (spatial tree), or ai (chat panel).', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { tab: { t:'string', e:['models','clashes','issues','navigator','ai'], r:1 } } - }, - filter_clashes: { - desc: 'Filter the clash list.', - mcpDesc: 'Apply filters to the displayed clash list by status and/or priority level. Does not modify clashes, only changes which ones are shown in the UI.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - status: { t:'string', e:['open','resolved','all'], md:'Filter by resolution status.' }, - priority: { t:'string', e:['critical','high','normal','low','all'], md:'Filter by priority level.' } - } - }, - sort_clashes: { - desc: 'Sort the clash list.', - mcpDesc: 'Sort the displayed clash list by a given property. Sorting by priority or distance helps identify the most critical or closest clashes first.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { sortBy: { t:'string', e:['priority','status','type','storey','date','distance'], r:1, md:'Property to sort by.' } } - }, - group_clashes: { - desc: 'Group clashes by category.', - mcpDesc: 'Group the clash list by a category to identify patterns. Grouping by discipline shows which team pairs have the most conflicts; by storey shows which floors are most problematic.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { groupBy: { t:'string', e:['storey','discipline','status','type','none'], r:1, md:'Grouping category, or "none" to flatten.' } } - }, - export_bcf: { - desc: 'Export clashes/issues as BCF.', - mcpDesc: 'Export all clashes and issues as a BCF (BIM Collaboration Format) file, triggering a download in the browser. BCF files can be imported into Revit, Navisworks, Solibri, and other BIM tools for coordination workflows.', - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { version: { t:'string', e:['2.1','3.0'], md:'BCF version: 2.1 (widest compatibility) or 3.0 (latest spec).' } } - }, - create_project: { - desc: 'Create a new project.', - mcpDesc: 'Create a new ClashControl project. Projects organize clash detection sessions, allowing separate tracking for different buildings or coordination phases.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - params: { name: { t:'string', r:1, md:'Name for the new project.' } } - }, - switch_project: { - desc: 'Switch to a project by name.', - mcpDesc: 'Switch to an existing project by name. Loads that project\'s models, clash results, and settings.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { name: { t:'string', r:1, md:'Project name or substring to match.' } } - }, - measure: { - desc: 'Start or stop measurement mode.', - mcpDesc: 'Activate measurement mode in the 3D viewport: measure distances (length), angles between surfaces, or areas. Use "stop" to exit measurement mode, "clear" to remove measurement annotations.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { mode: { t:'string', e:['length','angle','area','stop','clear'], r:1, md:'Measurement type, or "stop"/"clear" to exit/reset.' } } - }, - walk_mode: { - desc: 'Enter or exit walk mode.', - mcpDesc: 'Enter or exit first-person walk mode for navigating through the building model at human scale. Useful for understanding spatial relationships and clash locations in context.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { enabled: { t:'boolean', r:1, md:'true to enter walk mode, false to exit.' } } - }, -}; +const { TOOLS, MCP_INSTRUCTIONS, registerMcpTools, registerMcpResources, registerMcpPrompts } = require('./tools.js'); // ── REST API ────────────────────────────────────────────────────── @@ -554,134 +376,11 @@ async function startMcpServer() { const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); const { z } = require('zod'); - const MCP_INSTRUCTIONS = - 'ClashControl is a BIM (Building Information Modeling) clash detection tool running in the browser. ' + - 'Every tool call is relayed to the ClashControl web app via WebSocket — the browser must be open with the Smart Bridge addon enabled. ' + - 'Typical workflow: (1) get_status to confirm connection and see loaded IFC models, ' + - '(2) run_detection to find clashes between discipline groups (e.g. structural vs MEP), ' + - '(3) get_clashes to review results, (4) fly_to_clash to inspect individual collisions, ' + - '(5) update_clash or batch_update_clashes to triage. ' + - 'Hard clashes = physical intersections. Soft clashes = clearance violations within a gap tolerance (mm). ' + - 'Always start with get_status to verify the browser is connected and models are loaded.'; - const mcp = new McpServer({ name: 'ClashControl', version: VERSION }, { instructions: MCP_INSTRUCTIONS }); - // ── Register tools from shared TOOLS definition ── - for (const [name, tool] of Object.entries(TOOLS)) { - const schema = {}; - for (const [pn, pd] of Object.entries(tool.params)) { - if (pd.e) schema[pn] = pd.r ? z.enum(pd.e) : z.enum(pd.e).optional(); - else if (pd.t === 'number') schema[pn] = pd.r ? z.number() : z.number().optional(); - else if (pd.t === 'boolean') schema[pn] = pd.r ? z.boolean() : z.boolean().optional(); - else schema[pn] = pd.r ? z.string() : z.string().optional(); - const paramDesc = pd.md || pd.d; - if (paramDesc && schema[pn].describe) schema[pn] = schema[pn].describe(paramDesc); - } - - mcp.registerTool(name, { - description: tool.mcpDesc || tool.desc, - inputSchema: Object.keys(schema).length > 0 ? schema : undefined, - annotations: tool.annotations - }, async (params) => { - try { - const result = await sendToBrowser(name, params); - return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; - } catch (e) { - return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true }; - } - }); - } - - // ── MCP Resources ── - mcp.registerResource('status', 'clashcontrol://status', { - description: 'Current ClashControl state: loaded IFC models, clash count, active project, detection rules, browser connection status.', - mimeType: 'application/json' - }, async (uri) => { - try { - const result = await sendToBrowser('get_status', {}); - return { contents: [{ uri: uri.href, mimeType: 'application/json', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; - } catch (e) { - return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: e.message, connected: false }) }] }; - } - }); - - mcp.registerResource('clash-summary', 'clashcontrol://clash-summary', { - description: 'High-level clash summary: total counts by status, type, and priority.', - mimeType: 'application/json' - }, async (uri) => { - try { - const result = await sendToBrowser('get_clashes', { status: 'all', limit: 500 }); - const clashes = Array.isArray(result) ? result : (result && result.clashes) || []; - const summary = { total: clashes.length, byStatus: {}, byType: {}, byPriority: {} }; - for (const c of clashes) { - if (c.status) summary.byStatus[c.status] = (summary.byStatus[c.status] || 0) + 1; - if (c.type) summary.byType[c.type] = (summary.byType[c.type] || 0) + 1; - if (c.priority) summary.byPriority[c.priority] = (summary.byPriority[c.priority] || 0) + 1; - } - return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(summary, null, 2) }] }; - } catch (e) { - return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: e.message }) }] }; - } - }); - - // ── MCP Prompts ── - mcp.registerPrompt('analyze-clash-report', { - description: 'Systematic analysis of current clash detection results with triage recommendations.' - }, async () => ({ - messages: [{ role: 'user', content: { type: 'text', text: - 'Analyze the current BIM clash detection results in ClashControl:\n\n' + - '1. Call get_status to confirm which IFC models are loaded and current detection settings.\n' + - '2. Call get_clashes with status "all" to retrieve the full clash list.\n' + - '3. Summarize: total clashes, breakdown by status (open vs resolved), by type (hard vs soft), by priority, and by discipline pairs.\n' + - '4. Identify patterns: clashes concentrated on specific storeys? Between specific disciplines? Clusters of duplicates?\n' + - '5. Recommend a triage strategy: which clashes to address first, which might be false positives, and which discipline teams should coordinate.\n' + - '6. If there are many duplicates, suggest using batch_update_clashes to resolve them before manual review.' - }}] - })); - - mcp.registerPrompt('investigate-clash', { - description: 'Deep-dive investigation of a specific clash with visual inspection and resolution advice.', - argsSchema: { clashIndex: z.string().describe('Zero-based index of the clash to investigate') } - }, async ({ clashIndex }) => ({ - messages: [{ role: 'user', content: { type: 'text', text: - 'Investigate clash #' + clashIndex + ' in ClashControl:\n\n' + - '1. Call fly_to_clash with clashIndex ' + clashIndex + ' to navigate the 3D view to this clash.\n' + - '2. Call get_clashes to get details about this clash (elements involved, type, distance, storey).\n' + - '3. Try different viewing angles: set_view with "front", "top", and "isometric".\n' + - '4. If elements are hard to see, try set_render_style with "wireframe" or color_by with "discipline".\n' + - '5. Explain what the two clashing elements are, why they might be colliding, and whether this is a real coordination issue or a modeling artifact.\n' + - '6. Suggest a resolution: flag as critical, assign to a discipline team, or resolve as false positive.' - }}] - })); - - mcp.registerPrompt('coordination-review', { - description: 'Discipline coordination checklist for BIM review meetings.', - argsSchema: { discipline: z.string().describe('Primary discipline to review, e.g. "structural", "mechanical", "electrical", "plumbing"') } - }, async ({ discipline }) => ({ - messages: [{ role: 'user', content: { type: 'text', text: - 'Run a coordination review for the ' + discipline + ' discipline:\n\n' + - '1. Call get_status to see which models and disciplines are loaded.\n' + - '2. Call get_clashes to find all clashes involving ' + discipline + ' elements.\n' + - '3. Use group_clashes with "discipline" to see which other disciplines clash most with ' + discipline + '.\n' + - '4. For each discipline pair, summarize: clash count, types (hard vs soft), affected storeys, severity.\n' + - '5. Identify the top 3 most critical coordination issues needing team discussion.\n' + - '6. Provide a checklist of action items for the ' + discipline + ' coordination lead.' - }}] - })); - - mcp.registerPrompt('compare-clash-runs', { - description: 'Compare current clash results to track coordination progress over time.' - }, async () => ({ - messages: [{ role: 'user', content: { type: 'text', text: - 'Compare clash detection results to track coordination progress:\n\n' + - '1. Call get_status to see the current project and detection settings.\n' + - '2. Call get_clashes with status "all" to get the complete clash list.\n' + - '3. Summarize: total clashes, open vs resolved, by type and priority.\n' + - '4. Compute the resolution rate: what percentage of clashes are resolved?\n' + - '5. Identify new open clashes with high/critical priority — these need immediate attention.\n' + - '6. Produce a progress report for a BIM coordination meeting: overall trend, improvements, remaining problem areas, recommended next steps.' - }}] - })); + registerMcpTools(mcp, z, sendToBrowser); + registerMcpResources(mcp, sendToBrowser); + registerMcpPrompts(mcp, z); log('MCP server starting on stdio...'); const transport = new StdioServerTransport(); From c4546f6c15f17c788a9d29ffb63f0fef6f0e50b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 06:24:45 +0000 Subject: [PATCH 10/10] refactor: index.js imports from shared tools.js Replace inline TOOLS object and 24 individual tool registrations with shared tools.js module. File goes from ~300 lines to ~95 lines. Same MCP capabilities: 24 tools, 2 resources, 4 prompts, instructions. https://claude.ai/code/session_01XEvUykWcLc452uxGWPb1mb --- index.js | 314 +------------------------------------------------------ 1 file changed, 5 insertions(+), 309 deletions(-) diff --git a/index.js b/index.js index 44450b8..c768023 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,8 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio const { z } = require('zod'); const WebSocket = require('ws'); +const { TOOLS, MCP_INSTRUCTIONS, registerMcpTools, registerMcpResources, registerMcpPrompts } = require('./tools.js'); + const VERSION = require('./package.json').version; const WS_PORT = 19802; const REQUEST_TIMEOUT = 15000; // 15s for browser to respond @@ -71,319 +73,13 @@ function sendToBrowser(action, params) { }); } -// ── Tool definitions ───────────────────────────────────────────── -// Same enriched TOOLS as smart-bridge.js: desc (REST), mcpDesc (Claude), annotations, md (param descriptions) - -const TOOLS = { - get_status: { - desc: 'Get current state: loaded models, clash count, active project, detection rules.', - mcpDesc: 'Retrieve the current state of ClashControl: which IFC models are loaded, total clash count, active project name, and detection rule settings (gap tolerance, hard/soft mode). Call this first to confirm the browser is connected and models are loaded before running other tools.', - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: {} - }, - get_clashes: { - desc: 'Get the clash list with details.', - mcpDesc: 'Retrieve detected clash pairs between IFC model elements. Each clash includes the two colliding elements (with IFC type, discipline, storey), clash type (hard intersection or soft clearance violation), distance in mm, status (open/resolved), and priority level. Use after run_detection or to inspect existing results.', - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - status: { t:'string', e:['open','resolved','all'], md:'Filter clashes by resolution status: "open" = unresolved conflicts needing attention, "resolved" = already addressed, "all" = both' }, - limit: { t:'number', md:'Maximum number of clash pairs to return (default 50). Use lower values for overview, higher for full export.' } - } - }, - get_issues: { - desc: 'Get the issues list.', - mcpDesc: 'Retrieve the list of manually created issues (distinct from auto-detected clashes). Issues are user-authored coordination notes attached to the project.', - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { limit: { t:'number', md:'Maximum number of issues to return (default 50).' } } - }, - run_detection: { - desc: 'Run clash detection between model groups.', - mcpDesc: 'Execute clash detection between two sets of IFC model elements. Specify model names, discipline labels, or "all". Use "+" to combine groups (e.g. "structural + architectural" vs "MEP"). Hard mode detects physical intersections; soft mode detects clearance violations within the gap tolerance. Results replace the current clash list.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - modelA: { t:'string', r:1, md:'First side of detection: model name, discipline label, or "all". Combine with "+" (e.g. "structural + architectural").' }, - modelB: { t:'string', r:1, md:'Second side of detection: model name, discipline label, or "all".' }, - maxGap: { t:'number', md:'Gap tolerance in millimeters (default 10). Elements closer than this trigger a soft clash.' }, - hard: { t:'boolean', md:'true = detect hard clashes (physical intersections only), false = detect soft clashes (clearance violations within gap tolerance).' }, - excludeSelf: { t:'boolean', md:'true = skip clashes between elements within the same model file.' } - } - }, - set_detection_rules: { - desc: 'Update detection settings without running.', - mcpDesc: 'Update clash detection configuration (gap tolerance, hard/soft mode, self-clash filtering, duplicate handling) without triggering a new detection run. Settings take effect on the next run_detection call.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - maxGap: { t:'number', md:'Gap tolerance in millimeters.' }, - hard: { t:'boolean', md:'true for hard/intersection mode, false for soft/clearance mode.' }, - excludeSelf: { t:'boolean', md:'Exclude self-clashes within same model.' }, - duplicates: { t:'boolean', md:'Include duplicate clash pairs in results.' } - } - }, - update_clash: { - desc: 'Update a specific clash.', - mcpDesc: 'Modify a single clash entry: change its resolution status, priority level, assigned reviewer, or descriptive title. Use clashIndex from the current clash list (0-based).', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - clashIndex: { t:'number', r:1, md:'Zero-based index of the clash in the current list.' }, - status: { t:'string', e:['open','resolved'], md:'Set resolution status.' }, - priority: { t:'string', e:['critical','high','normal','low'], md:'Set priority level for triage.' }, - assignee: { t:'string', md:'Name of the person or team responsible for resolving this clash.' }, - title: { t:'string', md:'Short descriptive label for the clash.' } - } - }, - batch_update_clashes: { - desc: 'Bulk update clashes.', - mcpDesc: 'Bulk update multiple clashes at once by filter category. Can mass-resolve duplicates, set priority on all hard clashes, etc. Use with caution — affects many clashes at once.', - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, - params: { - action: { t:'string', e:['resolve','set_priority','set_status'], r:1, md:'Bulk action: "resolve" marks matching clashes as resolved, "set_priority" changes their priority, "set_status" changes their status.' }, - filter: { t:'string', e:['duplicates','soft','hard','all'], r:1, md:'Filter: "duplicates" = repeated clash pairs, "soft" = clearance violations, "hard" = physical intersections, "all" = every clash.' }, - value: { t:'string', md:'Value for the action (e.g. priority level for set_priority, status for set_status).' } - } - }, - set_view: { - desc: 'Set camera to a preset angle.', - mcpDesc: 'Set the 3D camera to a preset viewing angle. Useful for inspecting clashes from different perspectives or resetting the view.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { view: { t:'string', e:['top','front','back','left','right','isometric','reset'], r:1, md:'Camera preset angle. "reset" returns to the default view.' } } - }, - set_render_style: { - desc: 'Change 3D rendering style.', - mcpDesc: 'Change how the 3D model is rendered. Wireframe is useful for seeing through elements to inspect internal clashes. Shaded/rendered modes show solid surfaces.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { style: { t:'string', e:['wireframe','shaded','rendered','standard'], r:1, md:'Rendering mode: wireframe (see-through), shaded (basic lighting), rendered (full materials), standard (default).' } } - }, - set_section: { - desc: 'Add or clear section cut plane.', - mcpDesc: 'Apply a section cut plane to slice through the model along an axis, revealing internal geometry and hidden clashes. Use "none" to remove the cut.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { axis: { t:'string', e:['x','y','z','none'], r:1, md:'Cut axis (x/y/z), or "none" to remove the section plane.' } } - }, - color_by: { - desc: 'Color elements by property.', - mcpDesc: 'Color-code all model elements by a grouping property. Discipline coloring helps visualize which teams own which elements; storey coloring shows vertical distribution; type coloring distinguishes element categories (beams, ducts, pipes, etc.).', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { by: { t:'string', e:['type','storey','discipline','material','none'], r:1, md:'Color grouping: type (IFC class), storey (building level), discipline (MEP/structural/architectural), material, or none (reset).' } } - }, - set_theme: { - desc: 'Switch UI theme.', - mcpDesc: 'Switch the ClashControl UI between dark and light theme.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { theme: { t:'string', e:['dark','light'], r:1 } } - }, - set_visibility: { - desc: 'Show or hide UI overlays.', - mcpDesc: 'Toggle visibility of 3D viewport overlays: grid lines, coordinate axes, or clash markers.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - option: { t:'string', e:['grid','axes','markers'], r:1, md:'Overlay to toggle.' }, - visible: { t:'boolean', r:1, md:'true to show, false to hide.' } - } - }, - restore_visibility: { - desc: 'Restore all hidden/ghosted elements.', - mcpDesc: 'Restore all hidden, ghosted, or isolated elements back to full visibility. Resets any per-element visibility overrides.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: {} - }, - fly_to_clash: { - desc: 'Fly camera to a clash.', - mcpDesc: 'Animate the 3D camera to focus on a specific clash, centering the view on the collision point between the two elements. Use to visually inspect individual clashes.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { clashIndex: { t:'number', r:1, md:'Zero-based index of the clash to navigate to.' } } - }, - navigate_tab: { - desc: 'Switch to a UI tab.', - mcpDesc: 'Switch the ClashControl sidebar to a specific tab: models (loaded IFC files), clashes (detection results), issues (manual notes), navigator (spatial tree), or ai (chat panel).', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { tab: { t:'string', e:['models','clashes','issues','navigator','ai'], r:1 } } - }, - filter_clashes: { - desc: 'Filter the clash list.', - mcpDesc: 'Apply filters to the displayed clash list by status and/or priority level. Does not modify clashes, only changes which ones are shown in the UI.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { - status: { t:'string', e:['open','resolved','all'], md:'Filter by resolution status.' }, - priority: { t:'string', e:['critical','high','normal','low','all'], md:'Filter by priority level.' } - } - }, - sort_clashes: { - desc: 'Sort the clash list.', - mcpDesc: 'Sort the displayed clash list by a given property. Sorting by priority or distance helps identify the most critical or closest clashes first.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { sortBy: { t:'string', e:['priority','status','type','storey','date','distance'], r:1, md:'Property to sort by.' } } - }, - group_clashes: { - desc: 'Group clashes by category.', - mcpDesc: 'Group the clash list by a category to identify patterns. Grouping by discipline shows which team pairs have the most conflicts; by storey shows which floors are most problematic.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { groupBy: { t:'string', e:['storey','discipline','status','type','none'], r:1, md:'Grouping category, or "none" to flatten.' } } - }, - export_bcf: { - desc: 'Export clashes/issues as BCF.', - mcpDesc: 'Export all clashes and issues as a BCF (BIM Collaboration Format) file, triggering a download in the browser. BCF files can be imported into Revit, Navisworks, Solibri, and other BIM tools for coordination workflows.', - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { version: { t:'string', e:['2.1','3.0'], md:'BCF version: 2.1 (widest compatibility) or 3.0 (latest spec).' } } - }, - create_project: { - desc: 'Create a new project.', - mcpDesc: 'Create a new ClashControl project. Projects organize clash detection sessions, allowing separate tracking for different buildings or coordination phases.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - params: { name: { t:'string', r:1, md:'Name for the new project.' } } - }, - switch_project: { - desc: 'Switch to a project by name.', - mcpDesc: 'Switch to an existing project by name. Loads that project\'s models, clash results, and settings.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { name: { t:'string', r:1, md:'Project name or substring to match.' } } - }, - measure: { - desc: 'Start or stop measurement mode.', - mcpDesc: 'Activate measurement mode in the 3D viewport: measure distances (length), angles between surfaces, or areas. Use "stop" to exit measurement mode, "clear" to remove measurement annotations.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { mode: { t:'string', e:['length','angle','area','stop','clear'], r:1, md:'Measurement type, or "stop"/"clear" to exit/reset.' } } - }, - walk_mode: { - desc: 'Enter or exit walk mode.', - mcpDesc: 'Enter or exit first-person walk mode for navigating through the building model at human scale. Useful for understanding spatial relationships and clash locations in context.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - params: { enabled: { t:'boolean', r:1, md:'true to enter walk mode, false to exit.' } } - }, -}; - // ── MCP Server ──────────────────────────────────────────────────── -const MCP_INSTRUCTIONS = - 'ClashControl is a BIM (Building Information Modeling) clash detection tool running in the browser. ' + - 'Every tool call is relayed to the ClashControl web app via WebSocket — the browser must be open with the Smart Bridge addon enabled. ' + - 'Typical workflow: (1) get_status to confirm connection and see loaded IFC models, ' + - '(2) run_detection to find clashes between discipline groups (e.g. structural vs MEP), ' + - '(3) get_clashes to review results, (4) fly_to_clash to inspect individual collisions, ' + - '(5) update_clash or batch_update_clashes to triage. ' + - 'Hard clashes = physical intersections. Soft clashes = clearance violations within a gap tolerance (mm). ' + - 'Always start with get_status to verify the browser is connected and models are loaded.'; - const server = new McpServer({ name: 'ClashControl', version: VERSION }, { instructions: MCP_INSTRUCTIONS }); -// ── Register tools from shared TOOLS definition ── - -for (const [name, tool] of Object.entries(TOOLS)) { - const schema = {}; - for (const [pn, pd] of Object.entries(tool.params)) { - if (pd.e) schema[pn] = pd.r ? z.enum(pd.e) : z.enum(pd.e).optional(); - else if (pd.t === 'number') schema[pn] = pd.r ? z.number() : z.number().optional(); - else if (pd.t === 'boolean') schema[pn] = pd.r ? z.boolean() : z.boolean().optional(); - else schema[pn] = pd.r ? z.string() : z.string().optional(); - const paramDesc = pd.md || pd.d; - if (paramDesc && schema[pn].describe) schema[pn] = schema[pn].describe(paramDesc); - } - - server.registerTool(name, { - description: tool.mcpDesc || tool.desc, - inputSchema: Object.keys(schema).length > 0 ? schema : undefined, - annotations: tool.annotations - }, async (params) => { - try { - const result = await sendToBrowser(name, params); - return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; - } catch (e) { - return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true }; - } - }); -} - -// ── MCP Resources ── - -server.registerResource('status', 'clashcontrol://status', { - description: 'Current ClashControl state: loaded IFC models, clash count, active project, detection rules, browser connection status.', - mimeType: 'application/json' -}, async (uri) => { - try { - const result = await sendToBrowser('get_status', {}); - return { contents: [{ uri: uri.href, mimeType: 'application/json', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; - } catch (e) { - return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: e.message, connected: false }) }] }; - } -}); - -server.registerResource('clash-summary', 'clashcontrol://clash-summary', { - description: 'High-level clash summary: total counts by status, type, and priority.', - mimeType: 'application/json' -}, async (uri) => { - try { - const result = await sendToBrowser('get_clashes', { status: 'all', limit: 500 }); - const clashes = Array.isArray(result) ? result : (result && result.clashes) || []; - const summary = { total: clashes.length, byStatus: {}, byType: {}, byPriority: {} }; - for (const c of clashes) { - if (c.status) summary.byStatus[c.status] = (summary.byStatus[c.status] || 0) + 1; - if (c.type) summary.byType[c.type] = (summary.byType[c.type] || 0) + 1; - if (c.priority) summary.byPriority[c.priority] = (summary.byPriority[c.priority] || 0) + 1; - } - return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(summary, null, 2) }] }; - } catch (e) { - return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: e.message }) }] }; - } -}); - -// ── MCP Prompts ── - -server.registerPrompt('analyze-clash-report', { - description: 'Systematic analysis of current clash detection results with triage recommendations.' -}, async () => ({ - messages: [{ role: 'user', content: { type: 'text', text: - 'Analyze the current BIM clash detection results in ClashControl:\n\n' + - '1. Call get_status to confirm which IFC models are loaded and current detection settings.\n' + - '2. Call get_clashes with status "all" to retrieve the full clash list.\n' + - '3. Summarize: total clashes, breakdown by status (open vs resolved), by type (hard vs soft), by priority, and by discipline pairs.\n' + - '4. Identify patterns: clashes concentrated on specific storeys? Between specific disciplines? Clusters of duplicates?\n' + - '5. Recommend a triage strategy: which clashes to address first, which might be false positives, and which discipline teams should coordinate.\n' + - '6. If there are many duplicates, suggest using batch_update_clashes to resolve them before manual review.' - }}] -})); - -server.registerPrompt('investigate-clash', { - description: 'Deep-dive investigation of a specific clash with visual inspection and resolution advice.', - argsSchema: { clashIndex: z.string().describe('Zero-based index of the clash to investigate') } -}, async ({ clashIndex }) => ({ - messages: [{ role: 'user', content: { type: 'text', text: - 'Investigate clash #' + clashIndex + ' in ClashControl:\n\n' + - '1. Call fly_to_clash with clashIndex ' + clashIndex + ' to navigate the 3D view to this clash.\n' + - '2. Call get_clashes to get details about this clash (elements involved, type, distance, storey).\n' + - '3. Try different viewing angles: set_view with "front", "top", and "isometric".\n' + - '4. If elements are hard to see, try set_render_style with "wireframe" or color_by with "discipline".\n' + - '5. Explain what the two clashing elements are, why they might be colliding, and whether this is a real coordination issue or a modeling artifact.\n' + - '6. Suggest a resolution: flag as critical, assign to a discipline team, or resolve as false positive.' - }}] -})); - -server.registerPrompt('coordination-review', { - description: 'Discipline coordination checklist for BIM review meetings.', - argsSchema: { discipline: z.string().describe('Primary discipline to review, e.g. "structural", "mechanical", "electrical", "plumbing"') } -}, async ({ discipline }) => ({ - messages: [{ role: 'user', content: { type: 'text', text: - 'Run a coordination review for the ' + discipline + ' discipline:\n\n' + - '1. Call get_status to see which models and disciplines are loaded.\n' + - '2. Call get_clashes to find all clashes involving ' + discipline + ' elements.\n' + - '3. Use group_clashes with "discipline" to see which other disciplines clash most with ' + discipline + '.\n' + - '4. For each discipline pair, summarize: clash count, types (hard vs soft), affected storeys, severity.\n' + - '5. Identify the top 3 most critical coordination issues needing team discussion.\n' + - '6. Provide a checklist of action items for the ' + discipline + ' coordination lead.' - }}] -})); - -server.registerPrompt('compare-clash-runs', { - description: 'Compare current clash results to track coordination progress over time.' -}, async () => ({ - messages: [{ role: 'user', content: { type: 'text', text: - 'Compare clash detection results to track coordination progress:\n\n' + - '1. Call get_status to see the current project and detection settings.\n' + - '2. Call get_clashes with status "all" to get the complete clash list.\n' + - '3. Summarize: total clashes, open vs resolved, by type and priority.\n' + - '4. Compute the resolution rate: what percentage of clashes are resolved?\n' + - '5. Identify new open clashes with high/critical priority — these need immediate attention.\n' + - '6. Produce a progress report for a BIM coordination meeting: overall trend, improvements, remaining problem areas, recommended next steps.' - }}] -})); +registerMcpTools(server, z, sendToBrowser); +registerMcpResources(server, sendToBrowser); +registerMcpPrompts(server, z); // ── Start ─────────────────────────────────────────────────────────