From 473d17a04125ff20d959ea6e05e28c87ceffdf3e 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 --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/agenthub/_remote_a2a_service.py | 27 ++-- .../platform/orchestrator/_mcp_service.py | 46 +++++-- .../tests/services/test_mcp_service.py | 125 +++++++++++++++++- .../tests/services/test_remote_a2a_service.py | 90 ++++++++++++- packages/uipath-platform/uv.lock | 2 +- .../uipath/samples/list-mcp-agent/main.py | 4 +- packages/uipath/uv.lock | 2 +- 8 files changed, 264 insertions(+), 34 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index bbf16c8ca..d973884de 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.13" +version = "0.2.14" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" 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..f4c3521af 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 @@ -150,20 +151,20 @@ async def main(): data = response.json() return [RemoteA2aAgent.model_validate(agent) for agent in data.get("value", [])] - @resource_override(resource_type="remoteA2aAgent", resource_identifier="slug") + @resource_override(resource_type="remoteA2aAgent", resource_identifier="name") def retrieve( self, - slug: str, + name: str, *, folder_path: str | None = None, ) -> RemoteA2aAgent: - """Retrieve a specific Remote A2A agent by slug. + """Retrieve a specific Remote A2A agent by display name. .. warning:: This method is experimental and subject to change. Args: - slug: The unique slug identifier for the agent. + name: The display name of the agent. folder_path: The folder path where the agent is located. Returns: @@ -183,7 +184,7 @@ def retrieve( "remote_a2a.retrieve is experimental and subject to change.", stacklevel=2, ) - spec = self._retrieve_spec(slug=slug, folder_path=folder_path) + spec = self._retrieve_spec(name=name, folder_path=folder_path) response = self.request( spec.method, url=spec.endpoint, @@ -192,20 +193,20 @@ def retrieve( ) return RemoteA2aAgent.model_validate(response.json()) - @resource_override(resource_type="remoteA2aAgent", resource_identifier="slug") + @resource_override(resource_type="remoteA2aAgent", resource_identifier="name") async def retrieve_async( self, - slug: str, + name: str, *, folder_path: str | None = None, ) -> RemoteA2aAgent: - """Asynchronously retrieve a specific Remote A2A agent by slug. + """Asynchronously retrieve a specific Remote A2A agent by display name. .. warning:: This method is experimental and subject to change. Args: - slug: The unique slug identifier for the agent. + name: The display name of the agent. folder_path: The folder path where the agent is located. Returns: @@ -229,7 +230,7 @@ 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) + spec = self._retrieve_spec(name=name, folder_path=folder_path) response = await self.request_async( spec.method, url=spec.endpoint, @@ -279,14 +280,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/orchestrator/_mcp_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py index 195eb8240..57e569a63 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 @@ -109,18 +110,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, + name: str | None = None, *, + slug: 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. + name (Optional[str]): The display name of the server. + slug (Optional[str]): The legacy slug identifier of the server. folder_path (Optional[str]): The path of the folder where the server is located. Returns: @@ -132,12 +136,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 = self._resolve_retrieve_identifier(name=name, slug=slug) spec = self._retrieve_spec( - slug=slug, + name=identifier, folder_path=folder_path, ) @@ -150,18 +155,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") async def retrieve_async( self, - slug: str, + name: str | None = None, *, + slug: 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. + name (Optional[str]): The display name of the server. + slug (Optional[str]): The legacy slug identifier of the server. folder_path (Optional[str]): The path of the folder where the server is located. Returns: @@ -176,14 +184,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 = self._resolve_retrieve_identifier(name=name, slug=slug) spec = self._retrieve_spec( - slug=slug, + name=identifier, folder_path=folder_path, ) @@ -207,6 +216,19 @@ def _resolve_folder_key(self, folder_path: str | None) -> str | None: return self._folder_key + @staticmethod + def _resolve_retrieve_identifier( + name: str | None, + slug: str | None, + ) -> str: + 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.") + def _list_spec( self, *, @@ -223,14 +245,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..09cf72de1 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 @@ -266,7 +270,9 @@ def test_retrieve_server_with_folder_path( json=mock_server, ) - server = service.retrieve("test-mcp-server", folder_path="test-folder-path") + server = service.retrieve( + slug="test-mcp-server", folder_path="test-folder-path" + ) assert isinstance(server, McpServer) assert server.name == "Test MCP Server" @@ -340,7 +346,7 @@ async def test_retrieve_server_async( ) server = await service.retrieve_async( - "async-test-server", folder_path="test-folder-path" + slug="async-test-server", folder_path="test-folder-path" ) assert isinstance(server, McpServer) @@ -363,6 +369,113 @@ 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_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 +682,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..3e8919ca8 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,86 @@ 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("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("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("Friendly Agent/Europe") + + assert agent.name == "Friendly Agent/Europe" + assert "remote-a2a-agents/Friendly%20Agent%2FEurope" in str( + request.call_args.kwargs["url"] + ) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 295f77671..80451fb6f 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.13" +version = "0.2.14" source = { editable = "." } dependencies = [ { name = "anyio" }, 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/uv.lock b/packages/uipath/uv.lock index 7af888cf9..333debde1 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.13" +version = "0.2.14" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },