Skip to content

Commit 9318c71

Browse files
committed
fix: add name-based MCP and A2A lookup
1 parent bb00412 commit 9318c71

9 files changed

Lines changed: 314 additions & 26 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: 67 additions & 4 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
@@ -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(identifier=slug, folder_path=folder_path)
187188
response = self.request(
188189
spec.method,
189190
url=spec.endpoint,
@@ -229,7 +230,67 @@ 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(identifier=slug, folder_path=folder_path)
234+
response = await self.request_async(
235+
spec.method,
236+
url=spec.endpoint,
237+
params=spec.params,
238+
headers=spec.headers,
239+
)
240+
return RemoteA2aAgent.model_validate(response.json())
241+
242+
@resource_override(
243+
resource_type="remoteA2aAgent",
244+
resource_identifier="name",
245+
legacy_resource_identifier="legacy_slug",
246+
)
247+
def retrieve_by_name(
248+
self,
249+
name: str,
250+
*,
251+
legacy_slug: str | None = None,
252+
folder_path: str | None = None,
253+
) -> RemoteA2aAgent:
254+
"""Retrieve a Remote A2A agent by its display name.
255+
256+
``legacy_slug`` supports already-deployed slug-keyed bindings during
257+
the name-identity transition.
258+
"""
259+
warnings.warn(
260+
"remote_a2a.retrieve_by_name is experimental and subject to change.",
261+
stacklevel=2,
262+
)
263+
spec = self._retrieve_spec(identifier=name, folder_path=folder_path)
264+
response = self.request(
265+
spec.method,
266+
url=spec.endpoint,
267+
params=spec.params,
268+
headers=spec.headers,
269+
)
270+
return RemoteA2aAgent.model_validate(response.json())
271+
272+
@resource_override(
273+
resource_type="remoteA2aAgent",
274+
resource_identifier="name",
275+
legacy_resource_identifier="legacy_slug",
276+
)
277+
async def retrieve_by_name_async(
278+
self,
279+
name: str,
280+
*,
281+
legacy_slug: str | None = None,
282+
folder_path: str | None = None,
283+
) -> RemoteA2aAgent:
284+
"""Retrieve a Remote A2A agent by its display name.
285+
286+
``legacy_slug`` supports already-deployed slug-keyed bindings during
287+
the name-identity transition.
288+
"""
289+
warnings.warn(
290+
"remote_a2a.retrieve_by_name_async is experimental and subject to change.",
291+
stacklevel=2,
292+
)
293+
spec = self._retrieve_spec(identifier=name, folder_path=folder_path)
233294
response = await self.request_async(
234295
spec.method,
235296
url=spec.endpoint,
@@ -279,14 +340,16 @@ def _list_spec(
279340

280341
def _retrieve_spec(
281342
self,
282-
slug: str,
343+
identifier: str,
283344
*,
284345
folder_path: str | None,
285346
) -> RequestSpec:
286347
folder_key = self._resolve_folder_key(folder_path)
287348
return RequestSpec(
288349
method="GET",
289-
endpoint=Endpoint(f"/agenthub_/api/remote-a2a-agents/{slug}"),
350+
endpoint=Endpoint(
351+
f"/agenthub_/api/remote-a2a-agents/{quote(identifier, safe='')}"
352+
),
290353
headers={
291354
**header_folder(folder_key, None),
292355
},

packages/uipath-platform/src/uipath/platform/common/_bindings.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ def resource_override(
219219
resource_type: str,
220220
resource_identifier: str = "name",
221221
folder_identifier: str = "folder_path",
222+
legacy_resource_identifier: str | None = None,
222223
) -> Callable[..., Any]:
223224
"""Decorator for applying resource overrides for an overridable resource.
224225
@@ -229,6 +230,9 @@ def resource_override(
229230
resource_type: Type of resource to check for overrides (e.g., "asset", "bucket")
230231
resource_identifier: Key name for the resource ID in override data (default: "name")
231232
folder_identifier: Key name for the folder path in override data (default: "folder_path")
233+
legacy_resource_identifier: Optional argument containing the identifier used
234+
by older binding keys. It is checked only when the primary identifier
235+
does not match.
232236
233237
Returns:
234238
Decorated function that receives overridden resource identifiers when applicable
@@ -257,19 +261,26 @@ def process_args(args, kwargs) -> dict[str, Any]:
257261
context_overwrites = _resource_overwrites.get()
258262

259263
if context_overwrites is not None:
260-
resource_identifier_value = all_args.get(resource_identifier)
261264
folder_identifier_value = all_args.get(folder_identifier)
262-
263-
key = f"{resource_type}.{resource_identifier_value}"
264-
# try to apply folder path, fallback to resource_type.resource_name
265-
if folder_identifier_value:
266-
key = (
267-
f"{key}.{folder_identifier_value}"
268-
if f"{key}.{folder_identifier_value}" in context_overwrites
269-
else key
270-
)
271-
272-
matched_overwrite = context_overwrites.get(key)
265+
identifier_values = [all_args.get(resource_identifier)]
266+
if legacy_resource_identifier is not None:
267+
identifier_values.append(all_args.get(legacy_resource_identifier))
268+
269+
matched_overwrite = None
270+
key = f"{resource_type}.{identifier_values[0]}"
271+
for identifier_value in identifier_values:
272+
if identifier_value is None:
273+
continue
274+
candidate_key = f"{resource_type}.{identifier_value}"
275+
# Try the folder-qualified key first, then the bare key.
276+
if folder_identifier_value:
277+
folder_key = f"{candidate_key}.{folder_identifier_value}"
278+
if folder_key in context_overwrites:
279+
candidate_key = folder_key
280+
matched_overwrite = context_overwrites.get(candidate_key)
281+
key = candidate_key
282+
if matched_overwrite is not None:
283+
break
273284

274285
# Apply the matched overwrite
275286
if matched_overwrite is not None:

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

Lines changed: 59 additions & 4 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

@@ -137,7 +138,7 @@ def retrieve(
137138
```
138139
"""
139140
spec = self._retrieve_spec(
140-
slug=slug,
141+
identifier=slug,
141142
folder_path=folder_path,
142143
)
143144

@@ -183,7 +184,7 @@ async def main():
183184
```
184185
"""
185186
spec = self._retrieve_spec(
186-
slug=slug,
187+
identifier=slug,
187188
folder_path=folder_path,
188189
)
189190

@@ -196,6 +197,60 @@ async def main():
196197

197198
return McpServer.model_validate(response.json())
198199

200+
@resource_override(
201+
resource_type="mcpServer",
202+
resource_identifier="name",
203+
legacy_resource_identifier="legacy_slug",
204+
)
205+
@traced(name="mcp_retrieve_by_name", run_type="uipath")
206+
def retrieve_by_name(
207+
self,
208+
name: str,
209+
*,
210+
legacy_slug: str | None = None,
211+
folder_path: str | None = None,
212+
) -> McpServer:
213+
"""Retrieve an MCP server by its display name.
214+
215+
``legacy_slug`` lets already-deployed slug-keyed bindings apply their
216+
overwrite while packages transition to name-keyed bindings.
217+
"""
218+
spec = self._retrieve_spec(identifier=name, folder_path=folder_path)
219+
response = self.request(
220+
spec.method,
221+
url=spec.endpoint,
222+
params=spec.params,
223+
headers=spec.headers,
224+
)
225+
return McpServer.model_validate(response.json())
226+
227+
@resource_override(
228+
resource_type="mcpServer",
229+
resource_identifier="name",
230+
legacy_resource_identifier="legacy_slug",
231+
)
232+
@traced(name="mcp_retrieve_by_name", run_type="uipath")
233+
async def retrieve_by_name_async(
234+
self,
235+
name: str,
236+
*,
237+
legacy_slug: str | None = None,
238+
folder_path: str | None = None,
239+
) -> McpServer:
240+
"""Retrieve an MCP server by its display name.
241+
242+
``legacy_slug`` lets already-deployed slug-keyed bindings apply their
243+
overwrite while packages transition to name-keyed bindings.
244+
"""
245+
spec = self._retrieve_spec(identifier=name, folder_path=folder_path)
246+
response = await self.request_async(
247+
spec.method,
248+
url=spec.endpoint,
249+
params=spec.params,
250+
headers=spec.headers,
251+
)
252+
return McpServer.model_validate(response.json())
253+
199254
@property
200255
def custom_headers(self) -> dict[str, str]:
201256
return self.folder_headers
@@ -223,14 +278,14 @@ def _list_spec(
223278

224279
def _retrieve_spec(
225280
self,
226-
slug: str,
281+
identifier: str,
227282
*,
228283
folder_path: str | None,
229284
) -> RequestSpec:
230285
folder_key = self._resolve_folder_key(folder_path)
231286
return RequestSpec(
232287
method="GET",
233-
endpoint=Endpoint(f"/agenthub_/api/servers/{slug}"),
288+
endpoint=Endpoint(f"/agenthub_/api/servers/{quote(identifier, safe='')}"),
234289
headers={
235290
**header_folder(folder_key, None),
236291
},
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import pytest
2+
3+
from uipath.platform.common._bindings import (
4+
GenericResourceOverwrite,
5+
ResourceOverwrite,
6+
_resource_overwrites,
7+
resource_override,
8+
)
9+
10+
11+
@resource_override(
12+
resource_type="mcpServer",
13+
resource_identifier="name",
14+
legacy_resource_identifier="legacy_slug",
15+
)
16+
def _retrieve_by_name(
17+
name: str,
18+
*,
19+
legacy_slug: str | None,
20+
folder_path: str,
21+
) -> tuple[str, str]:
22+
return name, folder_path
23+
24+
25+
@pytest.mark.parametrize(
26+
("overwrites", "legacy_slug", "expected"),
27+
[
28+
(
29+
{
30+
"mcpServer.Friendly MCP.Original/Folder": GenericResourceOverwrite(
31+
resource_type="mcpServer",
32+
name="Folder replacement",
33+
folder_path="Replacement/Folder",
34+
)
35+
},
36+
"legacy-slug",
37+
("Folder replacement", "Replacement/Folder"),
38+
),
39+
(
40+
{
41+
"mcpServer.legacy-slug": GenericResourceOverwrite(
42+
resource_type="mcpServer",
43+
name="Legacy replacement",
44+
folder_path="Legacy/Folder",
45+
)
46+
},
47+
"legacy-slug",
48+
("Legacy replacement", "Legacy/Folder"),
49+
),
50+
({}, None, ("Friendly MCP", "Original/Folder")),
51+
],
52+
)
53+
def test_resource_override_supports_primary_and_legacy_binding_keys(
54+
overwrites: dict[str, ResourceOverwrite],
55+
legacy_slug: str | None,
56+
expected: tuple[str, str],
57+
) -> None:
58+
token = _resource_overwrites.set(overwrites)
59+
try:
60+
result = _retrieve_by_name(
61+
"Friendly MCP",
62+
legacy_slug=legacy_slug,
63+
folder_path="Original/Folder",
64+
)
65+
finally:
66+
_resource_overwrites.reset(token)
67+
68+
assert result == expected

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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
@@ -363,6 +363,41 @@ async def test_retrieve_server_async(
363363
== f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.McpService.retrieve_async/{version}"
364364
)
365365

366+
def test_retrieve_server_by_name(self, service: McpService) -> None:
367+
response = Mock()
368+
response.json.return_value = {
369+
"name": "Friendly MCP/Europe",
370+
"slug": "friendly-mcp-europe",
371+
}
372+
373+
with patch.object(service, "request", return_value=response) as request:
374+
server = service.retrieve_by_name("Friendly MCP/Europe")
375+
376+
assert server.name == "Friendly MCP/Europe"
377+
assert "api/servers/Friendly%20MCP%2FEurope" in str(
378+
request.call_args.kwargs["url"]
379+
)
380+
381+
@pytest.mark.anyio
382+
async def test_retrieve_server_by_name_async(self, service: McpService) -> None:
383+
response = Mock()
384+
response.json.return_value = {
385+
"name": "Friendly MCP/Europe",
386+
"slug": "friendly-mcp-europe",
387+
}
388+
389+
with patch.object(
390+
service,
391+
"request_async",
392+
new=AsyncMock(return_value=response),
393+
) as request:
394+
server = await service.retrieve_by_name_async("Friendly MCP/Europe")
395+
396+
assert server.name == "Friendly MCP/Europe"
397+
assert "api/servers/Friendly%20MCP%2FEurope" in str(
398+
request.call_args.kwargs["url"]
399+
)
400+
366401
class TestRequestKwargs:
367402
"""Test that all methods pass the correct kwargs to request/request_async."""
368403

@@ -569,3 +604,9 @@ def test_validate_swagger_server(self) -> None:
569604
)
570605
assert server.type == 7
571606
assert server.slug == "contoso-directory"
607+
608+
609+
def test_mcp_retrieve_spec_encodes_display_name(service: McpService) -> None:
610+
spec = service._retrieve_spec(identifier="Friendly MCP/Europe", folder_path=None)
611+
612+
assert "api/servers/Friendly%20MCP%2FEurope" in str(spec.endpoint)

0 commit comments

Comments
 (0)