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"