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,10 +141,14 @@ 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 if not self .context .folder_key :
80154 folder_path = os .environ .get ("UIPATH_FOLDER_PATH" )
@@ -98,17 +172,22 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
98172
99173 with tracer .start_as_current_span (self .slug ) as root_span :
100174 root_span .set_attribute ("runtime_id" , self ._runtime_id )
101- root_span .set_attribute ("command" , self ._server .command )
102- root_span .set_attribute ("args" , self ._server .args )
175+ root_span .set_attribute ("command" , str ( self ._server .command ) )
176+ root_span .set_attribute ("args" , json . dumps ( self ._server .args ) )
103177 root_span .set_attribute ("span_type" , "MCP Server" )
178+ trace_context = cast (UiPathTraceContext , self .context .trace_context )
179+ tenant_id = str (trace_context .tenant_id )
180+ org_id = str (trace_context .org_id )
181+
104182 self ._signalr_client = SignalRClient (
105183 signalr_url ,
106184 headers = {
107- "X-UiPath-Internal-TenantId" : self . context . trace_context . tenant_id ,
108- "X-UiPath-Internal-AccountId" : self . context . trace_context . org_id ,
185+ "X-UiPath-Internal-TenantId" : tenant_id ,
186+ "X-UiPath-Internal-AccountId" : org_id ,
109187 "X-UIPATH-FolderKey" : self .context .folder_key ,
110188 },
111189 )
190+
112191 self ._signalr_client .on ("MessageReceived" , self ._handle_signalr_message )
113192 self ._signalr_client .on (
114193 "SessionClosed" , self ._handle_signalr_session_closed
@@ -166,20 +245,9 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
166245 ) from e
167246 finally :
168247 await self .cleanup ()
169- if hasattr ( self , "trace_provider" ) and self .trace_provider :
248+ if self .trace_provider :
170249 self .trace_provider .shutdown ()
171250
172- async def validate (self ) -> None :
173- """Validate runtime inputs and load MCP server configuration."""
174- self ._server = self .context .config .get_server (self .context .entrypoint )
175- if not self ._server :
176- raise UiPathMcpRuntimeError (
177- "SERVER_NOT_FOUND" ,
178- "MCP server not found" ,
179- f"Server '{ self .context .entrypoint } ' not found in configuration" ,
180- UiPathErrorCategory .DEPLOYMENT ,
181- )
182-
183251 async def cleanup (self ) -> None :
184252 """Clean up all resources."""
185253
@@ -210,7 +278,7 @@ async def cleanup(self) -> None:
210278 if sys .platform == "win32" :
211279 await asyncio .sleep (0.5 )
212280
213- async def _handle_signalr_session_closed (self , args : list ) -> None :
281+ async def _handle_signalr_session_closed (self , args : list [ Any ] ) -> None :
214282 """
215283 Handle session closed by server.
216284 """
@@ -240,7 +308,7 @@ async def _handle_signalr_session_closed(self, args: list) -> None:
240308 except Exception as e :
241309 logger .error (f"Error terminating session { session_id } : { str (e )} " )
242310
243- async def _handle_signalr_message (self , args : list ) -> None :
311+ async def _handle_signalr_message (self , args : list [ Any ] ) -> None :
244312 """
245313 Handle incoming SignalR messages.
246314 """
@@ -254,10 +322,12 @@ async def _handle_signalr_message(self, args: list) -> None:
254322 logger .info (f"Received websocket notification... { session_id } " )
255323
256324 try :
325+ server = cast (McpServer , self ._server )
326+
257327 # Check if we have a session server for this session_id
258328 if session_id not in self ._session_servers :
259329 # Create and start a new session server
260- session_server = SessionServer (self . _server , self .slug , session_id )
330+ session_server = SessionServer (server , self .slug , session_id )
261331 try :
262332 await session_server .start ()
263333 except Exception as e :
@@ -293,11 +363,12 @@ async def _handle_signalr_close(self) -> None:
293363
294364 async def _register (self ) -> None :
295365 """Register the MCP server with UiPath."""
366+ server = cast (McpServer , self ._server )
296367
297368 initialization_successful = False
298- tools_result = None
369+ tools_result : Optional [ ListToolsResult ] = None
299370 server_stderr_output = ""
300- env_vars = self . _server . env
371+ env_vars = dict ( server . env )
301372
302373 # if server is Coded, include environment variables
303374 if self .server_type is UiPathServerType .Coded :
@@ -309,14 +380,15 @@ async def _register(self) -> None:
309380 try :
310381 # Create a temporary session to get tools
311382 server_params = StdioServerParameters (
312- command = self . _server .command ,
313- args = self . _server .args ,
383+ command = server .command ,
384+ args = server .args ,
314385 env = env_vars ,
315386 )
316387
317388 # Start a temporary stdio client to get tools
318389 # Use a temporary file to capture stderr
319- with tempfile .TemporaryFile (mode = "w+b" ) as stderr_temp :
390+ with tempfile .TemporaryFile (mode = "w+b" ) as stderr_temp_binary :
391+ stderr_temp = io .TextIOWrapper (stderr_temp_binary , encoding = "utf-8" )
320392 async with stdio_client (server_params , errlog = stderr_temp ) as (
321393 read ,
322394 write ,
@@ -336,11 +408,7 @@ async def _register(self) -> None:
336408 logger .error ("Initialization timed out" )
337409 # Capture stderr output here, after the timeout
338410 stderr_temp .seek (0 )
339- server_stderr_output = stderr_temp .read ().decode (
340- "utf-8" , errors = "replace"
341- )
342- # We'll handle this after exiting the context managers
343- # We don't continue with registration here - we'll do it after the context managers
411+ server_stderr_output = stderr_temp .read ()
344412
345413 except* Exception as eg :
346414 for e in eg .exceptions :
@@ -366,15 +434,24 @@ async def _register(self) -> None:
366434 # Now continue with registration
367435 logger .info ("Registering server runtime ..." )
368436 try :
437+ if not tools_result :
438+ raise UiPathMcpRuntimeError (
439+ "INITIALIZATION_ERROR" ,
440+ "Server initialization failed" ,
441+ "Failed to get tools list from server" ,
442+ UiPathErrorCategory .DEPLOYMENT ,
443+ )
444+
445+ tools_list : List [Dict [str , str | None ]] = []
369446 client_info = {
370447 "server" : {
371- "Id" : self .context .server_id ,
372448 "Name" : self .slug ,
373449 "Slug" : self .slug ,
450+ "Id" : self .context .server_id ,
374451 "Version" : "1.0.0" ,
375452 "Type" : self .server_type .value ,
376453 },
377- "tools" : [] ,
454+ "tools" : tools_list ,
378455 }
379456
380457 for tool in tools_result .tools :
@@ -386,7 +463,7 @@ async def _register(self) -> None:
386463 if tool .inputSchema
387464 else "{}" ,
388465 }
389- client_info [ "tools" ] .append (tool_info )
466+ tools_list .append (tool_info )
390467
391468 # Register with UiPath MCP Server
392469 await self ._uipath .api_client .request_async (
@@ -418,14 +495,14 @@ async def _on_session_start_error(self, session_id: str) -> None:
418495 try :
419496 response = await self ._uipath .api_client .request_async (
420497 "POST" ,
421- f"agenthub_/mcp/{ self .slug } /out/message?sessionId={ session_id } " ,
498+ f"agenthub_/mcp/{ self .slug } /out/message?sessionId={ session_id } " , # type: ignore
422499 json = JSONRPCResponse (
423500 jsonrpc = "2.0" ,
424501 id = 0 ,
425502 result = {
426503 "protocolVersion" : "initialize-failure" ,
427504 "capabilities" : {},
428- "serverInfo" : {"name" : self .slug , "version" : "1.0" },
505+ "serverInfo" : {"name" : self .slug , "version" : "1.0" }, # type: ignore
429506 },
430507 ).model_dump (),
431508 )
@@ -475,7 +552,7 @@ async def on_keep_alive_response(
475552 await self ._signalr_client .send (
476553 method = "OnKeepAlive" ,
477554 arguments = [],
478- on_invocation = on_keep_alive_response ,
555+ on_invocation = on_keep_alive_response , # type: ignore
479556 )
480557 except Exception as e :
481558 if not self ._cancel_event .is_set ():
@@ -497,7 +574,7 @@ async def _on_runtime_abort(self) -> None:
497574 try :
498575 response = await self ._uipath .api_client .request_async (
499576 "POST" ,
500- f"agenthub_/mcp/{ self .slug } /runtime/abort?runtimeId={ self ._runtime_id } " ,
577+ f"agenthub_/mcp/{ self .slug } /runtime/abort?runtimeId={ self ._runtime_id } " , # type: ignore
501578 )
502579 if response .status_code == 202 :
503580 logger .info (
@@ -530,7 +607,9 @@ def packaged(self) -> bool:
530607 Returns:
531608 bool: True if this is a packaged runtime (has a process), False otherwise.
532609 """
533- process_key = self .context .trace_context .process_key
610+ process_key = None
611+ if self .context .trace_context is not None :
612+ process_key = self .context .trace_context .process_key
534613
535614 return (
536615 process_key is not None
0 commit comments