2929
3030class UiPathMcpRuntime (UiPathBaseRuntime ):
3131 """
32- A runtime class implementing the async context manager protocol.
33- This allows using the class with 'async with' statements.
32+ A runtime class for hosting UiPath MCP servers.
3433 """
3534
3635 def __init__ (self , context : UiPathMcpRuntimeContext ):
3736 super ().__init__ (context )
3837 self .context : UiPathMcpRuntimeContext = context
39- self .server : Optional [McpServer ] = None
40- self .signalr_client : Optional [SignalRClient ] = None
41- self .session_servers : Dict [str , SessionServer ] = {}
38+ self ._server : Optional [McpServer ] = None
39+ self ._signalr_client : Optional [SignalRClient ] = None
40+ self ._session_servers : Dict [str , SessionServer ] = {}
41+ self ._session_outputs : Dict [str , str ] = {}
42+ self ._cancel_event = asyncio .Event ()
4243 self ._uipath = UiPath ()
4344
4445 async def execute (self ) -> Optional [UiPathRuntimeResult ]:
4546 """
46- Start the runtime and connect to SignalR .
47+ Start the MCP Server runtime .
4748
4849 Returns:
4950 Dictionary with execution results
@@ -54,46 +55,41 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
5455 await self .validate ()
5556
5657 try :
57- if self .server is None :
58+ if self ._server is None :
5859 return None
5960
6061 # Set up SignalR client
61- signalr_url = f"{ os .environ .get ('UIPATH_URL' )} /mcp_/wsstunnel?slug={ self .server .name } &sessionId={ self .server .session_id } "
62+ signalr_url = f"{ os .environ .get ('UIPATH_URL' )} /mcp_/wsstunnel?slug={ self ._server .name } &sessionId={ self ._server .session_id } "
6263
63- self .cancel_event = asyncio .Event ()
64-
65- with tracer .start_as_current_span (self .server .name ) as root_span :
66- root_span .set_attribute ("session_id" , self .server .session_id )
67- root_span .set_attribute ("command" , self .server .command )
68- root_span .set_attribute ("args" , self .server .args )
64+ with tracer .start_as_current_span (self ._server .name ) as root_span :
65+ root_span .set_attribute ("session_id" , self ._server .session_id )
66+ root_span .set_attribute ("command" , self ._server .command )
67+ root_span .set_attribute ("args" , self ._server .args )
6968 root_span .set_attribute ("span_type" , "MCP Server" )
70- self .signalr_client = SignalRClient (
69+ self ._signalr_client = SignalRClient (
7170 signalr_url ,
7271 headers = {
7372 "X-UiPath-Internal-TenantId" : self .context .trace_context .tenant_id ,
7473 "X-UiPath-Internal-AccountId" : self .context .trace_context .org_id ,
7574 },
7675 )
77- self .signalr_client .on ("MessageReceived" , self .handle_signalr_message )
78- self .signalr_client .on (
79- "SessionClosed" , self .handle_signalr_session_closed
76+ self ._signalr_client .on ("MessageReceived" , self ._handle_signalr_message )
77+ self ._signalr_client .on (
78+ "SessionClosed" , self ._handle_signalr_session_closed
8079 )
81- self .signalr_client .on_error (self .handle_signalr_error )
82- self .signalr_client .on_open (self .handle_signalr_open )
83- self .signalr_client .on_close (self .handle_signalr_close )
80+ self ._signalr_client .on_error (self ._handle_signalr_error )
81+ self ._signalr_client .on_open (self ._handle_signalr_open )
82+ self ._signalr_client .on_close (self ._handle_signalr_close )
8483
85- # Register the server with UiPath MCP Server
84+ # Register the local server with UiPath MCP Server
8685 await self ._register ()
8786
88- # Keep the runtime alive
89- # Start SignalR client and keep it running (this is a blocking call)
90- logger .info ("Starting websocket client..." )
91-
92- run_task = asyncio .create_task (self .signalr_client .run ())
87+ run_task = asyncio .create_task (self ._signalr_client .run ())
9388
9489 # Set up a task to wait for cancellation
95- cancel_task = asyncio .create_task (self .cancel_event .wait ())
90+ cancel_task = asyncio .create_task (self ._cancel_event .wait ())
9691
92+ # Keep the runtime alive
9793 # Wait for either the run to complete or cancellation
9894 done , pending = await asyncio .wait (
9995 [run_task , cancel_task ], return_when = asyncio .FIRST_COMPLETED
@@ -103,24 +99,14 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
10399 for task in pending :
104100 task .cancel ()
105101
106- session_outputs = {}
107- for session_id , session_server in self .session_servers .items ():
108- try :
109- await session_server .cleanup ()
110- stderr_output = session_server .get_server_stderr ()
111- if stderr_output :
112- session_outputs [session_id ] = stderr_output
113- except Exception as e :
114- logger .error (f"Error stopping session { session_id } : { str (e )} " )
115-
116102 output_result = {}
117- if len (session_outputs ) == 1 :
118- # If there's only one session, use a single "output " key
119- first_session_id = next (iter (session_outputs ))
120- output_result ["content" ] = session_outputs [ first_session_id ]
121- elif session_outputs :
122- # If there are multiple sessions, use the sessionId as the key
123- output_result = session_outputs
103+ if len (self . _session_outputs ) == 1 :
104+ # If there's only one session, use a single "content " key
105+ single_session_id = next (iter (self . _session_outputs ))
106+ output_result ["content" ] = self . _session_outputs [ single_session_id ]
107+ elif self . _session_outputs :
108+ # If there are multiple sessions, use the session_id as the key
109+ output_result = self . _session_outputs
124110
125111 self .context .result = UiPathRuntimeResult (output = output_result )
126112
@@ -129,55 +115,72 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
129115 except Exception as e :
130116 if isinstance (e , UiPathMcpRuntimeError ):
131117 raise
132-
133118 detail = f"Error: { str (e )} "
134-
135119 raise UiPathMcpRuntimeError (
136120 "EXECUTION_ERROR" ,
137121 "MCP Runtime execution failed" ,
138122 detail ,
139123 UiPathErrorCategory .USER ,
140124 ) from e
141-
142125 finally :
143126 wait_for_tracers ()
144127
145128 async def validate (self ) -> None :
146129 """Validate runtime inputs and load MCP server configuration."""
147- self .server = self .context .config .get_server (self .context .entrypoint )
148- if not self .server :
130+ self ._server = self .context .config .get_server (self .context .entrypoint )
131+ if not self ._server :
149132 raise UiPathMcpRuntimeError (
150133 "SERVER_NOT_FOUND" ,
151134 "MCP server not found" ,
152135 f"Server '{ self .context .entrypoint } ' not found in configuration" ,
153136 UiPathErrorCategory .DEPLOYMENT ,
154137 )
155138
156- async def handle_signalr_session_closed (self , args : list ) -> None :
139+ async def cleanup (self ) -> None :
140+ """Clean up all resources."""
141+ if self ._signalr_client and hasattr (self ._signalr_client , "_transport" ):
142+ transport = self ._signalr_client ._transport
143+ if transport and hasattr (transport , "_ws" ) and transport ._ws :
144+ try :
145+ await transport ._ws .close ()
146+ except Exception as e :
147+ logger .error (f"Error closing SignalR WebSocket: { str (e )} " )
148+
149+ # Add a small delay to allow the server to shut down gracefully
150+ if sys .platform == "win32" :
151+ await asyncio .sleep (0.1 )
152+
153+ async def _handle_signalr_session_closed (self , args : list ) -> None :
157154 """
158155 Handle session closed by server.
159156 """
160157 if len (args ) < 1 :
161- logger .error (f"Received invalid SignalR message arguments: { args } " )
158+ logger .error (f"Received invalid websocket message arguments: { args } " )
162159 return
163160
164161 session_id = args [0 ]
165162
166163 logger .info (f"Received closed signal for session { session_id } " )
167164
168165 try :
169- self .cancel_event .set ()
166+ session_server = self ._session_servers .pop (session_id , None )
167+ if session_server :
168+ await session_server .cleanup ()
169+ if session_server .output :
170+ self ._session_outputs [session_id ] = session_server .output
171+
172+ if len (self ._session_servers ) == 0 :
173+ self ._cancel_event .set ()
170174
171175 except Exception as e :
172176 logger .error (f"Error terminating session { session_id } : { str (e )} " )
173177
174- async def handle_signalr_message (self , args : list ) -> None :
178+ async def _handle_signalr_message (self , args : list ) -> None :
175179 """
176180 Handle incoming SignalR messages.
177- The SignalR client will call this with the arguments from the server.
178181 """
179182 if len (args ) < 1 :
180- logger .error (f"Received invalid SignalR message arguments: { args } " )
183+ logger .error (f"Received invalid websocket message arguments: { args } " )
181184 return
182185
183186 session_id = args [0 ]
@@ -186,50 +189,50 @@ async def handle_signalr_message(self, args: list) -> None:
186189
187190 try :
188191 # Check if we have a session server for this session_id
189- if session_id not in self .session_servers :
192+ if session_id not in self ._session_servers :
190193 # Create and start a new session server
191- session_server = SessionServer (self .server , session_id )
192- self .session_servers [session_id ] = session_server
194+ session_server = SessionServer (self ._server , session_id )
195+ self ._session_servers [session_id ] = session_server
193196 await session_server .start ()
194197
195198 # Get the session server for this session
196- session_server = self .session_servers [session_id ]
199+ session_server = self ._session_servers [session_id ]
197200
198201 # Forward the message to the session's MCP server
199- await session_server .get_incoming_messages ()
202+ await session_server .on_message_received ()
200203
201204 except Exception as e :
202205 logger .error (
203206 f"Error handling websocket notification for session { session_id } : { str (e )} "
204207 )
205208
206- async def handle_signalr_error (self , error : Any ) -> None :
209+ async def _handle_signalr_error (self , error : Any ) -> None :
207210 """Handle SignalR errors."""
208- logger .error (f"SignalR error: { error } " )
211+ logger .error (f"Websocket error: { error } " )
209212
210- async def handle_signalr_open (self ) -> None :
213+ async def _handle_signalr_open (self ) -> None :
211214 """Handle SignalR connection open event."""
212215
213216 logger .info ("Websocket connection established." )
214- if self .server .session_id :
217+ if self ._server .session_id :
215218 try :
216- session_server = SessionServer (self .server , self .server .session_id )
219+ session_server = SessionServer (self ._server , self ._server .session_id )
217220 await session_server .start ()
218- self .session_servers [self .server .session_id ] = session_server
219- await session_server .get_incoming_messages ()
221+ self ._session_servers [self ._server .session_id ] = session_server
222+ await session_server .on_message_received ()
220223 except Exception as e :
221- await self ._dispose_session ()
224+ await self ._on_initialization_failure ()
222225 logger .error (f"Error starting session server: { str (e )} " )
223226
224- async def handle_signalr_close (self ) -> None :
227+ async def _handle_signalr_close (self ) -> None :
225228 """Handle SignalR connection close event."""
226- logger .info ("SignalR connection closed." )
229+ logger .info ("Websocket connection closed." )
227230 # Clean up all session servers when the connection closes
228231 await self .cleanup ()
229232
230233 async def _register (self ) -> None :
231- """Register the MCP server type with UiPath."""
232- logger .info (f"Registering MCP server type : { self .server .name } " )
234+ """Register the MCP server with UiPath."""
235+ logger .info (f"Registering MCP server: { self ._server .name } " )
233236
234237 initialization_successful = False
235238 tools_result = None
@@ -238,9 +241,9 @@ async def _register(self) -> None:
238241 try :
239242 # Create a temporary session to get tools
240243 server_params = StdioServerParameters (
241- command = self .server .command ,
242- args = self .server .args ,
243- env = self .server .env ,
244+ command = self ._server .command ,
245+ args = self ._server .args ,
246+ env = self ._server .env ,
244247 )
245248
246249 # Start a temporary stdio client to get tools
@@ -275,7 +278,7 @@ async def _register(self) -> None:
275278
276279 # Now that we're outside the context managers, check if initialization succeeded
277280 if not initialization_successful :
278- await self ._dispose_session ()
281+ await self ._on_initialization_failure ()
279282 error_message = "The server process failed to initialize. Verify environment variables are set correctly."
280283 if server_stderr_output :
281284 error_message += f"\n Server error output:\n { server_stderr_output } "
@@ -291,8 +294,8 @@ async def _register(self) -> None:
291294 try :
292295 client_info = {
293296 "server" : {
294- "Name" : self .server .name ,
295- "Slug" : self .server .name ,
297+ "Name" : self ._server .name ,
298+ "Slug" : self ._server .name ,
296299 "Version" : "1.0.0" ,
297300 "Type" : 1 ,
298301 },
@@ -311,7 +314,7 @@ async def _register(self) -> None:
311314 # Register with UiPath MCP Server
312315 self ._uipath .api_client .request (
313316 "POST" ,
314- f"mcp_/api/servers-with-tools/{ self .server .name } " ,
317+ f"mcp_/api/servers-with-tools/{ self ._server .name } " ,
315318 json = client_info ,
316319 )
317320 logger .info ("Registered MCP Server type successfully" )
@@ -324,25 +327,28 @@ async def _register(self) -> None:
324327 UiPathErrorCategory .SYSTEM ,
325328 ) from e
326329
327- async def _dispose_session (self ) -> None :
330+ async def _on_initialization_failure (self ) -> None :
328331 """Dispose of the session on the server."""
332+ if self ._server .session_id is None :
333+ return
334+
329335 try :
330336 response = self ._uipath .api_client .request (
331337 "POST" ,
332- f"mcp_/mcp/{ self .server .name } /out/message?sessionId={ self .server .session_id } " ,
338+ f"mcp_/mcp/{ self ._server .name } /out/message?sessionId={ self ._server .session_id } " ,
333339 json = types .JSONRPCResponse (
334340 jsonrpc = "2.0" ,
335341 id = 0 ,
336342 result = {
337343 "protocolVersion" : "initiliaze-failure" ,
338344 "capabilities" : {},
339- "serverInfo" : {"name" : self .server .name , "version" : "1.0" },
345+ "serverInfo" : {"name" : self ._server .name , "version" : "1.0" },
340346 },
341347 ).model_dump (),
342348 )
343349 if response .status_code == 202 :
344350 logger .info (
345- f"Sent outgoing session dispose message to UiPath MCP Server: { self .server .session_id } "
351+ f"Sent outgoing session dispose message to UiPath MCP Server: { self ._server .session_id } "
346352 )
347353 else :
348354 logger .error (
@@ -352,21 +358,3 @@ async def _dispose_session(self) -> None:
352358 logger .error (
353359 f"Error sending session dispose signal to UiPath MCP Server: { e } "
354360 )
355-
356- async def cleanup (self ) -> None :
357- """Clean up all resources."""
358- logger .info ("Cleaning up all resources" )
359-
360- self .session_servers .clear ()
361-
362- if self .signalr_client and hasattr (self .signalr_client , "_transport" ):
363- transport = self .signalr_client ._transport
364- if transport and hasattr (transport , "_ws" ) and transport ._ws :
365- try :
366- await transport ._ws .close ()
367- except Exception as e :
368- logger .error (f"Error closing SignalR WebSocket: { str (e )} " )
369-
370- # Add a small delay to allow the server to shut down gracefully
371- if sys .platform == "win32" :
372- await asyncio .sleep (0.1 )
0 commit comments