diff --git a/.github/workflows/publish.yml b/.github/workflows/cd.yml similarity index 96% rename from .github/workflows/publish.yml rename to .github/workflows/cd.yml index b187cd3..64b94f8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/cd.yml @@ -9,6 +9,9 @@ on: - pyproject.toml jobs: + lint: + uses: ./.github/workflows/lint.yml + build: name: Build runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d124d75 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +permissions: + contents: read + +on: + push: + branches: + - main + paths-ignore: + - pyproject.toml + pull_request: + branches: + - main + +jobs: + commit-lint: + if: ${{ github.event_name == 'pull_request' }} + uses: ./.github/workflows/commitlint.yml + + lint: + uses: ./.github/workflows/lint.yml diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..8562e45 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,47 @@ +name: Commit Lint + +on: + workflow_call + +jobs: + commitlint: + name: Commit Lint + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 22 + + - name: Install Git + run: | + if ! command -v git &> /dev/null; then + echo "Git is not installed. Installing..." + sudo apt-get update + sudo apt-get install -y git + else + echo "Git is already installed." + fi + + - name: Install commitlint + run: | + npm install conventional-changelog-conventionalcommits + npm install commitlint@latest + npm install @commitlint/config-conventional + + - name: Configure + run: | + echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js + + - name: Validate PR commits with commitlint + run: | + git fetch origin pull/${{ github.event.pull_request.number }}/head:pr_branch + npx commitlint --from ${{ github.event.pull_request.base.sha }} --to pr_branch --verbose diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..f638454 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,53 @@ +name: Lint + +on: + workflow_call + +jobs: + # Job that runs when custom version testing is enabled - just completes successfully + skip-lint: + name: Skip Lint (Custom Version Testing) + runs-on: ubuntu-latest + if: contains(github.event.pull_request.labels.*.name, 'test-core-dev-version') + permissions: + contents: read + steps: + - name: Skip lint for custom version testing + run: | + echo "Custom version testing enabled - skipping normal lint process" + echo "This job completes successfully to allow PR merging" + + # Job that runs normal lint process when custom version testing is NOT enabled + lint: + name: Lint + runs-on: ubuntu-latest + if: "!contains(github.event.pull_request.labels.*.name, 'test-core-dev-version')" + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install dependencies + run: uv sync --all-extras + + - name: Check static types + run: uv run mypy --config-file pyproject.toml . + + - name: Check linting + run: uv run ruff check . + + - name: Check formatting + run: uv run ruff format --check . + diff --git a/pyproject.toml b/pyproject.toml index 4aee13d..64641c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,9 @@ line-ending = "auto" plugins = [ "pydantic.mypy" ] +exclude = [ + "samples/.*" +] follow_imports = "silent" warn_redundant_casts = true diff --git a/samples/github-slack-agent/main.py b/samples/github-slack-agent/main.py index 463957d..e8268bb 100644 --- a/samples/github-slack-agent/main.py +++ b/samples/github-slack-agent/main.py @@ -108,7 +108,9 @@ def system_prompt(state: AgentState) -> AgentState: _This review was generated automatically._ """ - return [{"role": "system", "content": system_message}] + state["messages"] + return [{"role": "system", "content": system_message}] + state[ + "messages" + ] agent = create_react_agent( model, diff --git a/samples/mcp-functions-agent/builder.py b/samples/mcp-functions-agent/builder.py index a62eef5..1294baa 100644 --- a/samples/mcp-functions-agent/builder.py +++ b/samples/mcp-functions-agent/builder.py @@ -180,9 +180,7 @@ async def validator_agent(state: GraphState) -> GraphState: **Example use case:** If the function reads files from disk, your setup function should create a temporary folder and write some files into it. Then, test the function against that folder path. """ - seeder = create_react_agent( - model, tools=tools, prompt=test_case_prompt - ) + seeder = create_react_agent(model, tools=tools, prompt=test_case_prompt) test_result = await seeder.ainvoke(state) diff --git a/samples/mcp-functions-server/server.py b/samples/mcp-functions-server/server.py index fa94425..2f26d2f 100644 --- a/samples/mcp-functions-server/server.py +++ b/samples/mcp-functions-server/server.py @@ -5,6 +5,7 @@ # Initialize the MCP server mcp = FastMCP("Code Functions MCP Server") + # Functions registry to track dynamically added code functions class FunctionRegistry: def __init__(self): diff --git a/src/uipath_mcp/_cli/_runtime/_context.py b/src/uipath_mcp/_cli/_runtime/_context.py index 5c00306..94a1fbc 100644 --- a/src/uipath_mcp/_cli/_runtime/_context.py +++ b/src/uipath_mcp/_cli/_runtime/_context.py @@ -53,10 +53,10 @@ class UiPathServerType(Enum): SelfHosted (3): Tunnel to externally hosted server """ - UiPath = 0 # type: int # Processes, Agents, Activities - Command = 1 # type: int # npx, uvx - Coded = 2 # type: int # PackageType.MCPServer - SelfHosted = 3 # type: int # tunnel to externally hosted server + UiPath = 0 # Processes, Agents, Activities + Command = 1 # npx, uvx + Coded = 2 # PackageType.MCPServer + SelfHosted = 3 # tunnel to externally hosted server @classmethod def from_string(cls, name: str) -> "UiPathServerType": diff --git a/src/uipath_mcp/_cli/_runtime/_runtime.py b/src/uipath_mcp/_cli/_runtime/_runtime.py index c276a3c..af7f465 100644 --- a/src/uipath_mcp/_cli/_runtime/_runtime.py +++ b/src/uipath_mcp/_cli/_runtime/_runtime.py @@ -1,19 +1,21 @@ import asyncio +import io import json import logging import os import sys import tempfile import uuid -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, cast from httpx import HTTPStatusError from mcp import ClientSession, StdioServerParameters, stdio_client -from mcp.types import JSONRPCResponse +from mcp.types import JSONRPCResponse, ListToolsResult from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor -from pysignalr.client import CompletionMessage, SignalRClient +from pysignalr.client import SignalRClient +from pysignalr.messages import CompletionMessage from uipath import UiPath from uipath._cli._runtime._contracts import ( UiPathBaseRuntime, @@ -47,9 +49,75 @@ def __init__(self, context: UiPathMcpRuntimeContext): self._session_servers: Dict[str, SessionServer] = {} self._session_output: Optional[str] = None self._cancel_event = asyncio.Event() - self._keep_alive_task: Optional[asyncio.Task] = None + self._keep_alive_task: Optional[asyncio.Task[None]] = None self._uipath = UiPath() + async def validate(self) -> None: + """Validate runtime inputs and load MCP server configuration.""" + if self.context.config is None: + raise UiPathMcpRuntimeError( + "CONFIGURATION_ERROR", + "Missing configuration", + "Configuration is required.", + UiPathErrorCategory.SYSTEM, + ) + + if self.context.entrypoint is None: + raise UiPathMcpRuntimeError( + "CONFIGURATION_ERROR", + "Missing entrypoint", + "Entrypoint is required.", + UiPathErrorCategory.SYSTEM, + ) + + self._server = self.context.config.get_server(self.context.entrypoint) + if not self._server: + raise UiPathMcpRuntimeError( + "SERVER_NOT_FOUND", + "MCP server not found", + f"Server '{self.context.entrypoint}' not found in configuration", + UiPathErrorCategory.DEPLOYMENT, + ) + + def _validate_auth(self) -> None: + """Validate authentication-related configuration. + + Raises: + UiPathMcpRuntimeError: If any required authentication values are missing. + """ + uipath_url = os.environ.get("UIPATH_URL") + if not uipath_url: + raise UiPathMcpRuntimeError( + "CONFIGURATION_ERROR", + "Missing UIPATH_URL environment variable", + "Please run 'uipath auth'.", + UiPathErrorCategory.USER, + ) + + if not self.context.trace_context: + raise UiPathMcpRuntimeError( + "CONFIGURATION_ERROR", + "Missing trace context", + "Trace context is required for SignalR connection.", + UiPathErrorCategory.SYSTEM, + ) + + if not self.context.trace_context.tenant_id: + raise UiPathMcpRuntimeError( + "CONFIGURATION_ERROR", + "Missing tenant ID", + "Please run 'uipath auth'.", + UiPathErrorCategory.SYSTEM, + ) + + if not self.context.trace_context.org_id: + raise UiPathMcpRuntimeError( + "CONFIGURATION_ERROR", + "Missing organization ID", + "Please run 'uipath auth'.", + UiPathErrorCategory.SYSTEM, + ) + async def execute(self) -> Optional[UiPathRuntimeResult]: """ Start the MCP Server runtime. @@ -71,10 +139,14 @@ async def execute(self) -> Optional[UiPathRuntimeResult]: trace.set_tracer_provider(self.trace_provider) self.trace_provider.add_span_processor( BatchSpanProcessor(LlmOpsHttpExporter()) - ) # type: ignore + ) + + # Validate authentication configuration + self._validate_auth() # Set up SignalR client - signalr_url = f"{os.environ.get('UIPATH_URL')}/agenthub_/wsstunnel?slug={self._server.name}&runtimeId={self._runtime_id}" + uipath_url = os.environ.get("UIPATH_URL") + signalr_url = f"{uipath_url}/agenthub_/wsstunnel?slug={self.slug}&runtimeId={self._runtime_id}" if not self.context.folder_key: folder_path = os.environ.get("UIPATH_FOLDER_PATH") @@ -85,7 +157,9 @@ async def execute(self) -> Optional[UiPathRuntimeResult]: "Please set the UIPATH_FOLDER_PATH or UIPATH_FOLDER_KEY environment variable.", UiPathErrorCategory.USER, ) - self.context.folder_key = self._uipath.folders.retrieve_key(folder_path=folder_path) + self.context.folder_key = self._uipath.folders.retrieve_key( + folder_path=folder_path + ) if not self.context.folder_key: raise UiPathMcpRuntimeError( "REGISTRATION_ERROR", @@ -98,14 +172,15 @@ async def execute(self) -> Optional[UiPathRuntimeResult]: with tracer.start_as_current_span(self.slug) as root_span: root_span.set_attribute("runtime_id", self._runtime_id) - root_span.set_attribute("command", self._server.command) - root_span.set_attribute("args", self._server.args) + root_span.set_attribute("command", str(self._server.command)) + root_span.set_attribute("args", json.dumps(self._server.args)) root_span.set_attribute("span_type", "MCP Server") + self._signalr_client = SignalRClient( signalr_url, headers={ - "X-UiPath-Internal-TenantId": self.context.trace_context.tenant_id, - "X-UiPath-Internal-AccountId": self.context.trace_context.org_id, + "X-UiPath-Internal-TenantId": self.context.trace_context.tenant_id, # type: ignore + "X-UiPath-Internal-AccountId": self.context.trace_context.org_id, # type: ignore "X-UIPATH-FolderKey": self.context.folder_key, }, ) @@ -169,17 +244,6 @@ async def execute(self) -> Optional[UiPathRuntimeResult]: if hasattr(self, "trace_provider") and self.trace_provider: self.trace_provider.shutdown() - async def validate(self) -> None: - """Validate runtime inputs and load MCP server configuration.""" - self._server = self.context.config.get_server(self.context.entrypoint) - if not self._server: - raise UiPathMcpRuntimeError( - "SERVER_NOT_FOUND", - "MCP server not found", - f"Server '{self.context.entrypoint}' not found in configuration", - UiPathErrorCategory.DEPLOYMENT, - ) - async def cleanup(self) -> None: """Clean up all resources.""" @@ -210,7 +274,7 @@ async def cleanup(self) -> None: if sys.platform == "win32": await asyncio.sleep(0.5) - async def _handle_signalr_session_closed(self, args: list) -> None: + async def _handle_signalr_session_closed(self, args: list[str]) -> None: """ Handle session closed by server. """ @@ -240,7 +304,7 @@ async def _handle_signalr_session_closed(self, args: list) -> None: except Exception as e: logger.error(f"Error terminating session {session_id}: {str(e)}") - async def _handle_signalr_message(self, args: list) -> None: + async def _handle_signalr_message(self, args: list[str]) -> None: """ Handle incoming SignalR messages. """ @@ -254,10 +318,12 @@ async def _handle_signalr_message(self, args: list) -> None: logger.info(f"Received websocket notification... {session_id}") try: + server = cast(McpServer, self._server) + # Check if we have a session server for this session_id if session_id not in self._session_servers: # Create and start a new session server - session_server = SessionServer(self._server, self.slug, session_id) + session_server = SessionServer(server, self.slug, session_id) try: await session_server.start() except Exception as e: @@ -293,11 +359,12 @@ async def _handle_signalr_close(self) -> None: async def _register(self) -> None: """Register the MCP server with UiPath.""" + server = cast(McpServer, self._server) initialization_successful = False - tools_result = None + tools_result: Optional[ListToolsResult] = None server_stderr_output = "" - env_vars = self._server.env + env_vars = dict(server.env) # if server is Coded, include environment variables if self.server_type is UiPathServerType.Coded: @@ -309,14 +376,15 @@ async def _register(self) -> None: try: # Create a temporary session to get tools server_params = StdioServerParameters( - command=self._server.command, - args=self._server.args, + command=server.command, + args=server.args, env=env_vars, ) # Start a temporary stdio client to get tools # Use a temporary file to capture stderr - with tempfile.TemporaryFile(mode="w+b") as stderr_temp: + with tempfile.TemporaryFile(mode="w+b") as stderr_temp_binary: + stderr_temp = io.TextIOWrapper(stderr_temp_binary, encoding="utf-8") async with stdio_client(server_params, errlog=stderr_temp) as ( read, write, @@ -336,11 +404,7 @@ async def _register(self) -> None: logger.error("Initialization timed out") # Capture stderr output here, after the timeout stderr_temp.seek(0) - server_stderr_output = stderr_temp.read().decode( - "utf-8", errors="replace" - ) - # We'll handle this after exiting the context managers - # We don't continue with registration here - we'll do it after the context managers + server_stderr_output = stderr_temp.read() except* Exception as eg: for e in eg.exceptions: @@ -366,15 +430,24 @@ async def _register(self) -> None: # Now continue with registration logger.info("Registering server runtime ...") try: + if not tools_result: + raise UiPathMcpRuntimeError( + "INITIALIZATION_ERROR", + "Server initialization failed", + "Failed to get tools list from server", + UiPathErrorCategory.DEPLOYMENT, + ) + + tools_list: List[Dict[str, str | None]] = [] client_info = { "server": { - "Id": self.context.server_id, "Name": self.slug, "Slug": self.slug, + "Id": self.context.server_id, "Version": "1.0.0", "Type": self.server_type.value, }, - "tools": [], + "tools": tools_list, } for tool in tools_result.tools: @@ -386,7 +459,7 @@ async def _register(self) -> None: if tool.inputSchema else "{}", } - client_info["tools"].append(tool_info) + tools_list.append(tool_info) # Register with UiPath MCP Server await self._uipath.api_client.request_async( @@ -475,7 +548,7 @@ async def on_keep_alive_response( await self._signalr_client.send( method="OnKeepAlive", arguments=[], - on_invocation=on_keep_alive_response, + on_invocation=on_keep_alive_response, # type: ignore ) except Exception as e: if not self._cancel_event.is_set(): @@ -530,7 +603,9 @@ def packaged(self) -> bool: Returns: bool: True if this is a packaged runtime (has a process), False otherwise. """ - process_key = self.context.trace_context.process_key + process_key = None + if self.context.trace_context is not None: + process_key = self.context.trace_context.process_key return ( process_key is not None @@ -539,7 +614,7 @@ def packaged(self) -> bool: @property def slug(self) -> str: - return self.context.server_slug or self._server.name + return self.context.server_slug or self._server.name # type: ignore @property def server_type(self) -> UiPathServerType: diff --git a/src/uipath_mcp/_cli/_runtime/_session.py b/src/uipath_mcp/_cli/_runtime/_session.py index e0b97cb..2b5f6f5 100644 --- a/src/uipath_mcp/_cli/_runtime/_session.py +++ b/src/uipath_mcp/_cli/_runtime/_session.py @@ -1,4 +1,5 @@ import asyncio +import io import logging import tempfile from typing import Dict, Optional @@ -35,11 +36,11 @@ def __init__(self, server_config: McpServer, server_slug: str, session_id: str): self._read_stream = None self._write_stream = None self._mcp_session = None - self._run_task = None - self._message_queue = asyncio.Queue() + self._run_task: Optional[asyncio.Task[None]] = None + self._message_queue: asyncio.Queue[JSONRPCMessage] = asyncio.Queue() self._active_requests: Dict[str, str] = {} - self._last_request_id = None - self._last_message_id = None + self._last_request_id: Optional[str] = None + self._last_message_id: Optional[str] = None self._uipath = UiPath() self._mcp_tracer = McpTracer(tracer, logger) self._server_stderr_output: Optional[str] = None @@ -112,7 +113,8 @@ async def _run_server(self, server_params: StdioServerParameters) -> None: """Run the local MCP server process.""" logger.info(f"Starting local MCP Server process for session {self._session_id}") self._server_stderr_output = None - with tempfile.TemporaryFile(mode="w+b") as stderr_temp: + with tempfile.TemporaryFile(mode="w+b") as stderr_temp_binary: + stderr_temp = io.TextIOWrapper(stderr_temp_binary, encoding="utf-8") try: async with stdio_client(server_params, errlog=stderr_temp) as ( read, @@ -129,6 +131,10 @@ async def _run_server(self, server_params: StdioServerParameters) -> None: # Get message from local server session_message = None try: + if self._read_stream is None: + logger.error("Read stream is not initialized") + break + session_message = await self._read_stream.receive() if isinstance(session_message, Exception): logger.error(f"Received error: {session_message}") @@ -149,14 +155,16 @@ async def _run_server(self, server_params: StdioServerParameters) -> None: del self._active_requests[message_id] else: # If no mapping found, use the last known request_id + if self._last_request_id is not None: + await self._send_message( + message, self._last_request_id + ) + else: + # For non-responses, use the last known request_id + if self._last_request_id is not None: await self._send_message( message, self._last_request_id ) - else: - # For non-responses, use the last known request_id - await self._send_message( - message, self._last_request_id - ) except Exception as e: if session_message: logger.info(session_message) @@ -164,20 +172,21 @@ async def _run_server(self, server_params: StdioServerParameters) -> None: f"Error processing message for session {self._session_id}: {e}", exc_info=True, ) - await self._send_message( - JSONRPCMessage( - root=JSONRPCError( - jsonrpc="2.0", - # Use the last known message id for error reporting - id=self._last_message_id, - error=ErrorData( - code=-32000, - message=f"Error processing message: {e}", - ), - ) - ), - self._last_request_id, - ) + if self._last_request_id is not None: + await self._send_message( + JSONRPCMessage( + root=JSONRPCError( + jsonrpc="2.0", + # Use the last known message id for error reporting + id=self._last_message_id, + error=ErrorData( + code=-32000, + message=f"Error processing message: {e}", + ), + ) + ), + self._last_request_id, + ) continue finally: # Cancel the consumer when we exit the loop @@ -188,18 +197,15 @@ async def _run_server(self, server_params: StdioServerParameters) -> None: pass except* Exception as eg: - for e in eg.exceptions: + for exception in eg.exceptions: logger.error( - f"Unexpected error for session {self._session_id}: {e}", + f"Unexpected error for session {self._session_id}: {exception}", exc_info=True, ) finally: stderr_temp.seek(0) - self._server_stderr_output = stderr_temp.read().decode( - "utf-8", errors="replace" - ) + self._server_stderr_output = stderr_temp.read() logger.error(self._server_stderr_output) - # The context managers will handle cleanup of resources def _run_server_callback(self, task): """Handle task completion.""" diff --git a/src/uipath_mcp/_cli/_runtime/_tracer.py b/src/uipath_mcp/_cli/_runtime/_tracer.py index ed2c7a4..060d509 100644 --- a/src/uipath_mcp/_cli/_runtime/_tracer.py +++ b/src/uipath_mcp/_cli/_runtime/_tracer.py @@ -53,10 +53,10 @@ def create_span_for_message(self, message: types.JSONRPCMessage, **context) -> S span.set_attribute("type", "response") span.set_attribute("span_type", "MCP response") span.set_attribute("id", str(root_value.id)) - #if isinstance(root_value.result, dict): - #parent_span.set_attribute("output", json.dumps(root_value.result)) + # if isinstance(root_value.result, dict): + # parent_span.set_attribute("output", json.dumps(root_value.result)) self._add_response_attributes(span, root_value) - else: # JSONRPCError + elif isinstance(root_value, types.JSONRPCError): # JSONRPCError span = self._tracer.start_span("error", context=span_context) span.set_attribute("type", "error") span.set_attribute("span_type", "MCP error") @@ -65,7 +65,6 @@ def create_span_for_message(self, message: types.JSONRPCMessage, **context) -> S span.set_attribute("error", root_value.error.message) span.set_status(StatusCode.ERROR) - # Remove the request from active tracking self._active_request_spans.pop(request_id, None) else: @@ -131,9 +130,7 @@ def _add_request_attributes( if "arguments" in request.params and isinstance( request.params["arguments"], dict ): - span.set_attribute( - "input", json.dumps(request.params["arguments"]) - ) + span.set_attribute("input", json.dumps(request.params["arguments"])) # Handle specific tracing for other method types elif request.method == "resources/read" and isinstance( diff --git a/src/uipath_mcp/_cli/_templates/pyproject.toml.template b/src/uipath_mcp/_cli/_templates/pyproject.toml.template index 26226ee..caa7535 100644 --- a/src/uipath_mcp/_cli/_templates/pyproject.toml.template +++ b/src/uipath_mcp/_cli/_templates/pyproject.toml.template @@ -1,7 +1,7 @@ [project] name = "$project_name" version = "0.0.1" -description = "Description for '$project_name' project" +description = "Description for $project_name project" authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] dependencies = [ "uipath-mcp>=0.0.96", diff --git a/src/uipath_mcp/_cli/_utils/_config.py b/src/uipath_mcp/_cli/_utils/_config.py index 5e72c3a..597930e 100644 --- a/src/uipath_mcp/_cli/_utils/_config.py +++ b/src/uipath_mcp/_cli/_utils/_config.py @@ -5,10 +5,15 @@ logger = logging.getLogger(__name__) + class McpServer: """Model representing an MCP server configuration.""" - def __init__(self, name: str, server_config: Dict[str, Any], ): + def __init__( + self, + name: str, + server_config: Dict[str, Any], + ): self.name = name self.type = server_config.get("type") self.command = server_config.get("command") @@ -25,11 +30,7 @@ def file_path(self) -> Optional[str]: def to_dict(self) -> Dict[str, Any]: """Convert the server model back to a dictionary.""" - return { - "type": self.type, - "command": self.command, - "args": self.args - } + return {"type": self.type, "command": self.command, "args": self.args} def __repr__(self) -> str: return f"McpServer(name='{self.name}', type='{self.type}', command='{self.command}', args={self.args})" @@ -57,8 +58,7 @@ def _load_config(self) -> None: servers_config = self._raw_config.get("servers", {}) self._servers = { - name: McpServer(name, config) - for name, config in servers_config.items() + name: McpServer(name, config) for name, config in servers_config.items() } except json.JSONDecodeError as e: diff --git a/src/uipath_mcp/_cli/_utils/_diagnose.py b/src/uipath_mcp/_cli/_utils/_diagnose.py index bb3991c..d0cf26e 100644 --- a/src/uipath_mcp/_cli/_utils/_diagnose.py +++ b/src/uipath_mcp/_cli/_utils/_diagnose.py @@ -1,14 +1,15 @@ import os import platform import subprocess +from typing import Dict, Union def diagnose_binary(binary_path): """Diagnose why a binary file can't be executed.""" - results = {} + results: Dict[str, Union[bool, str]] = {} # Check if file exists - results["exists"] = os.path.exists(binary_path) + results["exists"] = bool(os.path.exists(binary_path)) if not results["exists"]: return f"Error: {binary_path} does not exist" @@ -60,23 +61,21 @@ def diagnose_binary(binary_path): print(f"Binary architecture: {results['binary_arch']}") # Provide potential solution - if "ELF" in results.get("file_type", "") and results["system_os"] == "Linux": - if ( - "64-bit" in results["file_type"] - and "x86-64" in results["file_type"] - and results["system_arch"] != "x86_64" - ): + file_type = str(results.get("file_type", "")) + system_os = str(results["system_os"]) + system_arch = str(results["system_arch"]) + binary_arch = str(results.get("binary_arch", "")) + + if "ELF" in file_type and system_os == "Linux": + if "64-bit" in file_type and "x86-64" in file_type and system_arch != "x86_64": return ( "Error: Binary was compiled for x86_64 architecture but your system is " - + results["system_arch"] + + system_arch ) - elif ( - "ARM" in results.get("binary_arch", "") - and "arm" not in results["system_arch"].lower() - ): + elif "ARM" in binary_arch and "arm" not in system_arch.lower(): return ( "Error: Binary was compiled for ARM architecture but your system is " - + results["system_arch"] + + system_arch ) return "Binary format may be incompatible with your system. You need a version compiled specifically for your architecture and operating system." diff --git a/src/uipath_mcp/_cli/cli_init.py b/src/uipath_mcp/_cli/cli_init.py index 86631fb..d7573bc 100644 --- a/src/uipath_mcp/_cli/cli_init.py +++ b/src/uipath_mcp/_cli/cli_init.py @@ -2,6 +2,7 @@ import json import uuid from typing import Any, Callable, overload + from uipath._cli._utils._console import ConsoleLogger from uipath._cli.middlewares import MiddlewareResult @@ -9,6 +10,7 @@ console = ConsoleLogger() + async def mcp_init_middleware_async( entrypoint: str, options: dict[str, Any] | None = None, @@ -52,7 +54,7 @@ async def mcp_init_middleware_async( with open(config_path, "w") as f: json.dump(uipath_data, f, indent=2) - console.success(f"Created '{config_path}' file." ) + console.success(f"Created '{config_path}' file.") return MiddlewareResult(should_continue=False) except Exception as e: diff --git a/src/uipath_mcp/_cli/cli_new.py b/src/uipath_mcp/_cli/cli_new.py index c2ae2e9..cf4e3e3 100644 --- a/src/uipath_mcp/_cli/cli_new.py +++ b/src/uipath_mcp/_cli/cli_new.py @@ -1,6 +1,6 @@ import os import shutil -from typing import Tuple, Optional, List +from typing import List, Optional, Tuple import click from uipath._cli._utils._console import ConsoleLogger @@ -8,6 +8,7 @@ console = ConsoleLogger() + def clean_directory(directory: str) -> None: """Clean up Python files in the specified directory. @@ -20,11 +21,17 @@ def clean_directory(directory: str) -> None: for file_name in os.listdir(directory): file_path = os.path.join(directory, file_name) - if os.path.isfile(file_path) and file_name.endswith('.py'): + if os.path.isfile(file_path) and file_name.endswith(".py"): # Delete the file os.remove(file_path) -def write_template_file(target_directory:str, file_path: str, file_name: str, replace_tuple: Optional[List[Tuple[str, str]]] = None) -> None: + +def write_template_file( + target_directory: str, + file_path: str, + file_name: str, + replace_tuple: Optional[List[Tuple[str, str]]] = None, +) -> None: """Write a template file to the target directory with optional placeholder replacements. Args: @@ -37,9 +44,7 @@ def write_template_file(target_directory:str, file_path: str, file_name: str, re This function copies a template file to the target directory and optionally replaces placeholders with specified values. It logs a success message after creating the file. """ - template_path = os.path.join( - os.path.dirname(__file__), file_path - ) + template_path = os.path.join(os.path.dirname(__file__), file_path) target_path = os.path.join(target_directory, file_name) if replace_tuple is not None: # replace the template placeholders @@ -67,24 +72,22 @@ def generate_files(target_directory: str, server_name: str): - pyproject.toml: Project metadata and dependencies """ write_template_file( - target_directory, - "_templates/server.py.template", - "server.py", - None + target_directory, "_templates/server.py.template", "server.py", None ) write_template_file( target_directory, "_templates/mcp.json.template", "mcp.json", - [("$server_name", server_name)] + [("$server_name", server_name)], ) write_template_file( target_directory, "_templates/pyproject.toml.template", "pyproject.toml", - [("$project_name", server_name)] + [("$project_name", server_name)], ) + def mcp_new_middleware(name: str) -> MiddlewareResult: """Create a new MCP server project with template files. @@ -105,7 +108,9 @@ def mcp_new_middleware(name: str) -> MiddlewareResult: directory = os.getcwd() try: - with console.spinner(f"Creating new mcp server '{name}' in current directory ..."): + with console.spinner( + f"Creating new mcp server '{name}' in current directory ..." + ): clean_directory(directory) generate_files(directory, name) init_command = """uipath init""" @@ -117,11 +122,21 @@ def mcp_new_middleware(name: str) -> MiddlewareResult: line = click.style("═" * 60, bold=True) console.info(line) - console.info(click.style(f"""Start '{name}' as a self-hosted MCP server""", fg="magenta", bold=True)) + console.info( + click.style( + f"""Start '{name}' as a self-hosted MCP server""", + fg="magenta", + bold=True, + ) + ) console.info(line) - console.hint(f""" 1. Set {click.style("UIPATH_FOLDER_PATH", fg="cyan")} environment variable""") - console.hint(f""" 2. Start the server locally: {click.style(run_command, fg="cyan")}""") + console.hint( + f""" 1. Set {click.style("UIPATH_FOLDER_PATH", fg="cyan")} environment variable""" + ) + console.hint( + f""" 2. Start the server locally: {click.style(run_command, fg="cyan")}""" + ) return MiddlewareResult(should_continue=False) except Exception as e: console.error(f"Error creating demo agent {str(e)}") diff --git a/src/uipath_mcp/middlewares.py b/src/uipath_mcp/middlewares.py index 5ffd266..5d98704 100644 --- a/src/uipath_mcp/middlewares.py +++ b/src/uipath_mcp/middlewares.py @@ -1,8 +1,9 @@ from uipath._cli.middlewares import Middlewares from ._cli.cli_init import mcp_init_middleware -from ._cli.cli_run import mcp_run_middleware from ._cli.cli_new import mcp_new_middleware +from ._cli.cli_run import mcp_run_middleware + def register_middleware(): """This function will be called by the entry point system when uipath-mcp is installed""" diff --git a/uv.lock b/uv.lock index 1e663df..8fe3826 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.11" [[package]] @@ -2017,7 +2018,7 @@ wheels = [ [[package]] name = "uipath-mcp" -version = "0.0.96" +version = "0.0.98" source = { editable = "." } dependencies = [ { name = "mcp" },