diff --git a/assistants/codespace-assistant/assistant/response/completion_handler.py b/assistants/codespace-assistant/assistant/response/completion_handler.py index ab1bc283..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,26 @@ async def on_logging_message(msg: str) -> None: on_logging_message, ) except Exception as e: - logger.exception(f"Error handling tool call: {e}") - return await handle_error("An error occurred while handling the tool call.") + logger.exception(f"Error handling tool call '{tool_call.name}': {e}") + 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=f"Error executing tool '{tool_call.name}': {e}", + message_type=MessageType.notice, + 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) 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/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/__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..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", @@ -211,6 +228,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 +238,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 8a5abb84..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,9 +69,9 @@ 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, sse_read_timeout=60 * 15 + url=server_config.command, headers=headers, timeout=60 * 5, sse_read_timeout=60 * 15 ) as ( read_stream, write_stream, @@ -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/.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..baa79fcb --- /dev/null +++ b/mcp-servers/mcp-server-fusion/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + "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 + }, + { + "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 new file mode 100644 index 00000000..3d80b90d --- /dev/null +++ b/mcp-servers/mcp-server-fusion/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "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 new file mode 100644 index 00000000..ccc3f30b --- /dev/null +++ b/mcp-servers/mcp-server-fusion/FusionMCPServerAddIn.py @@ -0,0 +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 add-in instance +_addin = None + +def run(context): + """Add-in entry point""" + global _addin + if _addin is None: + _addin = FusionMCPAddIn() + _addin.start() + +def stop(context): + """Add-in cleanup point""" + global _addin + if _addin is not None: + _addin.stop() + _addin = None diff --git a/mcp-servers/mcp-server-fusion/README.md b/mcp-servers/mcp-server-fusion/README.md new file mode 100644 index 00000000..f830cda7 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/README.md @@ -0,0 +1,94 @@ +# 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 ./mcp_server_fusion/vendor +``` + +To create the virtual environment and install dependencies. + +### Running the Server + +- 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 +curl -N http://127.0.0.1:6050/sse +``` + +Which should return something similar to: + +``` +C:\>curl -N http://127.0.0.1:6010/sse +event: endpoint +data: /messages?sessionId=947e3ec6-7d10-442f-af8e-e8fe9779f285 +``` + +Use `Ctrl+C` to disconnect the curl command. + +### 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: + +### SSE + +The SSE URL is: + +```bash +http://127.0.0.1:6050/sse +``` + +```json +{ + "mcpServers": { + "mcp-server-fusion": { + "command": "http://127.0.0.1:6050/sse", + "args": [] + } + } +} +``` + +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 +``` 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_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_mcp_server.py b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_mcp_server.py new file mode 100644 index 00000000..4d2e3fe0 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_mcp_server.py @@ -0,0 +1,196 @@ +import asyncio +import logging +from contextlib import suppress +from typing import Optional +import threading +import socket +import time + +from .vendor.mcp.server.fastmcp import FastMCP +from .vendor.anyio import BrokenResourceError +from .mcp_tools import ( + Fusion3DOperationTools, + FusionGeometryTools, + FusionPatternTools, + FusionSketchTools, +) + +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 + 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: + 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: + self.logger.error(f"Server error: {e}") + raise + finally: + self.running = False + + def start(self): + """Start the server in the current thread""" + if self.running: + return + + try: + # 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: + self.logger.error(f"Error starting server: {e}") + raise + + 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: + 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.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 + + 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 new file mode 100644 index 00000000..73000eda --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/__init__.py @@ -0,0 +1,26 @@ +from .general_utils import log, handle_error +from .event_utils import ( + clear_handlers, + add_handler, +) +from .tool_utils import ( + convert_direction, + errorHandler, + FusionContext, + GeometryValidator, + get_sketch_by_name, + UnitsConverter, +) + +__all__ = [ + 'add_handler', + 'clear_handlers', + 'convert_direction', + 'errorHandler', + 'FusionContext', + 'GeometryValidator', + 'get_sketch_by_name', + 'handle_error', + 'log', + 'UnitsConverter', +] 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/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..498d84af --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/fusion_utils/tool_utils.py @@ -0,0 +1,88 @@ +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 + + @property + def fusionUnitsManager(self) -> adsk.fusion.FusionUnitsManager: + return self.design.fusionUnitsManager + + +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 + + +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().fusionUnitsManager.defaultLengthUnits + return f"{direction[0]} {unit}, {direction[1]} {unit}, {direction[2]} {unit}" + +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_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..38b6369e --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/mcp_tools/fusion_3d_operation.py @@ -0,0 +1,128 @@ +import adsk.core +import adsk.fusion + +from textwrap import dedent + +from ..fusion_utils import ( + convert_direction, + 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_input = adsk.core.ValueInput.createByReal(distance) + + # Set up the extrusion + extInput = extrudes.createInput(profile, adsk.fusion.FeatureOperations.NewBodyFeatureOperation) + + # 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.setOneSideExtent(extent, adsk.fusion.ExtentDirections.PositiveExtentDirection) + + # 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_input = adsk.core.ValueInput.createByReal(distance) + + # Set up the extrusion + extInput = extrudes.createInput(profile, adsk.fusion.FeatureOperations.CutFeatureOperation) + + # 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.setOneSideExtent(extent, adsk.fusion.ExtentDirections.PositiveExtentDirection) + + # Add the target body to participants + extInput.participantBodies = [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 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 new file mode 100644 index 00000000..545b5a9b --- /dev/null +++ b/mcp-servers/mcp-server-fusion/mcp_server_fusion/vendor/README.md @@ -0,0 +1,10 @@ +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 +# from project root directory, where requirements.txt is located +pip install -r requirements.txt --target ./mcp_server_fusion/vendor +``` diff --git a/mcp-servers/mcp-server-fusion/requirements.txt b/mcp-servers/mcp-server-fusion/requirements.txt new file mode 100644 index 00000000..84056878 --- /dev/null +++ b/mcp-servers/mcp-server-fusion/requirements.txt @@ -0,0 +1,7 @@ +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 +debugpy>=1.8.12 diff --git a/semantic-workbench.code-workspace b/semantic-workbench.code-workspace index 1c5b8ac8..713ae341 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"