Skip to content

Commit 09ae039

Browse files
committed
feat: support force flag
1 parent 9d54c8b commit 09ae039

File tree

6 files changed

+196
-15
lines changed

6 files changed

+196
-15
lines changed

pyproject.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ dependencies = [
88
"mcp==1.26.0",
99
"pysignalr==1.3.0",
1010
# "uipath>=2.8.23, <2.9.0",
11-
"uipath==2.10.12.dev1014315279",
11+
"uipath==2.10.12.dev1014315285",
1212
"uipath-runtime>=0.9.1, <0.10.0",
1313
]
1414
classifiers = [
@@ -43,14 +43,13 @@ dev = [
4343
"mypy>=1.14.1",
4444
"ruff>=0.9.4",
4545
"pytest>=7.4.0",
46-
"pytest-asyncio>=0.23.0",
46+
"pytest-asyncio>=1.3.0",
4747
"pytest-cov>=4.1.0",
4848
"pytest-mock>=3.11.1",
4949
"pre-commit>=4.5.1",
5050
"filelock>=3.20.3",
5151
"virtualenv>=20.36.1",
5252
"numpy>=1.24.0",
53-
"pytest-asyncio>=1.3.0",
5453
]
5554

5655
[tool.ruff]

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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ def get_server(self, name: str) -> McpServer | None:
109109
"""
110110
return self._servers.get(name)
111111

112+
def get_single_server(self) -> McpServer | None:
113+
"""Return the server if there is exactly one, None otherwise."""
114+
if len(self._servers) == 1:
115+
return next(iter(self._servers.values()))
116+
return None
117+
112118
def get_server_names(self) -> list[str]:
113119
"""Get list of all server names."""
114120
return list(self._servers.keys())

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: 4 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)