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
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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),
},
Expand Down
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 Down Expand Up @@ -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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preserve the old positional slug contract as well. retrieve("legacy-slug") now binds to name and queries it as a display name, so existing positional callers can fail even though slug= keyword callers work. Keep slug positional and make name keyword-only, then add a positional-slug regression test.

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

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

Expand All @@ -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,
*,
Expand All @@ -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),
},
Expand Down
125 changes: 122 additions & 3 deletions 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 @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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."""

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