Skip to content

Commit 6e6afd4

Browse files
committed
fix: add single entrypoint verification
1 parent 775726b commit 6e6afd4

File tree

7 files changed

+241
-35
lines changed

7 files changed

+241
-35
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ cython_debug/
182182

183183
**/samples/**/.agent/
184184
**/samples/**/.claude/
185+
.claude/
185186
**/samples/**/AGENTS.md
186187
**/samples/**/CLAUDE.md
187188
**/samples/**/entry-points.json

pyproject.toml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
[project]
22
name = "uipath-mcp"
3-
version = "0.1.4"
3+
version = "0.1.5"
44
description = "UiPath MCP SDK"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
88
"mcp==1.26.0",
99
"pysignalr==1.3.0",
10-
"uipath>=2.8.23, <2.9.0",
11-
"uipath-runtime>=0.8.0, <0.9.0",
10+
# "uipath>=2.8.23, <2.9.0",
11+
"uipath==2.10.12.dev1014315285",
12+
"uipath-runtime>=0.9.1, <0.10.0",
1213
]
1314
classifiers = [
1415
"Development Status :: 3 - Alpha",
@@ -42,14 +43,13 @@ dev = [
4243
"mypy>=1.14.1",
4344
"ruff>=0.9.4",
4445
"pytest>=7.4.0",
45-
"pytest-asyncio>=0.23.0",
46+
"pytest-asyncio>=1.3.0",
4647
"pytest-cov>=4.1.0",
4748
"pytest-mock>=3.11.1",
4849
"pre-commit>=4.5.1",
4950
"filelock>=3.20.3",
5051
"virtualenv>=20.36.1",
5152
"numpy>=1.24.0",
52-
"pytest-asyncio>=1.3.0",
5353
]
5454

5555
[tool.ruff]
@@ -93,3 +93,6 @@ name = "testpypi"
9393
url = "https://test.pypi.org/simple/"
9494
publish-url = "https://test.pypi.org/legacy/"
9595
explicit = true
96+
97+
[tool.uv.sources]
98+
uipath = { index = "testpypi" }

src/uipath_mcp/_cli/_runtime/_factory.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,35 @@ async def new_runtime(
101101
UiPathErrorCategory.DEPLOYMENT,
102102
)
103103

104+
force = kwargs.get("force", False)
104105
server = mcp_config.get_server(entrypoint)
105106
logger.info("Creating MCP runtime for entrypoint '%s'", entrypoint)
106107
logger.info(server)
107108
if not server:
108-
available = ", ".join(mcp_config.get_server_names())
109-
raise UiPathMcpRuntimeError(
110-
McpErrorCode.SERVER_NOT_FOUND,
111-
"MCP server not found",
112-
f"Server '{entrypoint}' not found. Available: {available}",
113-
UiPathErrorCategory.USER,
114-
)
109+
if force:
110+
server = mcp_config.get_single_server()
111+
if server:
112+
logger.info(
113+
"Force mode: using server '%s' config with entrypoint '%s'",
114+
server.name,
115+
entrypoint,
116+
)
117+
else:
118+
available = ", ".join(mcp_config.get_server_names())
119+
raise UiPathMcpRuntimeError(
120+
McpErrorCode.SERVER_NOT_FOUND,
121+
"MCP server not found",
122+
f"--force requires exactly one server in mcp.json, but found: {available}",
123+
UiPathErrorCategory.USER,
124+
)
125+
else:
126+
available = ", ".join(mcp_config.get_server_names())
127+
raise UiPathMcpRuntimeError(
128+
McpErrorCode.SERVER_NOT_FOUND,
129+
"MCP server not found",
130+
f"Server '{entrypoint}' not found. Available: {available}",
131+
UiPathErrorCategory.USER,
132+
)
115133

116134
# Validate streamable-http configuration
117135
if server.is_streamable_http:

src/uipath_mcp/_cli/_utils/_config.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,14 @@ def get_servers(self) -> list[McpServer]:
106106
def get_server(self, name: str) -> McpServer | None:
107107
"""
108108
Get a server model by name.
109-
If there's only one server available, return that one regardless of name.
110-
Otherwise, look up the server by the provided name.
111109
"""
112-
# If there's only one server, return it
110+
return self._servers.get(name)
111+
112+
def get_single_server(self) -> McpServer | None:
113+
"""Return the server if there is exactly one, None otherwise."""
113114
if len(self._servers) == 1:
114115
return next(iter(self._servers.values()))
115-
116-
# Otherwise, fall back to looking up by name
117-
return self._servers.get(name)
116+
return None
118117

119118
def get_server_names(self) -> list[str]:
120119
"""Get list of all server names."""

tests/cli/test_config.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import json
2+
3+
from uipath_mcp._cli._utils._config import McpConfig
4+
5+
6+
def test_get_single_server_with_one(tmp_path):
7+
config_path = tmp_path / "mcp.json"
8+
config_path.write_text(
9+
json.dumps(
10+
{"servers": {"only-server": {"command": "python", "args": ["s.py"]}}}
11+
)
12+
)
13+
cfg = McpConfig(str(config_path))
14+
server = cfg.get_single_server()
15+
assert server is not None
16+
assert server.name == "only-server"
17+
18+
19+
def test_get_single_server_with_multiple(tmp_path):
20+
config_path = tmp_path / "mcp.json"
21+
config_path.write_text(
22+
json.dumps(
23+
{
24+
"servers": {
25+
"server-a": {"command": "python", "args": ["a.py"]},
26+
"server-b": {"command": "python", "args": ["b.py"]},
27+
}
28+
}
29+
)
30+
)
31+
cfg = McpConfig(str(config_path))
32+
assert cfg.get_single_server() is None
33+
34+
35+
def test_get_single_server_with_none(tmp_path):
36+
config_path = tmp_path / "mcp.json"
37+
config_path.write_text(json.dumps({"servers": {}}))
38+
cfg = McpConfig(str(config_path))
39+
assert cfg.get_single_server() is None

tests/cli/test_factory.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import json
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
6+
from uipath_mcp._cli._runtime._exception import UiPathMcpRuntimeError
7+
from uipath_mcp._cli._runtime._factory import UiPathMcpRuntimeFactory
8+
9+
# Patch UiPath() constructor which requires auth env vars
10+
_UIPATH_PATCH = patch("uipath_mcp._cli._runtime._runtime.UiPath")
11+
12+
13+
@pytest.fixture
14+
def mcp_json_single(tmp_path):
15+
"""Create a temporary mcp.json with a single server."""
16+
config = {
17+
"servers": {
18+
"math-server": {
19+
"transport": "stdio",
20+
"command": "python",
21+
"args": ["server.py"],
22+
}
23+
}
24+
}
25+
config_path = tmp_path / "mcp.json"
26+
config_path.write_text(json.dumps(config))
27+
return str(config_path)
28+
29+
30+
@pytest.fixture
31+
def mcp_json_multiple(tmp_path):
32+
"""Create a temporary mcp.json with multiple servers."""
33+
config = {
34+
"servers": {
35+
"math-server": {
36+
"transport": "stdio",
37+
"command": "python",
38+
"args": ["math_server.py"],
39+
},
40+
"date-server": {
41+
"transport": "stdio",
42+
"command": "python",
43+
"args": ["date_server.py"],
44+
},
45+
}
46+
}
47+
config_path = tmp_path / "mcp.json"
48+
config_path.write_text(json.dumps(config))
49+
return str(config_path)
50+
51+
52+
@pytest.fixture
53+
def factory(tmp_path):
54+
context = MagicMock()
55+
context.config_path = str(tmp_path / "uipath.json")
56+
context.folder_key = "test-folder-key"
57+
context.mcp_server_id = "test-server-id"
58+
return UiPathMcpRuntimeFactory(context=context)
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_exact_match_works(factory, mcp_json_single):
63+
"""Server found by exact name match."""
64+
factory._mcp_config = None
65+
with _UIPATH_PATCH, patch.object(factory, "_load_mcp_config") as mock_load:
66+
from uipath_mcp._cli._utils._config import McpConfig
67+
68+
mock_load.return_value = McpConfig(mcp_json_single)
69+
runtime = await factory.new_runtime(
70+
"math-server", "00000000-0000-0000-0000-000000000001"
71+
)
72+
assert runtime._entrypoint == "math-server"
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_wrong_name_without_force_raises(factory, mcp_json_single):
77+
"""Wrong entrypoint without --force raises SERVER_NOT_FOUND."""
78+
with patch.object(factory, "_load_mcp_config") as mock_load:
79+
from uipath_mcp._cli._utils._config import McpConfig
80+
81+
mock_load.return_value = McpConfig(mcp_json_single)
82+
with pytest.raises(UiPathMcpRuntimeError) as exc_info:
83+
await factory.new_runtime("my-mcp2", "00000000-0000-0000-0000-000000000001")
84+
assert "not found" in str(exc_info.value).lower()
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_force_single_server_uses_config_with_new_entrypoint(
89+
factory, mcp_json_single
90+
):
91+
"""--force with single server loads its config but uses the requested entrypoint."""
92+
with _UIPATH_PATCH, patch.object(factory, "_load_mcp_config") as mock_load:
93+
from uipath_mcp._cli._utils._config import McpConfig
94+
95+
mock_load.return_value = McpConfig(mcp_json_single)
96+
runtime = await factory.new_runtime(
97+
"my-mcp2", "00000000-0000-0000-0000-000000000001", force=True
98+
)
99+
# Entrypoint is the requested name, not the original server name
100+
assert runtime._entrypoint == "my-mcp2"
101+
# Server config comes from the existing server
102+
assert runtime._server.command == "python"
103+
assert runtime._server.args == ["server.py"]
104+
105+
106+
@pytest.mark.asyncio
107+
async def test_force_multiple_servers_raises(factory, mcp_json_multiple):
108+
"""--force with multiple servers raises an error."""
109+
with patch.object(factory, "_load_mcp_config") as mock_load:
110+
from uipath_mcp._cli._utils._config import McpConfig
111+
112+
mock_load.return_value = McpConfig(mcp_json_multiple)
113+
with pytest.raises(UiPathMcpRuntimeError) as exc_info:
114+
await factory.new_runtime(
115+
"my-mcp2", "00000000-0000-0000-0000-000000000001", force=True
116+
)
117+
assert "--force requires exactly one server" in str(exc_info.value)
118+
119+
120+
@pytest.mark.asyncio
121+
async def test_force_with_exact_match_still_works(factory, mcp_json_single):
122+
"""--force with a correct entrypoint name works normally."""
123+
with _UIPATH_PATCH, patch.object(factory, "_load_mcp_config") as mock_load:
124+
from uipath_mcp._cli._utils._config import McpConfig
125+
126+
mock_load.return_value = McpConfig(mcp_json_single)
127+
runtime = await factory.new_runtime(
128+
"math-server", "00000000-0000-0000-0000-000000000001", force=True
129+
)
130+
assert runtime._entrypoint == "math-server"

0 commit comments

Comments
 (0)