Skip to content

Commit ea28f73

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

File tree

7 files changed

+229
-33
lines changed

7 files changed

+229
-33
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: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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(json.dumps({
9+
"servers": {"only-server": {"command": "python", "args": ["s.py"]}}
10+
}))
11+
cfg = McpConfig(str(config_path))
12+
server = cfg.get_single_server()
13+
assert server is not None
14+
assert server.name == "only-server"
15+
16+
17+
def test_get_single_server_with_multiple(tmp_path):
18+
config_path = tmp_path / "mcp.json"
19+
config_path.write_text(json.dumps({
20+
"servers": {
21+
"server-a": {"command": "python", "args": ["a.py"]},
22+
"server-b": {"command": "python", "args": ["b.py"]},
23+
}
24+
}))
25+
cfg = McpConfig(str(config_path))
26+
assert cfg.get_single_server() is None
27+
28+
29+
def test_get_single_server_with_none(tmp_path):
30+
config_path = tmp_path / "mcp.json"
31+
config_path.write_text(json.dumps({"servers": {}}))
32+
cfg = McpConfig(str(config_path))
33+
assert cfg.get_single_server() is None

tests/cli/test_factory.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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("math-server", "00000000-0000-0000-0000-000000000001")
70+
assert runtime._entrypoint == "math-server"
71+
72+
73+
@pytest.mark.asyncio
74+
async def test_wrong_name_without_force_raises(factory, mcp_json_single):
75+
"""Wrong entrypoint without --force raises SERVER_NOT_FOUND."""
76+
with patch.object(factory, "_load_mcp_config") as mock_load:
77+
from uipath_mcp._cli._utils._config import McpConfig
78+
79+
mock_load.return_value = McpConfig(mcp_json_single)
80+
with pytest.raises(UiPathMcpRuntimeError) as exc_info:
81+
await factory.new_runtime("my-mcp2", "00000000-0000-0000-0000-000000000001")
82+
assert "not found" in str(exc_info.value).lower()
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_force_single_server_uses_config_with_new_entrypoint(factory, mcp_json_single):
87+
"""--force with single server loads its config but uses the requested entrypoint."""
88+
with _UIPATH_PATCH, patch.object(factory, "_load_mcp_config") as mock_load:
89+
from uipath_mcp._cli._utils._config import McpConfig
90+
91+
mock_load.return_value = McpConfig(mcp_json_single)
92+
runtime = await factory.new_runtime(
93+
"my-mcp2", "00000000-0000-0000-0000-000000000001", force=True
94+
)
95+
# Entrypoint is the requested name, not the original server name
96+
assert runtime._entrypoint == "my-mcp2"
97+
# Server config comes from the existing server
98+
assert runtime._server.command == "python"
99+
assert runtime._server.args == ["server.py"]
100+
101+
102+
@pytest.mark.asyncio
103+
async def test_force_multiple_servers_raises(factory, mcp_json_multiple):
104+
"""--force with multiple servers raises an error."""
105+
with patch.object(factory, "_load_mcp_config") as mock_load:
106+
from uipath_mcp._cli._utils._config import McpConfig
107+
108+
mock_load.return_value = McpConfig(mcp_json_multiple)
109+
with pytest.raises(UiPathMcpRuntimeError) as exc_info:
110+
await factory.new_runtime(
111+
"my-mcp2", "00000000-0000-0000-0000-000000000001", force=True
112+
)
113+
assert "--force requires exactly one server" in str(exc_info.value)
114+
115+
116+
@pytest.mark.asyncio
117+
async def test_force_with_exact_match_still_works(factory, mcp_json_single):
118+
"""--force with a correct entrypoint name works normally."""
119+
with _UIPATH_PATCH, patch.object(factory, "_load_mcp_config") as mock_load:
120+
from uipath_mcp._cli._utils._config import McpConfig
121+
122+
mock_load.return_value = McpConfig(mcp_json_single)
123+
runtime = await factory.new_runtime(
124+
"math-server", "00000000-0000-0000-0000-000000000001", force=True
125+
)
126+
assert runtime._entrypoint == "math-server"

uv.lock

Lines changed: 32 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)