Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@

import warnings
from typing import Any, List
from urllib.parse import quote

from ..common._base_service import BaseService
from ..common._bindings import resource_override
from ..common._config import UiPathApiConfig
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

Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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.")
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import List
from urllib.parse import quote

from uipath.core.tracing import traced

Expand All @@ -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

Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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:
Expand All @@ -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,
)

Expand All @@ -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:
Expand All @@ -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,
)

Expand Down Expand Up @@ -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),
},
Expand Down
131 changes: 130 additions & 1 deletion packages/uipath-platform/tests/services/test_mcp_service.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
Loading
Loading