From ee57d1281e6703faeaff6b049de85ba696315d28 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sat, 22 Feb 2025 23:38:17 +0000 Subject: [PATCH 01/10] initial commit of fusion mcp server --- mcp-servers/mcp-server-fusion/.gitignore | 38 +++++++++++ .../mcp-server-fusion/.vscode/launch.json | 15 +++++ .../mcp-server-fusion/.vscode/settings.json | 58 ++++++++++++++++ .../mcp-server-fusion/FusionMCPServerAddIn.py | 55 +++++++++++++++ mcp-servers/mcp-server-fusion/README.md | 67 +++++++++++++++++++ .../mcp-server-fusion/mcp_server/__init__.py | 6 ++ .../mcp-server-fusion/mcp_server/config.py | 15 +++++ .../mcp-server-fusion/mcp_server/server.py | 41 ++++++++++++ .../mcp-server-fusion/mcp_server/start.py | 33 +++++++++ .../mcp-server-fusion/requirements.txt | 6 ++ .../mcp-server-fusion/vendor/README.md | 9 +++ semantic-workbench.code-workspace | 6 +- 12 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 mcp-servers/mcp-server-fusion/.gitignore create mode 100644 mcp-servers/mcp-server-fusion/.vscode/launch.json create mode 100644 mcp-servers/mcp-server-fusion/.vscode/settings.json create mode 100644 mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py create mode 100644 mcp-servers/mcp-server-fusion/README.md create mode 100644 mcp-servers/mcp-server-fusion/mcp_server/__init__.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server/config.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server/server.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server/start.py create mode 100644 mcp-servers/mcp-server-fusion/requirements.txt create mode 100644 mcp-servers/mcp-server-fusion/vendor/README.md diff --git a/mcp-servers/mcp-server-fusion/.gitignore b/mcp-servers/mcp-server-fusion/.gitignore new file mode 100644 index 00000000..59bccd24 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/.gitignore @@ -0,0 +1,38 @@ +# Python files +__pycache__/ +*.py[cod] + +# Virtual environment +.venv + +# Poetry +poetry.lock + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache + +# Environment variables +.env diff --git a/mcp-servers/mcp-server-fusion/.vscode/launch.json b/mcp-servers/mcp-server-fusion/.vscode/launch.json new file mode 100644 index 00000000..739b29f5 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "debugpy", + "request": "launch", + "name": "mcp-servers: mcp-server-fusion", + "cwd": "${workspaceFolder}", + "module": "mcp_server.start", + "args": ["--transport", "sse", "--port", "6050"], + "consoleTitle": "mcp-server-fusion" + // "justMyCode": false // Set to false to debug external libraries + } + ] +} diff --git a/mcp-servers/mcp-server-fusion/.vscode/settings.json b/mcp-servers/mcp-server-fusion/.vscode/settings.json new file mode 100644 index 00000000..aab3597f --- /dev/null +++ b/mcp-servers/mcp-server-fusion/.vscode/settings.json @@ -0,0 +1,58 @@ +{ + "editor.bracketPairColorization.enabled": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll": "explicit" + }, + "editor.guides.bracketPairs": "active", + "editor.formatOnPaste": true, + "editor.formatOnType": true, + "editor.formatOnSave": true, + "files.eol": "\n", + "files.trimTrailingWhitespace": true, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "python.analysis.autoFormatStrings": true, + "python.analysis.autoImportCompletions": true, + "python.analysis.diagnosticMode": "workspace", + "python.analysis.fixAll": ["source.unusedImports"], + // Project specific paths + "python.analysis.ignore": ["libs"], + "python.analysis.inlayHints.functionReturnTypes": true, + "python.analysis.typeCheckingMode": "standard", + "python.defaultInterpreterPath": "${workspaceFolder}/.venv", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.unusedImports": "explicit", + "source.organizeImports": "explicit", + "source.formatDocument": "explicit" + } + }, + "ruff.nativeServer": "on", + "search.exclude": { + "**/.venv": true, + "**/.data": true + }, + // For use with optional extension: "streetsidesoftware.code-spell-checker" + "cSpell.ignorePaths": [ + ".git", + ".gitignore", + ".vscode", + ".venv", + "node_modules", + "package-lock.json", + "pyproject.toml", + "settings.json", + "uv.lock" + ], + "cSpell.words": ["dotenv", "fastmcp", "toplevel"] +} diff --git a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py new file mode 100644 index 00000000..2d806043 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py @@ -0,0 +1,55 @@ +import adsk.core, adsk.fusion, adsk.cam +import threading, time +import traceback +import sys, os + + + +# Global flag to signal the MCP server thread to stop. +mcp_running = True +mcp_thread = None + +def start_mcp_server(): + from .mcp_server.server import create_mcp_server # our existing function in mcp-server-fusion + try: + # Create the MCP server instance. + mcp = create_mcp_server() + # Set the server to use SSE transport and a chosen port. + # Here, we explicitly set the port in the server settings. + mcp.settings.port = 6050 + # Run the MCP server; this call will block until the server stops. + mcp.run(transport='sse') + except Exception as e: + app = adsk.core.Application.get() + ui = app.userInterface + ui.messageBox(f'Error in MCP server thread: {traceback.format_exc()}') + +def run(context): + global mcp_running, mcp_thread + app = adsk.core.Application.get() + ui = app.userInterface + try: + # Start the background thread to run the MCP server. + mcp_running = True + mcp_thread = threading.Thread(target=start_mcp_server, daemon=True) + mcp_thread.start() + ui.messageBox('Fusion MCP Server launched in background.') + except Exception as e: + ui.messageBox(f'Failed to start background MCP server: {e}') + +def stop(context): + global mcp_running, mcp_thread + app = adsk.core.Application.get() + ui = app.userInterface + try: + # Signal the background thread to stop. + mcp_running = False + # In our simple setup, assuming the MCP server checks for cancellation. + # If needed, you might adjust the FastMCP server to poll the mcp_running flag. + ui.messageBox('Fusion MCP Server add-in stopping. Please wait.') + # Optionally, join the thread briefly. + if mcp_thread is not None: + mcp_thread.join(5) + ui.messageBox('Fusion MCP Server add-in stopped.') + except Exception as e: + ui.messageBox(f'Error stopping add-in: {e}') \ No newline at end of file diff --git a/mcp-servers/mcp-server-fusion/README.md b/mcp-servers/mcp-server-fusion/README.md new file mode 100644 index 00000000..dde8c753 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/README.md @@ -0,0 +1,67 @@ +# Fusion MCP Server + +Fusion MCP Server for help creating 3D models + +This is a [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) server project. + +## Setup and Installation + +Simply run: + +```bash +pip install -r requirements.txt --target ./vendor +``` + +To create the virtual environment and install dependencies. + +### Running the Server + +Use the VSCode launch configuration, or run manually: + +Defaults to stdio transport: + +```bash +python -m mcp_server.start +``` + +For SSE transport: + +```bash +python -m mcp_server.start --transport sse --port 6050 +``` + +The SSE URL is: + +```bash +http://127.0.0.1:6050/sse +``` + +## Client Configuration + +To use this MCP server in your setup, consider the following configuration: + +### Stdio + +```json +{ + "mcpServers": { + "mcp-server-fusion": { + "command": "python", + "args": ["run", "-m", "mcp_server.start"] + } + } +} +``` + +### SSE + +```json +{ + "mcpServers": { + "mcp-server-fusion": { + "command": "http://127.0.0.1:6050/sse", + "args": [] + } + } +} +``` diff --git a/mcp-servers/mcp-server-fusion/mcp_server/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server/__init__.py new file mode 100644 index 00000000..a9a07f43 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server/__init__.py @@ -0,0 +1,6 @@ +import sys, os + +# Add the vendor directory to sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'vendor'))) +from . import config # Ensure relative imports for Fusion Add-In compatibility +settings = config.Settings() diff --git a/mcp-servers/mcp-server-fusion/mcp_server/config.py b/mcp-servers/mcp-server-fusion/mcp_server/config.py new file mode 100644 index 00000000..958674bd --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server/config.py @@ -0,0 +1,15 @@ +import os +from pydantic_settings import BaseSettings + +log_level = os.environ.get("LOG_LEVEL", "INFO") + +def load_required_env_var(env_var_name: str) -> str: + value = os.environ.get(env_var_name, "") + if not value: + raise ValueError(f"Missing required environment variable: {env_var_name}") + return value + + +class Settings(BaseSettings): + log_level: str = log_level + diff --git a/mcp-servers/mcp-server-fusion/mcp_server/server.py b/mcp-servers/mcp-server-fusion/mcp_server/server.py new file mode 100644 index 00000000..a4f1dc67 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server/server.py @@ -0,0 +1,41 @@ +from mcp.server.fastmcp import FastMCP +import adsk.core + + +from . import settings + +# Set the name of the MCP server +server_name = "Fusion MCP Server" + +def create_mcp_server() -> FastMCP: + + # Initialize FastMCP with debug logging. + mcp = FastMCP(name=server_name, log_level=settings.log_level) + + # Define each tool and its setup. + + # Fusion 360 Specific Tool - Create a Rectangle + @mcp.tool() + async def create_rectangle(x1: float, y1: float, x2: float, y2: float) -> str: + """Creates a rectangle on the XY plane in the open Fusion design. + + Parameters: + x1, y1 - bottom-left coordinates + x2, y2 - top-right coordinates + """ + # Get the Fusion 360 application and active design. + app = adsk.core.Application.get() + ui = app.userInterface + try: + design = app.activeProduct + rootComp = design.rootComponent + sketches = rootComp.sketches + xyPlane = rootComp.xYConstructionPlane + sketch = sketches.add(xyPlane) + lines = sketch.sketchCurves.sketchLines + lines.addTwoPointRectangle(adsk.core.Point3D.create(x1, y1, 0), + adsk.core.Point3D.create(x2, y2, 0)) + return "Rectangle created successfully." + except Exception as e: + return f"Error creating rectangle: {e}" + return mcp diff --git a/mcp-servers/mcp-server-fusion/mcp_server/start.py b/mcp-servers/mcp-server-fusion/mcp_server/start.py new file mode 100644 index 00000000..55ee00dc --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server/start.py @@ -0,0 +1,33 @@ +# Main entry point for the MCP Server + +import argparse + +from .server import create_mcp_server + + +def main() -> None: + # Command-line arguments for transport and port + parse_args = argparse.ArgumentParser(description="Start the MCP server.") + parse_args.add_argument( + "--transport", + default="stdio", + choices=["stdio", "sse"], + help="Transport protocol to use ('stdio' or 'sse'). Default is 'stdio'.", + ) + parse_args.add_argument( + "--port", + type=int, + default=8000, + help="Port to use for SSE (default is 8000)." + ) + args = parse_args.parse_args() + + mcp = create_mcp_server() + if args.transport == "sse": + mcp.settings.port = args.port + + mcp.run(transport=args.transport) + + +if __name__ == "__main__": + main() diff --git a/mcp-servers/mcp-server-fusion/requirements.txt b/mcp-servers/mcp-server-fusion/requirements.txt new file mode 100644 index 00000000..906b9b91 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/requirements.txt @@ -0,0 +1,6 @@ +adsk>=2.0.0 +mcp==1.3.0 +pydantic==2.10.6 +pydantic-settings==2.8.0 +anyio==4.8.0 +httpx==0.28.1 diff --git a/mcp-servers/mcp-server-fusion/vendor/README.md b/mcp-servers/mcp-server-fusion/vendor/README.md new file mode 100644 index 00000000..207dffa3 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/vendor/README.md @@ -0,0 +1,9 @@ +This directory contains vendored third-party dependencies for the Fusion MCP Add-In. + +Dependencies are included here to make the add-in portable and self-contained, since Fusion 360's embedded Python environment does not resolve external dependencies automatically. + +Install from project root: + +```bash +pip install -r requirements.txt --target ./vendor +``` diff --git a/semantic-workbench.code-workspace b/semantic-workbench.code-workspace index 335ced14..6fad6ec3 100644 --- a/semantic-workbench.code-workspace +++ b/semantic-workbench.code-workspace @@ -128,9 +128,13 @@ "path": "mcp-servers/ai-assist-content" }, { - "name": "mcp-server-bing-search", + "name": "mcp-servers:mcp-server-bing-search", "path": "mcp-servers/mcp-server-bing-search" }, + { + "name": "mcp-servers:mcp-server-fusion", + "path": "mcp-servers/mcp-server-fusion" + }, { "name": "mcp-servers:mcp-server-giphy", "path": "mcp-servers/mcp-server-giphy" From bc28f9ab9f525cc90d7447bae43d78d6704448e3 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 23 Feb 2025 00:42:00 +0000 Subject: [PATCH 02/10] pre-refactor save --- .../mcp-server-fusion/.vscode/settings.json | 9 ++++++++- .../mcp-server-fusion/FusionMCPServerAddIn.py | 7 ++----- .../mcp-server-fusion/mcp_server/config.py | 8 -------- .../mcp-server-fusion/mcp_server/server.py | 15 +++++++++++---- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/mcp-servers/mcp-server-fusion/.vscode/settings.json b/mcp-servers/mcp-server-fusion/.vscode/settings.json index aab3597f..625ed30e 100644 --- a/mcp-servers/mcp-server-fusion/.vscode/settings.json +++ b/mcp-servers/mcp-server-fusion/.vscode/settings.json @@ -54,5 +54,12 @@ "settings.json", "uv.lock" ], - "cSpell.words": ["dotenv", "fastmcp", "toplevel"] + "cSpell.words": [ + "anyio", + "dotenv", + "fastmcp", + "httpx", + "pydantic", + "toplevel" + ] } diff --git a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py index 2d806043..1986b2cf 100644 --- a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py +++ b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py @@ -1,9 +1,6 @@ import adsk.core, adsk.fusion, adsk.cam -import threading, time +import threading import traceback -import sys, os - - # Global flag to signal the MCP server thread to stop. mcp_running = True @@ -52,4 +49,4 @@ def stop(context): mcp_thread.join(5) ui.messageBox('Fusion MCP Server add-in stopped.') except Exception as e: - ui.messageBox(f'Error stopping add-in: {e}') \ No newline at end of file + ui.messageBox(f'Error stopping add-in: {e}') diff --git a/mcp-servers/mcp-server-fusion/mcp_server/config.py b/mcp-servers/mcp-server-fusion/mcp_server/config.py index 958674bd..fba1d82f 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server/config.py +++ b/mcp-servers/mcp-server-fusion/mcp_server/config.py @@ -3,13 +3,5 @@ log_level = os.environ.get("LOG_LEVEL", "INFO") -def load_required_env_var(env_var_name: str) -> str: - value = os.environ.get(env_var_name, "") - if not value: - raise ValueError(f"Missing required environment variable: {env_var_name}") - return value - - class Settings(BaseSettings): log_level: str = log_level - diff --git a/mcp-servers/mcp-server-fusion/mcp_server/server.py b/mcp-servers/mcp-server-fusion/mcp_server/server.py index a4f1dc67..b8c25952 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server/server.py +++ b/mcp-servers/mcp-server-fusion/mcp_server/server.py @@ -1,6 +1,6 @@ -from mcp.server.fastmcp import FastMCP import adsk.core +from mcp.server.fastmcp import FastMCP from . import settings @@ -28,14 +28,21 @@ async def create_rectangle(x1: float, y1: float, x2: float, y2: float) -> str: ui = app.userInterface try: design = app.activeProduct + if not design or not isinstance(design, adsk.fusion.Design): + return "No active Fusion design found." + rootComp = design.rootComponent sketches = rootComp.sketches xyPlane = rootComp.xYConstructionPlane + + # Create the rectangle sketch. sketch = sketches.add(xyPlane) lines = sketch.sketchCurves.sketchLines - lines.addTwoPointRectangle(adsk.core.Point3D.create(x1, y1, 0), - adsk.core.Point3D.create(x2, y2, 0)) - return "Rectangle created successfully." + lines.addTwoPointRectangle( + adsk.core.Point3D.create(x1, y1, 0), + adsk.core.Point3D.create(x2, y2, 0) + ) + return "Rectangle created successfully in the active design." except Exception as e: return f"Error creating rectangle: {e}" return mcp From 6888c622f448860e0b4c92ea4a8e5a6b38ddaa22 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 23 Feb 2025 11:46:27 +0000 Subject: [PATCH 03/10] functional multi-tool fusion --- .../mcp-server-fusion/.vscode/settings.json | 69 +-------- .../mcp-server-fusion/FusionMCPServerAddIn.py | 66 ++++++--- .../mcp-server-fusion/mcp_server/__init__.py | 8 +- .../mcp-server-fusion/mcp_server/config.py | 2 +- .../mcp-server-fusion/mcp_server/server.py | 139 +++++++++++++----- .../mcp_server/tools/__init__.py | 11 ++ .../mcp_server/tools/feature_tools.py | 40 +++++ .../mcp_server/tools/query_tools.py | 40 +++++ .../mcp_server/tools/sketch_tools.py | 60 ++++++++ 9 files changed, 313 insertions(+), 122 deletions(-) create mode 100644 mcp-servers/mcp-server-fusion/mcp_server/tools/__init__.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server/tools/feature_tools.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server/tools/query_tools.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server/tools/sketch_tools.py diff --git a/mcp-servers/mcp-server-fusion/.vscode/settings.json b/mcp-servers/mcp-server-fusion/.vscode/settings.json index 625ed30e..a2f36241 100644 --- a/mcp-servers/mcp-server-fusion/.vscode/settings.json +++ b/mcp-servers/mcp-server-fusion/.vscode/settings.json @@ -1,65 +1,10 @@ { - "editor.bracketPairColorization.enabled": true, - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit", - "source.fixAll": "explicit" - }, - "editor.guides.bracketPairs": "active", - "editor.formatOnPaste": true, - "editor.formatOnType": true, - "editor.formatOnSave": true, - "files.eol": "\n", - "files.trimTrailingWhitespace": true, - "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "python.analysis.autoFormatStrings": true, - "python.analysis.autoImportCompletions": true, - "python.analysis.diagnosticMode": "workspace", - "python.analysis.fixAll": ["source.unusedImports"], - // Project specific paths - "python.analysis.ignore": ["libs"], - "python.analysis.inlayHints.functionReturnTypes": true, - "python.analysis.typeCheckingMode": "standard", - "python.defaultInterpreterPath": "${workspaceFolder}/.venv", - "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll": "explicit", - "source.unusedImports": "explicit", - "source.organizeImports": "explicit", - "source.formatDocument": "explicit" - } - }, - "ruff.nativeServer": "on", - "search.exclude": { - "**/.venv": true, - "**/.data": true - }, - // For use with optional extension: "streetsidesoftware.code-spell-checker" - "cSpell.ignorePaths": [ - ".git", - ".gitignore", - ".vscode", - ".venv", - "node_modules", - "package-lock.json", - "pyproject.toml", - "settings.json", - "uv.lock" + "python.autoComplete.extraPaths": [ + "C:/Users/brkrabac/AppData/Roaming/Autodesk/Autodesk Fusion 360/API/Python/defs" ], - "cSpell.words": [ - "anyio", - "dotenv", - "fastmcp", - "httpx", - "pydantic", - "toplevel" - ] + "python.analysis.extraPaths": [ + "C:/Users/brkrabac/AppData/Roaming/Autodesk/Autodesk Fusion 360/API/Python/defs" + ], + "python.defaultInterpreterPath": "C:/Users/brkrabac/AppData/Local/Autodesk/webdeploy/production/ec15d50cfe0119bd0166ce9a1aa68bd8f670e085/Python/python.exe", + "cSpell.words": ["addin", "fastmcp", "levelname"] } diff --git a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py index 1986b2cf..5e0c6656 100644 --- a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py +++ b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py @@ -1,38 +1,62 @@ -import adsk.core, adsk.fusion, adsk.cam +import adsk.core +import adsk.fusion +import adsk.cam import threading -import traceback +import logging +import os +import tempfile # Global flag to signal the MCP server thread to stop. mcp_running = True mcp_thread = None -def start_mcp_server(): - from .mcp_server.server import create_mcp_server # our existing function in mcp-server-fusion - try: - # Create the MCP server instance. - mcp = create_mcp_server() - # Set the server to use SSE transport and a chosen port. - # Here, we explicitly set the port in the server settings. - mcp.settings.port = 6050 - # Run the MCP server; this call will block until the server stops. - mcp.run(transport='sse') - except Exception as e: - app = adsk.core.Application.get() - ui = app.userInterface - ui.messageBox(f'Error in MCP server thread: {traceback.format_exc()}') - def run(context): global mcp_running, mcp_thread app = adsk.core.Application.get() - ui = app.userInterface + ui = app.userInterface try: - # Start the background thread to run the MCP server. + # Set up logging with both file and stream handlers + log_path = os.path.join(tempfile.gettempdir(), 'fusion_addin.log') + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(log_path, mode='w'), + logging.StreamHandler() # This will print to system console + ], + force=True + ) + + ui.messageBox(f'Log file location: {log_path}') + logging.info('Starting MCP Server add-in') + + # Start the background thread mcp_running = True mcp_thread = threading.Thread(target=start_mcp_server, daemon=True) mcp_thread.start() - ui.messageBox('Fusion MCP Server launched in background.') + logging.info('Background thread started') + except Exception as e: - ui.messageBox(f'Failed to start background MCP server: {e}') + logging.error(f'Failed to start: {str(e)}', exc_info=True) + ui.messageBox(f'Failed to start: {str(e)}') + +def start_mcp_server(): + try: + logging.info('MCP server thread initializing') + from .mcp_server.server import create_mcp_server + + logging.info('Creating MCP server') + mcp = create_mcp_server() + + logging.info('Configuring MCP server') + mcp.settings.port = 6050 + + logging.info(f'Starting MCP server on port {mcp.settings.port}') + mcp.run(transport='sse') + + except Exception: + logging.error('Error in MCP server thread', exc_info=True) + # Don't use UI messages in the thread def stop(context): global mcp_running, mcp_thread diff --git a/mcp-servers/mcp-server-fusion/mcp_server/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server/__init__.py index a9a07f43..d00736d8 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server/__init__.py +++ b/mcp-servers/mcp-server-fusion/mcp_server/__init__.py @@ -1,6 +1,8 @@ -import sys, os +# import os +# import sys + +# # Add the vendor directory to sys.path +# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'vendor'))) -# Add the vendor directory to sys.path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'vendor'))) from . import config # Ensure relative imports for Fusion Add-In compatibility settings = config.Settings() diff --git a/mcp-servers/mcp-server-fusion/mcp_server/config.py b/mcp-servers/mcp-server-fusion/mcp_server/config.py index fba1d82f..92324c55 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server/config.py +++ b/mcp-servers/mcp-server-fusion/mcp_server/config.py @@ -1,5 +1,5 @@ import os -from pydantic_settings import BaseSettings +from ..vendor.pydantic_settings import BaseSettings log_level = os.environ.get("LOG_LEVEL", "INFO") diff --git a/mcp-servers/mcp-server-fusion/mcp_server/server.py b/mcp-servers/mcp-server-fusion/mcp_server/server.py index b8c25952..4f9d8bae 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server/server.py +++ b/mcp-servers/mcp-server-fusion/mcp_server/server.py @@ -1,6 +1,9 @@ -import adsk.core -from mcp.server.fastmcp import FastMCP +from textwrap import dedent +from typing import Annotated + +from ..vendor.mcp.server.fastmcp import FastMCP +from ..vendor.pydantic import Field from . import settings @@ -12,37 +15,103 @@ def create_mcp_server() -> FastMCP: # Initialize FastMCP with debug logging. mcp = FastMCP(name=server_name, log_level=settings.log_level) - # Define each tool and its setup. - - # Fusion 360 Specific Tool - Create a Rectangle - @mcp.tool() - async def create_rectangle(x1: float, y1: float, x2: float, y2: float) -> str: - """Creates a rectangle on the XY plane in the open Fusion design. - - Parameters: - x1, y1 - bottom-left coordinates - x2, y2 - top-right coordinates - """ - # Get the Fusion 360 application and active design. - app = adsk.core.Application.get() - ui = app.userInterface - try: - design = app.activeProduct - if not design or not isinstance(design, adsk.fusion.Design): - return "No active Fusion design found." - - rootComp = design.rootComponent - sketches = rootComp.sketches - xyPlane = rootComp.xYConstructionPlane - - # Create the rectangle sketch. - sketch = sketches.add(xyPlane) - lines = sketch.sketchCurves.sketchLines - lines.addTwoPointRectangle( - adsk.core.Point3D.create(x1, y1, 0), - adsk.core.Point3D.create(x2, y2, 0) - ) - return "Rectangle created successfully in the active design." - except Exception as e: - return f"Error creating rectangle: {e}" + # Import and register tools + from .tools import ( + create_sketch_tool, + add_geometry_to_sketch_tool, + extrude_profile_tool, + list_design_elements_tool, + measure_feature_tool + ) + + @mcp.tool( + name="create_sketch_tool", + description="Creates a new sketch on a specified plane." + ) + async def create_sketch( + plane: Annotated[ + str, + Field(description="The plane on which to create the sketch (XY, XZ, or YZ).") + ], + sketch_name: Annotated[ + str, + Field(description="Optional name for the sketch (default: None).", default=None) + ] = None + ) -> str: + return create_sketch_tool(plane, sketch_name) + + @mcp.tool( + name="add_geometry_to_sketch_tool", + description="Adds geometry to an existing sketch." + ) + async def add_geometry_to_sketch( + sketch_id: Annotated[ + str, + Field(description="The identifier of the target sketch.") + ], + geometry: Annotated[ + dict, + Field(description=dedent(""" + A dictionary describing the type ('rectangle', 'circle') and parameters. + Use arrays for coordinates. + For example: + { + 'type': 'rectangle', + 'corner1': [x1, y1], + 'corner2': [x2, y2] + } + or + { + 'type': 'circle', + 'center': [xc, yc], + 'radius': r + } + """)) + ] + ) -> str: + return add_geometry_to_sketch_tool(sketch_id, geometry) + + @mcp.tool( + name="extrude_profile_tool", + description="Extrudes a closed profile from a sketch to create or modify a 3D feature." + ) + async def extrude_profile( + sketch_id: Annotated[ + str, + Field(description="The identifier of the sketch containing the profile.") + ], + profile_index: Annotated[ + int, + Field(description="The index of the profile in the sketch to extrude.") + ], + distance: Annotated[ + float, + Field(description="Extrusion distance (positive = join).") + ], + operation: Annotated[ + str, + Field(description="Type of operation ('join', 'cut', or 'newBody').") + ] + ) -> str: + return extrude_profile_tool(sketch_id, profile_index, distance, operation) + + @mcp.tool( + name="list_design_elements_tool", + description="Lists key design elements such as sketches and bodies." + ) + async def list_design_elements() -> dict: + return list_design_elements_tool() + + @mcp.tool( + name="measure_feature_tool", + description="Measures a given feature in the design." + ) + async def measure_feature( + object_id: Annotated[ + str, + Field(description="ID of the target object.") + ] + ) -> dict: + return measure_feature_tool(object_id) + return mcp diff --git a/mcp-servers/mcp-server-fusion/mcp_server/tools/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server/tools/__init__.py new file mode 100644 index 00000000..e8f29c24 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server/tools/__init__.py @@ -0,0 +1,11 @@ +from .sketch_tools import create_sketch_tool, add_geometry_to_sketch_tool +from .feature_tools import extrude_profile_tool +from .query_tools import list_design_elements_tool, measure_feature_tool + +__all__ = [ + "create_sketch_tool", + "add_geometry_to_sketch_tool", + "extrude_profile_tool", + "list_design_elements_tool", + "measure_feature_tool" +] diff --git a/mcp-servers/mcp-server-fusion/mcp_server/tools/feature_tools.py b/mcp-servers/mcp-server-fusion/mcp_server/tools/feature_tools.py new file mode 100644 index 00000000..c018d2e9 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server/tools/feature_tools.py @@ -0,0 +1,40 @@ +import adsk.core + +# Tool: Extrude Profile +def extrude_profile_tool(sketch_id: str, profile_index: int, distance: float, operation: str) -> str: + """Extrudes a closed profile from a sketch to create or modify a 3D feature.""" + app = adsk.core.Application.get() + + try: + design = app.activeProduct + if not isinstance(design, adsk.fusion.Design): + return "No active Fusion design found." + + rootComp = design.rootComponent + sketch = rootComp.sketches.itemByName(sketch_id) + if not sketch: + return f"Sketch with id '{sketch_id}' not found." + + profile = sketch.profiles[profile_index] + if not profile: + return f"Profile with index {profile_index} not found in sketch '{sketch_id}'." + + extrudes = rootComp.features.extrudeFeatures + distance_value = adsk.core.ValueInput.createByReal(distance) + + if operation == 'join': + operation_type = adsk.fusion.FeatureOperations.JoinFeatureOperation + elif operation == 'cut': + operation_type = adsk.fusion.FeatureOperations.CutFeatureOperation + elif operation == 'newBody': + operation_type = adsk.fusion.FeatureOperations.NewBodyFeatureOperation + else: + return "Invalid operation type. Choose 'join', 'cut', or 'newBody'." + + extrude_input = extrudes.createInput(profile, operation_type) + extrude_input.setDistanceExtent(False, distance_value) + extrudes.add(extrude_input) + + return "Profile extruded successfully." + except Exception as e: + return f"Error extruding profile: {e}" diff --git a/mcp-servers/mcp-server-fusion/mcp_server/tools/query_tools.py b/mcp-servers/mcp-server-fusion/mcp_server/tools/query_tools.py new file mode 100644 index 00000000..f8e9ba29 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server/tools/query_tools.py @@ -0,0 +1,40 @@ +import adsk.core + +# Tool: List Design Elements +def list_design_elements_tool() -> dict: + """Lists key design elements such as sketches and bodies.""" + app = adsk.core.Application.get() + + design = app.activeProduct + if not design or not isinstance(design, adsk.fusion.Design): + return {"error": "No active Fusion design found."} + + rootComp = design.rootComponent + elements = { + "sketches": [ + {"name": sketch.name, "index": i} for i, sketch in enumerate(rootComp.sketches) + ], + "bodies": [ + {"name": body.name, "index": i} for i, body in enumerate(rootComp.bRepBodies) + ] + } + return elements + +# Tool: Measure Feature +def measure_feature_tool(object_id: str) -> dict: + """Measures a given feature in the design.""" + app = adsk.core.Application.get() + design = app.activeProduct + + try: + # Example measurement logic (expand as needed): + selected_obj = design.find(object_id) + if not selected_obj: + return {"error": f"Object '{object_id}' not found."} + + # TODO: Here you might implement measurements for length, volume, etc. + # Currently placeholder for sample implementation. + return {"area": "N/A", "volume": "N/A"} + + except Exception as e: + return {"error": f"Error measuring feature: {e}"} \ No newline at end of file diff --git a/mcp-servers/mcp-server-fusion/mcp_server/tools/sketch_tools.py b/mcp-servers/mcp-server-fusion/mcp_server/tools/sketch_tools.py new file mode 100644 index 00000000..bce846cf --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server/tools/sketch_tools.py @@ -0,0 +1,60 @@ +import adsk.core + +# Tool: Create Sketch +def create_sketch_tool(plane: str, sketch_name: str = None) -> str: + """Creates a new sketch on a specified plane.""" + app = adsk.core.Application.get() + + try: + design = app.activeProduct + if not isinstance(design, adsk.fusion.Design): + return "No active Fusion design found." + + rootComp = design.rootComponent + if plane == 'XY': + selected_plane = rootComp.xYConstructionPlane + elif plane == 'XZ': + selected_plane = rootComp.xZConstructionPlane + elif plane == 'YZ': + selected_plane = rootComp.yZConstructionPlane + else: + return "Invalid plane specified. Choose XY, XZ, or YZ." + + sketch = rootComp.sketches.add(selected_plane) + if sketch_name: + sketch.name = sketch_name + + return f"Sketch '{sketch.name}' created successfully." + except Exception as e: + return f"Error creating sketch: {e}" + +# Tool: Add Geometry to Sketch +def add_geometry_to_sketch_tool(sketch_id: str, geometry: dict) -> str: + """Adds geometry to an existing sketch.""" + app = adsk.core.Application.get() + + try: + design = app.activeProduct + if not isinstance(design, adsk.fusion.Design): + return "No active Fusion design found." + + rootComp = design.rootComponent + sketch = rootComp.sketches.itemByName(sketch_id) + if not sketch: + return f"Sketch with id '{sketch_id}' not found." + + sketch_curves = sketch.sketchCurves + if geometry['type'] == 'rectangle': + p1 = adsk.core.Point3D.create(geometry['corner1'][0], geometry['corner1'][1], 0) + p2 = adsk.core.Point3D.create(geometry['corner2'][0], geometry['corner2'][1], 0) + sketch_curves.sketchLines.addTwoPointRectangle(p1, p2) + elif geometry['type'] == 'circle': + center = adsk.core.Point3D.create(geometry['center'][0], geometry['center'][1], 0) + radius = geometry['radius'] + sketch_curves.sketchCircles.addByCenterRadius(center, radius) + else: + return "Unsupported geometry type. Supported types are 'rectangle' and 'circle'." + + return "Geometry added successfully." + except Exception as e: + return f"Error adding geometry to sketch: {e}" \ No newline at end of file From 14fe6d159822114cb9046f8b0c4658bd6b4adb45 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 23 Feb 2025 20:07:28 +0000 Subject: [PATCH 04/10] better for fusion env --- .../mcp-server-fusion/.vscode/launch.json | 19 ++ .../mcp-server-fusion/.vscode/settings.json | 9 +- mcp-servers/mcp-server-fusion/AddInIcon.svg | 23 +++ .../FusionMCPServerAddIn.manifest | 14 ++ .../mcp-server-fusion/FusionMCPServerAddIn.py | 86 +++++---- mcp-servers/mcp-server-fusion/README.md | 8 +- mcp-servers/mcp-server-fusion/config.py | 21 +++ .../mcp-server-fusion/mcp_server/__init__.py | 8 - .../mcp-server-fusion/mcp_server/server.py | 117 ------------ .../mcp_server_fusion/__init__.py | 8 + .../fusion_utils/__init__.py | 12 ++ .../fusion_utils/event_utils.py | 88 +++++++++ .../fusion_utils/general_utils.py | 64 +++++++ .../mcp_server_fusion/mcp_server/__init__.py | 2 + .../mcp_server/config.py | 0 .../mcp_server_fusion/mcp_server/server.py | 167 ++++++++++++++++++ .../mcp_server/start.py | 2 +- .../mcp_server/tools/__init__.py | 0 .../mcp_server/tools/feature_tools.py | 0 .../mcp_server/tools/query_tools.py | 0 .../mcp_server/tools/sketch_tools.py | 0 .../{ => mcp_server_fusion}/vendor/README.md | 0 .../mcp-server-fusion/requirements.txt | 1 + 23 files changed, 467 insertions(+), 182 deletions(-) create mode 100644 mcp-servers/mcp-server-fusion/AddInIcon.svg create mode 100644 mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.manifest create mode 100644 mcp-servers/mcp-server-fusion/config.py delete mode 100644 mcp-servers/mcp-server-fusion/mcp_server/__init__.py delete mode 100644 mcp-servers/mcp-server-fusion/mcp_server/server.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/__init__.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/event_utils.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/general_utils.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/__init__.py rename mcp-servers/mcp-server-fusion/{ => mcp_server_fusion}/mcp_server/config.py (100%) create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/server.py rename mcp-servers/mcp-server-fusion/{ => mcp_server_fusion}/mcp_server/start.py (92%) rename mcp-servers/mcp-server-fusion/{ => mcp_server_fusion}/mcp_server/tools/__init__.py (100%) rename mcp-servers/mcp-server-fusion/{ => mcp_server_fusion}/mcp_server/tools/feature_tools.py (100%) rename mcp-servers/mcp-server-fusion/{ => mcp_server_fusion}/mcp_server/tools/query_tools.py (100%) rename mcp-servers/mcp-server-fusion/{ => mcp_server_fusion}/mcp_server/tools/sketch_tools.py (100%) rename mcp-servers/mcp-server-fusion/{ => mcp_server_fusion}/vendor/README.md (100%) diff --git a/mcp-servers/mcp-server-fusion/.vscode/launch.json b/mcp-servers/mcp-server-fusion/.vscode/launch.json index 739b29f5..baa79fcb 100644 --- a/mcp-servers/mcp-server-fusion/.vscode/launch.json +++ b/mcp-servers/mcp-server-fusion/.vscode/launch.json @@ -10,6 +10,25 @@ "args": ["--transport", "sse", "--port", "6050"], "consoleTitle": "mcp-server-fusion" // "justMyCode": false // Set to false to debug external libraries + }, + { + "name": "mcp-servers: mcp-server-fusion (attach)", + "type": "python", + "request": "attach", + "pathMappings": [ + { + "localRoot": "${workspaceRoot}", + "remoteRoot": "${workspaceRoot}" + } + ], + "osx": { + "filePath": "${file}" + }, + "windows": { + "filePath": "${file}" + }, + "port": 9000, + "host": "localhost" } ] } diff --git a/mcp-servers/mcp-server-fusion/.vscode/settings.json b/mcp-servers/mcp-server-fusion/.vscode/settings.json index a2f36241..3d80b90d 100644 --- a/mcp-servers/mcp-server-fusion/.vscode/settings.json +++ b/mcp-servers/mcp-server-fusion/.vscode/settings.json @@ -1,10 +1,3 @@ { - "python.autoComplete.extraPaths": [ - "C:/Users/brkrabac/AppData/Roaming/Autodesk/Autodesk Fusion 360/API/Python/defs" - ], - "python.analysis.extraPaths": [ - "C:/Users/brkrabac/AppData/Roaming/Autodesk/Autodesk Fusion 360/API/Python/defs" - ], - "python.defaultInterpreterPath": "C:/Users/brkrabac/AppData/Local/Autodesk/webdeploy/production/ec15d50cfe0119bd0166ce9a1aa68bd8f670e085/Python/python.exe", - "cSpell.words": ["addin", "fastmcp", "levelname"] + "cSpell.words": ["addin", "fastmcp", "futil", "levelname"] } diff --git a/mcp-servers/mcp-server-fusion/AddInIcon.svg b/mcp-servers/mcp-server-fusion/AddInIcon.svg new file mode 100644 index 00000000..32a14789 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/AddInIcon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.manifest b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.manifest new file mode 100644 index 00000000..77a2c1b1 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.manifest @@ -0,0 +1,14 @@ +{ + "autodeskProduct": "Fusion", + "type": "addin", + "id": "872f127f-65bc-4603-aa29-db6bd9823e6f", + "author": "Semantic Workbench Team", + "description": { + "": "Fusion MCP Server add-in for creating 3D models" + }, + "version": "0.1.0", + "runOnStartup": false, + "supportedOS": "windows|mac", + "editEnabled": true, + "iconFilename": "AddInIcon.svg" +} diff --git a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py index 5e0c6656..0e675e08 100644 --- a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py +++ b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py @@ -1,76 +1,74 @@ -import adsk.core -import adsk.fusion -import adsk.cam +import atexit import threading -import logging -import os -import tempfile +import signal -# Global flag to signal the MCP server thread to stop. +from .mcp_server_fusion.mcp_server.server import create_mcp_server +from .mcp_server_fusion import fusion_utils + + +# Global variables mcp_running = True mcp_thread = None +show_errors_in_ui = True + def run(context): global mcp_running, mcp_thread - app = adsk.core.Application.get() - ui = app.userInterface + try: - # Set up logging with both file and stream handlers - log_path = os.path.join(tempfile.gettempdir(), 'fusion_addin.log') - logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler(log_path, mode='w'), - logging.StreamHandler() # This will print to system console - ], - force=True - ) - - ui.messageBox(f'Log file location: {log_path}') - logging.info('Starting MCP Server add-in') + fusion_utils.log('Starting MCP Server add-in', force_console=True) # Start the background thread mcp_running = True mcp_thread = threading.Thread(target=start_mcp_server, daemon=True) mcp_thread.start() - logging.info('Background thread started') + fusion_utils.log('Background thread started') + + atexit.register(stop, None) + signal.signal(signal.SIGTERM, lambda sig, frame: stop(None)) + except Exception as e: - logging.error(f'Failed to start: {str(e)}', exc_info=True) - ui.messageBox(f'Failed to start: {str(e)}') + fusion_utils.handle_error('run', show_errors_in_ui) + fusion_utils.log(f'Failed to start: {str(e)}') + def start_mcp_server(): try: - logging.info('MCP server thread initializing') - from .mcp_server.server import create_mcp_server + fusion_utils.log('MCP server thread initializing') - logging.info('Creating MCP server') + fusion_utils.log('Creating MCP server') mcp = create_mcp_server() - logging.info('Configuring MCP server') + fusion_utils.log('Configuring MCP server') mcp.settings.port = 6050 - logging.info(f'Starting MCP server on port {mcp.settings.port}') + fusion_utils.log(f'Starting MCP server on port {mcp.settings.port}') mcp.run(transport='sse') - except Exception: - logging.error('Error in MCP server thread', exc_info=True) - # Don't use UI messages in the thread + except Exception as e: + fusion_utils.handle_error('start_mcp_server', show_errors_in_ui) + fusion_utils.log(f'Error in MCP server thread: {str(e)}') + def stop(context): - global mcp_running, mcp_thread - app = adsk.core.Application.get() - ui = app.userInterface + global mcp_running, mcp_thread, queue_listener + # app = adsk.core.Application.get() + # ui = app.userInterface try: - # Signal the background thread to stop. + # Remove all of the event handlers your app has created + fusion_utils.clear_handlers() + + # Signal the background thread to stop mcp_running = False - # In our simple setup, assuming the MCP server checks for cancellation. - # If needed, you might adjust the FastMCP server to poll the mcp_running flag. - ui.messageBox('Fusion MCP Server add-in stopping. Please wait.') - # Optionally, join the thread briefly. + fusion_utils.log('Stopping MCP Server add-in') + + # Stop the thread if mcp_thread is not None: mcp_thread.join(5) - ui.messageBox('Fusion MCP Server add-in stopped.') + + fusion_utils.log('MCP Server add-in stopped') + except Exception as e: - ui.messageBox(f'Error stopping add-in: {e}') + fusion_utils.handle_error('stop', show_errors_in_ui) + fusion_utils.log(f'Error stopping add-in: {str(e)}') diff --git a/mcp-servers/mcp-server-fusion/README.md b/mcp-servers/mcp-server-fusion/README.md index dde8c753..b25bae25 100644 --- a/mcp-servers/mcp-server-fusion/README.md +++ b/mcp-servers/mcp-server-fusion/README.md @@ -9,7 +9,7 @@ This is a [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP Simply run: ```bash -pip install -r requirements.txt --target ./vendor +pip install -r requirements.txt --target ./mcp_server_fusion/vendor ``` To create the virtual environment and install dependencies. @@ -21,13 +21,13 @@ Use the VSCode launch configuration, or run manually: Defaults to stdio transport: ```bash -python -m mcp_server.start +python -m mcp_server_fusion.mcp_server.start ``` For SSE transport: ```bash -python -m mcp_server.start --transport sse --port 6050 +python -m mcp_server_fusion.mcp_server.start --transport sse --port 6050 ``` The SSE URL is: @@ -47,7 +47,7 @@ To use this MCP server in your setup, consider the following configuration: "mcpServers": { "mcp-server-fusion": { "command": "python", - "args": ["run", "-m", "mcp_server.start"] + "args": ["run", "-m", "mcp_server_fusion.mcp_server.start"] } } } diff --git a/mcp-servers/mcp-server-fusion/config.py b/mcp-servers/mcp-server-fusion/config.py new file mode 100644 index 00000000..9ded5c85 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/config.py @@ -0,0 +1,21 @@ +# Application Global Variables +# This module serves as a way to share variables across different +# modules (global variables). + +import os + +# Flag that indicates to run in Debug mode or not. When running in Debug mode +# more information is written to the Text Command window. Generally, it's useful +# to set this to True while developing an add-in and set it to False when you +# are ready to distribute it. +DEBUG = True + +# Gets the name of the add-in from the name of the folder the py file is in. +# This is used when defining unique internal names for various UI elements +# that need a unique name. It's also recommended to use a company name as +# part of the ID to better ensure the ID is unique. +ADDIN_NAME = os.path.basename(os.path.dirname(__file__)) +COMPANY_NAME = 'SEMANTIC_WORKBENCH' + +# Palettes +sample_palette_id = f'{COMPANY_NAME}_{ADDIN_NAME}_palette_id' \ No newline at end of file diff --git a/mcp-servers/mcp-server-fusion/mcp_server/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server/__init__.py deleted file mode 100644 index d00736d8..00000000 --- a/mcp-servers/mcp-server-fusion/mcp_server/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# import os -# import sys - -# # Add the vendor directory to sys.path -# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'vendor'))) - -from . import config # Ensure relative imports for Fusion Add-In compatibility -settings = config.Settings() diff --git a/mcp-servers/mcp-server-fusion/mcp_server/server.py b/mcp-servers/mcp-server-fusion/mcp_server/server.py deleted file mode 100644 index 4f9d8bae..00000000 --- a/mcp-servers/mcp-server-fusion/mcp_server/server.py +++ /dev/null @@ -1,117 +0,0 @@ - -from textwrap import dedent -from typing import Annotated - -from ..vendor.mcp.server.fastmcp import FastMCP -from ..vendor.pydantic import Field - -from . import settings - -# Set the name of the MCP server -server_name = "Fusion MCP Server" - -def create_mcp_server() -> FastMCP: - - # Initialize FastMCP with debug logging. - mcp = FastMCP(name=server_name, log_level=settings.log_level) - - # Import and register tools - from .tools import ( - create_sketch_tool, - add_geometry_to_sketch_tool, - extrude_profile_tool, - list_design_elements_tool, - measure_feature_tool - ) - - @mcp.tool( - name="create_sketch_tool", - description="Creates a new sketch on a specified plane." - ) - async def create_sketch( - plane: Annotated[ - str, - Field(description="The plane on which to create the sketch (XY, XZ, or YZ).") - ], - sketch_name: Annotated[ - str, - Field(description="Optional name for the sketch (default: None).", default=None) - ] = None - ) -> str: - return create_sketch_tool(plane, sketch_name) - - @mcp.tool( - name="add_geometry_to_sketch_tool", - description="Adds geometry to an existing sketch." - ) - async def add_geometry_to_sketch( - sketch_id: Annotated[ - str, - Field(description="The identifier of the target sketch.") - ], - geometry: Annotated[ - dict, - Field(description=dedent(""" - A dictionary describing the type ('rectangle', 'circle') and parameters. - Use arrays for coordinates. - For example: - { - 'type': 'rectangle', - 'corner1': [x1, y1], - 'corner2': [x2, y2] - } - or - { - 'type': 'circle', - 'center': [xc, yc], - 'radius': r - } - """)) - ] - ) -> str: - return add_geometry_to_sketch_tool(sketch_id, geometry) - - @mcp.tool( - name="extrude_profile_tool", - description="Extrudes a closed profile from a sketch to create or modify a 3D feature." - ) - async def extrude_profile( - sketch_id: Annotated[ - str, - Field(description="The identifier of the sketch containing the profile.") - ], - profile_index: Annotated[ - int, - Field(description="The index of the profile in the sketch to extrude.") - ], - distance: Annotated[ - float, - Field(description="Extrusion distance (positive = join).") - ], - operation: Annotated[ - str, - Field(description="Type of operation ('join', 'cut', or 'newBody').") - ] - ) -> str: - return extrude_profile_tool(sketch_id, profile_index, distance, operation) - - @mcp.tool( - name="list_design_elements_tool", - description="Lists key design elements such as sketches and bodies." - ) - async def list_design_elements() -> dict: - return list_design_elements_tool() - - @mcp.tool( - name="measure_feature_tool", - description="Measures a given feature in the design." - ) - async def measure_feature( - object_id: Annotated[ - str, - Field(description="ID of the target object.") - ] - ) -> dict: - return measure_feature_tool(object_id) - - return mcp diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/__init__.py new file mode 100644 index 00000000..1c3c9bf8 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/__init__.py @@ -0,0 +1,8 @@ +import os +import sys + +# Add the vendor directory to sys.path so that packages like pydantic can be imported +vendor_path = os.path.join(os.path.dirname(__file__), 'vendor') +if vendor_path not in sys.path: + sys.path.insert(0, vendor_path) + \ No newline at end of file diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py new file mode 100644 index 00000000..8bc20916 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py @@ -0,0 +1,12 @@ +from .general_utils import log, handle_error +from .event_utils import ( + clear_handlers, + add_handler, +) + +__all__ = [ + 'log', + 'handle_error', + 'clear_handlers', + 'add_handler', +] diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/event_utils.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/event_utils.py new file mode 100644 index 00000000..97a09b0a --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/event_utils.py @@ -0,0 +1,88 @@ +# Copyright 2022 by Autodesk, Inc. +# Permission to use, copy, modify, and distribute this software in object code form +# for any purpose and without fee is hereby granted, provided that the above copyright +# notice appears in all copies and that both that copyright notice and the limited +# warranty and restricted rights notice below appear in all supporting documentation. +# +# AUTODESK PROVIDES THIS PROGRAM "AS IS" AND WITH ALL FAULTS. AUTODESK SPECIFICALLY +# DISCLAIMS ANY IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR USE. +# AUTODESK, INC. DOES NOT WARRANT THAT THE OPERATION OF THE PROGRAM WILL BE +# UNINTERRUPTED OR ERROR FREE. + +import sys +from typing import Callable + +import adsk.core +from .general_utils import handle_error + + +# Global Variable to hold Event Handlers +_handlers = [] + + +def add_handler( + event: adsk.core.Event, + callback: Callable, + *, + name: str = None, + local_handlers: list = None +): + """Adds an event handler to the specified event. + + Arguments: + event -- The event object you want to connect a handler to. + callback -- The function that will handle the event. + name -- A name to use in logging errors associated with this event. + Otherwise the name of the event object is used. This argument + must be specified by its keyword. + local_handlers -- A list of handlers you manage that is used to maintain + a reference to the handlers so they aren't released. + This argument must be specified by its keyword. If not + specified the handler is added to a global list and can + be cleared using the clear_handlers function. You may want + to maintain your own handler list so it can be managed + independently for each command. + + :returns: + The event handler that was created. You don't often need this reference, but it can be useful in some cases. + """ + module = sys.modules[event.__module__] + handler_type = module.__dict__[event.add.__annotations__['handler']] + handler = _create_handler(handler_type, callback, event, name, local_handlers) + event.add(handler) + return handler + + +def clear_handlers(): + """Clears the global list of handlers. + """ + global _handlers + _handlers = [] + + +def _create_handler( + handler_type, + callback: Callable, + event: adsk.core.Event, + name: str = None, + local_handlers: list = None +): + handler = _define_handler(handler_type, callback, name)() + (local_handlers if local_handlers is not None else _handlers).append(handler) + return handler + + +def _define_handler(handler_type, callback, name: str = None): + name = name or handler_type.__name__ + + class Handler(handler_type): + def __init__(self): + super().__init__() + + def notify(self, args): + try: + callback(args) + except: + handle_error(name) + + return Handler diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/general_utils.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/general_utils.py new file mode 100644 index 00000000..03d66d67 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/general_utils.py @@ -0,0 +1,64 @@ +# Copyright 2022 by Autodesk, Inc. +# Permission to use, copy, modify, and distribute this software in object code form +# for any purpose and without fee is hereby granted, provided that the above copyright +# notice appears in all copies and that both that copyright notice and the limited +# warranty and restricted rights notice below appear in all supporting documentation. +# +# AUTODESK PROVIDES THIS PROGRAM "AS IS" AND WITH ALL FAULTS. AUTODESK SPECIFICALLY +# DISCLAIMS ANY IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR USE. +# AUTODESK, INC. DOES NOT WARRANT THAT THE OPERATION OF THE PROGRAM WILL BE +# UNINTERRUPTED OR ERROR FREE. + +# import os +import traceback +import adsk.core + +app = adsk.core.Application.get() +ui = app.userInterface + +# Attempt to read DEBUG flag from parent config. +try: + from ... import config + DEBUG = config.DEBUG +except Exception: + DEBUG = False + + +def log(message: str, level: adsk.core.LogLevels = adsk.core.LogLevels.InfoLogLevel, force_console: bool = False): + """Utility function to easily handle logging in your app. + + Arguments: + message -- The message to log. + level -- The logging severity level. + force_console -- Forces the message to be written to the Text Command window. + """ + # Always print to console, only seen through IDE. + print(message) + + # Log all errors to Fusion log file. + if level == adsk.core.LogLevels.ErrorLogLevel: + log_type = adsk.core.LogTypes.FileLogType + app.log(message, level, log_type) + + # If config.DEBUG is True write all log messages to the console. + if DEBUG or force_console: + log_type = adsk.core.LogTypes.ConsoleLogType + app.log(message, level, log_type) + + +def handle_error(name: str, show_message_box: bool = False): + """Utility function to simplify error handling. + + Arguments: + name -- A name used to label the error. + show_message_box -- Indicates if the error should be shown in the message box. + If False, it will only be shown in the Text Command window + and logged to the log file. + """ + + log('===== Error =====', adsk.core.LogLevels.ErrorLogLevel) + log(f'{name}\n{traceback.format_exc()}', adsk.core.LogLevels.ErrorLogLevel) + + # If desired you could show an error as a message box. + if show_message_box: + ui.messageBox(f'{name}\n{traceback.format_exc()}') diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/__init__.py new file mode 100644 index 00000000..d04494ac --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/__init__.py @@ -0,0 +1,2 @@ +from . import config +settings = config.Settings() diff --git a/mcp-servers/mcp-server-fusion/mcp_server/config.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/config.py similarity index 100% rename from mcp-servers/mcp-server-fusion/mcp_server/config.py rename to mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/config.py diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/server.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/server.py new file mode 100644 index 00000000..aa391f68 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/server.py @@ -0,0 +1,167 @@ +import asyncio + +from ..vendor.mcp.server.fastmcp import FastMCP + +from .. import fusion_utils + +from . import settings + + +# Set the name of the MCP server +server_name = "Fusion MCP Server" + + +async def run_tool_async( + tool_func, *args, **kwargs +) -> str: + """ + Runs a tool function asynchronously. + + Args: + tool_func (function): The tool function to run. + *args: Positional arguments for the tool function. + **kwargs: Keyword arguments for the tool function. + + Returns: + str: Result of the tool function. + """ + + try: + fusion_utils.log(f"Running tool: {tool_func.__name__}, args: {args}, kwargs: {kwargs}") + result = await asyncio.to_thread(tool_func, *args, **kwargs) + + fusion_utils.log(f"Tool result: {result}") + return result + + except Exception as e: + fusion_utils.handle_error(tool_func.__name__, settings.show_errors_in_ui) + fusion_utils.log(f"Error in tool {tool_func.__name__}: {str(e)}") + raise + + +def create_mcp_server() -> FastMCP: + + # Initialize FastMCP with debug logging. + mcp = FastMCP(name=server_name, log_level=settings.log_level) + + # Import and register tools + from .tools import ( + create_sketch_tool, + add_geometry_to_sketch_tool, + extrude_profile_tool, + list_design_elements_tool, + measure_feature_tool + ) + + @mcp.tool() + async def create_sketch( + plane: str, + sketch_name: str = None + ) -> str: + """ + Creates a new sketch on the specified plane. + + Args: + plane (str): The plane on which to create the sketch (XY, XZ, or YZ). + sketch_name (str, optional): Optional name for the sketch. Defaults to None. + Returns: + str: Success or error message. + """ + return await run_tool_async( + tool_func=create_sketch_tool, + plane=plane, + sketch_name=sketch_name + ) + + @mcp.tool() + async def add_geometry_to_sketch( + sketch_id: str, + geometry: dict + ) -> str: + """ + Adds geometry to an existing sketch. + + For rectangles, provide 'corner1' and 'corner2' as [x, y] arrays. + For circles, provide 'center' as [x, y] and 'radius'. + + Example: + { + 'type': 'rectangle', + 'corner1': [0, 0], + 'corner2': [10, 5] + } + or + { + 'type': 'circle', + 'center': [5, 5], + 'radius': 3 + } + + Args: + sketch_id (str): The identifier of the target sketch. + geometry (dict): Geometry details. + Returns: + str: Success or error message. + """ + return await run_tool_async( + tool_func=add_geometry_to_sketch_tool, + sketch_id=sketch_id, + geometry=geometry + ) + + @mcp.tool() + async def extrude_profile( + sketch_id: str, + profile_index: int, + distance: float, + operation: str + ) -> str: + """ + Extrudes a closed profile from a sketch to create or modify a 3D feature. + + Args: + sketch_id (str): The identifier of the sketch containing the profile. + profile_index (int): The index of the profile in the sketch to extrude. + distance (float): Extrusion distance (positive = join). + operation (str): Type of operation ('join', 'cut', or 'newBody'). + Returns: + str: Success or error message. + """ + return await run_tool_async( + tool_func=extrude_profile_tool, + sketch_id=sketch_id, + profile_index=profile_index, + distance=distance, + operation=operation + ) + + @mcp.tool() + async def list_design_elements() -> dict: + """ + Lists key design elements such as sketches and bodies. + + Returns: + dict: Dictionary containing lists of sketches and bodies. + """ + return await run_tool_async( + tool_func=list_design_elements_tool + ) + + @mcp.tool() + async def measure_feature( + object_id: str + ) -> dict: + """ + Measures a given feature in the design. + + Args: + object_id (str): ID of the target object. + Returns: + dict: Measurement results. + """ + return await run_tool_async( + tool_func=measure_feature_tool, + object_id=object_id + ) + + return mcp diff --git a/mcp-servers/mcp-server-fusion/mcp_server/start.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/start.py similarity index 92% rename from mcp-servers/mcp-server-fusion/mcp_server/start.py rename to mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/start.py index 55ee00dc..e0efc9b5 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server/start.py +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/start.py @@ -2,7 +2,7 @@ import argparse -from .server import create_mcp_server +from mcp_server_fusion.mcp_server.server import create_mcp_server def main() -> None: diff --git a/mcp-servers/mcp-server-fusion/mcp_server/tools/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/__init__.py similarity index 100% rename from mcp-servers/mcp-server-fusion/mcp_server/tools/__init__.py rename to mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/__init__.py diff --git a/mcp-servers/mcp-server-fusion/mcp_server/tools/feature_tools.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/feature_tools.py similarity index 100% rename from mcp-servers/mcp-server-fusion/mcp_server/tools/feature_tools.py rename to mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/feature_tools.py diff --git a/mcp-servers/mcp-server-fusion/mcp_server/tools/query_tools.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/query_tools.py similarity index 100% rename from mcp-servers/mcp-server-fusion/mcp_server/tools/query_tools.py rename to mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/query_tools.py diff --git a/mcp-servers/mcp-server-fusion/mcp_server/tools/sketch_tools.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/sketch_tools.py similarity index 100% rename from mcp-servers/mcp-server-fusion/mcp_server/tools/sketch_tools.py rename to mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/sketch_tools.py diff --git a/mcp-servers/mcp-server-fusion/vendor/README.md b/mcp-servers/mcp-server-fusion/mcp_server_fusion/vendor/README.md similarity index 100% rename from mcp-servers/mcp-server-fusion/vendor/README.md rename to mcp-servers/mcp-server-fusion/mcp_server_fusion/vendor/README.md diff --git a/mcp-servers/mcp-server-fusion/requirements.txt b/mcp-servers/mcp-server-fusion/requirements.txt index 906b9b91..84056878 100644 --- a/mcp-servers/mcp-server-fusion/requirements.txt +++ b/mcp-servers/mcp-server-fusion/requirements.txt @@ -4,3 +4,4 @@ pydantic==2.10.6 pydantic-settings==2.8.0 anyio==4.8.0 httpx==0.28.1 +debugpy>=1.8.12 From 95e3db1633c73c2837c863bf4174b2ae910600ee Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 24 Feb 2025 01:56:55 +0000 Subject: [PATCH 05/10] updates to latest mcp python pattern --- .../assistant/response/utils/openai_utils.py | 1 + .../assistant_extensions/mcp/_server_utils.py | 2 +- .../mcp-server-fusion/FusionMCPServerAddIn.py | 63 ++++--- .../mcp_server_fusion/fusion_mcp_server.py | 151 ++++++++++++++++ .../fusion_utils/__init__.py | 20 ++- .../fusion_utils/tool_utils.py | 68 +++++++ .../mcp_server_fusion/mcp_server/__init__.py | 2 - .../mcp_server_fusion/mcp_server/config.py | 7 - .../mcp_server_fusion/mcp_server/server.py | 167 ------------------ .../mcp_server_fusion/mcp_server/start.py | 33 ---- .../mcp_server/tools/__init__.py | 11 -- .../mcp_server/tools/feature_tools.py | 40 ----- .../mcp_server/tools/query_tools.py | 40 ----- .../mcp_server/tools/sketch_tools.py | 60 ------- .../mcp_server_fusion/mcp_tools/__init__.py | 11 ++ .../mcp_tools/fusion_3d_operation.py | 119 +++++++++++++ .../mcp_tools/fusion_geometry.py | 123 +++++++++++++ .../mcp_tools/fusion_pattern.py | 63 +++++++ .../mcp_tools/fusion_sketch.py | 98 ++++++++++ 19 files changed, 686 insertions(+), 393 deletions(-) create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_mcp_server.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py delete mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/__init__.py delete mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/config.py delete mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/server.py delete mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/start.py delete mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/__init__.py delete mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/feature_tools.py delete mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/query_tools.py delete mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/sketch_tools.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/__init__.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_geometry.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_pattern.py create mode 100644 mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_sketch.py diff --git a/assistants/codespace-assistant/assistant/response/utils/openai_utils.py b/assistants/codespace-assistant/assistant/response/utils/openai_utils.py index d99ba500..ea9224c2 100644 --- a/assistants/codespace-assistant/assistant/response/utils/openai_utils.py +++ b/assistants/codespace-assistant/assistant/response/utils/openai_utils.py @@ -67,6 +67,7 @@ async def get_completion( completion_args["tools"] = tools or NotGiven() if tools is not None: completion_args["tool_choice"] = "auto" + completion_args["parallel_tool_calls"] = False logger.debug( dedent(f""" diff --git a/libraries/python/assistant-extensions/assistant_extensions/mcp/_server_utils.py b/libraries/python/assistant-extensions/assistant_extensions/mcp/_server_utils.py index 8a5abb84..c056a44a 100644 --- a/libraries/python/assistant-extensions/assistant_extensions/mcp/_server_utils.py +++ b/libraries/python/assistant-extensions/assistant_extensions/mcp/_server_utils.py @@ -72,7 +72,7 @@ async def connect_to_mcp_server_sse( # FIXME: Bumping timeout to 15 minutes, but this should be configurable async with sse_client( - url=server_config.command, headers=headers, sse_read_timeout=60 * 15 + url=server_config.command, headers=headers, timeout=60 * 5, sse_read_timeout=60 * 15 ) as ( read_stream, write_stream, diff --git a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py index 0e675e08..32a52419 100644 --- a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py +++ b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py @@ -2,13 +2,14 @@ import threading import signal -from .mcp_server_fusion.mcp_server.server import create_mcp_server -from .mcp_server_fusion import fusion_utils +from .mcp_server_fusion.fusion_mcp_server import FusionMCPServer +from .mcp_server_fusion.fusion_utils import log, handle_error # Global variables mcp_running = True mcp_thread = None +fusion_mcp_server = None show_errors_in_ui = True @@ -16,59 +17,65 @@ def run(context): global mcp_running, mcp_thread try: - fusion_utils.log('Starting MCP Server add-in', force_console=True) + log('Starting MCP Server add-in') # Start the background thread + log('Starting background thread') mcp_running = True mcp_thread = threading.Thread(target=start_mcp_server, daemon=True) mcp_thread.start() - fusion_utils.log('Background thread started') + log('Background thread started') atexit.register(stop, None) signal.signal(signal.SIGTERM, lambda sig, frame: stop(None)) except Exception as e: - fusion_utils.handle_error('run', show_errors_in_ui) - fusion_utils.log(f'Failed to start: {str(e)}') + handle_error('run', show_errors_in_ui) + log(f'Failed to start: {str(e)}') -def start_mcp_server(): +def start_mcp_server(port: int = 6050): + global fusion_mcp_server + try: - fusion_utils.log('MCP server thread initializing') - - fusion_utils.log('Creating MCP server') - mcp = create_mcp_server() - - fusion_utils.log('Configuring MCP server') - mcp.settings.port = 6050 - - fusion_utils.log(f'Starting MCP server on port {mcp.settings.port}') - mcp.run(transport='sse') + log('Creating MCP server') + fusion_mcp_server = FusionMCPServer(port) + + log(f'Starting MCP server on port {port}') + fusion_mcp_server.start() + log('MCP server thread started') except Exception as e: - fusion_utils.handle_error('start_mcp_server', show_errors_in_ui) - fusion_utils.log(f'Error in MCP server thread: {str(e)}') + handle_error('start_mcp_server', show_errors_in_ui) + log(f'Error in MCP server thread: {str(e)}') def stop(context): - global mcp_running, mcp_thread, queue_listener - # app = adsk.core.Application.get() - # ui = app.userInterface + global mcp_running, mcp_thread, fusion_mcp_server + try: - # Remove all of the event handlers your app has created - fusion_utils.clear_handlers() + log('Stopping MCP Server add-in') # Signal the background thread to stop mcp_running = False - fusion_utils.log('Stopping MCP Server add-in') + log('Signaling background thread to stop') + + if fusion_mcp_server is not None: + fusion_mcp_server.shutdown() + log('MCP server stopped') + else: + log('No MCP server to stop') # Stop the thread if mcp_thread is not None: mcp_thread.join(5) + log('Background thread stopped') + else: + log('No background thread to stop') - fusion_utils.log('MCP Server add-in stopped') + log('MCP Server add-in stopped') except Exception as e: - fusion_utils.handle_error('stop', show_errors_in_ui) - fusion_utils.log(f'Error stopping add-in: {str(e)}') + handle_error('stop', show_errors_in_ui) + log(f'Error stopping add-in: {str(e)}') diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_mcp_server.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_mcp_server.py new file mode 100644 index 00000000..8ccb65b7 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_mcp_server.py @@ -0,0 +1,151 @@ +import asyncio + +from .vendor import anyio +from .vendor.mcp.server.fastmcp import FastMCP + +from .mcp_tools import ( + Fusion3DOperationTools, + FusionGeometryTools, + FusionPatternTools, + FusionSketchTools, +) + +def custom_exception_handler(loop, context): + """ + Custom exception handler for the event loop. + Ignores certain exceptions that occur during normal client disconnects. + """ + exception = context.get("exception") + if isinstance(exception, (anyio.BrokenResourceError, anyio.WouldBlock, asyncio.CancelledError)): + # These exceptions are expected during disconnections or cancellation. + # You can log a warning if desired, or simply ignore. + print("Warning (ignored):", exception) + else: + # For all other exceptions, use the default handler. + loop.default_exception_handler(context) + +class FusionMCPServer: + def __init__(self, port: int): + self.running = False + self.mcp = FastMCP(name="Fusion MCP Server", log_level="DEBUG") + self.mcp.settings.port = port + + # Register tools + Fusion3DOperationTools().register_tools(self.mcp) + FusionGeometryTools().register_tools(self.mcp) + FusionPatternTools().register_tools(self.mcp) + FusionSketchTools().register_tools(self.mcp) + + async def _run_server(self): + try: + await self.mcp.run_sse_async() + except (asyncio.CancelledError, anyio.BrokenResourceError) as e: + # This block might still be hit on shutdown, so we can ignore these exceptions. + print("Server run cancelled or broken:", e) + except Exception as e: + # Re-raise unexpected exceptions. + raise e + + def start(self): + self.running = True + self.loop = asyncio.new_event_loop() + # Set the custom exception handler on this loop. + self.loop.set_exception_handler(custom_exception_handler) + asyncio.set_event_loop(self.loop) + self.task = self.loop.create_task(self._run_server()) + try: + self.loop.run_forever() + except Exception as e: + print("Exception in run_forever:", e) + finally: + self.loop.close() + + def shutdown(self): + if self.loop and self.loop.is_running(): + self.task.cancel() + try: + self.loop.run_until_complete(self.task) + except (asyncio.CancelledError, anyio.BrokenResourceError): + pass + self.loop.call_soon_threadsafe(self.loop.stop) + self.running = False + + +# import asyncio +# import time +# import subprocess +# import re + +# from .vendor.mcp.server.fastmcp import FastMCP +# from .mcp_tools import ( +# Fusion3DOperationTools, +# FusionGeometryTools, +# FusionPatternTools, +# FusionSketchTools, +# ) + + +# def kill_process_on_port(port): +# # FIXME: End processes on port +# return +# try: +# # Run netstat to get the list of active connections and listening ports +# output = subprocess.check_output("netstat -ano", shell=True, text=True) +# # Iterate over each line of the netstat output +# for line in output.splitlines(): +# # Look for lines that contain the specified port and are in the LISTENING state +# if re.search(rf":{port}\s+", line) and "LISTENING" in line: +# parts = line.split() +# pid = parts[-1] # PID is the last element on the line +# # Forcefully kill the process using taskkill +# subprocess.run(f"taskkill /PID {pid} /F", shell=True) +# print(f"Killed process {pid} on port {port}") +# except Exception as e: +# print(f"Error killing process on port {port}: {e}") + + + +# class FusionMCPServer: +# def __init__(self, port: int): +# self.running = False + +# # Initialize MCP server +# self.mcp = FastMCP(name="Fusion MCP Server", log_level="DEBUG") +# self.mcp.settings.port = port + +# # Register tools +# Fusion3DOperationTools().register_tools(self.mcp) +# FusionGeometryTools().register_tools(self.mcp) +# FusionPatternTools().register_tools(self.mcp) +# FusionSketchTools().register_tools(self.mcp) + +# async def cleanup(self): +# # Perform any necessary cleanup here +# pass + +# def start(self): +# self.running = True +# self.loop = asyncio.new_event_loop() +# asyncio.set_event_loop(self.loop) +# # Create a task for the uvicorn-based server +# self.task = self.loop.create_task(self.mcp.run_sse_async()) +# try: +# self.loop.run_forever() +# except Exception as e: +# print(f"Exception in server loop: {e}") +# finally: +# self.loop.close() + +# # Example shutdown method in your FusionMCPServer class: +# def shutdown(self): +# if self.loop and self.loop.is_running(): +# # Cancel the running uvicorn server task +# self.task.cancel() +# # Stop the event loop +# self.loop.call_soon_threadsafe(self.loop.stop) +# self.running = False +# # Give a moment for shutdown to process +# time.sleep(1) +# # Kill any lingering process on the port +# kill_process_on_port(self.mcp.settings.port) + diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py index 8bc20916..0b453a1f 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py @@ -2,11 +2,23 @@ from .event_utils import ( clear_handlers, add_handler, -) +) +from .tool_utils import ( + errorHandler, + FusionContext, + GeometryValidator, + get_sketch_by_name, + UnitsConverter, +) __all__ = [ - 'log', - 'handle_error', - 'clear_handlers', 'add_handler', + 'clear_handlers', + 'errorHandler', + 'FusionContext', + 'GeometryValidator', + 'get_sketch_by_name', + 'handle_error', + 'log', + 'UnitsConverter', ] diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py new file mode 100644 index 00000000..7d4b4f77 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py @@ -0,0 +1,68 @@ +import adsk.core +import adsk.fusion +from functools import wraps + + +class FusionContext: + """Utility class to manage Fusion 360 application context""" + + @property + def app(self) -> adsk.core.Application: + return adsk.core.Application.get() + + @property + def design(self) -> adsk.fusion.Design: + if not self.app.activeProduct: + raise RuntimeError('No active product') + if not isinstance(self.app.activeProduct, adsk.fusion.Design): + raise RuntimeError('Active product is not a Fusion design') + + return self.app.activeProduct + + @property + def rootComp(self) -> adsk.fusion.Component: + return self.design.rootComponent + +def get_sketch_by_name(name: str | None) -> adsk.fusion.Sketch | None: + """Get a sketch by its name""" + if not name: + return None + + ctx = FusionContext() + return ctx.rootComp.sketches.itemByName(name) + +def errorHandler(func: callable) -> callable: + """Decorator to handle Fusion 360 API errors""" + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + return f"Tool {func.__name__} error: {str(e)}" + return wrapper + + +class UnitsConverter: + """Handles unit conversion between different measurement systems using static calls.""" + + @staticmethod + def mmToInternal(mm_value: float) -> float: + return mm_value / 10.0 + + @staticmethod + def internalToMm(internal_value: float) -> float: + return internal_value * 10.0 + +class GeometryValidator: + """Validates geometry inputs for common operations""" + + @staticmethod + def validatePoint(point: list[float]) -> None: + if len(point) != 3: + raise ValueError("Point must contain three coordinates (x, y, z)") + + @staticmethod + def validateVector(vector: list[float]) -> None: + if len(vector) != 3: + raise ValueError("Vector must contain three components (x, y, z)") + diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/__init__.py deleted file mode 100644 index d04494ac..00000000 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from . import config -settings = config.Settings() diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/config.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/config.py deleted file mode 100644 index 92324c55..00000000 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/config.py +++ /dev/null @@ -1,7 +0,0 @@ -import os -from ..vendor.pydantic_settings import BaseSettings - -log_level = os.environ.get("LOG_LEVEL", "INFO") - -class Settings(BaseSettings): - log_level: str = log_level diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/server.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/server.py deleted file mode 100644 index aa391f68..00000000 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/server.py +++ /dev/null @@ -1,167 +0,0 @@ -import asyncio - -from ..vendor.mcp.server.fastmcp import FastMCP - -from .. import fusion_utils - -from . import settings - - -# Set the name of the MCP server -server_name = "Fusion MCP Server" - - -async def run_tool_async( - tool_func, *args, **kwargs -) -> str: - """ - Runs a tool function asynchronously. - - Args: - tool_func (function): The tool function to run. - *args: Positional arguments for the tool function. - **kwargs: Keyword arguments for the tool function. - - Returns: - str: Result of the tool function. - """ - - try: - fusion_utils.log(f"Running tool: {tool_func.__name__}, args: {args}, kwargs: {kwargs}") - result = await asyncio.to_thread(tool_func, *args, **kwargs) - - fusion_utils.log(f"Tool result: {result}") - return result - - except Exception as e: - fusion_utils.handle_error(tool_func.__name__, settings.show_errors_in_ui) - fusion_utils.log(f"Error in tool {tool_func.__name__}: {str(e)}") - raise - - -def create_mcp_server() -> FastMCP: - - # Initialize FastMCP with debug logging. - mcp = FastMCP(name=server_name, log_level=settings.log_level) - - # Import and register tools - from .tools import ( - create_sketch_tool, - add_geometry_to_sketch_tool, - extrude_profile_tool, - list_design_elements_tool, - measure_feature_tool - ) - - @mcp.tool() - async def create_sketch( - plane: str, - sketch_name: str = None - ) -> str: - """ - Creates a new sketch on the specified plane. - - Args: - plane (str): The plane on which to create the sketch (XY, XZ, or YZ). - sketch_name (str, optional): Optional name for the sketch. Defaults to None. - Returns: - str: Success or error message. - """ - return await run_tool_async( - tool_func=create_sketch_tool, - plane=plane, - sketch_name=sketch_name - ) - - @mcp.tool() - async def add_geometry_to_sketch( - sketch_id: str, - geometry: dict - ) -> str: - """ - Adds geometry to an existing sketch. - - For rectangles, provide 'corner1' and 'corner2' as [x, y] arrays. - For circles, provide 'center' as [x, y] and 'radius'. - - Example: - { - 'type': 'rectangle', - 'corner1': [0, 0], - 'corner2': [10, 5] - } - or - { - 'type': 'circle', - 'center': [5, 5], - 'radius': 3 - } - - Args: - sketch_id (str): The identifier of the target sketch. - geometry (dict): Geometry details. - Returns: - str: Success or error message. - """ - return await run_tool_async( - tool_func=add_geometry_to_sketch_tool, - sketch_id=sketch_id, - geometry=geometry - ) - - @mcp.tool() - async def extrude_profile( - sketch_id: str, - profile_index: int, - distance: float, - operation: str - ) -> str: - """ - Extrudes a closed profile from a sketch to create or modify a 3D feature. - - Args: - sketch_id (str): The identifier of the sketch containing the profile. - profile_index (int): The index of the profile in the sketch to extrude. - distance (float): Extrusion distance (positive = join). - operation (str): Type of operation ('join', 'cut', or 'newBody'). - Returns: - str: Success or error message. - """ - return await run_tool_async( - tool_func=extrude_profile_tool, - sketch_id=sketch_id, - profile_index=profile_index, - distance=distance, - operation=operation - ) - - @mcp.tool() - async def list_design_elements() -> dict: - """ - Lists key design elements such as sketches and bodies. - - Returns: - dict: Dictionary containing lists of sketches and bodies. - """ - return await run_tool_async( - tool_func=list_design_elements_tool - ) - - @mcp.tool() - async def measure_feature( - object_id: str - ) -> dict: - """ - Measures a given feature in the design. - - Args: - object_id (str): ID of the target object. - Returns: - dict: Measurement results. - """ - return await run_tool_async( - tool_func=measure_feature_tool, - object_id=object_id - ) - - return mcp diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/start.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/start.py deleted file mode 100644 index e0efc9b5..00000000 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/start.py +++ /dev/null @@ -1,33 +0,0 @@ -# Main entry point for the MCP Server - -import argparse - -from mcp_server_fusion.mcp_server.server import create_mcp_server - - -def main() -> None: - # Command-line arguments for transport and port - parse_args = argparse.ArgumentParser(description="Start the MCP server.") - parse_args.add_argument( - "--transport", - default="stdio", - choices=["stdio", "sse"], - help="Transport protocol to use ('stdio' or 'sse'). Default is 'stdio'.", - ) - parse_args.add_argument( - "--port", - type=int, - default=8000, - help="Port to use for SSE (default is 8000)." - ) - args = parse_args.parse_args() - - mcp = create_mcp_server() - if args.transport == "sse": - mcp.settings.port = args.port - - mcp.run(transport=args.transport) - - -if __name__ == "__main__": - main() diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/__init__.py deleted file mode 100644 index e8f29c24..00000000 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .sketch_tools import create_sketch_tool, add_geometry_to_sketch_tool -from .feature_tools import extrude_profile_tool -from .query_tools import list_design_elements_tool, measure_feature_tool - -__all__ = [ - "create_sketch_tool", - "add_geometry_to_sketch_tool", - "extrude_profile_tool", - "list_design_elements_tool", - "measure_feature_tool" -] diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/feature_tools.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/feature_tools.py deleted file mode 100644 index c018d2e9..00000000 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/feature_tools.py +++ /dev/null @@ -1,40 +0,0 @@ -import adsk.core - -# Tool: Extrude Profile -def extrude_profile_tool(sketch_id: str, profile_index: int, distance: float, operation: str) -> str: - """Extrudes a closed profile from a sketch to create or modify a 3D feature.""" - app = adsk.core.Application.get() - - try: - design = app.activeProduct - if not isinstance(design, adsk.fusion.Design): - return "No active Fusion design found." - - rootComp = design.rootComponent - sketch = rootComp.sketches.itemByName(sketch_id) - if not sketch: - return f"Sketch with id '{sketch_id}' not found." - - profile = sketch.profiles[profile_index] - if not profile: - return f"Profile with index {profile_index} not found in sketch '{sketch_id}'." - - extrudes = rootComp.features.extrudeFeatures - distance_value = adsk.core.ValueInput.createByReal(distance) - - if operation == 'join': - operation_type = adsk.fusion.FeatureOperations.JoinFeatureOperation - elif operation == 'cut': - operation_type = adsk.fusion.FeatureOperations.CutFeatureOperation - elif operation == 'newBody': - operation_type = adsk.fusion.FeatureOperations.NewBodyFeatureOperation - else: - return "Invalid operation type. Choose 'join', 'cut', or 'newBody'." - - extrude_input = extrudes.createInput(profile, operation_type) - extrude_input.setDistanceExtent(False, distance_value) - extrudes.add(extrude_input) - - return "Profile extruded successfully." - except Exception as e: - return f"Error extruding profile: {e}" diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/query_tools.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/query_tools.py deleted file mode 100644 index f8e9ba29..00000000 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/query_tools.py +++ /dev/null @@ -1,40 +0,0 @@ -import adsk.core - -# Tool: List Design Elements -def list_design_elements_tool() -> dict: - """Lists key design elements such as sketches and bodies.""" - app = adsk.core.Application.get() - - design = app.activeProduct - if not design or not isinstance(design, adsk.fusion.Design): - return {"error": "No active Fusion design found."} - - rootComp = design.rootComponent - elements = { - "sketches": [ - {"name": sketch.name, "index": i} for i, sketch in enumerate(rootComp.sketches) - ], - "bodies": [ - {"name": body.name, "index": i} for i, body in enumerate(rootComp.bRepBodies) - ] - } - return elements - -# Tool: Measure Feature -def measure_feature_tool(object_id: str) -> dict: - """Measures a given feature in the design.""" - app = adsk.core.Application.get() - design = app.activeProduct - - try: - # Example measurement logic (expand as needed): - selected_obj = design.find(object_id) - if not selected_obj: - return {"error": f"Object '{object_id}' not found."} - - # TODO: Here you might implement measurements for length, volume, etc. - # Currently placeholder for sample implementation. - return {"area": "N/A", "volume": "N/A"} - - except Exception as e: - return {"error": f"Error measuring feature: {e}"} \ No newline at end of file diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/sketch_tools.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/sketch_tools.py deleted file mode 100644 index bce846cf..00000000 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_server/tools/sketch_tools.py +++ /dev/null @@ -1,60 +0,0 @@ -import adsk.core - -# Tool: Create Sketch -def create_sketch_tool(plane: str, sketch_name: str = None) -> str: - """Creates a new sketch on a specified plane.""" - app = adsk.core.Application.get() - - try: - design = app.activeProduct - if not isinstance(design, adsk.fusion.Design): - return "No active Fusion design found." - - rootComp = design.rootComponent - if plane == 'XY': - selected_plane = rootComp.xYConstructionPlane - elif plane == 'XZ': - selected_plane = rootComp.xZConstructionPlane - elif plane == 'YZ': - selected_plane = rootComp.yZConstructionPlane - else: - return "Invalid plane specified. Choose XY, XZ, or YZ." - - sketch = rootComp.sketches.add(selected_plane) - if sketch_name: - sketch.name = sketch_name - - return f"Sketch '{sketch.name}' created successfully." - except Exception as e: - return f"Error creating sketch: {e}" - -# Tool: Add Geometry to Sketch -def add_geometry_to_sketch_tool(sketch_id: str, geometry: dict) -> str: - """Adds geometry to an existing sketch.""" - app = adsk.core.Application.get() - - try: - design = app.activeProduct - if not isinstance(design, adsk.fusion.Design): - return "No active Fusion design found." - - rootComp = design.rootComponent - sketch = rootComp.sketches.itemByName(sketch_id) - if not sketch: - return f"Sketch with id '{sketch_id}' not found." - - sketch_curves = sketch.sketchCurves - if geometry['type'] == 'rectangle': - p1 = adsk.core.Point3D.create(geometry['corner1'][0], geometry['corner1'][1], 0) - p2 = adsk.core.Point3D.create(geometry['corner2'][0], geometry['corner2'][1], 0) - sketch_curves.sketchLines.addTwoPointRectangle(p1, p2) - elif geometry['type'] == 'circle': - center = adsk.core.Point3D.create(geometry['center'][0], geometry['center'][1], 0) - radius = geometry['radius'] - sketch_curves.sketchCircles.addByCenterRadius(center, radius) - else: - return "Unsupported geometry type. Supported types are 'rectangle' and 'circle'." - - return "Geometry added successfully." - except Exception as e: - return f"Error adding geometry to sketch: {e}" \ No newline at end of file diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/__init__.py new file mode 100644 index 00000000..21b9de6e --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/__init__.py @@ -0,0 +1,11 @@ +from .fusion_3d_operation import Fusion3DOperationTools +from .fusion_geometry import FusionGeometryTools +from .fusion_pattern import FusionPatternTools +from .fusion_sketch import FusionSketchTools + +__all__ = [ + "Fusion3DOperationTools", + "FusionGeometryTools", + "FusionPatternTools", + "FusionSketchTools", +] diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py new file mode 100644 index 00000000..54f9ae88 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py @@ -0,0 +1,119 @@ +import adsk.core +import adsk.fusion + +from textwrap import dedent + +from ..fusion_utils import ( + errorHandler, + FusionContext, + GeometryValidator, +) +from ..vendor.mcp.server.fastmcp import FastMCP + + +class Fusion3DOperationTools: + def __init__(self): + self.ctx = FusionContext() + self.validator = GeometryValidator() + + def register_tools(self, mcp: FastMCP): + """ + Register tools with the MCP server. + """ + + @mcp.tool( + name="extrude", + description=dedent(""" + Creates an extrusion from a sketch profile. + + Args: + sketch_name (str): The name of the sketch containing the profile. + distance (float): The extrusion distance. + direction (list[float], optional): The extrusion direction (x,y,z). Defaults to None. + Returns: + str: The created body name. + """).strip(), + ) + @errorHandler + def extrude( + sketch_name: str, + distance: float, + direction: list[float] = None + ) -> str: + # Get the sketch by name + sketch = self.ctx.rootComp.sketches.itemByName(sketch_name) + if not sketch: + raise ValueError(f"Sketch '{sketch_name}' not found.") + + # Get the profile from sketch + profile = sketch.profiles.item(0) + + # Create extrusion input + extrudes = self.ctx.rootComp.features.extrudeFeatures + distance = adsk.core.ValueInput.createByReal(distance) + + # Set up the extrusion + extInput = extrudes.createInput(profile, adsk.fusion.FeatureOperations.NewBodyFeatureOperation) + if direction: + self.validator.validateVector(direction) + direction = adsk.core.Vector3D.create(*direction) + extInput.setDirectionAndDistance(direction, distance) + else: + extInput.setDistanceExtent(False, distance) + + # Create the extrusion + ext = extrudes.add(extInput) + return ext.bodies.item(0).name + + + @mcp.tool( + name="cut_extrude", + description=dedent(""" + Creates a cut extrusion from a sketch profile. + + Args: + sketch_name (str): The name of the sketch containing the profile. + distance (float): The extrusion distance. + target_body_name (str): The target body name. + direction (list[float], optional): The extrusion direction (x,y,z). Defaults to None. + Returns: + str: The created body name. + """).strip(), + ) + @errorHandler + def cut_extrude( + sketch_name: str, + distance: float, + target_body_name: str, + direction: list[float] = None + ) -> str: + # Get the sketch by name + sketch = self.ctx.rootComp.sketches.itemByName(sketch_name) + if not sketch: + raise ValueError(f"Sketch '{sketch_name}' not found.") + + # Get the target body by name + target_body = self.ctx.rootComp.bRepBodies.itemByName(target_body_name) + if not target_body: + raise ValueError(f"Target body '{target_body_name}' not found.") + + # Get the profile from sketch + profile = sketch.profiles.item(0) + + # Create extrusion input + extrudes = self.ctx.rootComp.features.extrudeFeatures + distance = adsk.core.ValueInput.createByReal(distance) + + # Set up the extrusion + extInput = extrudes.createInput(profile, adsk.fusion.FeatureOperations.CutFeatureOperation) + if direction: + self.validator.validateVector(direction) + direction = adsk.core.Vector3D.create(*direction) + extInput.setDirectionAndDistance(direction, distance) + else: + extInput.setDistanceExtent(False, distance) + extInput.setTargetBody(target_body) + + # Create the extrusion + ext = extrudes.add(extInput) + return ext.bodies.item(0).name diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_geometry.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_geometry.py new file mode 100644 index 00000000..84b43644 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_geometry.py @@ -0,0 +1,123 @@ +import adsk.core + +from textwrap import dedent +from typing import List + +from ..fusion_utils import ( + errorHandler, + FusionContext, + GeometryValidator, + get_sketch_by_name, +) +from ..vendor.mcp.server.fastmcp import FastMCP + + +class FusionGeometryTools: + def __init__(self): + self.ctx = FusionContext() + self.validator = GeometryValidator() + + def register_tools(self, mcp: FastMCP): + """ + Register tools with the MCP server. + """ + + @mcp.tool( + name="create_line", + description=dedent(""" + Creates a line between two points. + + Args: + start_point (list[float]): Start point of the line, e.g., [x, y, z]. + end_point (list[float]): End point of the line, e.g., [x, y, z]. + sketch_name (str, optional): The name of the sketch to create the line in. Defaults to None. + Returns: + bool: True if the line was created successfully. + """).strip(), + ) + @errorHandler + def createLine( + start_point: list[float], + end_point: list[float], + sketch_name: str = None + ) -> bool: + # Convert to adsk.core.Point3D + self.validator.validatePoint(start_point) + self.validator.validatePoint(end_point) + start_point = adsk.core.Point3D.create(*start_point) + end_point = adsk.core.Point3D.create(*end_point) + + # Create the line + sketch = get_sketch_by_name(sketch_name) + if sketch: + sketch_lines = sketch.sketchCurves.sketchLines + sketch_lines.addByTwoPoints(start_point, end_point) + else: + self.ctx.rootComp.constructionLines.addByTwoPoints(start_point, end_point) + return True + + @mcp.tool( + name="create_circle", + description=dedent(""" + Creates a circle. + + Args: + center (list[float]): Center point of the circle, e.g., [x, y, z]. + radius (float): Radius of the circle. + sketch_name (str, optional): The name of the sketch to create the circle in. Defaults to None. + Returns: + bool: True if the circle was created successfully. + """).strip(), + ) + @errorHandler + def createCircle( + center: list[float], + radius: float, + sketch_name: str = None + ) -> bool: + # Convert to adsk.core.Point3D + self.validator.validatePoint(center) + center = adsk.core.Point3D.create(*center) + + # Create the circle + sketch = get_sketch_by_name(sketch_name) + if sketch: + sketch_circles = sketch.sketchCurves.sketchCircles + sketch_circles.addByCenterRadius(center, radius) + else: + self.ctx.rootComp.constructionCircles.addByCenterRadius(center, radius) + return True + + @mcp.tool( + name="create_rectangle", + description=dedent(""" + Creates a rectangle between two points. + + Args: + point_1 (list[float]): First point of the rectangle, e.g., [x, y, z]. + point_2 (list[float]): Second point of the rectangle, e.g., [x, y, z]. + sketch_name (str, optional): The name of the sketch to create the rectangle in. Defaults to None. + Returns: + bool: True if the rectangle was created successfully. + """).strip(), + ) + @errorHandler + def createRectangle( + point_1: list[float], + point_2: list[float], + sketch_name: str = None + ) -> bool: + # Convert to adsk.core.Point3D + self.validator.validatePoint(point_1) + self.validator.validatePoint(point_2) + point_1 = adsk.core.Point3D.create(*point_1) + point_2 = adsk.core.Point3D.create(*point_2) + + # Create the rectangle + sketch = get_sketch_by_name(sketch_name) + if sketch: + sketch_lines = sketch.sketchCurves.sketchLines + sketch_lines.addTwoPointRectangle(point_1, point_2) + else: + sketch_lines = self.ctx.rootComp.constructionLines.addTwoPointRectangle(point_1, point_2) + return True diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_pattern.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_pattern.py new file mode 100644 index 00000000..8faeeb9a --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_pattern.py @@ -0,0 +1,63 @@ +import adsk.core + +from textwrap import dedent +from typing import List + +from ..fusion_utils import ( + errorHandler, + FusionContext, + GeometryValidator, +) +from ..vendor.mcp.server.fastmcp import FastMCP + + +class FusionPatternTools: + def __init__(self): + self.ctx = FusionContext() + self.validator = GeometryValidator() + + def register_tools(self, mcp: FastMCP): + """ + Register tools with the MCP server. + """ + + @mcp.tool( + name="rectangular_pattern", + description=dedent(""" + Creates a rectangular pattern of entities in the Fusion 360 workspace. + + Args: + entity_names (List[str]): List of entity names to be patterned. + xCount (int): Number of instances in the X direction. + xSpacing (float): Spacing between instances in the X direction. + yCount (int): Number of instances in the Y direction. + ySpacing (float): Spacing between instances in the Y direction. + Returns: + List[str]: List of patterned entity names. + """).strip(), + ) + @errorHandler + def rectangular_pattern( + entity_names: List[str], + xCount: int, + xSpacing: float, + yCount: int, + ySpacing: float, + ) -> List[str]: + # Get the entities by name + entities = [self.ctx.rootComp.bRepBodies.itemByName(name) for name in entity_names] + + # Create the pattern + pattern = self.ctx.rootComp.features.rectangularPatternFeatures + + patternInput = pattern.createInput(entities, adsk.fusion.PatternDistanceType.SpacingPatternDistanceType) + patternInput.directionOne = self.ctx.rootComp.xConstructionAxis + patternInput.directionTwo = self.ctx.rootComp.yConstructionAxis + patternInput.quantityOne = xCount + patternInput.quantityTwo = yCount + patternInput.spacingOne = adsk.core.ValueInput.createByReal(xSpacing) + patternInput.spacingTwo = adsk.core.ValueInput.createByReal(ySpacing) + + pattern.add(patternInput) + + return [entity.name for entity in entities] diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_sketch.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_sketch.py new file mode 100644 index 00000000..2a7176fb --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_sketch.py @@ -0,0 +1,98 @@ +from textwrap import dedent + +from ..fusion_utils import ( + errorHandler, + FusionContext, + GeometryValidator, + get_sketch_by_name, +) +from ..vendor.mcp.server.fastmcp import FastMCP + +class FusionSketchTools: + def __init__(self): + self.ctx = FusionContext() + self.validator = GeometryValidator() + + def register_tools(self, mcp: FastMCP): + """ + Register tools with the MCP server. + """ + + @mcp.tool( + name="sketches", + description=dedent(""" + Returns the names of all sketches in the root component. + + Returns: + list[str]: List of sketch names. + """).strip(), + ) + @errorHandler + def sketches() -> list[str]: + # Get all sketches in the root component + sketches = self.ctx.rootComp.sketches + return [sketch.name for sketch in sketches] + + @mcp.tool( + name="create_sketch", + description=dedent(""" + Creates a new sketch on the specified plane. + + Args: + plane (str): The plane to create the sketch on, e.g., "XY", "XZ", "YZ". + sketch_name (str, optional): The name of the sketch. Defaults to None. + Returns: + str: The created sketch name. + """).strip(), + ) + @errorHandler + def create_sketch( + plane: str, + sketch_name: str = None, + ) -> str: + # Create a new sketch on the specified plane + sketches = self.ctx.rootComp.sketches + + if plane == "XY": + plane = self.ctx.rootComp.xYConstructionPlane + elif plane == "XZ": + plane = self.ctx.rootComp.xZConstructionPlane + elif plane == "YZ": + plane = self.ctx.rootComp.yZConstructionPlane + else: + raise ValueError("Invalid plane specified.") + + # Create the sketch + sketch = sketches.add(plane) + if sketch_name: + sketch.name = sketch_name + return sketch.name + + @mcp.tool( + name="project_to_sketch", + description=dedent(""" + Projects an entity to the active sketch. + Args: + entity_name (str): The name of the entity to project. + sketch_name (str): The name of the sketch to project to. + Returns: + str: The name of the projected entity. + """).strip(), + ) + @errorHandler + def project_to_sketch( + entity_name: str, + sketch_name: str, + ) -> str: + # Get the active sketch and entity + sketch = get_sketch_by_name(sketch_name) + if not sketch: + raise ValueError(f"Sketch '{sketch_name}' not found.") + + entity = self.ctx.rootComp.bRepBodies.itemByName(entity_name) + if not entity: + raise ValueError(f"Entity '{entity_name}' not found.") + + # Project the entity to the sketch + projected_entity = sketch.project(entity) + return projected_entity.name From c38e43af4dbab332850570c6eee963377f1ce795 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 24 Feb 2025 07:23:42 +0000 Subject: [PATCH 06/10] better error handling --- .../assistant/response/completion_handler.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/assistants/codespace-assistant/assistant/response/completion_handler.py b/assistants/codespace-assistant/assistant/response/completion_handler.py index ab1bc283..626a52bd 100644 --- a/assistants/codespace-assistant/assistant/response/completion_handler.py +++ b/assistants/codespace-assistant/assistant/response/completion_handler.py @@ -182,7 +182,26 @@ async def on_logging_message(msg: str) -> None: ) except Exception as e: logger.exception(f"Error handling tool call: {e}") - return await handle_error("An error occurred while handling the tool call.") + # Don't end without sending an assistant message, otherwise the conversation will be stuck + deepmerge.always_merger.merge( + step_result.metadata, + { + "debug": { + f"{metadata_key}:request:tool_call_{tool_call_count}": { + "error": str(e), + }, + }, + }, + ) + await context.send_messages( + NewConversationMessage( + content="An error occurred while handling the tool call.", + message_type=MessageType.chat, + metadata=step_result.metadata, + ) + ) + step_result.status = "error" + return step_result # Update content and metadata with tool call result metadata deepmerge.always_merger.merge(step_result.metadata, tool_call_result.metadata) From 5632f558e9beb73534056fa839c32c1da254ba8b Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 24 Feb 2025 07:24:01 +0000 Subject: [PATCH 07/10] additional files --- .../assistant/response/completion_handler.py | 20 +- .../assistant/response/response.py | 35 ++- .../assistant_extensions/mcp/__init__.py | 3 +- .../assistant_extensions/mcp/_model.py | 2 + .../assistant_extensions/mcp/_server_utils.py | 85 ++++-- .../assistant_extensions/mcp/_tool_utils.py | 62 ++-- .../mcp-extensions/mcp_extensions/__init__.py | 8 +- .../mcp_extensions/_tool_utils.py | 21 +- .../mcp-server-fusion/FusionMCPServerAddIn.py | 162 +++++----- .../mcp_server_fusion/fusion_mcp_server.py | 287 ++++++++++-------- .../fusion_utils/__init__.py | 2 + .../fusion_utils/tool_utils.py | 20 ++ .../mcp_tools/fusion_3d_operation.py | 53 ++-- 13 files changed, 474 insertions(+), 286 deletions(-) diff --git a/assistants/codespace-assistant/assistant/response/completion_handler.py b/assistants/codespace-assistant/assistant/response/completion_handler.py index 626a52bd..23844f74 100644 --- a/assistants/codespace-assistant/assistant/response/completion_handler.py +++ b/assistants/codespace-assistant/assistant/response/completion_handler.py @@ -39,18 +39,6 @@ async def handle_completion( metadata_key: str, response_start_time: float, ) -> StepResult: - # helper function for handling errors - async def handle_error(error_message: str) -> StepResult: - await context.send_messages( - NewConversationMessage( - content=error_message, - message_type=MessageType.notice, - metadata=step_result.metadata, - ) - ) - step_result.status = "error" - return step_result - # get service and request configuration for generative model generative_request_config = request_config @@ -166,7 +154,6 @@ async def handle_error(error_message: str) -> StepResult: tool_call_count = 0 for tool_call in tool_calls: tool_call_count += 1 - tool_call_status = f"using tool `{tool_call.name}`" async with context.set_status(f"{tool_call_status}..."): @@ -181,8 +168,7 @@ async def on_logging_message(msg: str) -> None: on_logging_message, ) except Exception as e: - logger.exception(f"Error handling tool call: {e}") - # Don't end without sending an assistant message, otherwise the conversation will be stuck + logger.exception(f"Error handling tool call '{tool_call.name}': {e}") deepmerge.always_merger.merge( step_result.metadata, { @@ -195,8 +181,8 @@ async def on_logging_message(msg: str) -> None: ) await context.send_messages( NewConversationMessage( - content="An error occurred while handling the tool call.", - message_type=MessageType.chat, + content=f"Error executing tool '{tool_call.name}': {e}", + message_type=MessageType.notice, metadata=step_result.metadata, ) ) diff --git a/assistants/codespace-assistant/assistant/response/response.py b/assistants/codespace-assistant/assistant/response/response.py index 4123591f..61809ed7 100644 --- a/assistants/codespace-assistant/assistant/response/response.py +++ b/assistants/codespace-assistant/assistant/response/response.py @@ -3,7 +3,7 @@ from typing import Any, List from assistant_extensions.attachments import AttachmentsExtension -from assistant_extensions.mcp import MCPSession, establish_mcp_sessions, get_mcp_server_prompts +from assistant_extensions.mcp import MCPSession, establish_mcp_sessions, get_mcp_server_prompts, refresh_mcp_sessions from semantic_workbench_api_model.workbench_model import ( ConversationMessage, MessageType, @@ -33,16 +33,28 @@ async def respond_to_conversation( async with AsyncExitStack() as stack: # If tools are enabled, establish connections to the MCP servers mcp_sessions: List[MCPSession] = [] - if config.extensions_config.tools.enabled: - mcp_sessions = await establish_mcp_sessions(config.extensions_config.tools, stack) - if not mcp_sessions: - await context.send_messages( - NewConversationMessage( - content="Unable to connect to any MCP servers. Please ensure the servers are running.", - message_type=MessageType.notice, - metadata=metadata, - ) + + async def error_handler(server_config, error) -> None: + logger.error(f"Failed to connect to MCP server {server_config.key}: {error}") + # Also notify the user about this server failure here. + await context.send_messages( + NewConversationMessage( + content=f"Failed to connect to MCP server {server_config.key}: {error}", + message_type=MessageType.notice, + metadata=metadata, ) + ) + + if config.extensions_config.tools.enabled: + mcp_sessions = await establish_mcp_sessions( + tools_config=config.extensions_config.tools, + stack=stack, + error_handler=error_handler + ) + + if len(config.extensions_config.tools.mcp_servers) > 0 and len(mcp_sessions) == 0: + # No MCP servers are available, so we should not continue + logger.error("No MCP servers are available.") return # Retrieve prompts from the MCP servers @@ -77,6 +89,9 @@ async def respond_to_conversation( logger.info("Response interrupted.") break + # Reconnect to the MCP servers if they were disconnected + mcp_sessions = await refresh_mcp_sessions(mcp_sessions) + step_result = await next_step( mcp_sessions=mcp_sessions, mcp_prompts=mcp_prompts, diff --git a/libraries/python/assistant-extensions/assistant_extensions/mcp/__init__.py b/libraries/python/assistant-extensions/assistant_extensions/mcp/__init__.py index 7ea594cd..dfd5ac28 100644 --- a/libraries/python/assistant-extensions/assistant_extensions/mcp/__init__.py +++ b/libraries/python/assistant-extensions/assistant_extensions/mcp/__init__.py @@ -4,7 +4,7 @@ MCPSession, MCPToolsConfigModel, ) -from ._server_utils import establish_mcp_sessions, get_mcp_server_prompts +from ._server_utils import establish_mcp_sessions, get_mcp_server_prompts, refresh_mcp_sessions from ._tool_utils import handle_mcp_tool_call, retrieve_mcp_tools_from_sessions __all__ = [ @@ -15,5 +15,6 @@ "establish_mcp_sessions", "get_mcp_server_prompts", "handle_mcp_tool_call", + "refresh_mcp_sessions", "retrieve_mcp_tools_from_sessions", ] diff --git a/libraries/python/assistant-extensions/assistant_extensions/mcp/_model.py b/libraries/python/assistant-extensions/assistant_extensions/mcp/_model.py index 7b7f2c41..9323acb2 100644 --- a/libraries/python/assistant-extensions/assistant_extensions/mcp/_model.py +++ b/libraries/python/assistant-extensions/assistant_extensions/mcp/_model.py @@ -211,6 +211,7 @@ class MCPSession: config: MCPServerConfig client_session: ClientSession tools: List[Tool] = [] + is_connected: bool = True def __init__(self, config: MCPServerConfig, client_session: ClientSession) -> None: self.config = config @@ -220,6 +221,7 @@ async def initialize(self) -> None: # Load all tools from the session, later we can do the same for resources, prompts, etc. tools_result = await self.client_session.list_tools() self.tools = tools_result.tools + self.is_connected = True logger.debug( f"Loaded {len(tools_result.tools)} tools from session '{self.config.key}'" ) diff --git a/libraries/python/assistant-extensions/assistant_extensions/mcp/_server_utils.py b/libraries/python/assistant-extensions/assistant_extensions/mcp/_server_utils.py index c056a44a..9b1a0819 100644 --- a/libraries/python/assistant-extensions/assistant_extensions/mcp/_server_utils.py +++ b/libraries/python/assistant-extensions/assistant_extensions/mcp/_server_utils.py @@ -1,8 +1,7 @@ import logging from asyncio import CancelledError from contextlib import AsyncExitStack, asynccontextmanager -from typing import AsyncIterator, List, Optional - +from typing import AsyncIterator, Callable, List, Optional from mcp import ClientSession from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters, stdio_client @@ -70,7 +69,7 @@ async def connect_to_mcp_server_sse( ) headers = get_env_dict(server_config) - # FIXME: Bumping timeout to 15 minutes, but this should be configurable + # FIXME: Bumping sse_read_timeout to 15 minutes and timeout to 5 minutes, but this should be configurable async with sse_client( url=server_config.command, headers=headers, timeout=60 * 5, sse_read_timeout=60 * 15 ) as ( @@ -83,9 +82,13 @@ async def connect_to_mcp_server_sse( except ExceptionGroup as e: logger.exception(f"TaskGroup failed in SSE client for {server_config.key}: {e}") - for sub_extension in e.exceptions: - logger.error(f"Sub-exception: {server_config.key}: {sub_extension}") - raise + for sub in e.exceptions: + logger.error(f"Sub-exception: {server_config.key}: {sub}") + # If there's exactly one underlying exception, re-raise it + if len(e.exceptions) == 1: + raise e.exceptions[0] + else: + raise except CancelledError as e: logger.exception( f"Task was cancelled in SSE client for {server_config.key}: {e}" @@ -98,32 +101,74 @@ async def connect_to_mcp_server_sse( logger.exception(f"Error connecting to {server_config.key}: {e}") raise +async def refresh_mcp_sessions(mcp_sessions: list[MCPSession]) -> list[MCPSession]: + """ + Check each MCP session for connectivity. If a session is marked as disconnected, + attempt to reconnect it using reconnect_mcp_session. + """ + active_sessions = [] + for session in mcp_sessions: + if not session.is_connected: + logger.info(f"Session {session.config.key} is disconnected. Attempting to reconnect...") + new_session = await reconnect_mcp_session(session.config) + if new_session: + active_sessions.append(new_session) + else: + logger.error(f"Failed to reconnect MCP server {session.config.key}.") + else: + active_sessions.append(session) + return active_sessions -async def establish_mcp_sessions( - tools_config: MCPToolsConfigModel, stack: AsyncExitStack -) -> List[MCPSession]: + +async def reconnect_mcp_session(server_config: MCPServerConfig) -> MCPSession | None: """ - Establish connections to MCP servers using the provided AsyncExitStack. + Attempt to reconnect to the MCP server using the provided configuration. + Returns a new MCPSession if successful, or None otherwise. + This version relies directly on the existing connection context manager + to avoid interfering with cancel scopes. """ + try: + async with connect_to_mcp_server(server_config) as client_session: + if client_session is None: + logger.error(f"Reconnection returned no client session for {server_config.key}") + return None + + new_session = MCPSession(config=server_config, client_session=client_session) + await new_session.initialize() + new_session.is_connected = True + logger.info(f"Successfully reconnected to MCP server {server_config.key}") + return new_session + except Exception as e: + logger.exception(f"Error reconnecting MCP server {server_config.key}: {e}") + return None + +async def establish_mcp_sessions( + tools_config: MCPToolsConfigModel, stack: AsyncExitStack, error_handler: Optional[Callable] = None +) -> List[MCPSession]: mcp_sessions: List[MCPSession] = [] for server_config in tools_config.mcp_servers: - # Check to see if the server is enabled if not server_config.enabled: logger.debug(f"Skipping disabled server: {server_config.key}") continue + try: + client_session: ClientSession | None = await stack.enter_async_context( + connect_to_mcp_server(server_config) + ) + except Exception as e: + # Log a cleaner error message for this specific server + logger.error(f"Failed to connect to MCP server {server_config.key}: {e}") + # Also notify the user about this server failure here. + if error_handler: + await error_handler(server_config, e) + # Abort the connection attempt for the servers to avoid only partial server connections + # This could lead to assistant creatively trying to use the other tools to compensate + # for the missing tools, which can sometimes be very problematic. + return [] - client_session: ClientSession | None = await stack.enter_async_context( - connect_to_mcp_server(server_config) - ) if client_session: - # Create an MCP session with the client session - mcp_session = MCPSession( - config=server_config, client_session=client_session - ) - # Initialize the session to load tools, resources, etc. + mcp_session = MCPSession(config=server_config, client_session=client_session) await mcp_session.initialize() - # Add the session to the list of established sessions mcp_sessions.append(mcp_session) else: logger.warning(f"Could not establish session with {server_config.key}") diff --git a/libraries/python/assistant-extensions/assistant_extensions/mcp/_tool_utils.py b/libraries/python/assistant-extensions/assistant_extensions/mcp/_tool_utils.py index d930f16f..3e060e93 100644 --- a/libraries/python/assistant-extensions/assistant_extensions/mcp/_tool_utils.py +++ b/libraries/python/assistant-extensions/assistant_extensions/mcp/_tool_utils.py @@ -1,4 +1,5 @@ # utils/tool_utils.py +import asyncio import logging from textwrap import dedent from typing import AsyncGenerator, List @@ -6,7 +7,7 @@ import deepmerge from mcp import ServerNotification, Tool from mcp.types import CallToolResult, EmbeddedResource, ImageContent, TextContent -from mcp_extensions import execute_tool_with_notifications +from mcp_extensions import execute_tool_with_retries from ._model import ( ExtendedCallToolRequestParams, @@ -57,14 +58,8 @@ async def handle_mcp_tool_call( method_metadata_key: str, on_logging_message: OnMCPLoggingMessageHandler, ) -> ExtendedCallToolResult: - """ - Handle the tool call by invoking the appropriate tool and returning a ToolCallResult. - """ - - # Find the tool and session from the full collection of sessions - mcp_session, tool = get_mcp_session_and_tool_by_tool_name( - mcp_sessions, tool_call.name - ) + # Find the tool and session by tool name. + mcp_session, tool = get_mcp_session_and_tool_by_tool_name(mcp_sessions, tool_call.name) if not mcp_session or not tool: return ExtendedCallToolResult( @@ -72,16 +67,15 @@ async def handle_mcp_tool_call( content=[ TextContent( type="text", - text=f"Tool '{tool_call.name}' not found in any of the sessions.", + text=f"Tool '{tool_call.name}' not found in any session.", ) ], isError=True, metadata={}, ) - return await execute_tool( - mcp_session, tool_call, method_metadata_key, on_logging_message - ) + # Execute the tool call using our robust error-handling function. + return await execute_tool(mcp_session, tool_call, method_metadata_key, on_logging_message) async def handle_long_running_tool_call( @@ -145,7 +139,7 @@ async def execute_tool( # Initialize metadata metadata = {} - # Initialize tool_result + # Prepare to capture tool output tool_result = None tool_output: list[TextContent | ImageContent | EmbeddedResource] = [] content_items: List[str] = [] @@ -164,12 +158,33 @@ async def notification_handler(message: ServerNotification) -> None: logger.debug( f"Invoking '{mcp_session.config.key}.{tool_call.name}' with arguments: {tool_call.arguments}" ) - tool_result = await execute_tool_with_notifications( - mcp_session.client_session, tool_call_function, notification_handler - ) + + try: + tool_result = await execute_tool_with_retries( + mcp_session, tool_call_function, notification_handler, tool_call.name + ) + except asyncio.CancelledError: + raise + except Exception as e: + if isinstance(e, ExceptionGroup) and len(e.exceptions) == 1: + e = e.exceptions[0] + error_message = str(e).strip() or "Peer disconnected; no error message received." + # Check if the error indicates a disconnection. + if "peer closed connection" in error_message.lower(): + mcp_session.is_connected = False + logger.exception(f"Error executing tool '{tool_call.name}': {error_message}") + error_text = f"Tool '{tool_call.name}' failed with error: {error_message}" + return ExtendedCallToolResult( + id=tool_call.id, + content=[TextContent(type="text", text=error_text)], + isError=True, + metadata={"debug": {method_metadata_key: {"error": error_message}}}, + ) + + tool_output = tool_result.content - # Update metadata with tool result + # Merge debug metadata for the successful result deepmerge.always_merger.merge( metadata, { @@ -182,24 +197,25 @@ async def notification_handler(message: ServerNotification) -> None: ) # FIXME: for now, we'll just dump the tool output as text but we should support other content types + # Process tool output and convert to text content. for tool_output_item in tool_output: if isinstance(tool_output_item, TextContent): if tool_output_item.text.strip() != "": content_items.append(tool_output_item.text) - if isinstance(tool_output_item, ImageContent): + elif isinstance(tool_output_item, ImageContent): content_items.append(tool_output_item.model_dump_json()) - if isinstance(tool_output_item, EmbeddedResource): + elif isinstance(tool_output_item, EmbeddedResource): content_items.append(tool_output_item.model_dump_json()) - # Return the tool call result + # Return the successful tool call result return ExtendedCallToolResult( id=tool_call.id, content=[ TextContent( type="text", text="\n\n".join(content_items) - if len(content_items) > 0 - else "[tool call successful, but no output, this may indicate empty file, directory, etc.]", + if content_items + else "[tool call successful, but no output]", ), ], metadata=metadata, diff --git a/libraries/python/mcp-extensions/mcp_extensions/__init__.py b/libraries/python/mcp-extensions/mcp_extensions/__init__.py index a67d0dd5..3beac40a 100644 --- a/libraries/python/mcp-extensions/mcp_extensions/__init__.py +++ b/libraries/python/mcp-extensions/mcp_extensions/__init__.py @@ -1,5 +1,10 @@ from ._model import ServerNotificationHandler, ToolCallFunction, ToolCallProgressMessage -from ._tool_utils import convert_tools_to_openai_tools, execute_tool_with_notifications, send_tool_call_progress +from ._tool_utils import ( + convert_tools_to_openai_tools, + execute_tool_with_notifications, + execute_tool_with_retries, + send_tool_call_progress +) # Exported utilities and models for external use. # These components enhance interactions with MCP workflows by providing utilities for notifications, @@ -7,6 +12,7 @@ __all__ = [ "convert_tools_to_openai_tools", "execute_tool_with_notifications", + "execute_tool_with_retries", "send_tool_call_progress", "ServerNotificationHandler", "ToolCallFunction", diff --git a/libraries/python/mcp-extensions/mcp_extensions/_tool_utils.py b/libraries/python/mcp-extensions/mcp_extensions/_tool_utils.py index 08db3892..0ff3a6cd 100644 --- a/libraries/python/mcp-extensions/mcp_extensions/_tool_utils.py +++ b/libraries/python/mcp-extensions/mcp_extensions/_tool_utils.py @@ -1,9 +1,9 @@ # utils/tool_utils.py import asyncio +import deepmerge import logging -from typing import Any, List -import deepmerge +from typing import Any, List from mcp import ClientSession, ServerNotification, Tool from mcp.server.fastmcp import Context from mcp.types import CallToolResult @@ -16,6 +16,7 @@ logger = logging.getLogger(__name__) +MAX_RETRIES = 2 async def send_tool_call_progress( fastmcp_server_context: Context, message: str, data: dict[str, Any] | None = None @@ -43,6 +44,22 @@ async def send_tool_call_progress( # await session._write_stream.send(JSONRPCMessage(jsonrpc_notification)) +async def execute_tool_with_retries(mcp_session, tool_call_function, notification_handler, tool_name) -> CallToolResult: + retries = 0 + while True: + try: + return await execute_tool_with_notifications( + mcp_session.client_session, tool_call_function, notification_handler + ) + except (TimeoutError, ConnectionError) as e: + if retries < MAX_RETRIES: + logger.warning(f"Transient error in tool '{tool_name}', retrying... ({retries+1}/{MAX_RETRIES})") + retries += 1 + await asyncio.sleep(1) # brief delay before retrying + else: + raise + + async def execute_tool_with_notifications( session: ClientSession, tool_call_function: ToolCallFunction, diff --git a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py index 32a52419..ccc3f30b 100644 --- a/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py +++ b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py @@ -1,81 +1,105 @@ import atexit import threading import signal +import logging from .mcp_server_fusion.fusion_mcp_server import FusionMCPServer from .mcp_server_fusion.fusion_utils import log, handle_error +class FusionMCPAddIn: + def __init__(self, port: int = 6050, show_errors: bool = True): + self.port = port + self.show_errors = show_errors + self.server = None + self.server_thread = None + self.shutdown_event = threading.Event() + self.logger = logging.getLogger(__name__) + + def start(self): + """Start the MCP server in a background thread""" + if self.server_thread and self.server_thread.is_alive(): + return + + try: + log('Starting MCP Server add-in') + + # Create server instance + self.server = FusionMCPServer(self.port) + + # Start server in background thread + self.server_thread = threading.Thread( + target=self._run_server, + daemon=True, + name="MCPServerThread" + ) + self.server_thread.start() + + # Set up signal handlers + signal.signal(signal.SIGTERM, lambda sig, frame: self.stop()) + atexit.register(self.stop) + + log('MCP Server add-in started successfully') + + except Exception as e: + handle_error('start', self.show_errors) + self.logger.error(f'Failed to start add-in: {str(e)}') + self.stop() + + def _run_server(self): + """Run the server in the background thread""" + try: + log('Starting MCP server thread') + if self.server: + self.server.start() + log('MCP server thread stopping') + + except Exception as e: + if not self.shutdown_event.is_set(): + handle_error('_run_server', self.show_errors) + self.logger.error(f'Error in server thread: {str(e)}') + finally: + self.shutdown_event.set() + + def stop(self): + """Stop the MCP server and clean up resources""" + if self.shutdown_event.is_set(): + return + + try: + log('Stopping MCP Server add-in') + self.shutdown_event.set() + + # Stop server + if self.server: + self.server.shutdown() + self.server = None + log('MCP server stopped') + + # Wait for thread to finish + if self.server_thread and self.server_thread.is_alive(): + self.server_thread.join(timeout=5) + self.server_thread = None + log('Server thread stopped') + + log('MCP Server add-in stopped') + + except Exception as e: + handle_error('stop', self.show_errors) + self.logger.error(f'Error stopping add-in: {str(e)}') -# Global variables -mcp_running = True -mcp_thread = None -fusion_mcp_server = None -show_errors_in_ui = True - +# Global add-in instance +_addin = None def run(context): - global mcp_running, mcp_thread - - try: - log('Starting MCP Server add-in') - - # Start the background thread - log('Starting background thread') - mcp_running = True - mcp_thread = threading.Thread(target=start_mcp_server, daemon=True) - mcp_thread.start() - log('Background thread started') - - atexit.register(stop, None) - signal.signal(signal.SIGTERM, lambda sig, frame: stop(None)) - - - except Exception as e: - handle_error('run', show_errors_in_ui) - log(f'Failed to start: {str(e)}') - - -def start_mcp_server(port: int = 6050): - global fusion_mcp_server - - try: - log('Creating MCP server') - fusion_mcp_server = FusionMCPServer(port) - - log(f'Starting MCP server on port {port}') - fusion_mcp_server.start() - log('MCP server thread started') - - except Exception as e: - handle_error('start_mcp_server', show_errors_in_ui) - log(f'Error in MCP server thread: {str(e)}') - + """Add-in entry point""" + global _addin + if _addin is None: + _addin = FusionMCPAddIn() + _addin.start() def stop(context): - global mcp_running, mcp_thread, fusion_mcp_server - - try: - log('Stopping MCP Server add-in') - - # Signal the background thread to stop - mcp_running = False - log('Signaling background thread to stop') - - if fusion_mcp_server is not None: - fusion_mcp_server.shutdown() - log('MCP server stopped') - else: - log('No MCP server to stop') - - # Stop the thread - if mcp_thread is not None: - mcp_thread.join(5) - log('Background thread stopped') - else: - log('No background thread to stop') - - log('MCP Server add-in stopped') - - except Exception as e: - handle_error('stop', show_errors_in_ui) - log(f'Error stopping add-in: {str(e)}') + """Add-in cleanup point""" + global _addin + if _addin is not None: + _addin.stop() + _addin = None diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_mcp_server.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_mcp_server.py index 8ccb65b7..4d2e3fe0 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_mcp_server.py +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_mcp_server.py @@ -1,8 +1,13 @@ import asyncio +import logging +from contextlib import suppress +from typing import Optional +import threading +import socket +import time -from .vendor import anyio from .vendor.mcp.server.fastmcp import FastMCP - +from .vendor.anyio import BrokenResourceError from .mcp_tools import ( Fusion3DOperationTools, FusionGeometryTools, @@ -10,142 +15,182 @@ FusionSketchTools, ) -def custom_exception_handler(loop, context): - """ - Custom exception handler for the event loop. - Ignores certain exceptions that occur during normal client disconnects. - """ - exception = context.get("exception") - if isinstance(exception, (anyio.BrokenResourceError, anyio.WouldBlock, asyncio.CancelledError)): - # These exceptions are expected during disconnections or cancellation. - # You can log a warning if desired, or simply ignore. - print("Warning (ignored):", exception) - else: - # For all other exceptions, use the default handler. - loop.default_exception_handler(context) - class FusionMCPServer: def __init__(self, port: int): + self.port = port self.running = False + self.loop: Optional[asyncio.AbstractEventLoop] = None + self.shutdown_event = threading.Event() + self.server_task: Optional[asyncio.Task] = None + self.logger = logging.getLogger(__name__) + + # Initialize MCP server self.mcp = FastMCP(name="Fusion MCP Server", log_level="DEBUG") self.mcp.settings.port = port - + # Register tools - Fusion3DOperationTools().register_tools(self.mcp) - FusionGeometryTools().register_tools(self.mcp) - FusionPatternTools().register_tools(self.mcp) - FusionSketchTools().register_tools(self.mcp) + self._register_tools() + + def wait_for_port_available(self, timeout=30): + """Wait for the port to become available""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Try to bind to the port + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('0.0.0.0', self.port)) + return True + except OSError: + time.sleep(0.5) + return False + + async def _keep_loop_running(self): + """Keep the event loop running""" + while not self.shutdown_event.is_set(): + await asyncio.sleep(0.1) + + def _register_tools(self): + """Register all tool handlers""" + try: + Fusion3DOperationTools().register_tools(self.mcp) + FusionGeometryTools().register_tools(self.mcp) + FusionPatternTools().register_tools(self.mcp) + FusionSketchTools().register_tools(self.mcp) + except Exception as e: + self.logger.error(f"Error registering tools: {e}") + raise async def _run_server(self): + """Run the server""" try: - await self.mcp.run_sse_async() - except (asyncio.CancelledError, anyio.BrokenResourceError) as e: - # This block might still be hit on shutdown, so we can ignore these exceptions. - print("Server run cancelled or broken:", e) + self.running = True + + # Create task for server + server_task = asyncio.create_task(self.mcp.run_sse_async()) + keep_alive_task = asyncio.create_task(self._keep_loop_running()) + + # Wait for either task to complete + done, pending = await asyncio.wait( + [server_task, keep_alive_task], + return_when=asyncio.FIRST_COMPLETED + ) + + # Cancel remaining tasks + for task in pending: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + except asyncio.CancelledError: + self.logger.info("Server cancelled, shutting down...") except Exception as e: - # Re-raise unexpected exceptions. - raise e + self.logger.error(f"Server error: {e}") + raise + finally: + self.running = False def start(self): - self.running = True - self.loop = asyncio.new_event_loop() - # Set the custom exception handler on this loop. - self.loop.set_exception_handler(custom_exception_handler) - asyncio.set_event_loop(self.loop) - self.task = self.loop.create_task(self._run_server()) + """Start the server in the current thread""" + if self.running: + return + try: - self.loop.run_forever() + # Wait for port to become available + if not self.wait_for_port_available(): + raise RuntimeError(f"Port {self.port} did not become available") + + # Create new event loop + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + # Set up exception handler + def handle_exception(loop, context): + exception = context.get('exception') + if isinstance(exception, (asyncio.CancelledError, GeneratorExit)): + return + self.logger.error(f"Caught unhandled exception: {context}") + if not self.shutdown_event.is_set(): + self.shutdown() + + self.loop.set_exception_handler(handle_exception) + + # Run the server + try: + self.running = True + self.server_task = asyncio.ensure_future(self._run_server(), loop=self.loop) + self.loop.run_forever() + except BrokenResourceError: + self.logger.warning("Client disconnected during SSE, ignoring BrokenResourceError") + except KeyboardInterrupt: + pass + except Exception as e: + if not self.shutdown_event.is_set(): # Don't log during normal shutdown + self.logger.error(f"Error in server loop: {e}") + finally: + self._cleanup() + except Exception as e: - print("Exception in run_forever:", e) - finally: - self.loop.close() + self.logger.error(f"Error starting server: {e}") + raise - def shutdown(self): - if self.loop and self.loop.is_running(): - self.task.cancel() + def _cleanup(self): + """Clean up resources""" + try: + if self.loop and self.loop.is_running(): + # Cancel all tasks + tasks = [t for t in asyncio.all_tasks(self.loop) if not t.done()] + + if tasks: + # Cancel tasks + for task in tasks: + task.cancel() + + # Wait for tasks to finish with timeout + try: + self.loop.run_until_complete( + asyncio.wait(tasks, timeout=5) + ) + except Exception: + pass + + # Close the loop + if self.loop and not self.loop.is_closed(): + self.loop.close() + + # Ensure socket is closed properly try: - self.loop.run_until_complete(self.task) - except (asyncio.CancelledError, anyio.BrokenResourceError): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + s.connect(('127.0.0.1', self.port)) + s.close() + except Exception: pass - self.loop.call_soon_threadsafe(self.loop.stop) + self.running = False + + except Exception as e: + self.logger.error(f"Error during cleanup: {e}") + def shutdown(self): + """Shutdown the server safely""" + if not self.running: + return -# import asyncio -# import time -# import subprocess -# import re - -# from .vendor.mcp.server.fastmcp import FastMCP -# from .mcp_tools import ( -# Fusion3DOperationTools, -# FusionGeometryTools, -# FusionPatternTools, -# FusionSketchTools, -# ) - - -# def kill_process_on_port(port): -# # FIXME: End processes on port -# return -# try: -# # Run netstat to get the list of active connections and listening ports -# output = subprocess.check_output("netstat -ano", shell=True, text=True) -# # Iterate over each line of the netstat output -# for line in output.splitlines(): -# # Look for lines that contain the specified port and are in the LISTENING state -# if re.search(rf":{port}\s+", line) and "LISTENING" in line: -# parts = line.split() -# pid = parts[-1] # PID is the last element on the line -# # Forcefully kill the process using taskkill -# subprocess.run(f"taskkill /PID {pid} /F", shell=True) -# print(f"Killed process {pid} on port {port}") -# except Exception as e: -# print(f"Error killing process on port {port}: {e}") - - - -# class FusionMCPServer: -# def __init__(self, port: int): -# self.running = False - -# # Initialize MCP server -# self.mcp = FastMCP(name="Fusion MCP Server", log_level="DEBUG") -# self.mcp.settings.port = port - -# # Register tools -# Fusion3DOperationTools().register_tools(self.mcp) -# FusionGeometryTools().register_tools(self.mcp) -# FusionPatternTools().register_tools(self.mcp) -# FusionSketchTools().register_tools(self.mcp) - -# async def cleanup(self): -# # Perform any necessary cleanup here -# pass - -# def start(self): -# self.running = True -# self.loop = asyncio.new_event_loop() -# asyncio.set_event_loop(self.loop) -# # Create a task for the uvicorn-based server -# self.task = self.loop.create_task(self.mcp.run_sse_async()) -# try: -# self.loop.run_forever() -# except Exception as e: -# print(f"Exception in server loop: {e}") -# finally: -# self.loop.close() - -# # Example shutdown method in your FusionMCPServer class: -# def shutdown(self): -# if self.loop and self.loop.is_running(): -# # Cancel the running uvicorn server task -# self.task.cancel() -# # Stop the event loop -# self.loop.call_soon_threadsafe(self.loop.stop) -# self.running = False -# # Give a moment for shutdown to process -# time.sleep(1) -# # Kill any lingering process on the port -# kill_process_on_port(self.mcp.settings.port) - + try: + self.logger.info("Shutting down server...") + self.shutdown_event.set() + self.running = False + + if self.loop and self.loop.is_running(): + def stop_loop(): + # Stop the loop + self.loop.stop() + # Cancel any pending tasks + for task in asyncio.all_tasks(self.loop): + task.cancel() + + self.loop.call_soon_threadsafe(stop_loop) + + except Exception as e: + self.logger.error(f"Error during shutdown: {e}") + raise diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py index 0b453a1f..73000eda 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py @@ -4,6 +4,7 @@ add_handler, ) from .tool_utils import ( + convert_direction, errorHandler, FusionContext, GeometryValidator, @@ -14,6 +15,7 @@ __all__ = [ 'add_handler', 'clear_handlers', + 'convert_direction', 'errorHandler', 'FusionContext', 'GeometryValidator', diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py index 7d4b4f77..ac5f2a54 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py @@ -22,6 +22,11 @@ def design(self) -> adsk.fusion.Design: @property def rootComp(self) -> adsk.fusion.Component: return self.design.rootComponent + + @property + def unitsManager(self) -> adsk.core.UnitsManager: + return self.app.activeDocument.unitsManager + def get_sketch_by_name(name: str | None) -> adsk.fusion.Sketch | None: """Get a sketch by its name""" @@ -42,6 +47,21 @@ def wrapper(*args, **kwargs): return wrapper +def convert_direction(direction: list[float]) -> str: + """ + Converts a 3-element direction vector into a valid Fusion 360 expression string + using the active design's default length unit. + + Args: + direction (list[float]): A 3-element list representing the vector. + + Returns: + str: A string formatted as "x unit, y unit, z unit" + """ + GeometryValidator.validateVector(direction) + unit = FusionContext().unitsManager.defaultLengthUnits + return f"{direction[0]} {unit}, {direction[1]} {unit}, {direction[2]} {unit}" + class UnitsConverter: """Handles unit conversion between different measurement systems using static calls.""" diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py index 54f9ae88..e5446408 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py @@ -4,6 +4,7 @@ from textwrap import dedent from ..fusion_utils import ( + convert_direction, errorHandler, FusionContext, GeometryValidator, @@ -40,28 +41,31 @@ def extrude( distance: float, direction: list[float] = None ) -> str: - # Get the sketch by name + # Get the sketch by name. sketch = self.ctx.rootComp.sketches.itemByName(sketch_name) if not sketch: raise ValueError(f"Sketch '{sketch_name}' not found.") - # Get the profile from sketch + # Get the profile from the sketch. profile = sketch.profiles.item(0) - # Create extrusion input + # Create extrusion input. extrudes = self.ctx.rootComp.features.extrudeFeatures - distance = adsk.core.ValueInput.createByReal(distance) + distance_input = adsk.core.ValueInput.createByReal(distance) - # Set up the extrusion + # Set up the extrusion. extInput = extrudes.createInput(profile, adsk.fusion.FeatureOperations.NewBodyFeatureOperation) if direction: - self.validator.validateVector(direction) - direction = adsk.core.Vector3D.create(*direction) - extInput.setDirectionAndDistance(direction, distance) + # Use the helper to build a valid expression string with the design’s default unit. + direction_expr = convert_direction(direction) + direction_input = adsk.core.ValueInput.createByString(direction_expr) + # Create a distance extent definition. + extent = adsk.fusion.DistanceExtentDefinition.create(distance_input) + extInput.setOneSideExtent(extent, adsk.fusion.ExtentDirections.PositiveExtentDirection, direction_input) else: - extInput.setDistanceExtent(False, distance) + extInput.setDistanceExtent(False, distance_input) - # Create the extrusion + # Create the extrusion and return the name of the created body. ext = extrudes.add(extInput) return ext.bodies.item(0).name @@ -87,33 +91,38 @@ def cut_extrude( target_body_name: str, direction: list[float] = None ) -> str: - # Get the sketch by name + # Get the sketch by name. sketch = self.ctx.rootComp.sketches.itemByName(sketch_name) if not sketch: raise ValueError(f"Sketch '{sketch_name}' not found.") - # Get the target body by name + # Get the target body by name. target_body = self.ctx.rootComp.bRepBodies.itemByName(target_body_name) if not target_body: raise ValueError(f"Target body '{target_body_name}' not found.") - # Get the profile from sketch + # Get the profile from the sketch. profile = sketch.profiles.item(0) - - # Create extrusion input + + # Create extrusion input. extrudes = self.ctx.rootComp.features.extrudeFeatures - distance = adsk.core.ValueInput.createByReal(distance) + distance_input = adsk.core.ValueInput.createByReal(distance) - # Set up the extrusion + # Set up the cut extrusion. extInput = extrudes.createInput(profile, adsk.fusion.FeatureOperations.CutFeatureOperation) if direction: - self.validator.validateVector(direction) - direction = adsk.core.Vector3D.create(*direction) - extInput.setDirectionAndDistance(direction, distance) + # Convert the direction vector using the helper. + direction_expr = convert_direction(direction) + direction_input = adsk.core.ValueInput.createByString(direction_expr) + # Create a distance extent definition. + extent = adsk.fusion.DistanceExtentDefinition.create(distance_input) + extInput.setOneSideExtent(extent, adsk.fusion.ExtentDirections.PositiveExtentDirection, direction_input) else: - extInput.setDistanceExtent(False, distance) + extInput.setDistanceExtent(False, distance_input) + + # Set the target body for the cut. extInput.setTargetBody(target_body) - # Create the extrusion + # Create the cut extrusion and return the name of the resulting body. ext = extrudes.add(extInput) return ext.bodies.item(0).name From f5067236b3fbd3a24195b08483544ef5dfba858d Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 24 Feb 2025 15:12:47 +0000 Subject: [PATCH 08/10] working extrusion and improved instruction --- .../assistant_extensions/mcp/_model.py | 19 +++++- .../fusion_utils/tool_utils.py | 6 +- .../mcp_tools/fusion_3d_operation.py | 64 +++++++++---------- 3 files changed, 53 insertions(+), 36 deletions(-) diff --git a/libraries/python/assistant-extensions/assistant_extensions/mcp/_model.py b/libraries/python/assistant-extensions/assistant_extensions/mcp/_model.py index 9323acb2..1d7c5f70 100644 --- a/libraries/python/assistant-extensions/assistant_extensions/mcp/_model.py +++ b/libraries/python/assistant-extensions/assistant_extensions/mcp/_model.py @@ -155,10 +155,27 @@ class MCPToolsConfigModel(BaseModel): ), MCPServerConfig( key="giphy", - command="http://http://127.0.0.1:6000/sse", + command="http://127.0.0.1:6000/sse", args=[], enabled=False, ), + MCPServerConfig( + key="fusion", + command="http://127.0.0.1:6050/sse", + args=[], + prompt=dedent(""" + When creating models, remember the following: + - Z is vertical, X is horizontal, and Y is depth + - The top plane for an entity is an XY plane, at the Z coordinate of the top of the entity + - The bottom plane for an entity is an XY plane, at the Z coordinate of the bottom of the entity + - The front plane for an entity is an XZ plane, at the Y coordinate of the front of the entity + - The back plane for an entity is an XZ plane, at the Y coordinate of the back of the entity + - The left plane for an entity is a YZ plane, at the X coordinate of the left of the entity + - The right plane for an entity is a YZ plane, at the X coordinate of the right of the entity + - Remember to always use the correct plane and consider the amount of adjustment on the 3rd plane necessary + """).strip(), + enabled=False, + ), MCPServerConfig( key="memory", command="npx", diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py index ac5f2a54..498d84af 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py @@ -24,8 +24,8 @@ def rootComp(self) -> adsk.fusion.Component: return self.design.rootComponent @property - def unitsManager(self) -> adsk.core.UnitsManager: - return self.app.activeDocument.unitsManager + def fusionUnitsManager(self) -> adsk.fusion.FusionUnitsManager: + return self.design.fusionUnitsManager def get_sketch_by_name(name: str | None) -> adsk.fusion.Sketch | None: @@ -59,7 +59,7 @@ def convert_direction(direction: list[float]) -> str: str: A string formatted as "x unit, y unit, z unit" """ GeometryValidator.validateVector(direction) - unit = FusionContext().unitsManager.defaultLengthUnits + unit = FusionContext().fusionUnitsManager.defaultLengthUnits return f"{direction[0]} {unit}, {direction[1]} {unit}, {direction[2]} {unit}" class UnitsConverter: diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py index e5446408..38b6369e 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py @@ -41,34 +41,33 @@ def extrude( distance: float, direction: list[float] = None ) -> str: - # Get the sketch by name. + # Get the sketch by name sketch = self.ctx.rootComp.sketches.itemByName(sketch_name) if not sketch: raise ValueError(f"Sketch '{sketch_name}' not found.") - # Get the profile from the sketch. + # Get the profile from sketch profile = sketch.profiles.item(0) - # Create extrusion input. + # Create extrusion input extrudes = self.ctx.rootComp.features.extrudeFeatures distance_input = adsk.core.ValueInput.createByReal(distance) - # Set up the extrusion. + # Set up the extrusion extInput = extrudes.createInput(profile, adsk.fusion.FeatureOperations.NewBodyFeatureOperation) - if direction: - # Use the helper to build a valid expression string with the design’s default unit. - direction_expr = convert_direction(direction) - direction_input = adsk.core.ValueInput.createByString(direction_expr) - # Create a distance extent definition. - extent = adsk.fusion.DistanceExtentDefinition.create(distance_input) - extInput.setOneSideExtent(extent, adsk.fusion.ExtentDirections.PositiveExtentDirection, direction_input) + + # Create a distance extent definition + extent = adsk.fusion.DistanceExtentDefinition.create(distance_input) + + # Set the extent based on direction + if direction and direction[2] < 0: + extInput.setOneSideExtent(extent, adsk.fusion.ExtentDirections.NegativeExtentDirection) else: - extInput.setDistanceExtent(False, distance_input) + extInput.setOneSideExtent(extent, adsk.fusion.ExtentDirections.PositiveExtentDirection) - # Create the extrusion and return the name of the created body. + # Create the extrusion ext = extrudes.add(extInput) return ext.bodies.item(0).name - @mcp.tool( name="cut_extrude", @@ -91,38 +90,39 @@ def cut_extrude( target_body_name: str, direction: list[float] = None ) -> str: - # Get the sketch by name. + # Get the sketch by name sketch = self.ctx.rootComp.sketches.itemByName(sketch_name) if not sketch: raise ValueError(f"Sketch '{sketch_name}' not found.") - # Get the target body by name. + # Get the target body by name target_body = self.ctx.rootComp.bRepBodies.itemByName(target_body_name) if not target_body: raise ValueError(f"Target body '{target_body_name}' not found.") - # Get the profile from the sketch. + # Get the profile from sketch profile = sketch.profiles.item(0) - - # Create extrusion input. + + # Create extrusion input extrudes = self.ctx.rootComp.features.extrudeFeatures distance_input = adsk.core.ValueInput.createByReal(distance) - # Set up the cut extrusion. + # Set up the extrusion extInput = extrudes.createInput(profile, adsk.fusion.FeatureOperations.CutFeatureOperation) - if direction: - # Convert the direction vector using the helper. - direction_expr = convert_direction(direction) - direction_input = adsk.core.ValueInput.createByString(direction_expr) - # Create a distance extent definition. - extent = adsk.fusion.DistanceExtentDefinition.create(distance_input) - extInput.setOneSideExtent(extent, adsk.fusion.ExtentDirections.PositiveExtentDirection, direction_input) + + # Create a distance extent definition + extent = adsk.fusion.DistanceExtentDefinition.create(distance_input) + + # Set the extent based on direction + if direction and direction[2] < 0: + extInput.setOneSideExtent(extent, adsk.fusion.ExtentDirections.NegativeExtentDirection) else: - extInput.setDistanceExtent(False, distance_input) - - # Set the target body for the cut. - extInput.setTargetBody(target_body) + extInput.setOneSideExtent(extent, adsk.fusion.ExtentDirections.PositiveExtentDirection) - # Create the cut extrusion and return the name of the resulting body. + # Add the target body to participants + extInput.participantBodies = [target_body] + + # Create the extrusion ext = extrudes.add(extInput) return ext.bodies.item(0).name + From 3006ae1c38ca09c0a3da9fda1c06fb6dbc19d2d8 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 24 Feb 2025 15:19:44 +0000 Subject: [PATCH 09/10] adds note for requirements.txt in vendors folder --- .../mcp-server-fusion/mcp_server_fusion/vendor/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mcp-servers/mcp-server-fusion/mcp_server_fusion/vendor/README.md b/mcp-servers/mcp-server-fusion/mcp_server_fusion/vendor/README.md index 207dffa3..545b5a9b 100644 --- a/mcp-servers/mcp-server-fusion/mcp_server_fusion/vendor/README.md +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/vendor/README.md @@ -5,5 +5,6 @@ Dependencies are included here to make the add-in portable and self-contained, s Install from project root: ```bash -pip install -r requirements.txt --target ./vendor +# from project root directory, where requirements.txt is located +pip install -r requirements.txt --target ./mcp_server_fusion/vendor ``` From fa05206b3f8e16b00b1672e78b9d67ae1d0083d1 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 24 Feb 2025 15:32:31 +0000 Subject: [PATCH 10/10] updated readme --- mcp-servers/mcp-server-fusion/README.md | 71 +++++++++++++++++-------- 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/mcp-servers/mcp-server-fusion/README.md b/mcp-servers/mcp-server-fusion/README.md index b25bae25..f830cda7 100644 --- a/mcp-servers/mcp-server-fusion/README.md +++ b/mcp-servers/mcp-server-fusion/README.md @@ -16,44 +16,57 @@ To create the virtual environment and install dependencies. ### Running the Server -Use the VSCode launch configuration, or run manually: - -Defaults to stdio transport: +- Open Autodesk Fusion +- Utilities > Add-Ins > Scripts and Add-Ins... +- Select the Add-Ins tab +- Click the My AddIns "+" icon to add a new Add-In +- Choose the _main project directory_ + - This is the folder that contains [FusionMCPServerAddIn.py](./FusionMCPServerAddIn.py) +- After added, select `FusionMCPServerAddIn` and click `Run` +- Select `Run on Startup` to have the server start automatically when Fusion starts +- The server will start silently, you can test it from your terminal via: ```bash -python -m mcp_server_fusion.mcp_server.start +curl -N http://127.0.0.1:6050/sse ``` -For SSE transport: +Which should return something similar to: -```bash -python -m mcp_server_fusion.mcp_server.start --transport sse --port 6050 +``` +C:\>curl -N http://127.0.0.1:6010/sse +event: endpoint +data: /messages?sessionId=947e3ec6-7d10-442f-af8e-e8fe9779f285 ``` -The SSE URL is: +Use `Ctrl+C` to disconnect the curl command. -```bash -http://127.0.0.1:6050/sse +### Debugging the Server + +To run the server in debug mode, open the Scripts and Add-Ins dialog, select the `FusionMCPServerAddIn` and click `Debug`. +This will launch VS Code with the project open. Use the `F5` or `Run & Debug` button and select the `mcp-servers:mcp-server-fusion (attach)` configuration and click `Start Debugging`. This will attach to the running Fusion instance and allow you to debug the server. Wait until you see that the server is listening before attempting to connect. + +``` +c:\Users\\AppData\Roaming\Autodesk\Autodesk Fusion 360\API\AddIns\mcp-server-fusion +Starting MCP Server add-in +Starting MCP server thread +MCP Server add-in started successfully +INFO: Started server process [43816] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:6050 (Press CTRL+C to quit) ``` ## Client Configuration To use this MCP server in your setup, consider the following configuration: -### Stdio +### SSE -```json -{ - "mcpServers": { - "mcp-server-fusion": { - "command": "python", - "args": ["run", "-m", "mcp_server_fusion.mcp_server.start"] - } - } -} -``` +The SSE URL is: -### SSE +```bash +http://127.0.0.1:6050/sse +``` ```json { @@ -65,3 +78,17 @@ To use this MCP server in your setup, consider the following configuration: } } ``` + +Here are some extra instructions to consider adding to your assistant configuration, but feel free to experiment further: + +``` +When creating models, remember the following: +- Z is vertical, X is horizontal, and Y is depth +- The top plane for an entity is an XY plane, at the Z coordinate of the top of the entity +- The bottom plane for an entity is an XY plane, at the Z coordinate of the bottom of the entity +- The front plane for an entity is an XZ plane, at the Y coordinate of the front of the entity +- The back plane for an entity is an XZ plane, at the Y coordinate of the back of the entity +- The left plane for an entity is a YZ plane, at the X coordinate of the left of the entity +- The right plane for an entity is a YZ plane, at the X coordinate of the right of the entity +- Remember to always use the correct plane and consider the amount of adjustment on the 3rd plane necessary +```