11import asyncio
2+ import io
23import json
34import logging
45import os
56import sys
67import tempfile
78import uuid
8- from typing import Any , Dict , Optional
9+ from typing import Any , Dict , List , Optional , cast
910
1011from httpx import HTTPStatusError
1112from mcp import ClientSession , StdioServerParameters , stdio_client
12- from mcp .types import JSONRPCResponse
13+ from mcp .types import JSONRPCResponse , ListToolsResult
1314from opentelemetry import trace
1415from opentelemetry .sdk .trace import TracerProvider
1516from opentelemetry .sdk .trace .export import BatchSpanProcessor
16- from pysignalr .client import CompletionMessage , SignalRClient
17+ from pysignalr .client import SignalRClient
18+ from pysignalr .messages import CompletionMessage
1719from uipath import UiPath
1820from uipath ._cli ._runtime ._contracts import (
1921 UiPathBaseRuntime ,
2022 UiPathErrorCategory ,
2123 UiPathRuntimeResult ,
24+ UiPathTraceContext ,
2225)
2326from uipath .tracing import LlmOpsHttpExporter
2427
@@ -47,8 +50,75 @@ def __init__(self, context: UiPathMcpRuntimeContext):
4750 self ._session_servers : Dict [str , SessionServer ] = {}
4851 self ._session_output : Optional [str ] = None
4952 self ._cancel_event = asyncio .Event ()
50- self ._keep_alive_task : Optional [asyncio .Task ] = None
53+ self ._keep_alive_task : Optional [asyncio .Task [ None ] ] = None
5154 self ._uipath = UiPath ()
55+ self .trace_provider : Optional [TracerProvider ] = None
56+
57+ async def validate (self ) -> None :
58+ """Validate runtime inputs and load MCP server configuration."""
59+ if self .context .config is None :
60+ raise UiPathMcpRuntimeError (
61+ "CONFIGURATION_ERROR" ,
62+ "Missing configuration" ,
63+ "Configuration is required." ,
64+ UiPathErrorCategory .SYSTEM ,
65+ )
66+
67+ if self .context .entrypoint is None :
68+ raise UiPathMcpRuntimeError (
69+ "CONFIGURATION_ERROR" ,
70+ "Missing entrypoint" ,
71+ "Entrypoint is required." ,
72+ UiPathErrorCategory .SYSTEM ,
73+ )
74+
75+ self ._server = self .context .config .get_server (self .context .entrypoint )
76+ if not self ._server :
77+ raise UiPathMcpRuntimeError (
78+ "SERVER_NOT_FOUND" ,
79+ "MCP server not found" ,
80+ f"Server '{ self .context .entrypoint } ' not found in configuration" ,
81+ UiPathErrorCategory .DEPLOYMENT ,
82+ )
83+
84+ def _validate_auth (self ) -> None :
85+ """Validate authentication-related configuration.
86+
87+ Raises:
88+ UiPathMcpRuntimeError: If any required authentication values are missing.
89+ """
90+ uipath_url = os .environ .get ("UIPATH_URL" )
91+ if not uipath_url :
92+ raise UiPathMcpRuntimeError (
93+ "CONFIGURATION_ERROR" ,
94+ "Missing UIPATH_URL environment variable" ,
95+ "Please run 'uipath auth'." ,
96+ UiPathErrorCategory .USER ,
97+ )
98+
99+ if not self .context .trace_context :
100+ raise UiPathMcpRuntimeError (
101+ "CONFIGURATION_ERROR" ,
102+ "Missing trace context" ,
103+ "Trace context is required for SignalR connection." ,
104+ UiPathErrorCategory .SYSTEM ,
105+ )
106+
107+ if not self .context .trace_context .tenant_id :
108+ raise UiPathMcpRuntimeError (
109+ "CONFIGURATION_ERROR" ,
110+ "Missing tenant ID" ,
111+ "Please run 'uipath auth'." ,
112+ UiPathErrorCategory .SYSTEM ,
113+ )
114+
115+ if not self .context .trace_context .org_id :
116+ raise UiPathMcpRuntimeError (
117+ "CONFIGURATION_ERROR" ,
118+ "Missing organization ID" ,
119+ "Please run 'uipath auth'." ,
120+ UiPathErrorCategory .SYSTEM ,
121+ )
52122
53123 async def execute (self ) -> Optional [UiPathRuntimeResult ]:
54124 """
@@ -71,23 +141,32 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
71141 trace .set_tracer_provider (self .trace_provider )
72142 self .trace_provider .add_span_processor (
73143 BatchSpanProcessor (LlmOpsHttpExporter ())
74- ) # type: ignore
144+ )
145+
146+ # Validate authentication configuration
147+ self ._validate_auth ()
75148
76149 # Set up SignalR client
77- signalr_url = f"{ os .environ .get ('UIPATH_URL' )} /agenthub_/wsstunnel?slug={ self ._server .name } &runtimeId={ self ._runtime_id } "
150+ uipath_url = os .environ .get ("UIPATH_URL" )
151+ signalr_url = f"{ uipath_url } /agenthub_/wsstunnel?slug={ self ._server .name } &runtimeId={ self ._runtime_id } "
78152
79153 with tracer .start_as_current_span (self ._server .name ) as root_span :
80154 root_span .set_attribute ("runtime_id" , self ._runtime_id )
81- root_span .set_attribute ("command" , self ._server .command )
82- root_span .set_attribute ("args" , self ._server .args )
155+ root_span .set_attribute ("command" , str ( self ._server .command ) )
156+ root_span .set_attribute ("args" , json . dumps ( self ._server .args ) )
83157 root_span .set_attribute ("span_type" , "MCP Server" )
158+ trace_context = cast (UiPathTraceContext , self .context .trace_context )
159+ tenant_id = str (trace_context .tenant_id )
160+ org_id = str (trace_context .org_id )
161+
84162 self ._signalr_client = SignalRClient (
85163 signalr_url ,
86164 headers = {
87- "X-UiPath-Internal-TenantId" : self . context . trace_context . tenant_id ,
88- "X-UiPath-Internal-AccountId" : self . context . trace_context . org_id ,
165+ "X-UiPath-Internal-TenantId" : tenant_id ,
166+ "X-UiPath-Internal-AccountId" : org_id ,
89167 },
90168 )
169+
91170 self ._signalr_client .on ("MessageReceived" , self ._handle_signalr_message )
92171 self ._signalr_client .on (
93172 "SessionClosed" , self ._handle_signalr_session_closed
@@ -145,20 +224,9 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
145224 ) from e
146225 finally :
147226 await self .cleanup ()
148- if hasattr ( self , "trace_provider" ) and self .trace_provider :
227+ if self .trace_provider :
149228 self .trace_provider .shutdown ()
150229
151- async def validate (self ) -> None :
152- """Validate runtime inputs and load MCP server configuration."""
153- self ._server = self .context .config .get_server (self .context .entrypoint )
154- if not self ._server :
155- raise UiPathMcpRuntimeError (
156- "SERVER_NOT_FOUND" ,
157- "MCP server not found" ,
158- f"Server '{ self .context .entrypoint } ' not found in configuration" ,
159- UiPathErrorCategory .DEPLOYMENT ,
160- )
161-
162230 async def cleanup (self ) -> None :
163231 """Clean up all resources."""
164232
@@ -189,7 +257,7 @@ async def cleanup(self) -> None:
189257 if sys .platform == "win32" :
190258 await asyncio .sleep (0.5 )
191259
192- async def _handle_signalr_session_closed (self , args : list ) -> None :
260+ async def _handle_signalr_session_closed (self , args : list [ Any ] ) -> None :
193261 """
194262 Handle session closed by server.
195263 """
@@ -219,7 +287,7 @@ async def _handle_signalr_session_closed(self, args: list) -> None:
219287 except Exception as e :
220288 logger .error (f"Error terminating session { session_id } : { str (e )} " )
221289
222- async def _handle_signalr_message (self , args : list ) -> None :
290+ async def _handle_signalr_message (self , args : list [ Any ] ) -> None :
223291 """
224292 Handle incoming SignalR messages.
225293 """
@@ -233,10 +301,12 @@ async def _handle_signalr_message(self, args: list) -> None:
233301 logger .info (f"Received websocket notification... { session_id } " )
234302
235303 try :
304+ server = cast (McpServer , self ._server )
305+
236306 # Check if we have a session server for this session_id
237307 if session_id not in self ._session_servers :
238308 # Create and start a new session server
239- session_server = SessionServer (self . _server , session_id )
309+ session_server = SessionServer (server , session_id )
240310 try :
241311 await session_server .start ()
242312 except Exception as e :
@@ -283,10 +353,11 @@ async def _register(self) -> None:
283353 )
284354 logger .info (f"Folder key: { folder_key } " )
285355
356+ server = cast (McpServer , self ._server )
286357 initialization_successful = False
287- tools_result = None
358+ tools_result : Optional [ ListToolsResult ] = None
288359 server_stderr_output = ""
289- env_vars = self . _server . env
360+ env_vars = dict ( server . env )
290361
291362 # if server is Coded, include environment variables
292363 if self .server_type is UiPathServerType .Coded :
@@ -298,14 +369,15 @@ async def _register(self) -> None:
298369 try :
299370 # Create a temporary session to get tools
300371 server_params = StdioServerParameters (
301- command = self . _server .command ,
302- args = self . _server .args ,
372+ command = server .command ,
373+ args = server .args ,
303374 env = env_vars ,
304375 )
305376
306377 # Start a temporary stdio client to get tools
307378 # Use a temporary file to capture stderr
308- with tempfile .TemporaryFile (mode = "w+b" ) as stderr_temp :
379+ with tempfile .TemporaryFile (mode = "w+b" ) as stderr_temp_binary :
380+ stderr_temp = io .TextIOWrapper (stderr_temp_binary , encoding = "utf-8" )
309381 async with stdio_client (server_params , errlog = stderr_temp ) as (
310382 read ,
311383 write ,
@@ -325,11 +397,7 @@ async def _register(self) -> None:
325397 logger .error ("Initialization timed out" )
326398 # Capture stderr output here, after the timeout
327399 stderr_temp .seek (0 )
328- server_stderr_output = stderr_temp .read ().decode (
329- "utf-8" , errors = "replace"
330- )
331- # We'll handle this after exiting the context managers
332- # We don't continue with registration here - we'll do it after the context managers
400+ server_stderr_output = stderr_temp .read ()
333401
334402 except* Exception as eg :
335403 for e in eg .exceptions :
@@ -355,14 +423,24 @@ async def _register(self) -> None:
355423 # Now continue with registration
356424 logger .info ("Registering server runtime ..." )
357425 try :
426+ if not tools_result :
427+ raise UiPathMcpRuntimeError (
428+ "INITIALIZATION_ERROR" ,
429+ "Server initialization failed" ,
430+ "Failed to get tools list from server" ,
431+ UiPathErrorCategory .DEPLOYMENT ,
432+ )
433+
434+ server = cast (McpServer , self ._server )
435+ tools_list : List [Dict [str , str | None ]] = []
358436 client_info = {
359437 "server" : {
360- "Name" : self . _server .name ,
361- "Slug" : self . _server .name ,
438+ "Name" : server .name ,
439+ "Slug" : server .name ,
362440 "Version" : "1.0.0" ,
363441 "Type" : self .server_type .value ,
364442 },
365- "tools" : [] ,
443+ "tools" : tools_list ,
366444 }
367445
368446 for tool in tools_result .tools :
@@ -374,12 +452,12 @@ async def _register(self) -> None:
374452 if tool .inputSchema
375453 else "{}" ,
376454 }
377- client_info [ "tools" ] .append (tool_info )
455+ tools_list .append (tool_info )
378456
379457 # Register with UiPath MCP Server
380458 await self ._uipath .api_client .request_async (
381459 "POST" ,
382- f"agenthub_/mcp/{ self . _server .name } /runtime/start?runtimeId={ self ._runtime_id } " ,
460+ f"agenthub_/mcp/{ server .name } /runtime/start?runtimeId={ self ._runtime_id } " ,
383461 json = client_info ,
384462 headers = {"X-UIPATH-FolderKey" : folder_key },
385463 )
@@ -406,14 +484,14 @@ async def _on_session_start_error(self, session_id: str) -> None:
406484 try :
407485 response = await self ._uipath .api_client .request_async (
408486 "POST" ,
409- f"agenthub_/mcp/{ self ._server .name } /out/message?sessionId={ session_id } " ,
487+ f"agenthub_/mcp/{ self ._server .name } /out/message?sessionId={ session_id } " , # type: ignore
410488 json = JSONRPCResponse (
411489 jsonrpc = "2.0" ,
412490 id = 0 ,
413491 result = {
414492 "protocolVersion" : "initialize-failure" ,
415493 "capabilities" : {},
416- "serverInfo" : {"name" : self ._server .name , "version" : "1.0" },
494+ "serverInfo" : {"name" : self ._server .name , "version" : "1.0" }, # type: ignore
417495 },
418496 ).model_dump (),
419497 )
@@ -463,7 +541,7 @@ async def on_keep_alive_response(
463541 await self ._signalr_client .send (
464542 method = "OnKeepAlive" ,
465543 arguments = [],
466- on_invocation = on_keep_alive_response ,
544+ on_invocation = on_keep_alive_response , # type: ignore
467545 )
468546 except Exception as e :
469547 if not self ._cancel_event .is_set ():
@@ -485,7 +563,7 @@ async def _on_runtime_abort(self) -> None:
485563 try :
486564 response = await self ._uipath .api_client .request_async (
487565 "POST" ,
488- f"agenthub_/mcp/{ self ._server .name } /runtime/abort?runtimeId={ self ._runtime_id } " ,
566+ f"agenthub_/mcp/{ self ._server .name } /runtime/abort?runtimeId={ self ._runtime_id } " , # type: ignore
489567 )
490568 if response .status_code == 202 :
491569 logger .info (
@@ -518,7 +596,9 @@ def packaged(self) -> bool:
518596 Returns:
519597 bool: True if this is a packaged runtime (has a process), False otherwise.
520598 """
521- process_key = self .context .trace_context .process_key
599+ process_key = None
600+ if self .context .trace_context is not None :
601+ process_key = self .context .trace_context .process_key
522602
523603 return (
524604 process_key is not None
0 commit comments