From e5434b9d262a10db41a69dc5a785c5e26317a623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreu=20Mart=C3=ADnez=20Vidal?= Date: Sat, 21 Mar 2026 16:16:26 +0100 Subject: [PATCH 1/5] Add Unity MCP Semantic Kernel integration Introduce a new Unity MCP Semantic Kernel integration package under backend/app/agents/unity_mcp_sk. Adds core contracts and value objects, a default tool-definition mapper and parser, an MCP client adapter, a semantic plugin runtime, and SK integration helpers (kernel factory and registration helpers supporting expanded and router modes). Includes unit tests for discovery, registration, and metadata fidelity, and documents usage/behavior in docs/UNITY_MCP_SK_INTEGRATION.md. Also bump project versions to 1.8.0 (root, backend, frontend) and extend .gitignore/.dockerignore entries to exclude virtualenvs, editor folders, and coverage artifacts. --- .dockerignore | 14 ++ .gitignore | 14 ++ backend/app/agents/unity_mcp_sk/__init__.py | 19 ++ .../app/agents/unity_mcp_sk/core/__init__.py | 2 + .../app/agents/unity_mcp_sk/core/contracts.py | 88 +++++++++ .../agents/unity_mcp_sk/core/tool_mapper.py | 98 ++++++++++ .../unity_mcp_sk/infrastructure/__init__.py | 2 + .../unity_mcp_sk/infrastructure/mcp_client.py | 40 ++++ .../agents/unity_mcp_sk/plugin/__init__.py | 2 + .../plugin/unity_mcp_semantic_plugin.py | 63 ++++++ .../unity_mcp_sk/sk_integration/__init__.py | 2 + .../sk_integration/kernel_factory.py | 52 +++++ .../sk_integration/kernel_registration.py | 184 ++++++++++++++++++ backend/package.json | 2 +- backend/pyproject.toml | 2 +- .../tests/test_unity_mcp_sk_integration.py | 136 +++++++++++++ docs/UNITY_MCP_SK_INTEGRATION.md | 97 +++++---- frontend/.dockerignore | 14 ++ frontend/package.json | 2 +- package.json | 2 +- 20 files changed, 793 insertions(+), 42 deletions(-) create mode 100644 backend/app/agents/unity_mcp_sk/__init__.py create mode 100644 backend/app/agents/unity_mcp_sk/core/__init__.py create mode 100644 backend/app/agents/unity_mcp_sk/core/contracts.py create mode 100644 backend/app/agents/unity_mcp_sk/core/tool_mapper.py create mode 100644 backend/app/agents/unity_mcp_sk/infrastructure/__init__.py create mode 100644 backend/app/agents/unity_mcp_sk/infrastructure/mcp_client.py create mode 100644 backend/app/agents/unity_mcp_sk/plugin/__init__.py create mode 100644 backend/app/agents/unity_mcp_sk/plugin/unity_mcp_semantic_plugin.py create mode 100644 backend/app/agents/unity_mcp_sk/sk_integration/__init__.py create mode 100644 backend/app/agents/unity_mcp_sk/sk_integration/kernel_factory.py create mode 100644 backend/app/agents/unity_mcp_sk/sk_integration/kernel_registration.py create mode 100644 backend/tests/test_unity_mcp_sk_integration.py diff --git a/.dockerignore b/.dockerignore index 7271739c..488f2014 100644 --- a/.dockerignore +++ b/.dockerignore @@ -55,3 +55,17 @@ temp_extracted_asar dist-electron/ dist-electron-build/ out/ + +.venv +.venv-local +.venv-build +.vscode +.idea +.pytest_cache +.coverage +.coverage.* +.coverage.*.* +.coverage.*.*.* +.coverage.*.*.*.* +.coverage.*.*.*.*.* +.coverage.*.*.*.*.*.* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1ade672d..802e8fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,17 @@ BUILD_STATUS.md dist-electron/ dist-electron-build/ out/ + +.venv +.venv-local +.venv-build +.vscode +.idea +.pytest_cache +.coverage +.coverage.* +.coverage.*.* +.coverage.*.*.* +.coverage.*.*.*.* +.coverage.*.*.*.*.* +.coverage.*.*.*.*.*.* \ No newline at end of file diff --git a/backend/app/agents/unity_mcp_sk/__init__.py b/backend/app/agents/unity_mcp_sk/__init__.py new file mode 100644 index 00000000..e143fa94 --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/__init__.py @@ -0,0 +1,19 @@ +""" +Semantic Kernel integration for Unity MCP tools. + +This package supports two registration modes: +- Expanded mode: one SK function per MCP tool (recommended). +- Router mode: one generic SK function for tool routing. +""" + +from .plugin.unity_mcp_semantic_plugin import UnityMcpSemanticPlugin +from .sk_integration.kernel_factory import create_kernel_with_unity_async +from .sk_integration.kernel_registration import register_unity_router_function, register_unity_tools_as_functions + +__all__ = [ + "UnityMcpSemanticPlugin", + "create_kernel_with_unity_async", + "register_unity_router_function", + "register_unity_tools_as_functions", +] + diff --git a/backend/app/agents/unity_mcp_sk/core/__init__.py b/backend/app/agents/unity_mcp_sk/core/__init__.py new file mode 100644 index 00000000..95b4429a --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/core/__init__.py @@ -0,0 +1,2 @@ +"""Core contracts and mappers for Unity MCP SK integration.""" + diff --git a/backend/app/agents/unity_mcp_sk/core/contracts.py b/backend/app/agents/unity_mcp_sk/core/contracts.py new file mode 100644 index 00000000..47aaac88 --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/core/contracts.py @@ -0,0 +1,88 @@ +"""Core contracts and value objects for Unity MCP SK integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol + + +MCP_JSON_TYPE_TO_SK_TYPE: dict[str, str] = { + "string": "string", + "number": "number", + "integer": "integer", + "boolean": "boolean", + "array": "array", + "object": "object", +} + + +@dataclass(frozen=True) +class ToolParameterDefinition: + """ + Represents an MCP tool parameter as a typed, SK-ready value object. + + Args: + name: Exact parameter name from MCP schema. + description: Human-readable parameter description. + json_type: MCP JSON schema type. + required: Whether the parameter is required. + default: Optional default value from MCP schema. + schema: Full original JSON schema fragment for this parameter. + """ + + name: str + description: str + json_type: str + required: bool + default: Any = None + schema: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ToolDefinition: + """ + Represents one discovered MCP tool definition. + + Args: + name: MCP tool name. + description: Tool description exposed to SK planners/agents. + parameters: Parsed input parameters from MCP input schema. + return_schema: Optional output schema metadata when available. + raw_input_schema: Raw MCP input schema for metadata passthrough. + """ + + name: str + description: str + parameters: tuple[ToolParameterDefinition, ...] + return_schema: dict[str, Any] = field(default_factory=dict) + raw_input_schema: dict[str, Any] = field(default_factory=dict) + + +class ToolDefinitionMapper(Protocol): + """Maps/discovers MCP tool definitions and stores a deterministic registry.""" + + def initialize(self, tool_definitions: list[ToolDefinition]) -> None: + """Initialize mapper with tool definitions.""" + + def map_tool_definition(self, tool_definition: ToolDefinition) -> ToolDefinition: + """Normalize a tool definition for SK registration.""" + + def get_tool_by_name(self, name: str) -> ToolDefinition | None: + """Return a registered tool definition by name.""" + + def get_tool_names(self) -> list[str]: + """Return all registered tool names in deterministic order.""" + + def get_registered_tools(self) -> list[ToolDefinition]: + """Return all registered tool definitions in deterministic order.""" + + +class McpToolClient(Protocol): + """Client contract for listing and invoking MCP tools.""" + + async def list_tools(self) -> list[dict[str, Any]]: + """Return raw MCP tool definitions.""" + + async def invoke_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: + """Invoke one MCP tool with arguments.""" + diff --git a/backend/app/agents/unity_mcp_sk/core/tool_mapper.py b/backend/app/agents/unity_mcp_sk/core/tool_mapper.py new file mode 100644 index 00000000..f23be21b --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/core/tool_mapper.py @@ -0,0 +1,98 @@ +"""Default tool-definition mapper for Unity MCP SK integration.""" + +from __future__ import annotations + +from typing import Any + +from .contracts import MCP_JSON_TYPE_TO_SK_TYPE, ToolDefinition, ToolDefinitionMapper, ToolParameterDefinition + + +class DefaultToolDefinitionMapper(ToolDefinitionMapper): + """Stores normalized tool definitions and exposes deterministic lookup APIs.""" + + def __init__(self) -> None: + self._tools_by_name: dict[str, ToolDefinition] = {} + + def initialize(self, tool_definitions: list[ToolDefinition]) -> None: + self._tools_by_name = {tool.name: self.map_tool_definition(tool) for tool in tool_definitions} + + def map_tool_definition(self, tool_definition: ToolDefinition) -> ToolDefinition: + normalized_parameters = tuple( + ToolParameterDefinition( + name=parameter.name, + description=parameter.description, + json_type=parameter.json_type if parameter.json_type in MCP_JSON_TYPE_TO_SK_TYPE else "object", + required=parameter.required, + default=parameter.default, + schema=parameter.schema, + ) + for parameter in tool_definition.parameters + ) + return ToolDefinition( + name=tool_definition.name, + description=tool_definition.description, + parameters=normalized_parameters, + return_schema=tool_definition.return_schema, + raw_input_schema=tool_definition.raw_input_schema, + ) + + def get_tool_by_name(self, name: str) -> ToolDefinition | None: + return self._tools_by_name.get(name) + + def get_tool_names(self) -> list[str]: + return sorted(self._tools_by_name.keys()) + + def get_registered_tools(self) -> list[ToolDefinition]: + return [self._tools_by_name[name] for name in self.get_tool_names()] + + +def parse_mcp_tool_definition(raw_tool: dict[str, Any]) -> ToolDefinition: + """ + Parse one raw MCP tool definition into a typed value object. + + Args: + raw_tool: Raw MCP tool object with `name`, `description`, and `inputSchema`. + + Returns: + Parsed `ToolDefinition` with stable parameter extraction. + + Raises: + ValueError: If required fields are missing or malformed. + """ + name = raw_tool.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError("MCP tool definition is missing a valid 'name'.") + + description = raw_tool.get("description") if isinstance(raw_tool.get("description"), str) else "" + input_schema = raw_tool.get("inputSchema") if isinstance(raw_tool.get("inputSchema"), dict) else {} + return_schema = raw_tool.get("outputSchema") if isinstance(raw_tool.get("outputSchema"), dict) else {} + required = input_schema.get("required") if isinstance(input_schema.get("required"), list) else [] + required_names = {item for item in required if isinstance(item, str)} + properties = input_schema.get("properties") if isinstance(input_schema.get("properties"), dict) else {} + + parameters: list[ToolParameterDefinition] = [] + for parameter_name in sorted(properties.keys()): + schema = properties.get(parameter_name) + if not isinstance(schema, dict): + continue + parameter_description = schema.get("description") if isinstance(schema.get("description"), str) else "" + json_type = schema.get("type") if isinstance(schema.get("type"), str) else "object" + parameters.append( + ToolParameterDefinition( + name=parameter_name, + description=parameter_description, + json_type=json_type, + required=parameter_name in required_names, + default=schema.get("default"), + schema=schema, + ) + ) + + return ToolDefinition( + name=name, + description=description, + parameters=tuple(parameters), + return_schema=return_schema, + raw_input_schema=input_schema, + ) + diff --git a/backend/app/agents/unity_mcp_sk/infrastructure/__init__.py b/backend/app/agents/unity_mcp_sk/infrastructure/__init__.py new file mode 100644 index 00000000..144e2c5d --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/infrastructure/__init__.py @@ -0,0 +1,2 @@ +"""Infrastructure adapters for Unity MCP SK integration.""" + diff --git a/backend/app/agents/unity_mcp_sk/infrastructure/mcp_client.py b/backend/app/agents/unity_mcp_sk/infrastructure/mcp_client.py new file mode 100644 index 00000000..69dd974e --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/infrastructure/mcp_client.py @@ -0,0 +1,40 @@ +"""Infrastructure MCP client adapter for Unity MCP SK integration.""" + +from __future__ import annotations + +from typing import Any + +from ..core.contracts import McpToolClient + + +class UnityMcpClientAdapter(McpToolClient): + """ + Minimal adapter around an MCP session-like object. + + The wrapped object must expose: + - `list_tools()` -> object with `.tools` or a dict containing `tools` + - `call_tool(tool_name, arguments)` -> tool result + """ + + def __init__(self, session: Any) -> None: + if session is None: + raise ValueError("session must not be None") + self._session = session + + async def list_tools(self) -> list[dict[str, Any]]: + raw = await self._session.list_tools() + if isinstance(raw, dict): + tools = raw.get("tools", []) + return [item for item in tools if isinstance(item, dict)] + tools = getattr(raw, "tools", []) + if not isinstance(tools, list): + return [] + return [item for item in tools if isinstance(item, dict)] + + async def invoke_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: + if not tool_name or not isinstance(tool_name, str): + raise ValueError("tool_name must be a non-empty string.") + if arguments is None or not isinstance(arguments, dict): + raise ValueError("arguments must be a dictionary.") + return await self._session.call_tool(tool_name, arguments) + diff --git a/backend/app/agents/unity_mcp_sk/plugin/__init__.py b/backend/app/agents/unity_mcp_sk/plugin/__init__.py new file mode 100644 index 00000000..08a84225 --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/plugin/__init__.py @@ -0,0 +1,2 @@ +"""Plugin runtime layer for Unity MCP SK integration.""" + diff --git a/backend/app/agents/unity_mcp_sk/plugin/unity_mcp_semantic_plugin.py b/backend/app/agents/unity_mcp_sk/plugin/unity_mcp_semantic_plugin.py new file mode 100644 index 00000000..36aee4cb --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/plugin/unity_mcp_semantic_plugin.py @@ -0,0 +1,63 @@ +"""Unity MCP semantic plugin runtime abstraction.""" + +from __future__ import annotations + +from typing import Any + +from ..core.contracts import McpToolClient, ToolDefinition, ToolDefinitionMapper +from ..core.tool_mapper import DefaultToolDefinitionMapper, parse_mcp_tool_definition + + +class UnityMcpSemanticPlugin: + """Owns MCP discovery state and tool invocation behavior for SK registration.""" + + def __init__(self, client: McpToolClient, mapper: ToolDefinitionMapper | None = None) -> None: + if client is None: + raise ValueError("client must not be None.") + self._client = client + self._mapper = mapper or DefaultToolDefinitionMapper() + self._is_initialized = False + + @property + def is_initialized(self) -> bool: + """Return whether discovery has completed at least once.""" + return self._is_initialized + + async def initialize(self) -> None: + """ + Discover MCP tools once and cache them in the mapper. + + Repeated calls are idempotent and do not re-query the MCP server. + """ + if self._is_initialized: + return + raw_tools = await self._client.list_tools() + parsed_tools = [parse_mcp_tool_definition(raw_tool) for raw_tool in raw_tools] + self._mapper.initialize(parsed_tools) + self._is_initialized = True + + async def invoke_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: + """ + Invoke an MCP tool through the configured client. + + Args: + tool_name: Name of the tool to invoke. + arguments: Tool arguments. + + Returns: + Raw MCP tool invocation result. + """ + return await self._client.invoke_tool(tool_name=tool_name, arguments=arguments) + + def get_tool_by_name(self, name: str) -> ToolDefinition | None: + """Return a single registered tool definition by name.""" + return self._mapper.get_tool_by_name(name) + + def get_tool_names(self) -> list[str]: + """Return all registered tool names in deterministic order.""" + return self._mapper.get_tool_names() + + def get_registered_tools(self) -> list[ToolDefinition]: + """Return all registered tool definitions in deterministic order.""" + return self._mapper.get_registered_tools() + diff --git a/backend/app/agents/unity_mcp_sk/sk_integration/__init__.py b/backend/app/agents/unity_mcp_sk/sk_integration/__init__.py new file mode 100644 index 00000000..531ee72f --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/sk_integration/__init__.py @@ -0,0 +1,2 @@ +"""Semantic Kernel registration/factory helpers for Unity MCP tools.""" + diff --git a/backend/app/agents/unity_mcp_sk/sk_integration/kernel_factory.py b/backend/app/agents/unity_mcp_sk/sk_integration/kernel_factory.py new file mode 100644 index 00000000..ee686a06 --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/sk_integration/kernel_factory.py @@ -0,0 +1,52 @@ +"""Convenience kernel factory for Unity MCP Semantic Kernel integration.""" + +from __future__ import annotations + +from semantic_kernel import Kernel + +from ..core.contracts import McpToolClient +from ..plugin.unity_mcp_semantic_plugin import UnityMcpSemanticPlugin +from .kernel_registration import register_unity_router_function, register_unity_tools_as_functions + + +async def create_kernel_with_unity_async( + client: McpToolClient, + *, + plugin_name: str = "unity", + expanded_mode: bool = True, +) -> tuple[Kernel, UnityMcpSemanticPlugin]: + """ + Create an SK kernel with Unity MCP integration. + + Args: + client: MCP client used for discovery and invocation. + plugin_name: SK plugin namespace for Unity functions. + expanded_mode: True to register one function per tool (recommended), + False to keep router mode with a single invoke function. + + Returns: + Tuple of configured kernel and initialized Unity MCP plugin runtime. + + Raises: + ValueError: If inputs are invalid. + + Example: + >>> # doctest: +SKIP + >>> kernel, unity_plugin = await create_kernel_with_unity_async(client, expanded_mode=True) + """ + if client is None: + raise ValueError("client must not be None.") + if not plugin_name or not isinstance(plugin_name, str): + raise ValueError("plugin_name must be a non-empty string.") + + unity_plugin = UnityMcpSemanticPlugin(client=client) + await unity_plugin.initialize() + + kernel = Kernel() + if expanded_mode: + register_unity_tools_as_functions(kernel=kernel, plugin=unity_plugin, plugin_name=plugin_name) + else: + register_unity_router_function(kernel=kernel, plugin=unity_plugin, plugin_name=plugin_name) + + return kernel, unity_plugin + diff --git a/backend/app/agents/unity_mcp_sk/sk_integration/kernel_registration.py b/backend/app/agents/unity_mcp_sk/sk_integration/kernel_registration.py new file mode 100644 index 00000000..c694cc90 --- /dev/null +++ b/backend/app/agents/unity_mcp_sk/sk_integration/kernel_registration.py @@ -0,0 +1,184 @@ +"""Helpers for registering Unity MCP tools into Semantic Kernel.""" + +from __future__ import annotations + +from typing import Any + +from semantic_kernel import Kernel +from semantic_kernel.functions import KernelPlugin, kernel_function +from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod +from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata + +from ..core.contracts import MCP_JSON_TYPE_TO_SK_TYPE, ToolDefinition, ToolParameterDefinition +from ..plugin.unity_mcp_semantic_plugin import UnityMcpSemanticPlugin + +_JSON_TYPE_TO_PYTHON_TYPE: dict[str, type[Any]] = { + "string": str, + "number": float, + "integer": int, + "boolean": bool, + "array": list, + "object": dict, +} + + +def _build_parameter_metadata(parameter: ToolParameterDefinition) -> KernelParameterMetadata: + default_value = parameter.default + is_required = parameter.required + if default_value is not None and is_required: + # Default values imply optional semantics for planners. + is_required = False + json_type = parameter.json_type if parameter.json_type in MCP_JSON_TYPE_TO_SK_TYPE else "object" + return KernelParameterMetadata( + name=parameter.name, + description=parameter.description, + default_value=default_value, + type_=MCP_JSON_TYPE_TO_SK_TYPE[json_type], + is_required=is_required, + type_object=_JSON_TYPE_TO_PYTHON_TYPE.get(json_type, dict), + schema_data=parameter.schema, + ) + + +def _build_return_metadata(tool_definition: ToolDefinition) -> KernelParameterMetadata: + return KernelParameterMetadata( + name="return", + description=f"Result from MCP tool '{tool_definition.name}'.", + default_value=None, + type_="object", + is_required=True, + type_object=dict, + schema_data=tool_definition.return_schema or {"type": "object"}, + ) + + +def _create_tool_function(tool_definition: ToolDefinition, plugin: UnityMcpSemanticPlugin, plugin_name: str) -> Any: + @kernel_function(name=tool_definition.name, description=tool_definition.description) + async def _invoke_tool(**kwargs: Any) -> Any: + return await plugin.invoke_tool(tool_definition.name, kwargs) + + return KernelFunctionFromMethod( + method=_invoke_tool, + plugin_name=plugin_name, + parameters=[_build_parameter_metadata(parameter) for parameter in tool_definition.parameters], + return_parameter=_build_return_metadata(tool_definition), + additional_metadata={ + "mcp_input_schema": tool_definition.raw_input_schema, + "mcp_return_schema": tool_definition.return_schema, + }, + ) + + +def register_unity_tools_as_functions( + kernel: Kernel, + plugin: UnityMcpSemanticPlugin, + plugin_name: str = "unity", +) -> KernelPlugin: + """ + Register discovered MCP tools as individual Semantic Kernel functions. + + Args: + kernel: Target Semantic Kernel instance. + plugin: Initialized Unity MCP semantic plugin. + plugin_name: SK namespace under which all tool functions are registered. + + Returns: + Registered KernelPlugin handle. + + Raises: + RuntimeError: If plugin has no discovered tools (not initialized or empty). + """ + if kernel is None: + raise ValueError("kernel must not be None.") + if plugin is None: + raise ValueError("plugin must not be None.") + if not plugin_name or not isinstance(plugin_name, str): + raise ValueError("plugin_name must be a non-empty string.") + + tool_definitions = plugin.get_registered_tools() + if not tool_definitions: + raise RuntimeError( + "Unity MCP plugin has no registered tools. Initialize plugin before registering with Semantic Kernel." + ) + + functions = [_create_tool_function(tool_definition, plugin, plugin_name) for tool_definition in tool_definitions] + sk_plugin = KernelPlugin(name=plugin_name, description="Unity MCP tools", functions=functions) + return kernel.add_plugin(sk_plugin, plugin_name=plugin_name) + + +def register_unity_router_function( + kernel: Kernel, + plugin: UnityMcpSemanticPlugin, + plugin_name: str = "unity", +) -> KernelPlugin: + """Register a single generic router function for backward compatibility.""" + if kernel is None: + raise ValueError("kernel must not be None.") + if plugin is None: + raise ValueError("plugin must not be None.") + if not plugin_name or not isinstance(plugin_name, str): + raise ValueError("plugin_name must be a non-empty string.") + + @kernel_function( + name="invoke", + description="Invoke any Unity MCP tool by passing tool_name and arguments.", + ) + async def _invoke(**kwargs: Any) -> Any: + raw_arguments = kwargs.get("arguments") + if hasattr(raw_arguments, "items"): + argument_payload = dict(raw_arguments) + elif isinstance(raw_arguments, dict): + argument_payload = raw_arguments + else: + argument_payload = {} + + tool_name = kwargs.get("tool_name") or argument_payload.get("tool_name") + if not tool_name or not isinstance(tool_name, str): + raise ValueError("tool_name must be a non-empty string.") + + # Some SK call paths wrap both router args into one payload object. + if "arguments" in argument_payload and isinstance(argument_payload.get("arguments"), dict): + resolved_arguments = argument_payload["arguments"] + else: + resolved_arguments = argument_payload + if not isinstance(resolved_arguments, dict): + raise ValueError("arguments must be an object when provided.") + return await plugin.invoke_tool(tool_name, resolved_arguments) + + router_function = KernelFunctionFromMethod( + method=_invoke, + plugin_name=plugin_name, + parameters=[ + KernelParameterMetadata( + name="tool_name", + description="MCP tool name to invoke.", + default_value=None, + type_="string", + is_required=True, + type_object=str, + schema_data={"type": "string"}, + ), + KernelParameterMetadata( + name="arguments", + description="Arguments object passed to the selected MCP tool.", + default_value={}, + type_="object", + is_required=False, + type_object=dict, + schema_data={"type": "object"}, + ), + ], + return_parameter=KernelParameterMetadata( + name="return", + description="Result from the selected MCP tool.", + default_value=None, + type_="object", + is_required=True, + type_object=dict, + schema_data={"type": "object"}, + ), + ) + + sk_plugin = KernelPlugin(name=plugin_name, description="Unity MCP router", functions=[router_function]) + return kernel.add_plugin(sk_plugin, plugin_name=plugin_name) + diff --git a/backend/package.json b/backend/package.json index dcef7efd..2fbc51dd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "unity-generator-backend", - "version": "1.7.0", + "version": "1.8.0", "description": "Backend for Unity Generator", "private": true, "scripts": { diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8e27159a..e794b0e4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "unity-generator-backend" -version = "0.10.0" +version = "1.8.0" description = "Unity project generation and finalization backend with AI integration" requires-python = ">=3.11" license = {text = "MIT"} diff --git a/backend/tests/test_unity_mcp_sk_integration.py b/backend/tests/test_unity_mcp_sk_integration.py new file mode 100644 index 00000000..89859f18 --- /dev/null +++ b/backend/tests/test_unity_mcp_sk_integration.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from semantic_kernel.functions.kernel_arguments import KernelArguments + +from app.agents.unity_mcp_sk.core.tool_mapper import DefaultToolDefinitionMapper, parse_mcp_tool_definition +from app.agents.unity_mcp_sk.plugin.unity_mcp_semantic_plugin import UnityMcpSemanticPlugin +from app.agents.unity_mcp_sk.sk_integration.kernel_factory import create_kernel_with_unity_async +from app.agents.unity_mcp_sk.sk_integration.kernel_registration import register_unity_tools_as_functions + + +class FakeMcpClient: + def __init__(self, tools: list[dict[str, Any]]) -> None: + self._tools = tools + self.list_tools_calls = 0 + self.invoke_calls: list[tuple[str, dict[str, Any]]] = [] + + async def list_tools(self) -> list[dict[str, Any]]: + self.list_tools_calls += 1 + return self._tools + + async def invoke_tool(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: + self.invoke_calls.append((tool_name, arguments)) + return {"success": True, "tool": tool_name, "arguments": arguments} + + +def _build_tools_fixture() -> list[dict[str, Any]]: + return [ + { + "name": "z_ping", + "description": "Ping tool.", + "inputSchema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "build_scene", + "description": "Build a scene from parameters.", + "inputSchema": { + "type": "object", + "required": ["projectPath", "count"], + "properties": { + "projectPath": {"type": "string", "description": "Unity project path."}, + "count": {"type": "integer", "description": "Object count."}, + "usePhysics": {"type": "boolean", "description": "Enable physics.", "default": False}, + "weights": {"type": "array", "description": "Distribution weights."}, + "settings": {"type": "object", "description": "Scene settings."}, + "temperature": {"type": "number", "description": "Numeric tuning.", "default": 0.3}, + }, + }, + "outputSchema": {"type": "object"}, + }, + { + "name": "a_echo", + "description": "Echo tool.", + "inputSchema": { + "type": "object", + "required": ["message"], + "properties": {"message": {"type": "string", "description": "Message."}}, + }, + }, + ] + + +@pytest.mark.asyncio +async def test_get_registered_tools_returns_sorted_tools() -> None: + mapper = DefaultToolDefinitionMapper() + parsed = [parse_mcp_tool_definition(item) for item in _build_tools_fixture()] + mapper.initialize(parsed) + + registered_names = [tool.name for tool in mapper.get_registered_tools()] + assert registered_names == ["a_echo", "build_scene", "z_ping"] + + +@pytest.mark.asyncio +async def test_kernel_factory_lists_tools_only_once() -> None: + client = FakeMcpClient(_build_tools_fixture()) + kernel, plugin = await create_kernel_with_unity_async(client=client, plugin_name="unity", expanded_mode=True) + + assert kernel is not None + assert plugin.is_initialized is True + assert client.list_tools_calls == 1 + + register_unity_tools_as_functions(kernel=kernel, plugin=plugin, plugin_name="unity_v2") + assert client.list_tools_calls == 1 + + +@pytest.mark.asyncio +async def test_expanded_registration_uses_single_plugin_namespace() -> None: + client = FakeMcpClient(_build_tools_fixture()) + kernel, _ = await create_kernel_with_unity_async(client=client, plugin_name="unity", expanded_mode=True) + + plugin = kernel.get_plugin("unity") + assert plugin is not None + assert "unity" in list(kernel.plugins) + assert set(plugin.functions.keys()) == {"a_echo", "build_scene", "z_ping"} + + +@pytest.mark.asyncio +async def test_registered_metadata_matches_mcp_schema() -> None: + client = FakeMcpClient(_build_tools_fixture()) + kernel, _ = await create_kernel_with_unity_async(client=client, plugin_name="unity", expanded_mode=True) + + plugin = kernel.get_plugin("unity") + function = plugin["build_scene"] + parameters = {parameter.name: parameter for parameter in function.metadata.parameters} + + assert parameters["projectPath"].type_ == "string" + assert parameters["projectPath"].is_required is True + assert parameters["count"].type_ == "integer" + assert parameters["count"].is_required is True + assert parameters["temperature"].type_ == "number" + assert parameters["temperature"].default_value == 0.3 + assert parameters["temperature"].is_required is False + assert parameters["usePhysics"].type_ == "boolean" + assert parameters["usePhysics"].default_value is False + assert parameters["weights"].type_ == "array" + assert parameters["settings"].type_ == "object" + + +@pytest.mark.asyncio +async def test_router_mode_still_works() -> None: + client = FakeMcpClient(_build_tools_fixture()) + kernel, _ = await create_kernel_with_unity_async(client=client, plugin_name="unity", expanded_mode=False) + + plugin = kernel.get_plugin("unity") + assert set(plugin.functions.keys()) == {"invoke"} + + result = await kernel.invoke( + function_name="invoke", + plugin_name="unity", + arguments=KernelArguments(tool_name="a_echo", arguments={"message": "hello"}), + ) + assert result is not None + assert client.invoke_calls == [("a_echo", {"message": "hello"})] + diff --git a/docs/UNITY_MCP_SK_INTEGRATION.md b/docs/UNITY_MCP_SK_INTEGRATION.md index 7fe9826b..c7677e3e 100644 --- a/docs/UNITY_MCP_SK_INTEGRATION.md +++ b/docs/UNITY_MCP_SK_INTEGRATION.md @@ -1,53 +1,74 @@ -# Integrating Unity-MCP with Semantic Kernel (SK) in .NET 10 +# Unity MCP + Semantic Kernel Integration -## Overview -To connect your existing Unity-MCP server to Semantic Kernel (SK), you do **not** need to rewrite the server or create a separate plugin project. Instead, use the official MCP connector package for SK, which allows the kernel to discover and use your MCP tools automatically. +## Python Integration Modes -## Steps +The Python integration now supports two registration modes: -### 1. Install the MCP Connector Package +- **Expanded mode (recommended)**: each discovered MCP tool is exposed as an individual Semantic Kernel function under one plugin namespace (default: `unity`). +- **Router mode (backward compatible)**: one generic `invoke` function routes calls to a chosen MCP tool. -```sh -dotnet add package Microsoft.SemanticKernel.Connectors.Mcp -``` +Expanded mode improves planner/autonomous agent tool selection because functions and parameter schemas are visible as first-class SK functions. + +## Expanded Mode (Recommended) -### 2. Register the MCP Server as a Plugin in SK +```python +from app.agents.unity_mcp_sk.sk_integration.kernel_factory import create_kernel_with_unity_async -```csharp -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.Mcp; +# client must implement list_tools() and invoke_tool(tool_name, arguments) +kernel, unity_plugin = await create_kernel_with_unity_async( + client=my_mcp_client, + plugin_name="unity", + expanded_mode=True, +) -var builder = Kernel.CreateBuilder(); +# SK now sees one plugin namespace ("unity") with many functions: +# - unity_create_scene +# - unity_add_gameobject +# - unity_create_material +# - ... +``` -// Register the global unity-mcp tool via Stdio -var mcpPlugin = new McpStdioPlugin( - "UnityManager", - "unity-mcp" // The global tool name -); +## Router Mode (Backward Compatible) -builder.Plugins.Add(mcpPlugin); -var kernel = builder.Build(); +```python +from app.agents.unity_mcp_sk.sk_integration.kernel_factory import create_kernel_with_unity_async -// Example: Call a tool -var result = await kernel.InvokeAsync("UnityManager", "create_scene", new() { - ["name"] = "MainScene" -}); +kernel, unity_plugin = await create_kernel_with_unity_async( + client=my_mcp_client, + plugin_name="unity", + expanded_mode=False, +) + +# SK sees one function: +# unity.invoke(tool_name, arguments) ``` -### 3. How It Works -- The MCP server is a global tool (e.g., `unity-mcp`) and is discovered automatically. -- The connector queries the MCP server for available tools and exposes them as KernelFunctions. -- No .json/.yaml plugin project or manual tool registration is needed. +## Tradeoffs + +- **Expanded mode** + - Better discoverability and tool-calling reliability for planners/agents. + - Richer per-tool metadata (name, description, required/default, type mapping). + - Slightly larger function registry and token footprint. +- **Router mode** + - Smallest function footprint (single generic function). + - Weaker discoverability, often requiring stronger prompting to choose tools correctly. + +## Discovery and Registration Behavior + +- Tool discovery (`list_tools`) is performed once during plugin initialization. +- Registration uses the mapper's cached `get_registered_tools()` output as the source of truth. +- Tool registration order is deterministic (sorted by tool name). + +## Metadata Fidelity Notes + +For each MCP tool parameter, the integration preserves: -### 4. Best Practices -- Ensure `unity-mcp` is installed and available in your system PATH. -- Use the latest version of the connector for .NET 10 compatibility. -- Tool descriptions and parameters from MCP are surfaced in SK automatically. +- exact parameter name, +- description, +- required/optional state, +- default value when present, +- JSON type mapping: `string`, `number`, `integer`, `boolean`, `array`, `object`. ---- +Current Python SK limitation/workaround: -**Summary:** -- No plugin project required. -- Use the official connector package. -- Register your MCP server as shown above. -- All MCP tools become available in SK with no extra configuration. +- Complex JSON Schema constructs (`oneOf`, `anyOf`, tuple schemas, advanced constraints) are passed through in `schema_data`, but SK planner behavior primarily uses simplified `KernelParameterMetadata` fields (`type_`, `is_required`, `default_value`). diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 7575545d..cbb6a0de 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -56,3 +56,17 @@ Thumbs.db .cursor/ .qwen/ .kiro/ + +.venv +.venv-local +.venv-build +.vscode +.idea +.pytest_cache +.coverage +.coverage.* +.coverage.*.* +.coverage.*.*.* +.coverage.*.*.*.* +.coverage.*.*.*.*.* +.coverage.*.*.*.*.*.* diff --git a/frontend/package.json b/frontend/package.json index e7a9a431..5c01eb58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,7 @@ "name": "unity-generator-frontend", "description": "Frontend for Unity Generator", "private": true, - "version": "1.7.0", + "version": "1.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/package.json b/package.json index ba50e064..8f2c8b31 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.7.0", + "version": "1.8.0", "description": "Desktop app to generate Unity assets using AI (Electron + FastAPI)", "name": "unity-generator", "private": true, From 08a57dee6d875612c5ce0b1e967a392bf54a42ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreu=20Mart=C3=ADnez=20Vidal?= Date: Sun, 22 Mar 2026 11:32:49 +0100 Subject: [PATCH 2/5] Update project versions to 1.8.0 and enhance .dockerignore and .gitignore files Bump frontend, backend, and package versions to 1.8.0. Add common development environment files to .dockerignore and .gitignore for better project management. This includes entries for virtual environments, IDE configurations, and test coverage files. --- README.md | 16 +- backend/app/agents/SKILLS_USAGE.md | 2 +- backend/app/agents/unity_mcp_plugin.py | 45 ++- backend/app/agents/unity_mcp_sk/__init__.py | 19 -- .../app/agents/unity_mcp_sk/core/__init__.py | 2 - .../app/agents/unity_mcp_sk/core/contracts.py | 88 ------ .../agents/unity_mcp_sk/core/tool_mapper.py | 98 ------- .../unity_mcp_sk/infrastructure/__init__.py | 2 - .../unity_mcp_sk/infrastructure/mcp_client.py | 40 --- .../agents/unity_mcp_sk/plugin/__init__.py | 2 - .../plugin/unity_mcp_semantic_plugin.py | 63 ----- .../unity_mcp_sk/sk_integration/__init__.py | 2 - .../sk_integration/kernel_factory.py | 52 ---- .../sk_integration/kernel_registration.py | 184 ------------- backend/requirements.txt | 10 +- backend/tests/test_unity_mcp_pluginwrapper.py | 44 ++- .../tests/test_unity_mcp_sk_integration.py | 136 --------- docs/DEVELOPMENT.md | 2 +- docs/UNITY_MCP_INTEGRATION.md | 33 ++- docs/UNITY_MCP_SERVER.md | 116 ++++++++ docs/UNITY_MCP_SK_INTEGRATION.md | 82 +++--- frontend/src/i18n/index.ts | 7 +- frontend/src/i18n/locales/ar.ts | 1 + frontend/src/i18n/locales/bn.ts | 1 + frontend/src/i18n/locales/ca.ts | 1 + frontend/src/i18n/locales/de.ts | 1 + frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/es.ts | 1 + frontend/src/i18n/locales/eu.ts | 1 + frontend/src/i18n/locales/fr.ts | 1 + frontend/src/i18n/locales/gl.ts | 8 + frontend/src/i18n/locales/hi.ts | 1 + frontend/src/i18n/locales/id.ts | 1 + frontend/src/i18n/locales/it.ts | 1 + frontend/src/i18n/locales/ja.ts | 1 + frontend/src/i18n/locales/ko.ts | 1 + frontend/src/i18n/locales/oc.ts | 1 + frontend/src/i18n/locales/pl.ts | 1 + frontend/src/i18n/locales/pt.ts | 1 + frontend/src/i18n/locales/tr.ts | 258 ++++++++++++++++++ frontend/src/i18n/locales/uk.ts | 1 + frontend/src/i18n/locales/ur.ts | 1 + frontend/src/i18n/locales/vi.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + pyrightconfig.json | 3 +- 45 files changed, 560 insertions(+), 774 deletions(-) delete mode 100644 backend/app/agents/unity_mcp_sk/__init__.py delete mode 100644 backend/app/agents/unity_mcp_sk/core/__init__.py delete mode 100644 backend/app/agents/unity_mcp_sk/core/contracts.py delete mode 100644 backend/app/agents/unity_mcp_sk/core/tool_mapper.py delete mode 100644 backend/app/agents/unity_mcp_sk/infrastructure/__init__.py delete mode 100644 backend/app/agents/unity_mcp_sk/infrastructure/mcp_client.py delete mode 100644 backend/app/agents/unity_mcp_sk/plugin/__init__.py delete mode 100644 backend/app/agents/unity_mcp_sk/plugin/unity_mcp_semantic_plugin.py delete mode 100644 backend/app/agents/unity_mcp_sk/sk_integration/__init__.py delete mode 100644 backend/app/agents/unity_mcp_sk/sk_integration/kernel_factory.py delete mode 100644 backend/app/agents/unity_mcp_sk/sk_integration/kernel_registration.py delete mode 100644 backend/tests/test_unity_mcp_sk_integration.py create mode 100644 docs/UNITY_MCP_SERVER.md create mode 100644 frontend/src/i18n/locales/tr.ts diff --git a/README.md b/README.md index 3bd8cc0e..7ab90b2b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ and assets without wiring multiple tools together. It’s not a full IDE or asse - **Unity UI Elements**: Generate Unity UI prefabs (health bars, buttons, dialogue boxes, HUD layouts) for both uGUI and UI Toolkit - **Incremental Asset Generation**: Save assets directly into an active Unity project - **Pixel-Art Sprites**: Generate and process 2D sprite sheets with automatic cropping -- **Unity MCP Integration**: Real-time interaction with the Unity Editor using [Unity-MCP-SK-Plugin](https://github.com/Ozymandros/Unity-MCP-SK-Plugin) and [Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server) +- **Unity MCP integration**: Python bridge [`unity-mcp-plugin`](https://github.com/Ozymandros/Unity-MCP-SK-Plugin) (Semantic Kernel; see `backend/requirements.txt` for Git vs local editable install) plus MCP server [**Unity-MCP-Server**](https://github.com/Ozymandros/Unity-MCP-Server) — both are required; see [docs/UNITY_MCP_SERVER.md](docs/UNITY_MCP_SERVER.md) for server install/Git URLs/env vars, and [docs/UNITY_MCP_INTEGRATION.md](docs/UNITY_MCP_INTEGRATION.md) for the full SK setup. - Save and reuse provider settings and preferences locally - Configure global and per-request system key prompts for tailored generation - Keep output structured so Unity can open it right away @@ -80,6 +80,7 @@ Scaffolded and functional. See docs for development and packaging details. The p - [Architecture overview](docs/ARCHITECTURE.md) - [Development guide](docs/DEVELOPMENT.md) +- [Unity MCP Server (install, Git URL, env vars)](docs/UNITY_MCP_SERVER.md) - [Packaging and distribution](docs/PACKAGING.md) - [System Prompts guide](docs/SYSTEM_PROMPTS.md) @@ -358,9 +359,18 @@ Electron app. - If a provider request errors, check `logs/` for the failed request log. - If a Docker build is slow, ensure `node_modules/` and venvs are ignored. -## Unity MCP Integration +## Unity MCP integration -See [docs/UNITY_MCP_INTEGRATION.md](docs/UNITY_MCP_INTEGRATION.md) for details on the Semantic Kernel MCP integration, configuration, and usage. +Dependencies and setup (both repos are required): + +| Role | GitHub | +|------|--------| +| MCP server (Unity tools, stdio) | [Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server) | +| Python Semantic Kernel plugin (`unity_mcp`) | [Unity-MCP-SK-Plugin](https://github.com/Ozymandros/Unity-MCP-SK-Plugin) | + +- **Unity MCP Server only (not the Python plugin):** [docs/UNITY_MCP_SERVER.md](docs/UNITY_MCP_SERVER.md) +- **Semantic Kernel + paths:** [docs/UNITY_MCP_INTEGRATION.md](docs/UNITY_MCP_INTEGRATION.md) +- **Expanded vs router SK modes:** [docs/UNITY_MCP_SK_INTEGRATION.md](docs/UNITY_MCP_SK_INTEGRATION.md) ## Download diff --git a/backend/app/agents/SKILLS_USAGE.md b/backend/app/agents/SKILLS_USAGE.md index 41a062f7..7780dda4 100644 --- a/backend/app/agents/SKILLS_USAGE.md +++ b/backend/app/agents/SKILLS_USAGE.md @@ -33,7 +33,7 @@ Provides real-time integration with a running Unity Editor via the Unity MCP Ser - Plugin Source: [Unity-MCP-SK-Plugin](https://github.com/Ozymandros/Unity-MCP-SK-Plugin) - Server Source: [Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server) -Requires the `unity-mcp-plugin` package installed in the Python environment. For the full tool list and parameter details, see the server’s tool reference (e.g. Unity-MCP-Server `Skills/SKILL.md` or README). +Requires the `unity-mcp-plugin` package installed in the Python environment (see `backend/requirements.txt` and [docs/UNITY_MCP_INTEGRATION.md](../../../docs/UNITY_MCP_INTEGRATION.md) for the SK bridge). **MCP server install, clone URL, and `UNITY_MCP_*` locations:** [docs/UNITY_MCP_SERVER.md](../../../docs/UNITY_MCP_SERVER.md). For the full tool list and parameter details, see the server’s tool reference (e.g. Unity-MCP-Server `Skills/SKILL.md` or README). **Path handling:** The server requires **projectPath** (project root) and **fileName** (or **folderName** for folder/list tools) on all file, scene, and asset tools. Pass `projectPath` as the project root directory and `fileName` as the path under the project (e.g. `Assets/Scenes/MyScene.unity`). Do not duplicate path segments (e.g. do not put the project root inside `fileName`). diff --git a/backend/app/agents/unity_mcp_plugin.py b/backend/app/agents/unity_mcp_plugin.py index 79f850c6..21780bce 100644 --- a/backend/app/agents/unity_mcp_plugin.py +++ b/backend/app/agents/unity_mcp_plugin.py @@ -1,11 +1,15 @@ import asyncio import logging +import os from contextlib import asynccontextmanager from semantic_kernel import Kernel -from semantic_kernel.connectors.mcp import MCPStdioPlugin +from unity_mcp import UnityMcpOptions, UnityMCPPlugin LOGGER = logging.getLogger("unity_mcp_plugin") +_ENV_MCP_COMMAND = "UNITY_MCP_COMMAND" +_ENV_MCP_ARGS = "UNITY_MCP_ARGS" +_DEFAULT_MCP_EXECUTABLE = "unity-mcp" async def _check_unity_mcp_live() -> bool: @@ -20,6 +24,9 @@ async def _check_unity_mcp_live() -> bool: plugin = kernel.get_plugin("UnityMCP") if plugin and "ping" in plugin: await kernel.invoke(plugin["ping"]) + elif hasattr(mcp_plugin, "is_healthy"): + if not mcp_plugin.is_healthy(): + return False return True except Exception as e: LOGGER.debug("Unity MCP live check failed: %s", e) @@ -41,22 +48,36 @@ def unity_mcp_plugin_available_for_writing() -> bool: @asynccontextmanager async def create_unity_mcp_plugin(): """ - Async context manager for the Unity MCP plugin (global unity-mcp tool). + Async context manager for the Unity MCP plugin. + + Uses the local `unity-mcp-plugin` package implementation and initializes + the plugin against the configured Unity MCP executable. + Use with: async with create_unity_mcp_plugin() as mcp_plugin: ... """ LOGGER.info("Creating UnityMCP plugin...") + command = os.environ.get(_ENV_MCP_COMMAND, _DEFAULT_MCP_EXECUTABLE).strip() or _DEFAULT_MCP_EXECUTABLE + mcp_args = os.environ.get(_ENV_MCP_ARGS, "").strip() + if mcp_args: + LOGGER.warning( + "%s is set but ignored by unity-mcp-plugin local package implementation.", + _ENV_MCP_ARGS, + ) + try: - plugin = MCPStdioPlugin( - name="UnityMCP", - description="Unity Editor automation tools", - command="unity-mcp", - args=[], - load_tools=True, - request_timeout=30, + options = UnityMcpOptions( + executable_path=command, + connection_timeout_seconds=30, + request_timeout_seconds=30, + enable_message_logging=False, ) - async with plugin as mcp_plugin: - LOGGER.info("UnityMCP plugin created successfully.") - yield mcp_plugin + plugin = UnityMCPPlugin.create(options) + await plugin.initialize() + LOGGER.info("UnityMCP plugin created successfully.") + try: + yield plugin + finally: + await plugin.cleanup() except Exception as e: LOGGER.error("Failed to create UnityMCP plugin: %s", e) raise diff --git a/backend/app/agents/unity_mcp_sk/__init__.py b/backend/app/agents/unity_mcp_sk/__init__.py deleted file mode 100644 index e143fa94..00000000 --- a/backend/app/agents/unity_mcp_sk/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Semantic Kernel integration for Unity MCP tools. - -This package supports two registration modes: -- Expanded mode: one SK function per MCP tool (recommended). -- Router mode: one generic SK function for tool routing. -""" - -from .plugin.unity_mcp_semantic_plugin import UnityMcpSemanticPlugin -from .sk_integration.kernel_factory import create_kernel_with_unity_async -from .sk_integration.kernel_registration import register_unity_router_function, register_unity_tools_as_functions - -__all__ = [ - "UnityMcpSemanticPlugin", - "create_kernel_with_unity_async", - "register_unity_router_function", - "register_unity_tools_as_functions", -] - diff --git a/backend/app/agents/unity_mcp_sk/core/__init__.py b/backend/app/agents/unity_mcp_sk/core/__init__.py deleted file mode 100644 index 95b4429a..00000000 --- a/backend/app/agents/unity_mcp_sk/core/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Core contracts and mappers for Unity MCP SK integration.""" - diff --git a/backend/app/agents/unity_mcp_sk/core/contracts.py b/backend/app/agents/unity_mcp_sk/core/contracts.py deleted file mode 100644 index 47aaac88..00000000 --- a/backend/app/agents/unity_mcp_sk/core/contracts.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Core contracts and value objects for Unity MCP SK integration.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any, Protocol - - -MCP_JSON_TYPE_TO_SK_TYPE: dict[str, str] = { - "string": "string", - "number": "number", - "integer": "integer", - "boolean": "boolean", - "array": "array", - "object": "object", -} - - -@dataclass(frozen=True) -class ToolParameterDefinition: - """ - Represents an MCP tool parameter as a typed, SK-ready value object. - - Args: - name: Exact parameter name from MCP schema. - description: Human-readable parameter description. - json_type: MCP JSON schema type. - required: Whether the parameter is required. - default: Optional default value from MCP schema. - schema: Full original JSON schema fragment for this parameter. - """ - - name: str - description: str - json_type: str - required: bool - default: Any = None - schema: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class ToolDefinition: - """ - Represents one discovered MCP tool definition. - - Args: - name: MCP tool name. - description: Tool description exposed to SK planners/agents. - parameters: Parsed input parameters from MCP input schema. - return_schema: Optional output schema metadata when available. - raw_input_schema: Raw MCP input schema for metadata passthrough. - """ - - name: str - description: str - parameters: tuple[ToolParameterDefinition, ...] - return_schema: dict[str, Any] = field(default_factory=dict) - raw_input_schema: dict[str, Any] = field(default_factory=dict) - - -class ToolDefinitionMapper(Protocol): - """Maps/discovers MCP tool definitions and stores a deterministic registry.""" - - def initialize(self, tool_definitions: list[ToolDefinition]) -> None: - """Initialize mapper with tool definitions.""" - - def map_tool_definition(self, tool_definition: ToolDefinition) -> ToolDefinition: - """Normalize a tool definition for SK registration.""" - - def get_tool_by_name(self, name: str) -> ToolDefinition | None: - """Return a registered tool definition by name.""" - - def get_tool_names(self) -> list[str]: - """Return all registered tool names in deterministic order.""" - - def get_registered_tools(self) -> list[ToolDefinition]: - """Return all registered tool definitions in deterministic order.""" - - -class McpToolClient(Protocol): - """Client contract for listing and invoking MCP tools.""" - - async def list_tools(self) -> list[dict[str, Any]]: - """Return raw MCP tool definitions.""" - - async def invoke_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: - """Invoke one MCP tool with arguments.""" - diff --git a/backend/app/agents/unity_mcp_sk/core/tool_mapper.py b/backend/app/agents/unity_mcp_sk/core/tool_mapper.py deleted file mode 100644 index f23be21b..00000000 --- a/backend/app/agents/unity_mcp_sk/core/tool_mapper.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Default tool-definition mapper for Unity MCP SK integration.""" - -from __future__ import annotations - -from typing import Any - -from .contracts import MCP_JSON_TYPE_TO_SK_TYPE, ToolDefinition, ToolDefinitionMapper, ToolParameterDefinition - - -class DefaultToolDefinitionMapper(ToolDefinitionMapper): - """Stores normalized tool definitions and exposes deterministic lookup APIs.""" - - def __init__(self) -> None: - self._tools_by_name: dict[str, ToolDefinition] = {} - - def initialize(self, tool_definitions: list[ToolDefinition]) -> None: - self._tools_by_name = {tool.name: self.map_tool_definition(tool) for tool in tool_definitions} - - def map_tool_definition(self, tool_definition: ToolDefinition) -> ToolDefinition: - normalized_parameters = tuple( - ToolParameterDefinition( - name=parameter.name, - description=parameter.description, - json_type=parameter.json_type if parameter.json_type in MCP_JSON_TYPE_TO_SK_TYPE else "object", - required=parameter.required, - default=parameter.default, - schema=parameter.schema, - ) - for parameter in tool_definition.parameters - ) - return ToolDefinition( - name=tool_definition.name, - description=tool_definition.description, - parameters=normalized_parameters, - return_schema=tool_definition.return_schema, - raw_input_schema=tool_definition.raw_input_schema, - ) - - def get_tool_by_name(self, name: str) -> ToolDefinition | None: - return self._tools_by_name.get(name) - - def get_tool_names(self) -> list[str]: - return sorted(self._tools_by_name.keys()) - - def get_registered_tools(self) -> list[ToolDefinition]: - return [self._tools_by_name[name] for name in self.get_tool_names()] - - -def parse_mcp_tool_definition(raw_tool: dict[str, Any]) -> ToolDefinition: - """ - Parse one raw MCP tool definition into a typed value object. - - Args: - raw_tool: Raw MCP tool object with `name`, `description`, and `inputSchema`. - - Returns: - Parsed `ToolDefinition` with stable parameter extraction. - - Raises: - ValueError: If required fields are missing or malformed. - """ - name = raw_tool.get("name") - if not isinstance(name, str) or not name.strip(): - raise ValueError("MCP tool definition is missing a valid 'name'.") - - description = raw_tool.get("description") if isinstance(raw_tool.get("description"), str) else "" - input_schema = raw_tool.get("inputSchema") if isinstance(raw_tool.get("inputSchema"), dict) else {} - return_schema = raw_tool.get("outputSchema") if isinstance(raw_tool.get("outputSchema"), dict) else {} - required = input_schema.get("required") if isinstance(input_schema.get("required"), list) else [] - required_names = {item for item in required if isinstance(item, str)} - properties = input_schema.get("properties") if isinstance(input_schema.get("properties"), dict) else {} - - parameters: list[ToolParameterDefinition] = [] - for parameter_name in sorted(properties.keys()): - schema = properties.get(parameter_name) - if not isinstance(schema, dict): - continue - parameter_description = schema.get("description") if isinstance(schema.get("description"), str) else "" - json_type = schema.get("type") if isinstance(schema.get("type"), str) else "object" - parameters.append( - ToolParameterDefinition( - name=parameter_name, - description=parameter_description, - json_type=json_type, - required=parameter_name in required_names, - default=schema.get("default"), - schema=schema, - ) - ) - - return ToolDefinition( - name=name, - description=description, - parameters=tuple(parameters), - return_schema=return_schema, - raw_input_schema=input_schema, - ) - diff --git a/backend/app/agents/unity_mcp_sk/infrastructure/__init__.py b/backend/app/agents/unity_mcp_sk/infrastructure/__init__.py deleted file mode 100644 index 144e2c5d..00000000 --- a/backend/app/agents/unity_mcp_sk/infrastructure/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Infrastructure adapters for Unity MCP SK integration.""" - diff --git a/backend/app/agents/unity_mcp_sk/infrastructure/mcp_client.py b/backend/app/agents/unity_mcp_sk/infrastructure/mcp_client.py deleted file mode 100644 index 69dd974e..00000000 --- a/backend/app/agents/unity_mcp_sk/infrastructure/mcp_client.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Infrastructure MCP client adapter for Unity MCP SK integration.""" - -from __future__ import annotations - -from typing import Any - -from ..core.contracts import McpToolClient - - -class UnityMcpClientAdapter(McpToolClient): - """ - Minimal adapter around an MCP session-like object. - - The wrapped object must expose: - - `list_tools()` -> object with `.tools` or a dict containing `tools` - - `call_tool(tool_name, arguments)` -> tool result - """ - - def __init__(self, session: Any) -> None: - if session is None: - raise ValueError("session must not be None") - self._session = session - - async def list_tools(self) -> list[dict[str, Any]]: - raw = await self._session.list_tools() - if isinstance(raw, dict): - tools = raw.get("tools", []) - return [item for item in tools if isinstance(item, dict)] - tools = getattr(raw, "tools", []) - if not isinstance(tools, list): - return [] - return [item for item in tools if isinstance(item, dict)] - - async def invoke_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: - if not tool_name or not isinstance(tool_name, str): - raise ValueError("tool_name must be a non-empty string.") - if arguments is None or not isinstance(arguments, dict): - raise ValueError("arguments must be a dictionary.") - return await self._session.call_tool(tool_name, arguments) - diff --git a/backend/app/agents/unity_mcp_sk/plugin/__init__.py b/backend/app/agents/unity_mcp_sk/plugin/__init__.py deleted file mode 100644 index 08a84225..00000000 --- a/backend/app/agents/unity_mcp_sk/plugin/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Plugin runtime layer for Unity MCP SK integration.""" - diff --git a/backend/app/agents/unity_mcp_sk/plugin/unity_mcp_semantic_plugin.py b/backend/app/agents/unity_mcp_sk/plugin/unity_mcp_semantic_plugin.py deleted file mode 100644 index 36aee4cb..00000000 --- a/backend/app/agents/unity_mcp_sk/plugin/unity_mcp_semantic_plugin.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Unity MCP semantic plugin runtime abstraction.""" - -from __future__ import annotations - -from typing import Any - -from ..core.contracts import McpToolClient, ToolDefinition, ToolDefinitionMapper -from ..core.tool_mapper import DefaultToolDefinitionMapper, parse_mcp_tool_definition - - -class UnityMcpSemanticPlugin: - """Owns MCP discovery state and tool invocation behavior for SK registration.""" - - def __init__(self, client: McpToolClient, mapper: ToolDefinitionMapper | None = None) -> None: - if client is None: - raise ValueError("client must not be None.") - self._client = client - self._mapper = mapper or DefaultToolDefinitionMapper() - self._is_initialized = False - - @property - def is_initialized(self) -> bool: - """Return whether discovery has completed at least once.""" - return self._is_initialized - - async def initialize(self) -> None: - """ - Discover MCP tools once and cache them in the mapper. - - Repeated calls are idempotent and do not re-query the MCP server. - """ - if self._is_initialized: - return - raw_tools = await self._client.list_tools() - parsed_tools = [parse_mcp_tool_definition(raw_tool) for raw_tool in raw_tools] - self._mapper.initialize(parsed_tools) - self._is_initialized = True - - async def invoke_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: - """ - Invoke an MCP tool through the configured client. - - Args: - tool_name: Name of the tool to invoke. - arguments: Tool arguments. - - Returns: - Raw MCP tool invocation result. - """ - return await self._client.invoke_tool(tool_name=tool_name, arguments=arguments) - - def get_tool_by_name(self, name: str) -> ToolDefinition | None: - """Return a single registered tool definition by name.""" - return self._mapper.get_tool_by_name(name) - - def get_tool_names(self) -> list[str]: - """Return all registered tool names in deterministic order.""" - return self._mapper.get_tool_names() - - def get_registered_tools(self) -> list[ToolDefinition]: - """Return all registered tool definitions in deterministic order.""" - return self._mapper.get_registered_tools() - diff --git a/backend/app/agents/unity_mcp_sk/sk_integration/__init__.py b/backend/app/agents/unity_mcp_sk/sk_integration/__init__.py deleted file mode 100644 index 531ee72f..00000000 --- a/backend/app/agents/unity_mcp_sk/sk_integration/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Semantic Kernel registration/factory helpers for Unity MCP tools.""" - diff --git a/backend/app/agents/unity_mcp_sk/sk_integration/kernel_factory.py b/backend/app/agents/unity_mcp_sk/sk_integration/kernel_factory.py deleted file mode 100644 index ee686a06..00000000 --- a/backend/app/agents/unity_mcp_sk/sk_integration/kernel_factory.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Convenience kernel factory for Unity MCP Semantic Kernel integration.""" - -from __future__ import annotations - -from semantic_kernel import Kernel - -from ..core.contracts import McpToolClient -from ..plugin.unity_mcp_semantic_plugin import UnityMcpSemanticPlugin -from .kernel_registration import register_unity_router_function, register_unity_tools_as_functions - - -async def create_kernel_with_unity_async( - client: McpToolClient, - *, - plugin_name: str = "unity", - expanded_mode: bool = True, -) -> tuple[Kernel, UnityMcpSemanticPlugin]: - """ - Create an SK kernel with Unity MCP integration. - - Args: - client: MCP client used for discovery and invocation. - plugin_name: SK plugin namespace for Unity functions. - expanded_mode: True to register one function per tool (recommended), - False to keep router mode with a single invoke function. - - Returns: - Tuple of configured kernel and initialized Unity MCP plugin runtime. - - Raises: - ValueError: If inputs are invalid. - - Example: - >>> # doctest: +SKIP - >>> kernel, unity_plugin = await create_kernel_with_unity_async(client, expanded_mode=True) - """ - if client is None: - raise ValueError("client must not be None.") - if not plugin_name or not isinstance(plugin_name, str): - raise ValueError("plugin_name must be a non-empty string.") - - unity_plugin = UnityMcpSemanticPlugin(client=client) - await unity_plugin.initialize() - - kernel = Kernel() - if expanded_mode: - register_unity_tools_as_functions(kernel=kernel, plugin=unity_plugin, plugin_name=plugin_name) - else: - register_unity_router_function(kernel=kernel, plugin=unity_plugin, plugin_name=plugin_name) - - return kernel, unity_plugin - diff --git a/backend/app/agents/unity_mcp_sk/sk_integration/kernel_registration.py b/backend/app/agents/unity_mcp_sk/sk_integration/kernel_registration.py deleted file mode 100644 index c694cc90..00000000 --- a/backend/app/agents/unity_mcp_sk/sk_integration/kernel_registration.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Helpers for registering Unity MCP tools into Semantic Kernel.""" - -from __future__ import annotations - -from typing import Any - -from semantic_kernel import Kernel -from semantic_kernel.functions import KernelPlugin, kernel_function -from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod -from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata - -from ..core.contracts import MCP_JSON_TYPE_TO_SK_TYPE, ToolDefinition, ToolParameterDefinition -from ..plugin.unity_mcp_semantic_plugin import UnityMcpSemanticPlugin - -_JSON_TYPE_TO_PYTHON_TYPE: dict[str, type[Any]] = { - "string": str, - "number": float, - "integer": int, - "boolean": bool, - "array": list, - "object": dict, -} - - -def _build_parameter_metadata(parameter: ToolParameterDefinition) -> KernelParameterMetadata: - default_value = parameter.default - is_required = parameter.required - if default_value is not None and is_required: - # Default values imply optional semantics for planners. - is_required = False - json_type = parameter.json_type if parameter.json_type in MCP_JSON_TYPE_TO_SK_TYPE else "object" - return KernelParameterMetadata( - name=parameter.name, - description=parameter.description, - default_value=default_value, - type_=MCP_JSON_TYPE_TO_SK_TYPE[json_type], - is_required=is_required, - type_object=_JSON_TYPE_TO_PYTHON_TYPE.get(json_type, dict), - schema_data=parameter.schema, - ) - - -def _build_return_metadata(tool_definition: ToolDefinition) -> KernelParameterMetadata: - return KernelParameterMetadata( - name="return", - description=f"Result from MCP tool '{tool_definition.name}'.", - default_value=None, - type_="object", - is_required=True, - type_object=dict, - schema_data=tool_definition.return_schema or {"type": "object"}, - ) - - -def _create_tool_function(tool_definition: ToolDefinition, plugin: UnityMcpSemanticPlugin, plugin_name: str) -> Any: - @kernel_function(name=tool_definition.name, description=tool_definition.description) - async def _invoke_tool(**kwargs: Any) -> Any: - return await plugin.invoke_tool(tool_definition.name, kwargs) - - return KernelFunctionFromMethod( - method=_invoke_tool, - plugin_name=plugin_name, - parameters=[_build_parameter_metadata(parameter) for parameter in tool_definition.parameters], - return_parameter=_build_return_metadata(tool_definition), - additional_metadata={ - "mcp_input_schema": tool_definition.raw_input_schema, - "mcp_return_schema": tool_definition.return_schema, - }, - ) - - -def register_unity_tools_as_functions( - kernel: Kernel, - plugin: UnityMcpSemanticPlugin, - plugin_name: str = "unity", -) -> KernelPlugin: - """ - Register discovered MCP tools as individual Semantic Kernel functions. - - Args: - kernel: Target Semantic Kernel instance. - plugin: Initialized Unity MCP semantic plugin. - plugin_name: SK namespace under which all tool functions are registered. - - Returns: - Registered KernelPlugin handle. - - Raises: - RuntimeError: If plugin has no discovered tools (not initialized or empty). - """ - if kernel is None: - raise ValueError("kernel must not be None.") - if plugin is None: - raise ValueError("plugin must not be None.") - if not plugin_name or not isinstance(plugin_name, str): - raise ValueError("plugin_name must be a non-empty string.") - - tool_definitions = plugin.get_registered_tools() - if not tool_definitions: - raise RuntimeError( - "Unity MCP plugin has no registered tools. Initialize plugin before registering with Semantic Kernel." - ) - - functions = [_create_tool_function(tool_definition, plugin, plugin_name) for tool_definition in tool_definitions] - sk_plugin = KernelPlugin(name=plugin_name, description="Unity MCP tools", functions=functions) - return kernel.add_plugin(sk_plugin, plugin_name=plugin_name) - - -def register_unity_router_function( - kernel: Kernel, - plugin: UnityMcpSemanticPlugin, - plugin_name: str = "unity", -) -> KernelPlugin: - """Register a single generic router function for backward compatibility.""" - if kernel is None: - raise ValueError("kernel must not be None.") - if plugin is None: - raise ValueError("plugin must not be None.") - if not plugin_name or not isinstance(plugin_name, str): - raise ValueError("plugin_name must be a non-empty string.") - - @kernel_function( - name="invoke", - description="Invoke any Unity MCP tool by passing tool_name and arguments.", - ) - async def _invoke(**kwargs: Any) -> Any: - raw_arguments = kwargs.get("arguments") - if hasattr(raw_arguments, "items"): - argument_payload = dict(raw_arguments) - elif isinstance(raw_arguments, dict): - argument_payload = raw_arguments - else: - argument_payload = {} - - tool_name = kwargs.get("tool_name") or argument_payload.get("tool_name") - if not tool_name or not isinstance(tool_name, str): - raise ValueError("tool_name must be a non-empty string.") - - # Some SK call paths wrap both router args into one payload object. - if "arguments" in argument_payload and isinstance(argument_payload.get("arguments"), dict): - resolved_arguments = argument_payload["arguments"] - else: - resolved_arguments = argument_payload - if not isinstance(resolved_arguments, dict): - raise ValueError("arguments must be an object when provided.") - return await plugin.invoke_tool(tool_name, resolved_arguments) - - router_function = KernelFunctionFromMethod( - method=_invoke, - plugin_name=plugin_name, - parameters=[ - KernelParameterMetadata( - name="tool_name", - description="MCP tool name to invoke.", - default_value=None, - type_="string", - is_required=True, - type_object=str, - schema_data={"type": "string"}, - ), - KernelParameterMetadata( - name="arguments", - description="Arguments object passed to the selected MCP tool.", - default_value={}, - type_="object", - is_required=False, - type_object=dict, - schema_data={"type": "object"}, - ), - ], - return_parameter=KernelParameterMetadata( - name="return", - description="Result from the selected MCP tool.", - default_value=None, - type_="object", - is_required=True, - type_object=dict, - schema_data={"type": "object"}, - ), - ) - - sk_plugin = KernelPlugin(name=plugin_name, description="Unity MCP router", functions=[router_function]) - return kernel.add_plugin(sk_plugin, plugin_name=plugin_name) - diff --git a/backend/requirements.txt b/backend/requirements.txt index e50d0ace..85037c60 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,10 +1,16 @@ fastapi uvicorn mcp[cli] -semantic-kernel[mcp]>=1.15.0 pydantic requests Pillow jinja2 google-genai -anthropic \ No newline at end of file +anthropic +semantic-kernel[mcp]>=1.15.0 +# unity-mcp-plugin (Semantic Kernel bridge for Unity MCP) — use ONE of: +# (A) No sibling folder: install from Git (HTTPS or SSH). Example if you have access: +# unity-mcp-plugin @ git+ssh://git@github.com/Ozymandros/Unity-MCP-SK-Plugin.git +# (Public HTTPS fails with 404 if the repo is private — use SSH, a fork, or a wheel.) +# (B) Default dev layout: clone Unity-MCP-SK-Plugin next to this repo as "unity-mcp-server-plugin": +-e ../unity-mcp-server-plugin \ No newline at end of file diff --git a/backend/tests/test_unity_mcp_pluginwrapper.py b/backend/tests/test_unity_mcp_pluginwrapper.py index 38014676..936b017e 100644 --- a/backend/tests/test_unity_mcp_pluginwrapper.py +++ b/backend/tests/test_unity_mcp_pluginwrapper.py @@ -1,14 +1,42 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + import pytest +from app.agents.unity_mcp_plugin import create_unity_mcp_plugin, unity_mcp_plugin_available_for_writing + -# When re-enabling: update tests to match the new Unity-MCP-Server contract -# (projectPath + fileName/folderName on tools, ping with no args). -@pytest.mark.skip(reason="Requires running MCP server or better mocking of async connection") @pytest.mark.asyncio -async def test_unity_mcp_pluginwrapper_connect_and_discover(monkeypatch): - pass +async def test_unity_mcp_pluginwrapper_create_initialize_and_cleanup() -> None: + """Ensure wrapper creates plugin via local package and performs lifecycle calls.""" + mock_plugin = MagicMock() + mock_plugin.initialize = AsyncMock() + mock_plugin.cleanup = AsyncMock() + + with patch("app.agents.unity_mcp_plugin.UnityMCPPlugin.create", return_value=mock_plugin) as mock_create: + async with create_unity_mcp_plugin() as plugin: + assert plugin is mock_plugin + mock_create.assert_called_once() + mock_plugin.initialize.assert_awaited_once() + mock_plugin.cleanup.assert_awaited_once() + -@pytest.mark.skip(reason="Requires running MCP server or better mocking of async connection") @pytest.mark.asyncio -async def test_unity_mcp_pluginwrapper_error(monkeypatch): - pass +async def test_unity_mcp_pluginwrapper_error_propagates() -> None: + """Ensure initialization errors are not swallowed by wrapper context manager.""" + mock_plugin = MagicMock() + mock_plugin.initialize = AsyncMock(side_effect=RuntimeError("boom")) + mock_plugin.cleanup = AsyncMock() + + with patch("app.agents.unity_mcp_plugin.UnityMCPPlugin.create", return_value=mock_plugin): + with pytest.raises(RuntimeError, match="boom"): + async with create_unity_mcp_plugin(): + pass + mock_plugin.cleanup.assert_not_awaited() + + +def test_unity_mcp_plugin_available_for_writing_false_on_failure() -> None: + """Availability helper must return False when plugin creation fails.""" + with patch("app.agents.unity_mcp_plugin.create_unity_mcp_plugin", side_effect=RuntimeError("down")): + assert unity_mcp_plugin_available_for_writing() is False diff --git a/backend/tests/test_unity_mcp_sk_integration.py b/backend/tests/test_unity_mcp_sk_integration.py deleted file mode 100644 index 89859f18..00000000 --- a/backend/tests/test_unity_mcp_sk_integration.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import pytest -from semantic_kernel.functions.kernel_arguments import KernelArguments - -from app.agents.unity_mcp_sk.core.tool_mapper import DefaultToolDefinitionMapper, parse_mcp_tool_definition -from app.agents.unity_mcp_sk.plugin.unity_mcp_semantic_plugin import UnityMcpSemanticPlugin -from app.agents.unity_mcp_sk.sk_integration.kernel_factory import create_kernel_with_unity_async -from app.agents.unity_mcp_sk.sk_integration.kernel_registration import register_unity_tools_as_functions - - -class FakeMcpClient: - def __init__(self, tools: list[dict[str, Any]]) -> None: - self._tools = tools - self.list_tools_calls = 0 - self.invoke_calls: list[tuple[str, dict[str, Any]]] = [] - - async def list_tools(self) -> list[dict[str, Any]]: - self.list_tools_calls += 1 - return self._tools - - async def invoke_tool(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: - self.invoke_calls.append((tool_name, arguments)) - return {"success": True, "tool": tool_name, "arguments": arguments} - - -def _build_tools_fixture() -> list[dict[str, Any]]: - return [ - { - "name": "z_ping", - "description": "Ping tool.", - "inputSchema": {"type": "object", "properties": {}, "required": []}, - }, - { - "name": "build_scene", - "description": "Build a scene from parameters.", - "inputSchema": { - "type": "object", - "required": ["projectPath", "count"], - "properties": { - "projectPath": {"type": "string", "description": "Unity project path."}, - "count": {"type": "integer", "description": "Object count."}, - "usePhysics": {"type": "boolean", "description": "Enable physics.", "default": False}, - "weights": {"type": "array", "description": "Distribution weights."}, - "settings": {"type": "object", "description": "Scene settings."}, - "temperature": {"type": "number", "description": "Numeric tuning.", "default": 0.3}, - }, - }, - "outputSchema": {"type": "object"}, - }, - { - "name": "a_echo", - "description": "Echo tool.", - "inputSchema": { - "type": "object", - "required": ["message"], - "properties": {"message": {"type": "string", "description": "Message."}}, - }, - }, - ] - - -@pytest.mark.asyncio -async def test_get_registered_tools_returns_sorted_tools() -> None: - mapper = DefaultToolDefinitionMapper() - parsed = [parse_mcp_tool_definition(item) for item in _build_tools_fixture()] - mapper.initialize(parsed) - - registered_names = [tool.name for tool in mapper.get_registered_tools()] - assert registered_names == ["a_echo", "build_scene", "z_ping"] - - -@pytest.mark.asyncio -async def test_kernel_factory_lists_tools_only_once() -> None: - client = FakeMcpClient(_build_tools_fixture()) - kernel, plugin = await create_kernel_with_unity_async(client=client, plugin_name="unity", expanded_mode=True) - - assert kernel is not None - assert plugin.is_initialized is True - assert client.list_tools_calls == 1 - - register_unity_tools_as_functions(kernel=kernel, plugin=plugin, plugin_name="unity_v2") - assert client.list_tools_calls == 1 - - -@pytest.mark.asyncio -async def test_expanded_registration_uses_single_plugin_namespace() -> None: - client = FakeMcpClient(_build_tools_fixture()) - kernel, _ = await create_kernel_with_unity_async(client=client, plugin_name="unity", expanded_mode=True) - - plugin = kernel.get_plugin("unity") - assert plugin is not None - assert "unity" in list(kernel.plugins) - assert set(plugin.functions.keys()) == {"a_echo", "build_scene", "z_ping"} - - -@pytest.mark.asyncio -async def test_registered_metadata_matches_mcp_schema() -> None: - client = FakeMcpClient(_build_tools_fixture()) - kernel, _ = await create_kernel_with_unity_async(client=client, plugin_name="unity", expanded_mode=True) - - plugin = kernel.get_plugin("unity") - function = plugin["build_scene"] - parameters = {parameter.name: parameter for parameter in function.metadata.parameters} - - assert parameters["projectPath"].type_ == "string" - assert parameters["projectPath"].is_required is True - assert parameters["count"].type_ == "integer" - assert parameters["count"].is_required is True - assert parameters["temperature"].type_ == "number" - assert parameters["temperature"].default_value == 0.3 - assert parameters["temperature"].is_required is False - assert parameters["usePhysics"].type_ == "boolean" - assert parameters["usePhysics"].default_value is False - assert parameters["weights"].type_ == "array" - assert parameters["settings"].type_ == "object" - - -@pytest.mark.asyncio -async def test_router_mode_still_works() -> None: - client = FakeMcpClient(_build_tools_fixture()) - kernel, _ = await create_kernel_with_unity_async(client=client, plugin_name="unity", expanded_mode=False) - - plugin = kernel.get_plugin("unity") - assert set(plugin.functions.keys()) == {"invoke"} - - result = await kernel.invoke( - function_name="invoke", - plugin_name="unity", - arguments=KernelArguments(tool_name="a_echo", arguments={"message": "hello"}), - ) - assert result is not None - assert client.invoke_calls == [("a_echo", {"message": "hello"})] - diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3a085564..bd21085a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -340,7 +340,7 @@ Finalize supports two backends. In **Unity Engine Settings** (or the finalize re - **`batch`**: Always use injected Editor scripts and Unity batch mode. Use this for CI or headless environments where no Editor window is running. - **`mcp`**: Always use the MCP-Unity (Semantic Kernel) plugin. Fails with a clear error if the plugin is not configured. -To use MCP for local development, set **`UNITY_USE_MCP=1`** and either **`UNITY_MCP_SERVER_URL`** (HTTP) or ensure **`unity-mcp`** (or the command in **`UNITY_MCP_COMMAND`**) is on PATH for stdio. The backend connects via the MCP Python SDK and calls the Unity-MCP-Server contract tools. For CI pipelines, set `unity_automation_mode` to `"batch"` so finalize does not depend on MCP. See [Unity Engine Integration](UNITY_INTEGRATION.md) for the tool contract and configuration. +To use MCP for local development, set **`UNITY_USE_MCP=1`** and either **`UNITY_MCP_SERVER_URL`** (HTTP) or ensure **`unity-mcp`** (or the command in **`UNITY_MCP_COMMAND`**) is on PATH for stdio. The backend connects via the MCP Python SDK and calls the Unity-MCP-Server contract tools. For CI pipelines, set `unity_automation_mode` to `"batch"` so finalize does not depend on MCP. **Server install, Git clone URL, and where env vars are read:** [UNITY_MCP_SERVER.md](UNITY_MCP_SERVER.md). See [Unity Engine Integration](UNITY_INTEGRATION.md) for the tool contract and configuration. ### Debugging Unity batch execution diff --git a/docs/UNITY_MCP_INTEGRATION.md b/docs/UNITY_MCP_INTEGRATION.md index aeb3ef5e..48782bc5 100644 --- a/docs/UNITY_MCP_INTEGRATION.md +++ b/docs/UNITY_MCP_INTEGRATION.md @@ -1,6 +1,22 @@ # Unity MCP Integration with Semantic Kernel -This document describes the integration of Unity's Model Context Protocol (MCP) with Semantic Kernel using the official MCPSsePlugin, replacing legacy TCP/JSON-RPC code with a robust, maintainable, and auto-discoverable approach. +> **Unity MCP Server (install, Git URL, env vars — not the Python plugin):** see **[UNITY_MCP_SERVER.md](./UNITY_MCP_SERVER.md)**. + +This document describes the integration of Unity's Model Context Protocol (MCP) with Semantic Kernel using the Python **`unity-mcp-plugin`** package from **[Unity-MCP-SK-Plugin](https://github.com/Ozymandros/Unity-MCP-SK-Plugin)** (Git URL or local editable install—see `backend/requirements.txt`). The backend talks to the **Unity MCP Server** over stdio. Both pieces are required and are separate GitHub repositories. + +## Required dependencies (two repositories) + +| Component | Purpose | GitHub repository | How this project uses it | +|-----------|---------|-------------------|----------------------------| +| **Unity-MCP-Server** | Official MCP server that exposes Unity Editor automation tools (JSON-RPC over stdio when used as a global .NET tool). | **[github.com/Ozymandros/Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server)** | Install and run the `unity-mcp` global tool (or your built server executable). Point `UNITY_MCP_COMMAND` at that executable if it is not on `PATH`. | +| **unity-mcp-plugin** (Python distribution) | Semantic Kernel bridge: discovers MCP tools and registers them as SK functions. | **[github.com/Ozymandros/Unity-MCP-SK-Plugin](https://github.com/Ozymandros/Unity-MCP-SK-Plugin)** | `backend/requirements.txt`: default **`-e ../unity-mcp-server-plugin`**, or switch to **`unity-mcp-plugin @ git+...`** (SSH/fork if private). Imported as `from unity_mcp import UnityMCPPlugin, UnityMcpOptions`. | + +**Install order (typical dev setup):** + +1. Clone **[Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server)** and install the server per its README (often a global .NET tool named `unity-mcp` on your `PATH`; follow that repo’s install script or `dotnet tool` instructions). +2. Install **`unity-mcp-plugin`**: either follow the **default** in `backend/requirements.txt` (sibling clone + `-e`), **or** comment that out and use **`unity-mcp-plugin @ git+ssh://...`** (or your fork) so the app does not depend on a fixed folder next to this repo. If `git+https` fails with **404**, the GitHub repo is likely **private**—use SSH, a public fork, or a wheel. + +**Optional dev workflow:** clone **Unity-MCP-SK-Plugin** anywhere and use `pip install -e /path/to/clone` while developing the bridge. ## Path terminology (don’t confuse these) @@ -10,13 +26,13 @@ This document describes the integration of Unity's Model Context Protocol (MCP) ## Overview - **No Unity/C# code required**: This integration is entirely Python-side. - **Auto-discovery**: All Unity tools exposed by the MCP server are automatically available to Semantic Kernel. -- **No manual kernel_function decorators**: The MCPSsePlugin handles tool registration. +- **No manual kernel_function decorators**: The local Unity MCP plugin package handles tool registration. - **Robust error handling**: Connection and protocol errors are surfaced clearly. ## Configuration -- **Install requirements**: Ensure `semantic-kernel[mcp]` is in `backend/requirements.txt`. +- **Install requirements**: Ensure `backend/requirements.txt` installs **`unity-mcp-plugin`** (see comments in that file: Git URL vs `-e` sibling path). - **Set MCP server command** (this is the **Unity MCP Server executable**, not a Unity project path): - - `UNITY_MCP_COMMAND`: Path to the MCP server executable (default: `unity-mcp-server`) + - `UNITY_MCP_COMMAND`: Path to the MCP server executable (default: `unity-mcp`) - `UNITY_MCP_ARGS`: Arguments for the executable (default: empty) Example (path to the **server executable**, not to a Unity project): @@ -25,7 +41,7 @@ export UNITY_MCP_COMMAND="C:\Path\To\UnityMcp.Server.exe" ``` ## Usage -- The integration is implemented in `backend/app/agents/unity_mcp_plugin.py` as `UnityMCPPluginWrapper`. +- The integration is implemented in `backend/app/agents/unity_mcp_plugin.py` using `unity_mcp.UnityMCPPlugin` from the **`unity-mcp-plugin`** package. - The agent in `backend/app/agents/unity_agent.py` uses this wrapper to connect and interact with Unity tools. - All Unity automation tools (e.g., create_scene, create_script) are auto-discovered and callable from the agent. @@ -60,8 +76,11 @@ export UNITY_MCP_COMMAND="C:\Path\To\UnityMcp.Server.exe" - All Unity tool changes/additions are now automatically available to the agent—no Python code changes required. ## References -- [Semantic Kernel MCP Plugin Documentation](https://learn.microsoft.com/en-us/semantic-kernel/concepts/plugins/adding-mcp-plugins) -- [Model Context Protocol (MCP) Spec](https://modelcontextprotocol.io/) +- **[Unity MCP Server — install & config in this repo](./UNITY_MCP_SERVER.md)** (Git URLs, `UNITY_MCP_COMMAND`, file locations) +- [Unity-MCP-Server (GitHub)](https://github.com/Ozymandros/Unity-MCP-Server) +- [Unity-MCP-SK-Plugin / unity-mcp-plugin (GitHub)](https://github.com/Ozymandros/Unity-MCP-SK-Plugin) +- [Semantic Kernel — MCP plugins](https://learn.microsoft.com/en-us/semantic-kernel/concepts/plugins/adding-mcp-plugins) +- [Model Context Protocol (MCP) spec](https://modelcontextprotocol.io/) --- diff --git a/docs/UNITY_MCP_SERVER.md b/docs/UNITY_MCP_SERVER.md new file mode 100644 index 00000000..7334784c --- /dev/null +++ b/docs/UNITY_MCP_SERVER.md @@ -0,0 +1,116 @@ +# Unity MCP Server (Unity-MCP-Server) + +This document is about the **MCP server that talks to the Unity Editor** — **not** the Python `unity-mcp-plugin` package. +For the Semantic Kernel Python bridge, see [UNITY_MCP_INTEGRATION.md](./UNITY_MCP_INTEGRATION.md) and [UNITY_MCP_SK_INTEGRATION.md](./UNITY_MCP_SK_INTEGRATION.md). + +## Source repository (install here) + +| Item | Value | +|------|--------| +| **GitHub** | **[github.com/Ozymandros/Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server)** | +| **Clone (HTTPS)** | `https://github.com/Ozymandros/Unity-MCP-Server.git` | +| **Typical CLI name** | `unity-mcp` (global .NET tool or built executable — follow upstream README) | + +**Installation** is defined **upstream**: clone the repo and follow **Unity-MCP-Server** `README.md` (e.g. `dotnet tool install`, Unity package, or build steps). This Unity-Generator repo does **not** ship the server binary; it expects the executable to exist locally or on `PATH`. + +### Docker (only if upstream provides it) + +Check **[Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server)** for a **`Dockerfile`**, **`docker-compose.yml`**, or a **Docker** section in `README.md`. If the project does **not** document Docker, use clone + CLI install above (or ask upstream to add a container workflow). + +When Docker *is* documented upstream, use their image name, ports, and env vars as the source of truth. Below is how that maps to **Unity-Generator** only. + +| Integration path in this app | Transport | How Docker usually fits | +|------------------------------|-----------|-------------------------| +| **Finalize / orchestrator** (`unity_mcp_client.py`) | **Streamable HTTP** if you set **`UNITY_MCP_SERVER_URL`** | Run the container with a **published port** (e.g. `-p 8080:8080`). Set **`UNITY_USE_MCP=1`** and **`UNITY_MCP_SERVER_URL`** to the MCP HTTP endpoint the server exposes (must match upstream — often `http://127.0.0.1:/...`). In `unity_mcp_client.py`, if **`UNITY_MCP_SERVER_URL`** is set, the **HTTP** client is used and **stdio** (`UNITY_MCP_COMMAND`) is not used for that call. | +| **Semantic Kernel agent** (`unity_mcp_plugin.py`) | **stdio** (spawns **`UNITY_MCP_COMMAND`**) | A container is **not** a drop-in replacement unless you run a **host binary** or a small wrapper. Prefer installing the server on the host for the agent, or extend **`unity-mcp-plugin`** / server to support HTTP for the SK path (not configured in this repo today). | + +**Typical Docker flow for this app (HTTP-capable server):** + +1. Build/run the image **as documented in Unity-MCP-Server** (example shape only — replace with upstream values): + + ```bash + # Example only; use the exact commands from Ozymandros/Unity-MCP-Server README + docker compose up -d + # or: docker run -p : + ``` + +2. Confirm the Unity Editor / MCP server can still communicate (same machine or documented networking). + +3. For **finalize** MCP tools, set for the backend process: + + ```sh + set UNITY_USE_MCP=1 + set UNITY_MCP_SERVER_URL=http://127.0.0.1: + ``` + + Use the **same URL** your browser or `curl` would use for the server’s **Streamable HTTP** MCP endpoint per upstream docs. + +4. For HTTP-only finalize flows, **`UNITY_MCP_SERVER_URL`** alone is enough for `unity_mcp_client` (stdio command is skipped when the URL is set). + +**Security:** If the container exposes MCP on a network interface, restrict with firewall, bind to `127.0.0.1`, or upstream auth tokens — do not expose an unsecured MCP endpoint to the internet. + +--- + +## What this app uses it for + +- **Semantic Kernel agent** (`unity_mcp_plugin.py`): spawns/connects to the server process for **tool discovery** and Unity automation from the LLM. +- **Finalize / orchestrator** (`unity_mcp_client.py`, optional): when **`UNITY_USE_MCP=1`**, uses the MCP Python SDK to call **Unity-MCP-Server** tools (e.g. package install, scene setup) over **stdio** or **Streamable HTTP**. + +--- + +## Where configuration is defined in this repository + +### 1. Environment variables (runtime — backend) + +| Variable | Purpose | Default / notes | +|----------|-----------|-----------------| +| **`UNITY_MCP_COMMAND`** | Path or name of the **server executable** (not your Unity project folder). | `unity-mcp` | +| **`UNITY_MCP_ARGS`** | Extra arguments passed to that executable (space-separated in code). | empty | +| **`UNITY_MCP_SERVER_URL`** | Optional **HTTP** MCP endpoint (Streamable HTTP). If set, used when applicable; otherwise **stdio** via `UNITY_MCP_COMMAND`. | empty | +| **`UNITY_USE_MCP`** | **`1` / `true` / `yes`** enables MCP in **`unity_mcp_client.py`** (finalize/orchestrator path). | off (MCP client not used) | + +**Code references:** + +- `backend/app/agents/unity_mcp_plugin.py` — `UNITY_MCP_COMMAND`, `UNITY_MCP_ARGS` (SK plugin). +- `backend/app/services/unity_mcp_client.py` — `UNITY_USE_MCP`, `UNITY_MCP_SERVER_URL`, `UNITY_MCP_COMMAND`, `UNITY_MCP_ARGS`. + +### 2. Sample MCP JSON (optional) + +- **`backend/mcp_config.json`** — example `mcpServers.unity-mcp-server` with `command` / `args` (for tools that consume MCP-style config; **not** the only source of truth for FastAPI). + +### 3. Cursor / VS Code MCP (IDE only) + +- **`.vscode/mcp.json`** — **`mcpServers.unity-mcp-server`**: `command` + `args` for the **editor** MCP client. This configures **Cursor/VS Code**, not the Unity-Generator backend unless you mirror the same env vars. + +**Important:** `UNITY_MCP_*` env vars apply to **Python processes** (backend). The IDE file is separate; keep **`unity-mcp`** on `PATH` or align `command` with the same binary you use for the backend. + +### 4. Docs cross-references + +- **`docs/DEVELOPMENT.md`** — Automation mode (`UNITY_USE_MCP`, batch vs MCP). +- **`docs/UNITY_MCP_INTEGRATION.md`** — End-to-end SK + server overview. +- **`backend/app/agents/SKILLS_USAGE.md`** — Unity MCP skill notes and server link. + +--- + +## Path terminology (server vs Unity project) + +- **`UNITY_MCP_COMMAND`** must point to the **MCP server binary** (e.g. `unity-mcp` or `UnityMcp.Server.exe`), **not** to the Unity project root (`Assets/`). +- Your **Unity project path** is passed separately to tools (e.g. `projectPath` / session) — see [UNITY_MCP_INTEGRATION.md](./UNITY_MCP_INTEGRATION.md) § Path terminology. + +--- + +## Quick checklist + +1. Install **Unity-MCP-Server** from **[GitHub](https://github.com/Ozymandros/Unity-MCP-Server)** (upstream README), **or** run it via **Docker** if upstream documents `Dockerfile` / compose (see [Docker (only if upstream provides it)](#docker-only-if-upstream-provides-it)). +2. Ensure the server is running with Unity / Editor as required by that project. +3. Set **`UNITY_MCP_COMMAND`** if `unity-mcp` is not on `PATH` (stdio / SK agent path). +4. For finalize MCP automation, set **`UNITY_USE_MCP=1`** and configure **stdio** (`UNITY_MCP_COMMAND`) or **`UNITY_MCP_SERVER_URL`** (Docker/HTTP) per `unity_mcp_client.py`. + +--- + +## Related repositories + +| Role | GitHub | +|------|--------| +| **MCP server (this page)** | [Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server) | +| **Python SK bridge (`unity_mcp` package)** | [Unity-MCP-SK-Plugin](https://github.com/Ozymandros/Unity-MCP-SK-Plugin) | diff --git a/docs/UNITY_MCP_SK_INTEGRATION.md b/docs/UNITY_MCP_SK_INTEGRATION.md index c7677e3e..56c5f533 100644 --- a/docs/UNITY_MCP_SK_INTEGRATION.md +++ b/docs/UNITY_MCP_SK_INTEGRATION.md @@ -1,74 +1,60 @@ -# Unity MCP + Semantic Kernel Integration +# Unity MCP + Semantic Kernel Integration (Python) -## Python Integration Modes +> **Install/configure the Unity MCP *server* (Git URL, env vars):** [UNITY_MCP_SERVER.md](./UNITY_MCP_SERVER.md). -The Python integration now supports two registration modes: +This project relies on the **`unity-mcp-plugin`** Python package for Semantic Kernel integration. Source and releases live in **[Unity-MCP-SK-Plugin](https://github.com/Ozymandros/Unity-MCP-SK-Plugin)**. That plugin is a **client** of the MCP server **[Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server)** (stdio): you must have the server executable available (e.g. `unity-mcp` on `PATH`) or set `UNITY_MCP_COMMAND` (see [UNITY_MCP_INTEGRATION.md](./UNITY_MCP_INTEGRATION.md)). -- **Expanded mode (recommended)**: each discovered MCP tool is exposed as an individual Semantic Kernel function under one plugin namespace (default: `unity`). -- **Router mode (backward compatible)**: one generic `invoke` function routes calls to a chosen MCP tool. +| Piece | Repository | +|-------|------------| +| MCP server (tools, Unity contract) | [github.com/Ozymandros/Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server) | +| Python SK plugin (`unity_mcp` package) | [github.com/Ozymandros/Unity-MCP-SK-Plugin](https://github.com/Ozymandros/Unity-MCP-SK-Plugin) | -Expanded mode improves planner/autonomous agent tool selection because functions and parameter schemas are visible as first-class SK functions. +**Dependency:** `backend/requirements.txt` documents two ways to get **`unity-mcp-plugin`**: **Git URL** (no sibling folder) or **`-e ../unity-mcp-server-plugin`** (default sibling clone). Use **SSH** or a fork if the GitHub repo is private. For hacking the bridge, clone that repo anywhere and use `pip install -e `. ## Expanded Mode (Recommended) ```python -from app.agents.unity_mcp_sk.sk_integration.kernel_factory import create_kernel_with_unity_async +from unity_mcp import UnityMCPPlugin -# client must implement list_tools() and invoke_tool(tool_name, arguments) -kernel, unity_plugin = await create_kernel_with_unity_async( - client=my_mcp_client, - plugin_name="unity", - expanded_mode=True, -) +# Discovers MCP tools and registers them as individual SK functions +# under one plugin namespace ("unity" by default). +kernel = await UnityMCPPlugin.create_kernel_with_unity(plugin_name="unity") -# SK now sees one plugin namespace ("unity") with many functions: -# - unity_create_scene -# - unity_add_gameobject -# - unity_create_material -# - ... +# Example invocation: +result = await kernel.invoke("unity", "unity_create_scene", projectPath="C:/MyProj", fileName="Assets/Scenes/Main.unity") ``` ## Router Mode (Backward Compatible) ```python -from app.agents.unity_mcp_sk.sk_integration.kernel_factory import create_kernel_with_unity_async +from semantic_kernel import Kernel +from unity_mcp import UnityMCPPlugin -kernel, unity_plugin = await create_kernel_with_unity_async( - client=my_mcp_client, - plugin_name="unity", - expanded_mode=False, -) +plugin = UnityMCPPlugin.create() +await plugin.initialize() + +kernel = Kernel() +kernel.add_plugin(plugin, plugin_name="unity") -# SK sees one function: -# unity.invoke(tool_name, arguments) +result = await kernel.invoke( + "unity", + "invoke_unity_tool", + tool_name="unity_create_scene", + arguments_json='{"projectPath":"C:/MyProj","fileName":"Assets/Scenes/Main.unity"}', +) ``` ## Tradeoffs -- **Expanded mode** - - Better discoverability and tool-calling reliability for planners/agents. - - Richer per-tool metadata (name, description, required/default, type mapping). - - Slightly larger function registry and token footprint. -- **Router mode** - - Smallest function footprint (single generic function). - - Weaker discoverability, often requiring stronger prompting to choose tools correctly. - -## Discovery and Registration Behavior - -- Tool discovery (`list_tools`) is performed once during plugin initialization. -- Registration uses the mapper's cached `get_registered_tools()` output as the source of truth. -- Tool registration order is deterministic (sorted by tool name). +- **Expanded mode**: better discoverability and more reliable autonomous tool calling. +- **Router mode**: smaller tool-definition footprint but weaker discoverability. -## Metadata Fidelity Notes +## Metadata Fidelity -For each MCP tool parameter, the integration preserves: +The `unity-mcp-plugin` package maps MCP tool schemas into SK metadata (parameter name, description, required/default, and base JSON types: `string`, `number`, `integer`, `boolean`, `array`, `object`). -- exact parameter name, -- description, -- required/optional state, -- default value when present, -- JSON type mapping: `string`, `number`, `integer`, `boolean`, `array`, `object`. +Python SK limitation: advanced JSON Schema constructs are partially surfaced (typically via schema metadata) but not always fully interpreted as first-class planner constraints. -Current Python SK limitation/workaround: +## Router / parameter safety -- Complex JSON Schema constructs (`oneOf`, `anyOf`, tuple schemas, advanced constraints) are passed through in `schema_data`, but SK planner behavior primarily uses simplified `KernelParameterMetadata` fields (`type_`, `is_required`, `default_value`). +Older in-repo experiments under `backend/app/agents/unity_mcp_sk/` (removed) duplicated router logic; that path no longer exists here. The canonical implementation lives in the `unity-mcp-plugin` package: expanded-mode handlers only forward kwargs whose names match the MCP tool schema, and `invoke_unity_tool` strips `tool_name` / `toolName` from parsed JSON so they are never sent as MCP parameters. diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 47d9d877..ef957622 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -36,16 +36,17 @@ import id from "./locales/id"; import ja from "./locales/ja"; import vi from "./locales/vi"; import ko from "./locales/ko"; +import tr from "./locales/tr"; export type SupportedLocale = | "en" | "es" | "ca" | "eu" | "oc" | "uk" | "pt" | "gl" | "fr" | "it" | "pl" | "zh" | "ar" | "de" - | "hi" | "bn" | "ur" | "id" | "ja" | "vi" | "ko"; + | "hi" | "bn" | "ur" | "id" | "ja" | "vi" | "ko" | "tr"; export const SUPPORTED_LOCALES: SupportedLocale[] = [ "en", "es", "ca", "eu", "oc", "uk", "pt", "gl", "fr", "it", "pl", "zh", "ar", "de", - "hi", "bn", "ur", "id", "ja", "vi", "ko", + "hi", "bn", "ur", "id", "ja", "vi", "ko", "tr", ]; export const DEFAULT_LOCALE: SupportedLocale = "en"; export const LOCALE_STORAGE_KEY = "appLocale"; @@ -77,7 +78,7 @@ const i18n = createI18n({ legacy: false, // Composition API mode locale: getPersistedLocale(), fallbackLocale: DEFAULT_LOCALE, - messages: { en, es, ca, eu, oc, uk, pt, gl, fr, it, pl, zh, ar, de, hi, bn, ur, id, ja, vi, ko }, + messages: { en, es, ca, eu, oc, uk, pt, gl, fr, it, pl, zh, ar, de, hi, bn, ur, id, ja, vi, ko, tr }, // Silence missing-key warnings in production missingWarn: import.meta.env.DEV, fallbackWarn: import.meta.env.DEV, diff --git a/frontend/src/i18n/locales/ar.ts b/frontend/src/i18n/locales/ar.ts index 7321fefd..726ced3f 100644 --- a/frontend/src/i18n/locales/ar.ts +++ b/frontend/src/i18n/locales/ar.ts @@ -234,5 +234,6 @@ export default { ar: "العربية", de: "الألمانية", hi: "الهندية", bn: "البنغالية", ur: "الأردية", id: "الإندونيسية", ja: "اليابانية", vi: "الفيتنامية", ko: "الكورية", + tr: "التركية", }, } as const; diff --git a/frontend/src/i18n/locales/bn.ts b/frontend/src/i18n/locales/bn.ts index 8efaac8f..e886242b 100644 --- a/frontend/src/i18n/locales/bn.ts +++ b/frontend/src/i18n/locales/bn.ts @@ -107,5 +107,6 @@ export default { pt: "পর্তুগিজ", gl: "গ্যালিশিয়ান", fr: "ফরাসি", it: "ইতালীয়", pl: "পোলিশ", zh: "চীনা", ar: "আরবি", de: "জার্মান", hi: "হিন্দি", bn: "বাংলা", ur: "উর্দু", id: "ইন্দোনেশিয়ান", ja: "জাপানি", vi: "ভিয়েতনামি", ko: "কোরিয়ান", + tr: "তুর্কি", }, } as const; diff --git a/frontend/src/i18n/locales/ca.ts b/frontend/src/i18n/locales/ca.ts index b3c4607d..c4ee0185 100644 --- a/frontend/src/i18n/locales/ca.ts +++ b/frontend/src/i18n/locales/ca.ts @@ -299,5 +299,6 @@ export default { ja: "Japonès", vi: "Vietnamita", ko: "Coreà", + tr: "Turc", }, } as const; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 1b48b49c..07c4f244 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -241,5 +241,6 @@ export default { zh: "Chinesisch", ar: "Arabisch", de: "Deutsch", + tr: "Türkisch", }, } as const; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 9cbf833b..901503c4 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -315,5 +315,6 @@ export default { ja: "Japanese", vi: "Vietnamese", ko: "Korean", + tr: "Turkish", }, } as const; diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index d4b1ecf3..6cb8e27b 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -299,5 +299,6 @@ export default { ja: "Japonés", vi: "Vietnamita", ko: "Coreano", + tr: "Turco", }, } as const; diff --git a/frontend/src/i18n/locales/eu.ts b/frontend/src/i18n/locales/eu.ts index 6536bab8..978041af 100644 --- a/frontend/src/i18n/locales/eu.ts +++ b/frontend/src/i18n/locales/eu.ts @@ -299,5 +299,6 @@ export default { ja: "Japoniera", vi: "Vietnamera", ko: "Korear", + tr: "Turkiera", }, } as const; diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index ce9e84dc..982bd030 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -241,5 +241,6 @@ export default { zh: "Chinois", ar: "Arabe", de: "Allemand", + tr: "turc", }, } as const; diff --git a/frontend/src/i18n/locales/gl.ts b/frontend/src/i18n/locales/gl.ts index 00c5c9bd..d2dc72b6 100644 --- a/frontend/src/i18n/locales/gl.ts +++ b/frontend/src/i18n/locales/gl.ts @@ -241,5 +241,13 @@ export default { zh: "Chinés", ar: "Árabe", de: "Alemán", + hi: "Hindi", + bn: "Bengalí", + ur: "Urdu", + id: "Indonesio", + ja: "Xaponés", + vi: "Vietnamita", + ko: "Coreano", + tr: "Turco", }, } as const; diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts index 90b80c81..d10d10e2 100644 --- a/frontend/src/i18n/locales/hi.ts +++ b/frontend/src/i18n/locales/hi.ts @@ -108,5 +108,6 @@ export default { pt: "पुर्तगाली", gl: "गैलिशियन", fr: "फ्रेंच", it: "इतालवी", pl: "पोलिश", zh: "चीनी", ar: "अरबी", de: "जर्मन", hi: "हिंदी", bn: "बंगाली", ur: "उर्दू", id: "इंडोनेशियाई", ja: "जापानी", vi: "वियतनामी", ko: "कोरियाई", + tr: "तुर्की", }, } as const; diff --git a/frontend/src/i18n/locales/id.ts b/frontend/src/i18n/locales/id.ts index ea6e42a8..01b1cf64 100644 --- a/frontend/src/i18n/locales/id.ts +++ b/frontend/src/i18n/locales/id.ts @@ -107,5 +107,6 @@ export default { pt: "Portugis", gl: "Galisia", fr: "Prancis", it: "Italia", pl: "Polandia", zh: "Tionghoa", ar: "Arab", de: "Jerman", hi: "Hindi", bn: "Bengali", ur: "Urdu", id: "Indonesia", ja: "Jepang", vi: "Vietnam", ko: "Korea", + tr: "Turki", }, } as const; diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 2cfdaf50..32402d36 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -241,5 +241,6 @@ export default { zh: "Cinese", ar: "Arabo", de: "Tedesco", + tr: "Turco", }, } as const; diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index a4081461..8f4d865e 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -107,5 +107,6 @@ export default { pt: "ポルトガル語", gl: "ガリシア語", fr: "フランス語", it: "イタリア語", pl: "ポーランド語", zh: "中国語", ar: "アラビア語", de: "ドイツ語", hi: "ヒンディー語", bn: "ベンガル語", ur: "ウルドゥー語", id: "インドネシア語", ja: "日本語", vi: "ベトナム語", ko: "韓国語", + tr: "トルコ語", }, } as const; diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index c3140158..66b00abd 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -107,5 +107,6 @@ export default { pt: "포르투갈어", gl: "갈리시아어", fr: "프랑스어", it: "이탈리아어", pl: "폴란드어", zh: "중국어", ar: "아랍어", de: "독일어", hi: "힌디어", bn: "벵골어", ur: "우르두어", id: "인도네시아어", ja: "일본어", vi: "베트남어", ko: "한국어", + tr: "터키어", }, } as const; diff --git a/frontend/src/i18n/locales/oc.ts b/frontend/src/i18n/locales/oc.ts index 2fa48d50..b94a8bdd 100644 --- a/frontend/src/i18n/locales/oc.ts +++ b/frontend/src/i18n/locales/oc.ts @@ -248,5 +248,6 @@ export default { ja: "Japonés", vi: "Vietnamian", ko: "Corean", + tr: "Turc", }, } as const; diff --git a/frontend/src/i18n/locales/pl.ts b/frontend/src/i18n/locales/pl.ts index 4e4c9f85..d82a93d5 100644 --- a/frontend/src/i18n/locales/pl.ts +++ b/frontend/src/i18n/locales/pl.ts @@ -241,5 +241,6 @@ export default { zh: "Chiński", ar: "Arabski", de: "Niemiecki", + tr: "turecki", }, } as const; diff --git a/frontend/src/i18n/locales/pt.ts b/frontend/src/i18n/locales/pt.ts index 02216203..a84d1fe0 100644 --- a/frontend/src/i18n/locales/pt.ts +++ b/frontend/src/i18n/locales/pt.ts @@ -241,5 +241,6 @@ export default { zh: "Chinês", ar: "Árabe", de: "Alemão", + tr: "Turco", }, } as const; diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts new file mode 100644 index 00000000..afa68fee --- /dev/null +++ b/frontend/src/i18n/locales/tr.ts @@ -0,0 +1,258 @@ +/** + * Turkish (tr) locale. + * Structure mirrors `en.ts` exactly. + */ +export default { + app: { + title: "Unity Generator", + status: { + online: "Çevrimiçi", + offline: "Çevrimdışı", + }, + nav: { + settings: "Ayarlar", + scenes: "Sahneler", + code: "Kod", + text: "Metin", + image: "Görsel", + sprites: "Sprite'lar", + audio: "Ses", + unityUi: "Unity UI", + unityPhysics: "Unity Fizik", + unityProject: "Unity Projesi", + }, + actions: { repository: "Depo" }, + }, + common: { + loading: "Yükleniyor…", + saving: "Kaydediliyor…", + save: "Kaydet", + cancel: "İptal", + close: "Kapat", + ok: "Tamam", + yes: "Evet", + no: "Hayır", + error: "Hata", + warning: "Uyarı", + success: "Başarılı", + generate: "Oluştur", + result: "Sonuç", + provider: "Sağlayıcı", + model: "Model", + temperature: "Sıcaklık", + apiKey: "API Anahtarı (isteğe bağlı)", + systemPrompt: "Sistem istemi geçersiz kılma", + advancedOptions: "Gelişmiş seçenekler", + examplePrompts: "Örnek istemler", + manageModels: "Modelleri yönet", + selectProvider: "Sağlayıcı seç", + selectModel: "Model seç", + prompt: "İstem", + leaveEmptyForGlobalKey: "Genel anahtarı kullanmak için boş bırakın", + createdFiles: "Oluşturulan dosyalar", + viewSteps: "Adımları görüntüle ({n})", + waitingForLogs: "Günlükler bekleniyor…", + errorsDetected: "Hatalar algılandı", + }, + settings: { + title: "Yapılandırma", + subtitle: "Yapay zeka motorlarınızı, modellerinizi ve sistem istemlerinizi yönetin.", + tabs: { + general: "Genel", + providers: "Sağlayıcılar", + models: "Modeller", + prompts: "İstemler", + secrets: "Gizli bilgiler", + }, + }, + general: { + title: "Genel tercihler", + sections: { + networkApi: "Ağ ve API", + preferredIntelligence: "Tercih edilen yapay zeka", + appearance: "Görünüm", + language: "Dil", + }, + fields: { + backendUrl: "Backend URL", + backendUrlHint: "Unity Generator backend hizmetinizin adresi", + outputBasePath: "Temel yol (çıktı)", + outputBasePathHint: "Oluşturulan dosyaların kaydedildiği yer", + unityEditorPath: "Unity Editor yolu (isteğe bağlı)", + unityEditorPathHint: "Unity Editor yürütülebilir dosyasının tam yolu", + preferredLlm: "Varsayılan metin / mantık sağlayıcısı", + preferredLlmModel: "Varsayılan metin / mantık modeli", + preferredImage: "Varsayılan görsel oluşturma sağlayıcısı", + preferredImageModel: "Varsayılan görsel oluşturma modeli", + preferredAudio: "Varsayılan konuşma (TTS) sağlayıcısı", + preferredAudioModel: "Varsayılan konuşma (TTS) modeli", + preferredMusic: "Varsayılan müzik oluşturma sağlayıcısı", + preferredMusicModel: "Varsayılan müzik oluşturma modeli", + theme: "Tema", + language: "Arayüz dili", + }, + actions: { saveAll: "Tüm değişiklikleri kaydet" }, + status: { + saving: "Kaydediliyor…", + saved: "Tercihler başarıyla kaydedildi.", + modelNotRegistered: '"{provider}" sağlayıcısı için "{model}" modeli kayıtlı değil.', + networkError: "Ağ hatası: Backend'e ulaşılamadı.", + }, + }, + code: { + title: "Unity C# Kodu", + subtitle: "Yapay zeka ile Unity C# betikleri oluşturun.", + fields: { prompt: "İstem", maxTokens: "Maksimum token" }, + actions: { generate: "Kod oluştur" }, + activeProject: "Aktif proje: {name}", + autoSave: "Projeye otomatik kaydet", + }, + text: { + title: "Metin oluşturma", + subtitle: "Yapay zeka ile metin içeriği oluşturun.", + fields: { prompt: "İstem", length: "Uzunluk" }, + actions: { generate: "Metin oluştur" }, + }, + image: { + title: "Görsel oluşturma", + subtitle: "Yapay zeka ile görseller oluşturun.", + fields: { + prompt: "İstem", + aspectRatio: "En-boy oranı", + quality: "Kalite", + textureName: "Doku adı", + textureType: "Doku türü", + }, + actions: { generate: "Görsel oluştur", saveToUnity: "Unity'ye kaydet" }, + }, + sprites: { + title: "2D Sprite'lar", + badge: "Piksel sanatına uygun", + subtitle: "Unity için 2D sprite varlıkları oluşturun.", + fields: { + prompt: "İstem", + resolution: "Çözünürlük", + paletteSize: "Palet boyutu", + autoCrop: "Şeffaf kenarları otomatik kırp", + colors: "{n} renk", + }, + preview: "Önizleme alanı", + actions: { generate: "Sprite oluştur" }, + }, + audio: { + title: "Ses oluşturma", + subtitle: "Yapay zeka ile ses varlıkları oluşturun.", + generationType: "Oluşturma türü", + speechTts: "Konuşma (TTS)", + atmosphericMusic: "Atmosferik müzik", + fields: { + prompt: "İstem", + musicDescription: "Müzik açıklaması", + speechPrompt: "Konuşma istemi", + voiceOptional: "Ses (isteğe bağlı)", + musicModel: "Müzik modeli", + stability: "Kararlılık", + audioClipName: "Ses klibi adı", + audioFormat: "Ses formatı", + }, + actions: { generate: "Ses oluştur", saveToUnity: "Unity'ye kaydet" }, + }, + scenes: { + title: "Sahne oluşturucu", + subtitle: "Yapay zeka ile Unity sahneleri oluşturun.", + fields: { prompt: "Sahne açıklaması" }, + actions: { generate: "Sahne oluştur" }, + mediaReady: "İçe aktarmaya hazır medya", + mediaReadyText: + '{type} "{name}" Unity\'ye aktarılmaya hazır. Aşağıdaki istemi inceleyin ve devam etmek için "Sahne oluştur"a tıklayın.', + mediaTypeImage: "Görsel", + mediaTypeAudio: "Ses", + }, + unityUi: { + title: "Unity UI", + subtitle: "Yapay zeka ile Unity UI bileşenleri oluşturun.", + fields: { + uiSystem: "UI sistemi", + elementType: "Öğe türü", + prompt: "UI öğesi açıklaması", + outputFormat: "Çıktı formatı", + anchorPreset: "Sabitleme ön ayarı", + colourTheme: "Renk teması (isteğe bağlı)", + includeAnimations: "Animasyonları dahil et", + }, + actions: { generate: "UI oluştur" }, + }, + unityPhysics: { + title: "Unity Fizik", + subtitle: "Unity fizik yapılandırması oluşturun — Rigidbody'ler, çarpışma birimleri, yerçekimi ve daha fazlası.", + fields: { + physicsBackend: "Fizik motoru", + simulationMode: "Simülasyon modu", + gravityPreset: "Yerçekimi ön ayarı", + prompt: "Fizik açıklaması", + includeRigidbody: "Rigidbody kurulumunu dahil et", + includeColliders: "Çarpışma birimi kurulumunu dahil et", + includeLayers: "Fizik katmanlarını dahil et", + }, + actions: { generate: "Fizik yapılandırması oluştur" }, + quickActions: "Hızlı işlemler", + }, + unityProject: { + title: "Unity Projesi", + subtitle: "Tam bir Unity proje yapısı oluşturun.", + engineSettings: "Unity motor ayarları", + fields: { + projectName: "Proje adı", + unityTemplate: "Unity şablonu", + unityVersion: "Unity sürümü", + targetPlatform: "Hedef platform", + generateDefaultScene: "Varsayılan sahne oluştur", + autoInstallPackages: "UPM paketlerini otomatik kur", + setupUrp: "URP kurulumu", + upmPackages: "UPM paketleri (virgülle ayrılmış)", + sceneName: "Sahne adı", + unityEditorPath: "Unity Editor yolu (isteğe bağlı)", + timeout: "Zaman aşımı (saniye)", + resultJson: "Sonuç (JSON)", + }, + actions: { + generate: "Proje oluştur", + finalize: "Projeyi sonlandır", + openFolder: "Çıktı klasörünü aç", + addVersion: "Unity sürümü ekle", + downloadZip: "Sonlandırılmış projeyi indir (.zip)", + }, + dialogs: { + addVersion: { + title: "Unity sürümü ekle", + versionId: "Sürüm kimliği", + label: "Etiket (isteğe bağlı)", + }, + }, + }, + theme: { light: "Açık", dark: "Koyu", system: "Sistem" }, + languages: { + en: "İngilizce", + es: "İspanyolca", + ca: "Katalanca", + eu: "Baskça", + oc: "Oksitanca", + uk: "Ukraynaca", + pt: "Portekizce", + gl: "Galiçyaca", + fr: "Fransızca", + it: "İtalyanca", + pl: "Lehçe", + zh: "Çince", + ar: "Arapça", + de: "Almanca", + hi: "Hintçe", + bn: "Bengalce", + ur: "Urduca", + id: "Endonezce", + ja: "Japonca", + vi: "Vietnamca", + ko: "Korece", + tr: "Türkçe", + }, +} as const; diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index c91e845d..ff6d74e9 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -248,5 +248,6 @@ export default { ja: "Японська", vi: "В'єтнамська", ko: "Корейська", + tr: "Турецька", }, } as const; diff --git a/frontend/src/i18n/locales/ur.ts b/frontend/src/i18n/locales/ur.ts index 96bac5dd..1bb409da 100644 --- a/frontend/src/i18n/locales/ur.ts +++ b/frontend/src/i18n/locales/ur.ts @@ -107,5 +107,6 @@ export default { pt: "پرتگالی", gl: "گالیشین", fr: "فرانسیسی", it: "اطالوی", pl: "پولش", zh: "چینی", ar: "عربی", de: "جرمن", hi: "ہندی", bn: "بنگالی", ur: "اردو", id: "انڈونیشیائی", ja: "جاپانی", vi: "ویتنامی", ko: "کوریائی", + tr: "ترکی", }, } as const; diff --git a/frontend/src/i18n/locales/vi.ts b/frontend/src/i18n/locales/vi.ts index e8a28530..0b535379 100644 --- a/frontend/src/i18n/locales/vi.ts +++ b/frontend/src/i18n/locales/vi.ts @@ -108,5 +108,6 @@ export default { fr: "Tiếng Pháp", it: "Tiếng Ý", pl: "Tiếng Ba Lan", zh: "Tiếng Trung", ar: "Tiếng Ả Rập", de: "Tiếng Đức", hi: "Tiếng Hindi", bn: "Tiếng Bengali", ur: "Tiếng Urdu", id: "Tiếng Indonesia", ja: "Tiếng Nhật", vi: "Tiếng Việt", ko: "Tiếng Hàn", + tr: "Tiếng Thổ Nhĩ Kỳ", }, } as const; diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fdd7423e..8f3b627b 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -241,5 +241,6 @@ export default { zh: "中文", ar: "阿拉伯语", de: "德语", + tr: "土耳其语", }, } as const; diff --git a/pyrightconfig.json b/pyrightconfig.json index 238fbf51..eab8d4e8 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -20,7 +20,8 @@ "root": "backend", "extraPaths": [ "backend", - "backend/app" + "backend/app", + "../unity-mcp-server-plugin" ] } ], From 6c9fb8bd49b78a5aa1474bbeab2479103572fbb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreu=20Mart=C3=ADnez=20Vidal?= Date: Sun, 22 Mar 2026 11:49:28 +0100 Subject: [PATCH 3/5] Enhance documentation for Docker installation and usage Updated the UNITY_MCP_SERVER.md file to include detailed instructions for installing and running the Unity MCP Server using Docker from the GitHub Container Registry (GHCR). Added sections for pulling the Docker image, running the container, and mapping Docker configurations to the application. Clarified the installation process and provided examples for better user guidance. --- docs/UNITY_MCP_SERVER.md | 42 ++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/UNITY_MCP_SERVER.md b/docs/UNITY_MCP_SERVER.md index 7334784c..17060b50 100644 --- a/docs/UNITY_MCP_SERVER.md +++ b/docs/UNITY_MCP_SERVER.md @@ -9,15 +9,40 @@ For the Semantic Kernel Python bridge, see [UNITY_MCP_INTEGRATION.md](./UNITY_MC |------|--------| | **GitHub** | **[github.com/Ozymandros/Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server)** | | **Clone (HTTPS)** | `https://github.com/Ozymandros/Unity-MCP-Server.git` | +| **GHCR image (Docker)** | **`ghcr.io/ozymandros/unity-mcp-server`** — package page: **[github.com/Ozymandros/Unity-MCP-Server/pkgs/container/unity-mcp-server](https://github.com/Ozymandros/Unity-MCP-Server/pkgs/container/unity-mcp-server)** | | **Typical CLI name** | `unity-mcp` (global .NET tool or built executable — follow upstream README) | **Installation** is defined **upstream**: clone the repo and follow **Unity-MCP-Server** `README.md` (e.g. `dotnet tool install`, Unity package, or build steps). This Unity-Generator repo does **not** ship the server binary; it expects the executable to exist locally or on `PATH`. -### Docker (only if upstream provides it) +### Docker install from GHCR -Check **[Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server)** for a **`Dockerfile`**, **`docker-compose.yml`**, or a **Docker** section in `README.md`. If the project does **not** document Docker, use clone + CLI install above (or ask upstream to add a container workflow). +Official container images are published here: -When Docker *is* documented upstream, use their image name, ports, and env vars as the source of truth. Below is how that maps to **Unity-Generator** only. +**[Unity-MCP-Server — Package `unity-mcp-server` (GHCR)](https://github.com/Ozymandros/Unity-MCP-Server/pkgs/container/unity-mcp-server)** + +| Item | Value | +|------|--------| +| **Registry / image** | `ghcr.io/ozymandros/unity-mcp-server` | +| **Tags** | Use the package page for current tags (e.g. `latest`, version tags like `v3.2.1`, or `sha-` digests). Prefer a **version tag** or digest for reproducible installs. | + +**Pull** (replace `` with a tag from the [package page](https://github.com/Ozymandros/Unity-MCP-Server/pkgs/container/unity-mcp-server), e.g. `latest` or `v3.2.1`): + +```bash +docker pull ghcr.io/ozymandros/unity-mcp-server: +``` + +**Run** (example only — **port and env vars** must match **Unity-MCP-Server** docs for MCP HTTP vs stdio; adjust `-p` and any `-e` flags per upstream): + +```bash +docker run --rm -p : ghcr.io/ozymandros/unity-mcp-server: +``` + +- If the image is **private**, authenticate to GHCR before `pull` / `run`, e.g. [GitHub docs: Working with the Container registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) (`docker login ghcr.io` with a PAT that has `read:packages`). +- For **Unity-Generator** integration over **HTTP**, publish the port the server uses for **Streamable HTTP**, then set **`UNITY_MCP_SERVER_URL`** (see table below). + +You can also build/run from a local **`Dockerfile`** / **`docker-compose.yml`** in the **[Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server)** repo if you prefer; the **GHCR** image is the usual binary-free install path. + +#### How GHCR Docker maps to this app | Integration path in this app | Transport | How Docker usually fits | |------------------------------|-----------|-------------------------| @@ -26,14 +51,15 @@ When Docker *is* documented upstream, use their image name, ports, and env vars **Typical Docker flow for this app (HTTP-capable server):** -1. Build/run the image **as documented in Unity-MCP-Server** (example shape only — replace with upstream values): +1. **Pull** from GHCR and **run** the container (see [package tags](https://github.com/Ozymandros/Unity-MCP-Server/pkgs/container/unity-mcp-server)): ```bash - # Example only; use the exact commands from Ozymandros/Unity-MCP-Server README - docker compose up -d - # or: docker run -p : + docker pull ghcr.io/ozymandros/unity-mcp-server: + docker run --rm -p : ghcr.io/ozymandros/unity-mcp-server: ``` + Alternatively, use **`docker compose`** or a **Dockerfile** from the **[Unity-MCP-Server](https://github.com/Ozymandros/Unity-MCP-Server)** repo if upstream documents it. + 2. Confirm the Unity Editor / MCP server can still communicate (same machine or documented networking). 3. For **finalize** MCP tools, set for the backend process: @@ -101,7 +127,7 @@ When Docker *is* documented upstream, use their image name, ports, and env vars ## Quick checklist -1. Install **Unity-MCP-Server** from **[GitHub](https://github.com/Ozymandros/Unity-MCP-Server)** (upstream README), **or** run it via **Docker** if upstream documents `Dockerfile` / compose (see [Docker (only if upstream provides it)](#docker-only-if-upstream-provides-it)). +1. Install **Unity-MCP-Server** from **[GitHub](https://github.com/Ozymandros/Unity-MCP-Server)** (upstream README), **or** run it from **[GHCR](https://github.com/Ozymandros/Unity-MCP-Server/pkgs/container/unity-mcp-server)** (`docker pull ghcr.io/ozymandros/unity-mcp-server:` — see [Docker install from GHCR](#docker-install-from-ghcr)). 2. Ensure the server is running with Unity / Editor as required by that project. 3. Set **`UNITY_MCP_COMMAND`** if `unity-mcp` is not on `PATH` (stdio / SK agent path). 4. For finalize MCP automation, set **`UNITY_USE_MCP=1`** and configure **stdio** (`UNITY_MCP_COMMAND`) or **`UNITY_MCP_SERVER_URL`** (Docker/HTTP) per `unity_mcp_client.py`. From 52cd61c7f2d6f834b3b87cca68a9e9f45f27c84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreu=20Mart=C3=ADnez=20Vidal?= Date: Mon, 23 Mar 2026 07:46:47 +0100 Subject: [PATCH 4/5] Update unity-mcp-plugin dependency in requirements.txt to use HTTPS URL --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 85037c60..867e6bbb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,4 +13,4 @@ semantic-kernel[mcp]>=1.15.0 # unity-mcp-plugin @ git+ssh://git@github.com/Ozymandros/Unity-MCP-SK-Plugin.git # (Public HTTPS fails with 404 if the repo is private — use SSH, a fork, or a wheel.) # (B) Default dev layout: clone Unity-MCP-SK-Plugin next to this repo as "unity-mcp-server-plugin": --e ../unity-mcp-server-plugin \ No newline at end of file +unity-mcp-plugin @ git+https://github.com/Ozymandros/unity-mcp-server-plugin.git \ No newline at end of file From 54c623304c93a69b447f6dc237ebf5ee801b5215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreu=20Mart=C3=ADnez=20Vidal?= Date: Fri, 3 Apr 2026 18:40:55 +0200 Subject: [PATCH 5/5] REFACTORING: Extract component styles; add physics view-model Move scoped styles out of multiple SFCs into dedicated .css files and wire them via + diff --git a/frontend/src/components/ManagementDashboard.css b/frontend/src/components/ManagementDashboard.css new file mode 100644 index 00000000..9211e2f4 --- /dev/null +++ b/frontend/src/components/ManagementDashboard.css @@ -0,0 +1,69 @@ +.glass-drawer { + background: rgba(15, 23, 42, 0.98) !important; + backdrop-filter: blur(20px); + border-right: 1px solid rgba(255, 255, 255, 0.2) !important; +} + +.glass-card { + background: rgba(30, 41, 59, 0.8) !important; + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.2) !important; + border-radius: 20px !important; + transition: + transform 0.2s, + box-shadow 0.2s; +} + +.transparent-table :deep(table) { + background: transparent !important; +} + +.transparent-table :deep(thead th) { + font-weight: 800 !important; + color: #cbd5e1 !important; + /* Brighter gray */ + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.05em; +} + +.transparent-table :deep(tr:hover) { + background: rgba(255, 255, 255, 0.03) !important; +} + +.code-font :deep(textarea) { + font-family: "JetBrains Mono", "Fira Code", "Roboto Mono", monospace !important; + font-size: 0.85rem !important; + line-height: 1.7 !important; + color: #e2e8f0 !important; +} + +.v-list-subheader { + font-size: 0.7rem !important; + font-weight: 900 !important; + letter-spacing: 0.1em !important; + color: #cbd5e1 !important; + /* Brighter gray */ +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--v-primary-base); +} + +.h-100 { + height: 100%; +} diff --git a/frontend/src/components/ManagementDashboard.vue b/frontend/src/components/ManagementDashboard.vue index 2c2e67ba..55fa6689 100644 --- a/frontend/src/components/ManagementDashboard.vue +++ b/frontend/src/components/ManagementDashboard.vue @@ -1,58 +1,58 @@ - + diff --git a/frontend/src/components/UnityUIPanel/UnityUIPanel.css b/frontend/src/components/UnityUIPanel/UnityUIPanel.css new file mode 100644 index 00000000..16859d31 --- /dev/null +++ b/frontend/src/components/UnityUIPanel/UnityUIPanel.css @@ -0,0 +1,42 @@ +.panel { + max-width: 820px; + margin: 1rem; +} + +.header { + margin-bottom: 20px; +} + +h2 { + margin: 0 0 6px; +} + +.subtitle { + margin: 0; + color: #666; + font-size: 0.95em; +} + +.field-group { + margin-bottom: 12px; +} + +.options-row { + display: flex; + gap: 12px; + margin-bottom: 10px; +} + +.cursor-pointer { + cursor: pointer; +} + +.cursor-pointer:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.results { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} diff --git a/frontend/src/components/UnityUIPanel/UnityUIPanel.vue b/frontend/src/components/UnityUIPanel/UnityUIPanel.vue index 422af546..d99c4921 100644 --- a/frontend/src/components/UnityUIPanel/UnityUIPanel.vue +++ b/frontend/src/components/UnityUIPanel/UnityUIPanel.vue @@ -57,17 +57,6 @@ const anchorPresetOptions = [ { value: "stretch", label: "Stretch Horizontal" }, ]; -/** - * Handle quick action chip click — injects the chip's template prompt. - * - * @param action - The quick action that was clicked. - * - * @example - * ```typescript - * handleQuickActionClick(UI_QUICK_ACTIONS[0]); - * // prompt.value is now the health bar template - * ``` - */ function handleQuickActionClick(action: UIQuickAction): void { if (!action?.prompt) return; prompt.value = action.prompt; @@ -214,47 +203,4 @@ function handleQuickActionClick(action: UIQuickAction): void { - + diff --git a/frontend/src/components/generic/ModelManagerModal/ModelManagerModal.css b/frontend/src/components/generic/ModelManagerModal/ModelManagerModal.css new file mode 100644 index 00000000..ef64bc38 --- /dev/null +++ b/frontend/src/components/generic/ModelManagerModal/ModelManagerModal.css @@ -0,0 +1,105 @@ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.modal-content { + background: #1e1e1e; + color: white; + padding: 2rem; + border-radius: 12px; + width: 500px; + max-width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.close-btn { + background: transparent; + border: none; + font-size: 1.5rem; + color: #888; + cursor: pointer; +} + +.model-list { + margin-bottom: 2rem; +} + +ul { + list-style: none; + padding: 0; +} + +li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid #333; +} + +.add-model h4 { + margin-bottom: 1rem; +} + +.field-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +input { + background: #2d2d2d; + border: 1px solid #444; + color: white; + padding: 0.6rem; + border-radius: 4px; +} + +.danger-btn { + background: #ff4444; + color: white; + border: none; + padding: 0.3rem 0.6rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; +} + +.primary { + background: #007bff; + color: white; + border: none; + padding: 0.7rem; + border-radius: 4px; + cursor: pointer; + margin-top: 0.5rem; +} + +.primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.error { + color: #ff4444; + margin-bottom: 1rem; +} diff --git a/frontend/src/components/generic/ModelManagerModal/ModelManagerModal.vue b/frontend/src/components/generic/ModelManagerModal/ModelManagerModal.vue index 8f78a96b..51e9ea85 100644 --- a/frontend/src/components/generic/ModelManagerModal/ModelManagerModal.vue +++ b/frontend/src/components/generic/ModelManagerModal/ModelManagerModal.vue @@ -1,10 +1,6 @@