Skip to content

Commit 473d17a

Browse files
committed
fix: add name-based MCP and A2A lookup
1 parent 59365ec commit 473d17a

8 files changed

Lines changed: 264 additions & 34 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.2.13"
3+
version = "0.2.14"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import warnings
99
from typing import Any, List
10+
from urllib.parse import quote
1011

1112
from ..common._base_service import BaseService
1213
from ..common._bindings import resource_override
@@ -150,20 +151,20 @@ async def main():
150151
data = response.json()
151152
return [RemoteA2aAgent.model_validate(agent) for agent in data.get("value", [])]
152153

153-
@resource_override(resource_type="remoteA2aAgent", resource_identifier="slug")
154+
@resource_override(resource_type="remoteA2aAgent", resource_identifier="name")
154155
def retrieve(
155156
self,
156-
slug: str,
157+
name: str,
157158
*,
158159
folder_path: str | None = None,
159160
) -> RemoteA2aAgent:
160-
"""Retrieve a specific Remote A2A agent by slug.
161+
"""Retrieve a specific Remote A2A agent by display name.
161162
162163
.. warning::
163164
This method is experimental and subject to change.
164165
165166
Args:
166-
slug: The unique slug identifier for the agent.
167+
name: The display name of the agent.
167168
folder_path: The folder path where the agent is located.
168169
169170
Returns:
@@ -183,7 +184,7 @@ def retrieve(
183184
"remote_a2a.retrieve is experimental and subject to change.",
184185
stacklevel=2,
185186
)
186-
spec = self._retrieve_spec(slug=slug, folder_path=folder_path)
187+
spec = self._retrieve_spec(name=name, folder_path=folder_path)
187188
response = self.request(
188189
spec.method,
189190
url=spec.endpoint,
@@ -192,20 +193,20 @@ def retrieve(
192193
)
193194
return RemoteA2aAgent.model_validate(response.json())
194195

195-
@resource_override(resource_type="remoteA2aAgent", resource_identifier="slug")
196+
@resource_override(resource_type="remoteA2aAgent", resource_identifier="name")
196197
async def retrieve_async(
197198
self,
198-
slug: str,
199+
name: str,
199200
*,
200201
folder_path: str | None = None,
201202
) -> RemoteA2aAgent:
202-
"""Asynchronously retrieve a specific Remote A2A agent by slug.
203+
"""Asynchronously retrieve a specific Remote A2A agent by display name.
203204
204205
.. warning::
205206
This method is experimental and subject to change.
206207
207208
Args:
208-
slug: The unique slug identifier for the agent.
209+
name: The display name of the agent.
209210
folder_path: The folder path where the agent is located.
210211
211212
Returns:
@@ -229,7 +230,7 @@ async def main():
229230
"remote_a2a.retrieve_async is experimental and subject to change.",
230231
stacklevel=2,
231232
)
232-
spec = self._retrieve_spec(slug=slug, folder_path=folder_path)
233+
spec = self._retrieve_spec(name=name, folder_path=folder_path)
233234
response = await self.request_async(
234235
spec.method,
235236
url=spec.endpoint,
@@ -279,14 +280,16 @@ def _list_spec(
279280

280281
def _retrieve_spec(
281282
self,
282-
slug: str,
283+
name: str,
283284
*,
284285
folder_path: str | None,
285286
) -> RequestSpec:
286287
folder_key = self._resolve_folder_key(folder_path)
287288
return RequestSpec(
288289
method="GET",
289-
endpoint=Endpoint(f"/agenthub_/api/remote-a2a-agents/{slug}"),
290+
endpoint=Endpoint(
291+
f"/agenthub_/api/remote-a2a-agents/{quote(name, safe='')}"
292+
),
290293
headers={
291294
**header_folder(folder_key, None),
292295
},

packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import List
2+
from urllib.parse import quote
23

34
from uipath.core.tracing import traced
45

@@ -109,18 +110,21 @@ async def main():
109110

110111
return [McpServer.model_validate(server) for server in response.json()]
111112

113+
@resource_override(resource_type="mcpServer", resource_identifier="name")
112114
@resource_override(resource_type="mcpServer", resource_identifier="slug")
113115
@traced(name="mcp_retrieve", run_type="uipath")
114116
def retrieve(
115117
self,
116-
slug: str,
118+
name: str | None = None,
117119
*,
120+
slug: str | None = None,
118121
folder_path: str | None = None,
119122
) -> McpServer:
120-
"""Retrieve a specific MCP server by its slug.
123+
"""Retrieve a specific MCP server by its display name or legacy slug.
121124
122125
Args:
123-
slug (str): The unique slug identifier for the server.
126+
name (Optional[str]): The display name of the server.
127+
slug (Optional[str]): The legacy slug identifier of the server.
124128
folder_path (Optional[str]): The path of the folder where the server is located.
125129
126130
Returns:
@@ -132,12 +136,13 @@ def retrieve(
132136
133137
client = UiPath()
134138
135-
server = client.mcp.retrieve(slug="my-server-slug", folder_path="MyFolder")
139+
server = client.mcp.retrieve(name="My Server", folder_path="MyFolder")
136140
print(f"Server: {server.name}, URL: {server.mcp_url}")
137141
```
138142
"""
143+
identifier = self._resolve_retrieve_identifier(name=name, slug=slug)
139144
spec = self._retrieve_spec(
140-
slug=slug,
145+
name=identifier,
141146
folder_path=folder_path,
142147
)
143148

@@ -150,18 +155,21 @@ def retrieve(
150155

151156
return McpServer.model_validate(response.json())
152157

158+
@resource_override(resource_type="mcpServer", resource_identifier="name")
153159
@resource_override(resource_type="mcpServer", resource_identifier="slug")
154160
@traced(name="mcp_retrieve", run_type="uipath")
155161
async def retrieve_async(
156162
self,
157-
slug: str,
163+
name: str | None = None,
158164
*,
165+
slug: str | None = None,
159166
folder_path: str | None = None,
160167
) -> McpServer:
161-
"""Asynchronously retrieve a specific MCP server by its slug.
168+
"""Asynchronously retrieve an MCP server by its display name or legacy slug.
162169
163170
Args:
164-
slug (str): The unique slug identifier for the server.
171+
name (Optional[str]): The display name of the server.
172+
slug (Optional[str]): The legacy slug identifier of the server.
165173
folder_path (Optional[str]): The path of the folder where the server is located.
166174
167175
Returns:
@@ -176,14 +184,15 @@ async def retrieve_async(
176184
sdk = UiPath()
177185
178186
async def main():
179-
server = await sdk.mcp.retrieve_async(slug="my-server-slug", folder_path="MyFolder")
187+
server = await sdk.mcp.retrieve_async(name="My Server", folder_path="MyFolder")
180188
print(f"Server: {server.name}, URL: {server.mcp_url}")
181189
182190
asyncio.run(main())
183191
```
184192
"""
193+
identifier = self._resolve_retrieve_identifier(name=name, slug=slug)
185194
spec = self._retrieve_spec(
186-
slug=slug,
195+
name=identifier,
187196
folder_path=folder_path,
188197
)
189198

@@ -207,6 +216,19 @@ def _resolve_folder_key(self, folder_path: str | None) -> str | None:
207216

208217
return self._folder_key
209218

219+
@staticmethod
220+
def _resolve_retrieve_identifier(
221+
name: str | None,
222+
slug: str | None,
223+
) -> str:
224+
if name is not None and slug is not None:
225+
raise ValueError("Specify either 'name' or 'slug', not both.")
226+
if name is not None:
227+
return name
228+
if slug is not None:
229+
return slug
230+
raise TypeError("Either 'name' or 'slug' must be provided.")
231+
210232
def _list_spec(
211233
self,
212234
*,
@@ -223,14 +245,14 @@ def _list_spec(
223245

224246
def _retrieve_spec(
225247
self,
226-
slug: str,
248+
name: str,
227249
*,
228250
folder_path: str | None,
229251
) -> RequestSpec:
230252
folder_key = self._resolve_folder_key(folder_path)
231253
return RequestSpec(
232254
method="GET",
233-
endpoint=Endpoint(f"/agenthub_/api/servers/{slug}"),
255+
endpoint=Endpoint(f"/agenthub_/api/servers/{quote(name, safe='')}"),
234256
headers={
235257
**header_folder(folder_key, None),
236258
},

packages/uipath-platform/tests/services/test_mcp_service.py

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
from unittest.mock import Mock, patch
1+
from unittest.mock import AsyncMock, Mock, patch
22

33
import pytest
44
from pytest_httpx import HTTPXMock
55

66
from uipath.platform import UiPathApiConfig, UiPathExecutionContext
7+
from uipath.platform.common._bindings import (
8+
GenericResourceOverwrite,
9+
_resource_overwrites,
10+
)
711
from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT
812
from uipath.platform.orchestrator import McpService
913
from uipath.platform.orchestrator._folder_service import FolderService
@@ -266,7 +270,9 @@ def test_retrieve_server_with_folder_path(
266270
json=mock_server,
267271
)
268272

269-
server = service.retrieve("test-mcp-server", folder_path="test-folder-path")
273+
server = service.retrieve(
274+
slug="test-mcp-server", folder_path="test-folder-path"
275+
)
270276

271277
assert isinstance(server, McpServer)
272278
assert server.name == "Test MCP Server"
@@ -340,7 +346,7 @@ async def test_retrieve_server_async(
340346
)
341347

342348
server = await service.retrieve_async(
343-
"async-test-server", folder_path="test-folder-path"
349+
slug="async-test-server", folder_path="test-folder-path"
344350
)
345351

346352
assert isinstance(server, McpServer)
@@ -363,6 +369,113 @@ async def test_retrieve_server_async(
363369
== f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.McpService.retrieve_async/{version}"
364370
)
365371

372+
def test_retrieve_server_by_name(self, service: McpService) -> None:
373+
response = Mock()
374+
response.json.return_value = {
375+
"name": "Friendly MCP/Europe",
376+
"slug": "friendly-mcp-europe",
377+
}
378+
379+
with patch.object(service, "request", return_value=response) as request:
380+
server = service.retrieve(name="Friendly MCP/Europe")
381+
382+
assert server.name == "Friendly MCP/Europe"
383+
assert "api/servers/Friendly%20MCP%2FEurope" in str(
384+
request.call_args.kwargs["url"]
385+
)
386+
387+
def test_retrieve_applies_display_name_binding(
388+
self, service: McpService
389+
) -> None:
390+
response = Mock()
391+
response.json.return_value = {
392+
"name": "Replacement MCP",
393+
"slug": "replacement-mcp",
394+
}
395+
overwrite = GenericResourceOverwrite(
396+
resource_type="mcpServer",
397+
name="Replacement MCP",
398+
folder_path="Replacement Folder",
399+
)
400+
token = _resource_overwrites.set({"mcpServer.Original MCP": overwrite})
401+
402+
try:
403+
with (
404+
patch.object(service, "request", return_value=response) as request,
405+
patch.object(
406+
service._folders_service,
407+
"retrieve_folder_key",
408+
return_value="replacement-folder-key",
409+
),
410+
):
411+
service.retrieve(name="Original MCP")
412+
finally:
413+
_resource_overwrites.reset(token)
414+
415+
assert "api/servers/Replacement%20MCP" in str(
416+
request.call_args.kwargs["url"]
417+
)
418+
assert (
419+
request.call_args.kwargs["headers"][HEADER_FOLDER_KEY]
420+
== "replacement-folder-key"
421+
)
422+
423+
def test_retrieve_applies_legacy_slug_binding(
424+
self, service: McpService
425+
) -> None:
426+
response = Mock()
427+
response.json.return_value = {
428+
"name": "Replacement MCP",
429+
"slug": "replacement-mcp",
430+
}
431+
overwrite = GenericResourceOverwrite(
432+
resource_type="mcpServer",
433+
name="Replacement MCP",
434+
folder_path="Replacement Folder",
435+
)
436+
token = _resource_overwrites.set({"mcpServer.original-mcp": overwrite})
437+
438+
try:
439+
with (
440+
patch.object(service, "request", return_value=response) as request,
441+
patch.object(
442+
service._folders_service,
443+
"retrieve_folder_key",
444+
return_value="replacement-folder-key",
445+
),
446+
):
447+
service.retrieve(slug="original-mcp")
448+
finally:
449+
_resource_overwrites.reset(token)
450+
451+
assert "api/servers/Replacement%20MCP" in str(
452+
request.call_args.kwargs["url"]
453+
)
454+
assert (
455+
request.call_args.kwargs["headers"][HEADER_FOLDER_KEY]
456+
== "replacement-folder-key"
457+
)
458+
459+
@pytest.mark.anyio
460+
async def test_retrieve_server_by_name_async(self, service: McpService) -> None:
461+
response = Mock()
462+
response.json.return_value = {
463+
"name": "Friendly MCP/Europe",
464+
"slug": "friendly-mcp-europe",
465+
}
466+
467+
with patch.object(
468+
service,
469+
"request_async",
470+
new=AsyncMock(return_value=response),
471+
) as request:
472+
server = await service.retrieve_async(name="Friendly MCP/Europe")
473+
474+
assert server.name == "Friendly MCP/Europe"
475+
assert "api/servers/Friendly%20MCP%2FEurope" in str(
476+
request.call_args.kwargs["url"]
477+
)
478+
366479
class TestRequestKwargs:
367480
"""Test that all methods pass the correct kwargs to request/request_async."""
368481

@@ -569,3 +682,9 @@ def test_validate_swagger_server(self) -> None:
569682
)
570683
assert server.type == 7
571684
assert server.slug == "contoso-directory"
685+
686+
687+
def test_mcp_retrieve_spec_encodes_display_name(service: McpService) -> None:
688+
spec = service._retrieve_spec(name="Friendly MCP/Europe", folder_path=None)
689+
690+
assert "api/servers/Friendly%20MCP%2FEurope" in str(spec.endpoint)

0 commit comments

Comments
 (0)