From 4add5b89fc3c2d5e6decb32cef90f88431b08284 Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Wed, 22 Jul 2026 18:07:53 +0300 Subject: [PATCH] fix: add name-based MCP and A2A lookup --- .../platform/agenthub/_remote_a2a_service.py | 32 ++- .../platform/common/_resource_identifier.py | 12 ++ .../platform/orchestrator/_mcp_service.py | 38 ++-- .../tests/services/test_mcp_service.py | 131 +++++++++++- .../tests/services/test_remote_a2a_service.py | 188 +++++++++++++++++- .../uipath/samples/list-mcp-agent/main.py | 4 +- packages/uipath/tests/sdk/test_bindings.py | 52 +++++ 7 files changed, 428 insertions(+), 29 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/common/_resource_identifier.py diff --git a/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py b/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py index c8993e3ee..037f4ff7d 100644 --- a/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py +++ b/packages/uipath-platform/src/uipath/platform/agenthub/_remote_a2a_service.py @@ -7,6 +7,7 @@ import warnings from typing import Any, List +from urllib.parse import quote from ..common._base_service import BaseService from ..common._bindings import resource_override @@ -14,6 +15,7 @@ from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder from ..common._models import Endpoint, RequestSpec +from ..common._resource_identifier import resolve_retrieve_identifier from ..orchestrator import FolderService from .remote_a2a import RemoteA2aAgent @@ -150,20 +152,23 @@ async def main(): data = response.json() return [RemoteA2aAgent.model_validate(agent) for agent in data.get("value", [])] + @resource_override(resource_type="remoteA2aAgent", resource_identifier="name") @resource_override(resource_type="remoteA2aAgent", resource_identifier="slug") def retrieve( self, - slug: str, + slug: str | None = None, *, + name: str | None = None, folder_path: str | None = None, ) -> RemoteA2aAgent: - """Retrieve a specific Remote A2A agent by slug. + """Retrieve a Remote A2A agent by its display name or legacy slug. .. warning:: This method is experimental and subject to change. Args: - slug: The unique slug identifier for the agent. + slug: The legacy slug identifier of the agent. + name: The display name of the agent. folder_path: The folder path where the agent is located. Returns: @@ -183,7 +188,8 @@ def retrieve( "remote_a2a.retrieve is experimental and subject to change.", stacklevel=2, ) - spec = self._retrieve_spec(slug=slug, folder_path=folder_path) + identifier = resolve_retrieve_identifier(name=name, slug=slug) + spec = self._retrieve_spec(name=identifier, folder_path=folder_path) response = self.request( spec.method, url=spec.endpoint, @@ -192,20 +198,23 @@ def retrieve( ) return RemoteA2aAgent.model_validate(response.json()) + @resource_override(resource_type="remoteA2aAgent", resource_identifier="name") @resource_override(resource_type="remoteA2aAgent", resource_identifier="slug") async def retrieve_async( self, - slug: str, + slug: str | None = None, *, + name: str | None = None, folder_path: str | None = None, ) -> RemoteA2aAgent: - """Asynchronously retrieve a specific Remote A2A agent by slug. + """Asynchronously retrieve a Remote A2A agent by display name or legacy slug. .. warning:: This method is experimental and subject to change. Args: - slug: The unique slug identifier for the agent. + slug: The legacy slug identifier of the agent. + name: The display name of the agent. folder_path: The folder path where the agent is located. Returns: @@ -229,7 +238,8 @@ async def main(): "remote_a2a.retrieve_async is experimental and subject to change.", stacklevel=2, ) - spec = self._retrieve_spec(slug=slug, folder_path=folder_path) + identifier = resolve_retrieve_identifier(name=name, slug=slug) + spec = self._retrieve_spec(name=identifier, folder_path=folder_path) response = await self.request_async( spec.method, url=spec.endpoint, @@ -279,14 +289,16 @@ def _list_spec( def _retrieve_spec( self, - slug: str, + name: str, *, folder_path: str | None, ) -> RequestSpec: folder_key = self._resolve_folder_key(folder_path) return RequestSpec( method="GET", - endpoint=Endpoint(f"/agenthub_/api/remote-a2a-agents/{slug}"), + endpoint=Endpoint( + f"/agenthub_/api/remote-a2a-agents/{quote(name, safe='')}" + ), headers={ **header_folder(folder_key, None), }, diff --git a/packages/uipath-platform/src/uipath/platform/common/_resource_identifier.py b/packages/uipath-platform/src/uipath/platform/common/_resource_identifier.py new file mode 100644 index 000000000..3f90ae774 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_resource_identifier.py @@ -0,0 +1,12 @@ +"""Shared retrieve-identifier resolution for name/slug resources (MCP servers and Remote A2A agents).""" + + +def resolve_retrieve_identifier(name: str | None, slug: str | None) -> str: + """Resolve a retrieve identifier, preferring the display name over the legacy slug.""" + if name is not None and slug is not None: + raise ValueError("Specify either 'name' or 'slug', not both.") + if name is not None: + return name + if slug is not None: + return slug + raise TypeError("Either 'name' or 'slug' must be provided.") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py index 195eb8240..e2a9f13c3 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py @@ -1,4 +1,5 @@ from typing import List +from urllib.parse import quote from uipath.core.tracing import traced @@ -8,6 +9,7 @@ from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder from ..common._models import Endpoint, RequestSpec +from ..common._resource_identifier import resolve_retrieve_identifier from ._folder_service import FolderService from .mcp import McpServer @@ -66,7 +68,7 @@ def list( return [McpServer.model_validate(server) for server in response.json()] - @traced(name="mcp_list", run_type="uipath") + @traced(name="mcp_list_async", run_type="uipath") async def list_async( self, *, @@ -109,18 +111,21 @@ async def main(): return [McpServer.model_validate(server) for server in response.json()] + @resource_override(resource_type="mcpServer", resource_identifier="name") @resource_override(resource_type="mcpServer", resource_identifier="slug") @traced(name="mcp_retrieve", run_type="uipath") def retrieve( self, - slug: str, + slug: str | None = None, *, + name: str | None = None, folder_path: str | None = None, ) -> McpServer: - """Retrieve a specific MCP server by its slug. + """Retrieve a specific MCP server by its display name or legacy slug. Args: - slug (str): The unique slug identifier for the server. + slug (Optional[str]): The legacy slug identifier of the server. + name (Optional[str]): The display name of the server. folder_path (Optional[str]): The path of the folder where the server is located. Returns: @@ -132,12 +137,13 @@ def retrieve( client = UiPath() - server = client.mcp.retrieve(slug="my-server-slug", folder_path="MyFolder") + server = client.mcp.retrieve(name="My Server", folder_path="MyFolder") print(f"Server: {server.name}, URL: {server.mcp_url}") ``` """ + identifier = resolve_retrieve_identifier(name=name, slug=slug) spec = self._retrieve_spec( - slug=slug, + name=identifier, folder_path=folder_path, ) @@ -150,18 +156,21 @@ def retrieve( return McpServer.model_validate(response.json()) + @resource_override(resource_type="mcpServer", resource_identifier="name") @resource_override(resource_type="mcpServer", resource_identifier="slug") - @traced(name="mcp_retrieve", run_type="uipath") + @traced(name="mcp_retrieve_async", run_type="uipath") async def retrieve_async( self, - slug: str, + slug: str | None = None, *, + name: str | None = None, folder_path: str | None = None, ) -> McpServer: - """Asynchronously retrieve a specific MCP server by its slug. + """Asynchronously retrieve an MCP server by its display name or legacy slug. Args: - slug (str): The unique slug identifier for the server. + slug (Optional[str]): The legacy slug identifier of the server. + name (Optional[str]): The display name of the server. folder_path (Optional[str]): The path of the folder where the server is located. Returns: @@ -176,14 +185,15 @@ async def retrieve_async( sdk = UiPath() async def main(): - server = await sdk.mcp.retrieve_async(slug="my-server-slug", folder_path="MyFolder") + server = await sdk.mcp.retrieve_async(name="My Server", folder_path="MyFolder") print(f"Server: {server.name}, URL: {server.mcp_url}") asyncio.run(main()) ``` """ + identifier = resolve_retrieve_identifier(name=name, slug=slug) spec = self._retrieve_spec( - slug=slug, + name=identifier, folder_path=folder_path, ) @@ -223,14 +233,14 @@ def _list_spec( def _retrieve_spec( self, - slug: str, + name: str, *, folder_path: str | None, ) -> RequestSpec: folder_key = self._resolve_folder_key(folder_path) return RequestSpec( method="GET", - endpoint=Endpoint(f"/agenthub_/api/servers/{slug}"), + endpoint=Endpoint(f"/agenthub_/api/servers/{quote(name, safe='')}"), headers={ **header_folder(folder_key, None), }, diff --git a/packages/uipath-platform/tests/services/test_mcp_service.py b/packages/uipath-platform/tests/services/test_mcp_service.py index d83699f0c..002eb981f 100644 --- a/packages/uipath-platform/tests/services/test_mcp_service.py +++ b/packages/uipath-platform/tests/services/test_mcp_service.py @@ -1,9 +1,13 @@ -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from pytest_httpx import HTTPXMock from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, +) from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.orchestrator import McpService from uipath.platform.orchestrator._folder_service import FolderService @@ -363,6 +367,125 @@ async def test_retrieve_server_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.McpService.retrieve_async/{version}" ) + def test_retrieve_server_by_name(self, service: McpService) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly MCP/Europe", + "slug": "friendly-mcp-europe", + } + + with patch.object(service, "request", return_value=response) as request: + server = service.retrieve(name="Friendly MCP/Europe") + + assert server.name == "Friendly MCP/Europe" + assert "api/servers/Friendly%20MCP%2FEurope" in str( + request.call_args.kwargs["url"] + ) + + def test_retrieve_rejects_name_and_slug(self, service: McpService) -> None: + with pytest.raises( + ValueError, match="Specify either 'name' or 'slug', not both" + ): + service.retrieve("friendly-mcp", name="Friendly MCP") + + def test_retrieve_requires_name_or_slug(self, service: McpService) -> None: + with pytest.raises( + TypeError, match="Either 'name' or 'slug' must be provided" + ): + service.retrieve() + + def test_retrieve_applies_display_name_binding( + self, service: McpService + ) -> None: + response = Mock() + response.json.return_value = { + "name": "Replacement MCP", + "slug": "replacement-mcp", + } + overwrite = GenericResourceOverwrite( + resource_type="mcpServer", + name="Replacement MCP", + folder_path="Replacement Folder", + ) + token = _resource_overwrites.set({"mcpServer.Original MCP": overwrite}) + + try: + with ( + patch.object(service, "request", return_value=response) as request, + patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="replacement-folder-key", + ), + ): + service.retrieve(name="Original MCP") + finally: + _resource_overwrites.reset(token) + + assert "api/servers/Replacement%20MCP" in str( + request.call_args.kwargs["url"] + ) + assert ( + request.call_args.kwargs["headers"][HEADER_FOLDER_KEY] + == "replacement-folder-key" + ) + + def test_retrieve_applies_legacy_slug_binding( + self, service: McpService + ) -> None: + response = Mock() + response.json.return_value = { + "name": "Replacement MCP", + "slug": "replacement-mcp", + } + overwrite = GenericResourceOverwrite( + resource_type="mcpServer", + name="Replacement MCP", + folder_path="Replacement Folder", + ) + token = _resource_overwrites.set({"mcpServer.original-mcp": overwrite}) + + try: + with ( + patch.object(service, "request", return_value=response) as request, + patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="replacement-folder-key", + ), + ): + service.retrieve(slug="original-mcp") + finally: + _resource_overwrites.reset(token) + + assert "api/servers/Replacement%20MCP" in str( + request.call_args.kwargs["url"] + ) + assert ( + request.call_args.kwargs["headers"][HEADER_FOLDER_KEY] + == "replacement-folder-key" + ) + + @pytest.mark.anyio + async def test_retrieve_server_by_name_async(self, service: McpService) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly MCP/Europe", + "slug": "friendly-mcp-europe", + } + + with patch.object( + service, + "request_async", + new=AsyncMock(return_value=response), + ) as request: + server = await service.retrieve_async(name="Friendly MCP/Europe") + + assert server.name == "Friendly MCP/Europe" + assert "api/servers/Friendly%20MCP%2FEurope" in str( + request.call_args.kwargs["url"] + ) + class TestRequestKwargs: """Test that all methods pass the correct kwargs to request/request_async.""" @@ -569,3 +692,9 @@ def test_validate_swagger_server(self) -> None: ) assert server.type == 7 assert server.slug == "contoso-directory" + + +def test_mcp_retrieve_spec_encodes_display_name(service: McpService) -> None: + spec = service._retrieve_spec(name="Friendly MCP/Europe", folder_path=None) + + assert "api/servers/Friendly%20MCP%2FEurope" in str(spec.endpoint) diff --git a/packages/uipath-platform/tests/services/test_remote_a2a_service.py b/packages/uipath-platform/tests/services/test_remote_a2a_service.py index d5d611958..d6c56d6a2 100644 --- a/packages/uipath-platform/tests/services/test_remote_a2a_service.py +++ b/packages/uipath-platform/tests/services/test_remote_a2a_service.py @@ -1,7 +1,13 @@ +from unittest.mock import AsyncMock, Mock, patch + import pytest from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.agenthub._remote_a2a_service import RemoteA2aService +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, +) from uipath.platform.constants import HEADER_FOLDER_KEY from uipath.platform.orchestrator._folder_service import FolderService @@ -36,7 +42,7 @@ def test_falls_back_to_folder_context_when_folder_path_missing( self, service: RemoteA2aService ) -> None: """No folder_path (e.g. local debug) must not raise; it falls back to context.""" - spec = service._retrieve_spec(slug="weather", folder_path=None) + spec = service._retrieve_spec(name="weather", folder_path=None) assert "remote-a2a-agents/weather" in str(spec.endpoint) assert spec.headers[HEADER_FOLDER_KEY] == "context-folder-key" @@ -50,6 +56,184 @@ def test_resolves_explicit_folder_path( lambda folder_path: "resolved-folder-key", ) - spec = service._retrieve_spec(slug="weather", folder_path="MyFolder") + spec = service._retrieve_spec(name="weather", folder_path="MyFolder") assert spec.headers[HEADER_FOLDER_KEY] == "resolved-folder-key" + + def test_encodes_display_name_in_lookup_path( + self, service: RemoteA2aService + ) -> None: + spec = service._retrieve_spec(name="Friendly Agent/Europe", folder_path=None) + + assert "remote-a2a-agents/Friendly%20Agent%2FEurope" in str(spec.endpoint) + + +class TestRetrieveByName: + def test_retrieves_by_display_name(self, service: RemoteA2aService) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly Agent/Europe", + "slug": "friendly-agent-europe", + } + + with patch.object(service, "request", return_value=response) as request: + agent = service.retrieve(name="Friendly Agent/Europe") + + assert agent.name == "Friendly Agent/Europe" + assert "remote-a2a-agents/Friendly%20Agent%2FEurope" in str( + request.call_args.kwargs["url"] + ) + + def test_applies_display_name_binding(self, service: RemoteA2aService) -> None: + response = Mock() + response.json.return_value = { + "name": "Replacement Agent", + "slug": "replacement-agent", + } + overwrite = GenericResourceOverwrite( + resource_type="remoteA2aAgent", + name="Replacement Agent", + folder_path="Replacement Folder", + ) + token = _resource_overwrites.set({"remoteA2aAgent.Original Agent": overwrite}) + + try: + with ( + patch.object(service, "request", return_value=response) as request, + patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="replacement-folder-key", + ), + ): + service.retrieve(name="Original Agent") + finally: + _resource_overwrites.reset(token) + + assert "remote-a2a-agents/Replacement%20Agent" in str( + request.call_args.kwargs["url"] + ) + assert ( + request.call_args.kwargs["headers"][HEADER_FOLDER_KEY] + == "replacement-folder-key" + ) + + @pytest.mark.anyio + async def test_retrieves_by_display_name_async( + self, service: RemoteA2aService + ) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly Agent/Europe", + "slug": "friendly-agent-europe", + } + + with patch.object( + service, + "request_async", + new=AsyncMock(return_value=response), + ) as request: + agent = await service.retrieve_async(name="Friendly Agent/Europe") + + assert agent.name == "Friendly Agent/Europe" + assert "remote-a2a-agents/Friendly%20Agent%2FEurope" in str( + request.call_args.kwargs["url"] + ) + + +class TestRetrieveCompatibility: + def test_retrieves_by_positional_slug(self, service: RemoteA2aService) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly Agent", + "slug": "friendly-agent", + } + + with patch.object(service, "request", return_value=response) as request: + agent = service.retrieve("friendly-agent") + + assert agent.slug == "friendly-agent" + assert "remote-a2a-agents/friendly-agent" in str( + request.call_args.kwargs["url"] + ) + + def test_retrieves_by_slug_keyword(self, service: RemoteA2aService) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly Agent", + "slug": "friendly-agent", + } + + with patch.object(service, "request", return_value=response) as request: + agent = service.retrieve(slug="friendly-agent") + + assert agent.slug == "friendly-agent" + assert "remote-a2a-agents/friendly-agent" in str( + request.call_args.kwargs["url"] + ) + + def test_applies_legacy_slug_binding(self, service: RemoteA2aService) -> None: + response = Mock() + response.json.return_value = { + "name": "Replacement Agent", + "slug": "replacement-agent", + } + overwrite = GenericResourceOverwrite( + resource_type="remoteA2aAgent", + name="Replacement Agent", + folder_path="Replacement Folder", + ) + token = _resource_overwrites.set({"remoteA2aAgent.original-agent": overwrite}) + + try: + with ( + patch.object(service, "request", return_value=response) as request, + patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="replacement-folder-key", + ), + ): + service.retrieve("original-agent") + finally: + _resource_overwrites.reset(token) + + assert "remote-a2a-agents/Replacement%20Agent" in str( + request.call_args.kwargs["url"] + ) + assert ( + request.call_args.kwargs["headers"][HEADER_FOLDER_KEY] + == "replacement-folder-key" + ) + + @pytest.mark.anyio + async def test_retrieves_by_positional_slug_async( + self, service: RemoteA2aService + ) -> None: + response = Mock() + response.json.return_value = { + "name": "Friendly Agent", + "slug": "friendly-agent", + } + + with patch.object( + service, + "request_async", + new=AsyncMock(return_value=response), + ) as request: + agent = await service.retrieve_async("friendly-agent") + + assert agent.slug == "friendly-agent" + assert "remote-a2a-agents/friendly-agent" in str( + request.call_args.kwargs["url"] + ) + + def test_rejects_name_and_slug(self, service: RemoteA2aService) -> None: + with pytest.raises( + ValueError, match="Specify either 'name' or 'slug', not both" + ): + service.retrieve("friendly-agent", name="Friendly Agent") + + def test_requires_name_or_slug(self, service: RemoteA2aService) -> None: + with pytest.raises(TypeError, match="Either 'name' or 'slug' must be provided"): + service.retrieve() diff --git a/packages/uipath/samples/list-mcp-agent/main.py b/packages/uipath/samples/list-mcp-agent/main.py index 2d71c7697..d1c78b989 100644 --- a/packages/uipath/samples/list-mcp-agent/main.py +++ b/packages/uipath/samples/list-mcp-agent/main.py @@ -24,9 +24,9 @@ def list_mcp_servers() -> list[McpServer]: return uipath.mcp.list(folder_path="Shared") -def retrieve_mcp_server(slug: str) -> McpServer: +def retrieve_mcp_server(name: str) -> McpServer: uipath = UiPath() - return uipath.mcp.retrieve(slug, folder_path="Shared") + return uipath.mcp.retrieve(name, folder_path="Shared") async def connect_and_list_tools(server: McpServer) -> list[str]: diff --git a/packages/uipath/tests/sdk/test_bindings.py b/packages/uipath/tests/sdk/test_bindings.py index 1d51d7413..c581cb2ac 100644 --- a/packages/uipath/tests/sdk/test_bindings.py +++ b/packages/uipath/tests/sdk/test_bindings.py @@ -131,6 +131,58 @@ def dummy_func(name, folder_path): _resource_overwrites.reset(token) +class TestStackedResourceOverrideDecorators: + @pytest.mark.anyio + @pytest.mark.parametrize( + ("arguments", "expected"), + [ + ( + {"name": "original-resource"}, + (None, "replacement-resource", "replacement-folder"), + ), + ( + {"slug": "original-resource"}, + ("replacement-resource", None, "replacement-folder"), + ), + ], + ids=["outer-name-decorator", "inner-slug-decorator"], + ) + async def test_decorators_apply_override_for_active_identifier( + self, + arguments, + expected, + ): + """The outer name decorator must not prevent the inner slug override.""" + overwrite = GenericResourceOverwrite( + resource_type="mcpServer", + name="replacement-resource", + folder_path="replacement-folder", + ) + + @resource_override(resource_type="mcpServer", resource_identifier="name") + @resource_override(resource_type="mcpServer", resource_identifier="slug") + def retrieve(slug=None, *, name=None, folder_path=None): + return slug, name, folder_path + + @resource_override( + resource_type="mcpServer", + resource_identifier="name", + ) + @resource_override( + resource_type="mcpServer", + resource_identifier="slug", + ) + async def retrieve_async(slug=None, *, name=None, folder_path=None): + return slug, name, folder_path + + token = _resource_overwrites.set({"mcpServer.original-resource": overwrite}) + try: + assert retrieve(**arguments) == expected + assert await retrieve_async(**arguments) == expected + finally: + _resource_overwrites.reset(token) + + class TestResourceOverwritesContext: """Test that ResourceOverwritesContext works correctly with infer_bindings."""