Skip to content

Commit ed6ee6b

Browse files
committed
Add unit tests for the MCP server
Tests cover start_cluster (all topologies, version, auth, error cases), stop_cluster, list_clusters, and _ensure_running using mocked HTTP calls. No MongoDB binary or running mongo-orchestration instance required.
1 parent 519b8bc commit ed6ee6b

File tree

2 files changed

+356
-0
lines changed

2 files changed

+356
-0
lines changed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,12 @@ dependencies = [
4343
]
4444

4545
[project.optional-dependencies]
46+
mcp = [
47+
"mcp>=1.0",
48+
]
4649
test = [
4750
"coverage>=3.5",
51+
"mcp>=1.0",
4852
"pexpect",
4953
"pytest",
5054
]

tests/test_mcp_server.py

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
#!/usr/bin/python
2+
# coding=utf-8
3+
# Copyright 2026-Present MongoDB, Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""Tests for the MCP server tools (mongo_orchestration/mcp_server.py).
18+
19+
All HTTP calls and _ensure_running are mocked so no MongoDB binary or
20+
running mongo-orchestration instance is required.
21+
"""
22+
23+
import unittest
24+
from unittest.mock import MagicMock, call, patch
25+
26+
import mongo_orchestration.mcp_server as mcp_server
27+
28+
29+
# ---------------------------------------------------------------------------
30+
# Helpers
31+
# ---------------------------------------------------------------------------
32+
33+
def _mo_up():
34+
"""Patch _ensure_running to simulate mongo-orchestration already running."""
35+
return patch.object(mcp_server, "_ensure_running", return_value=None)
36+
37+
38+
def _get_side_effect(**resources):
39+
"""Return a _get side-effect that maps resource name → list of items."""
40+
def _get(path):
41+
return {path: resources.get(path, [])}
42+
return _get
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# start_cluster
47+
# ---------------------------------------------------------------------------
48+
49+
class TestStartClusterSingle(unittest.TestCase):
50+
51+
def setUp(self):
52+
self._mo = _mo_up()
53+
self._mo.start()
54+
55+
def tearDown(self):
56+
self._mo.stop()
57+
58+
@patch.object(mcp_server, "_post",
59+
return_value={"id": "srv1", "mongodb_uri": "mongodb://localhost:27017"})
60+
def test_returns_id_and_uri(self, mock_post):
61+
result = mcp_server.start_cluster(cluster_type="single")
62+
self.assertIn("srv1", result)
63+
self.assertIn("mongodb://localhost:27017", result)
64+
65+
@patch.object(mcp_server, "_post",
66+
return_value={"id": "srv1", "mongodb_uri": "mongodb://localhost:27017"})
67+
def test_post_path_and_name(self, mock_post):
68+
mcp_server.start_cluster(cluster_type="single")
69+
path, body = mock_post.call_args[0]
70+
self.assertEqual(path, "servers")
71+
self.assertEqual(body["name"], "mongod")
72+
73+
@patch.object(mcp_server, "_post",
74+
return_value={"id": "srv1", "mongodb_uri": "mongodb://localhost:27017"})
75+
def test_version_forwarded(self, mock_post):
76+
mcp_server.start_cluster(cluster_type="single", version="7.0")
77+
_, body = mock_post.call_args[0]
78+
self.assertEqual(body["version"], "7.0")
79+
80+
@patch.object(mcp_server, "_post",
81+
return_value={"id": "srv1",
82+
"mongodb_auth_uri": "mongodb://user:password@localhost:27017",
83+
"mongodb_uri": "mongodb://localhost:27017"})
84+
def test_auth_sets_credentials(self, mock_post):
85+
result = mcp_server.start_cluster(cluster_type="single", auth=True)
86+
_, body = mock_post.call_args[0]
87+
self.assertEqual(body["login"], "user")
88+
self.assertEqual(body["password"], "password")
89+
self.assertIn("auth_key", body)
90+
# auth URI takes precedence in the output
91+
self.assertIn("user:password", result)
92+
93+
@patch.object(mcp_server, "_post",
94+
return_value={"id": "srv1", "mongodb_uri": "mongodb://localhost:27017"})
95+
def test_no_version_key_when_empty(self, mock_post):
96+
mcp_server.start_cluster(cluster_type="single", version="")
97+
_, body = mock_post.call_args[0]
98+
self.assertNotIn("version", body)
99+
100+
101+
class TestStartClusterRepl(unittest.TestCase):
102+
103+
def setUp(self):
104+
self._mo = _mo_up()
105+
self._mo.start()
106+
107+
def tearDown(self):
108+
self._mo.stop()
109+
110+
@patch.object(mcp_server, "_post",
111+
return_value={"id": "rs1", "mongodb_uri": "mongodb://localhost:27017/?replicaSet=rs1"})
112+
def test_default_3_members(self, mock_post):
113+
result = mcp_server.start_cluster(cluster_type="repl")
114+
_, body = mock_post.call_args[0]
115+
self.assertEqual(len(body["members"]), 3)
116+
self.assertIn("3 member", result)
117+
118+
@patch.object(mcp_server, "_post",
119+
return_value={"id": "rs1", "mongodb_uri": "mongodb://localhost:27017/?replicaSet=rs1"})
120+
def test_single_member(self, mock_post):
121+
result = mcp_server.start_cluster(cluster_type="repl", single_member=True)
122+
_, body = mock_post.call_args[0]
123+
self.assertEqual(len(body["members"]), 1)
124+
self.assertIn("1 member", result)
125+
126+
@patch.object(mcp_server, "_post",
127+
return_value={"id": "rs1", "mongodb_uri": "mongodb://localhost:27017/?replicaSet=rs1"})
128+
def test_post_path(self, mock_post):
129+
mcp_server.start_cluster(cluster_type="repl")
130+
path, _ = mock_post.call_args[0]
131+
self.assertEqual(path, "replica_sets")
132+
133+
134+
class TestStartClusterShard(unittest.TestCase):
135+
136+
def setUp(self):
137+
self._mo = _mo_up()
138+
self._mo.start()
139+
140+
def tearDown(self):
141+
self._mo.stop()
142+
143+
@patch.object(mcp_server, "_post",
144+
return_value={"id": "sh1", "mongodb_uri": "mongodb://localhost:27017"})
145+
def test_body_has_required_keys(self, mock_post):
146+
mcp_server.start_cluster(cluster_type="shard")
147+
_, body = mock_post.call_args[0]
148+
self.assertIn("configsvrs", body)
149+
self.assertIn("routers", body)
150+
self.assertIn("shards", body)
151+
152+
@patch.object(mcp_server, "_post",
153+
return_value={"id": "sh1", "mongodb_uri": "mongodb://localhost:27017"})
154+
def test_routers_have_no_procparams(self, mock_post):
155+
# routers must be [{}], not [{"procParams": {}}] — the latter would
156+
# write "procParams={}" literally into the mongos config file.
157+
mcp_server.start_cluster(cluster_type="shard")
158+
_, body = mock_post.call_args[0]
159+
for router in body["routers"]:
160+
self.assertNotIn("procParams", router)
161+
162+
@patch.object(mcp_server, "_post",
163+
return_value={"id": "sh1", "mongodb_uri": "mongodb://localhost:27017"})
164+
def test_post_path(self, mock_post):
165+
mcp_server.start_cluster(cluster_type="shard")
166+
path, _ = mock_post.call_args[0]
167+
self.assertEqual(path, "sharded_clusters")
168+
169+
@patch.object(mcp_server, "_post",
170+
return_value={"id": "sh1", "mongodb_uri": "mongodb://localhost:27017"})
171+
def test_shard_member_count(self, mock_post):
172+
mcp_server.start_cluster(cluster_type="shard")
173+
_, body = mock_post.call_args[0]
174+
members = body["shards"][0]["shardParams"]["members"]
175+
self.assertEqual(len(members), 3)
176+
177+
@patch.object(mcp_server, "_post",
178+
return_value={"id": "sh1", "mongodb_uri": "mongodb://localhost:27017"})
179+
def test_single_member_shard(self, mock_post):
180+
mcp_server.start_cluster(cluster_type="shard", single_member=True)
181+
_, body = mock_post.call_args[0]
182+
members = body["shards"][0]["shardParams"]["members"]
183+
self.assertEqual(len(members), 1)
184+
185+
186+
class TestStartClusterErrors(unittest.TestCase):
187+
188+
def setUp(self):
189+
self._mo = _mo_up()
190+
self._mo.start()
191+
192+
def tearDown(self):
193+
self._mo.stop()
194+
195+
def test_unknown_cluster_type(self):
196+
result = mcp_server.start_cluster(cluster_type="unknown")
197+
self.assertIn("Unknown cluster_type", result)
198+
199+
def test_mo_not_running(self):
200+
with patch.object(mcp_server, "_ensure_running", return_value="connection refused"):
201+
result = mcp_server.start_cluster()
202+
self.assertIn("Error", result)
203+
204+
@patch.object(mcp_server, "_post", side_effect=RuntimeError("timeout"))
205+
def test_post_exception_returns_error(self, _):
206+
result = mcp_server.start_cluster(cluster_type="single")
207+
self.assertIn("Error", result)
208+
209+
210+
# ---------------------------------------------------------------------------
211+
# stop_cluster
212+
# ---------------------------------------------------------------------------
213+
214+
class TestStopCluster(unittest.TestCase):
215+
216+
def setUp(self):
217+
self._mo = _mo_up()
218+
self._mo.start()
219+
220+
def tearDown(self):
221+
self._mo.stop()
222+
223+
@patch.object(mcp_server, "_delete")
224+
@patch.object(mcp_server, "_get",
225+
side_effect=_get_side_effect(servers=[{"id": "srv1"}]))
226+
def test_stop_standalone(self, _get, mock_delete):
227+
result = mcp_server.stop_cluster("srv1")
228+
self.assertIn("stopped", result)
229+
mock_delete.assert_called_once_with("servers/srv1")
230+
231+
@patch.object(mcp_server, "_delete")
232+
@patch.object(mcp_server, "_get",
233+
side_effect=_get_side_effect(replica_sets=[{"id": "rs1"}]))
234+
def test_stop_replica_set(self, _get, mock_delete):
235+
result = mcp_server.stop_cluster("rs1")
236+
self.assertIn("stopped", result)
237+
mock_delete.assert_called_once_with("replica_sets/rs1")
238+
239+
@patch.object(mcp_server, "_delete")
240+
@patch.object(mcp_server, "_get",
241+
side_effect=_get_side_effect(sharded_clusters=[{"id": "sh1"}]))
242+
def test_stop_sharded_cluster(self, _get, mock_delete):
243+
result = mcp_server.stop_cluster("sh1")
244+
self.assertIn("stopped", result)
245+
mock_delete.assert_called_once_with("sharded_clusters/sh1")
246+
247+
@patch.object(mcp_server, "_get", side_effect=_get_side_effect())
248+
def test_stop_not_found(self, _):
249+
result = mcp_server.stop_cluster("nonexistent")
250+
self.assertIn("No cluster found", result)
251+
252+
def test_stop_mo_not_running(self):
253+
with patch.object(mcp_server, "_ensure_running", return_value="connection refused"):
254+
result = mcp_server.stop_cluster("srv1")
255+
self.assertIn("Error", result)
256+
257+
258+
# ---------------------------------------------------------------------------
259+
# list_clusters
260+
# ---------------------------------------------------------------------------
261+
262+
class TestListClusters(unittest.TestCase):
263+
264+
def setUp(self):
265+
self._mo = _mo_up()
266+
self._mo.start()
267+
268+
def tearDown(self):
269+
self._mo.stop()
270+
271+
@patch.object(mcp_server, "_get", side_effect=_get_side_effect())
272+
def test_empty(self, _):
273+
result = mcp_server.list_clusters()
274+
self.assertEqual(result, "No clusters running.")
275+
276+
@patch.object(mcp_server, "_get",
277+
side_effect=_get_side_effect(servers=[{"id": "srv1"}]))
278+
def test_lists_standalone(self, _):
279+
result = mcp_server.list_clusters()
280+
self.assertIn("srv1", result)
281+
self.assertIn("Standalone", result)
282+
283+
@patch.object(mcp_server, "_get",
284+
side_effect=_get_side_effect(replica_sets=[{"id": "rs1"}, {"id": "rs2"}]))
285+
def test_lists_multiple_replica_sets(self, _):
286+
result = mcp_server.list_clusters()
287+
self.assertIn("rs1", result)
288+
self.assertIn("rs2", result)
289+
self.assertIn("Replica", result)
290+
291+
@patch.object(mcp_server, "_get",
292+
side_effect=_get_side_effect(
293+
servers=[{"id": "srv1"}],
294+
sharded_clusters=[{"id": "sh1"}]))
295+
def test_lists_mixed(self, _):
296+
result = mcp_server.list_clusters()
297+
self.assertIn("srv1", result)
298+
self.assertIn("sh1", result)
299+
300+
def test_mo_not_running(self):
301+
with patch.object(mcp_server, "_ensure_running", return_value="connection refused"):
302+
result = mcp_server.list_clusters()
303+
self.assertIn("not running", result)
304+
305+
306+
# ---------------------------------------------------------------------------
307+
# _ensure_running
308+
# ---------------------------------------------------------------------------
309+
310+
class TestEnsureRunning(unittest.TestCase):
311+
312+
@patch.object(mcp_server, "_is_up", return_value=True)
313+
def test_already_up(self, _):
314+
self.assertIsNone(mcp_server._ensure_running())
315+
316+
@patch("time.sleep")
317+
@patch("subprocess.run")
318+
@patch.object(mcp_server, "_is_up", side_effect=[False] + [True])
319+
def test_starts_mo_when_down(self, _is_up, mock_run, _sleep):
320+
mock_run.return_value = MagicMock(returncode=0)
321+
result = mcp_server._ensure_running()
322+
self.assertIsNone(result)
323+
mock_run.assert_called_once()
324+
325+
@patch("time.sleep")
326+
@patch("subprocess.run", side_effect=FileNotFoundError)
327+
@patch.object(mcp_server, "_is_up", return_value=False)
328+
def test_binary_not_found(self, _is_up, _run, _sleep):
329+
result = mcp_server._ensure_running()
330+
self.assertIsNotNone(result)
331+
self.assertIn("not found", result)
332+
333+
@patch("time.sleep")
334+
@patch("subprocess.run")
335+
@patch.object(mcp_server, "_is_up", return_value=False)
336+
def test_never_becomes_reachable(self, _is_up, mock_run, _sleep):
337+
mock_run.return_value = MagicMock(returncode=0)
338+
result = mcp_server._ensure_running()
339+
self.assertIsNotNone(result)
340+
self.assertIn("not reachable", result)
341+
342+
@patch("time.sleep")
343+
@patch("subprocess.run", side_effect=__import__('subprocess').TimeoutExpired(cmd=[], timeout=30))
344+
@patch.object(mcp_server, "_is_up", return_value=False)
345+
def test_timeout(self, _is_up, _run, _sleep):
346+
result = mcp_server._ensure_running()
347+
self.assertIsNotNone(result)
348+
self.assertIn("Timeout", result)
349+
350+
351+
if __name__ == "__main__":
352+
unittest.main()

0 commit comments

Comments
 (0)