Didier Durand commited on
Commit
66a37df
·
1 Parent(s): 9d287ee

Adding tests for CLI

Browse files
Files changed (1) hide show
  1. tests/cli/test_cli.py +422 -0
tests/cli/test_cli.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the CLI module."""
2
+
3
+
4
+ import subprocess
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock, Mock, patch
7
+
8
+ import pytest
9
+ from typer.testing import CliRunner
10
+
11
+ from fastmcp.cli import cli
12
+
13
+
14
+ # Set up test runner
15
+ runner = CliRunner()
16
+
17
+
18
+ @pytest.fixture
19
+ def mock_console():
20
+ """Mock the rich console to test output."""
21
+ with patch("fastmcp.cli.cli.console") as mock_console:
22
+ yield mock_console
23
+
24
+
25
+ @pytest.fixture
26
+ def mock_logger():
27
+ """Mock the logger to test logging."""
28
+ with patch("fastmcp.cli.cli.logger") as mock_logger:
29
+ yield mock_logger
30
+
31
+
32
+ @pytest.fixture
33
+ def mock_exit():
34
+ """Mock sys.exit to prevent tests from exiting."""
35
+ with patch("sys.exit") as mock_exit:
36
+ yield mock_exit
37
+
38
+
39
+ @pytest.fixture
40
+ def temp_python_file(tmp_path):
41
+ """Create a temporary Python file with a test server."""
42
+ server_code = """
43
+ from mcp import Server
44
+
45
+ class TestServer(Server):
46
+ name = "test_server"
47
+ dependencies = ["package1", "package2"]
48
+
49
+ def run(self, **kwargs):
50
+ print("Running server with", kwargs)
51
+
52
+ mcp = TestServer()
53
+ server = TestServer()
54
+ app = TestServer()
55
+ custom_server = TestServer()
56
+ """
57
+ file_path = tmp_path / "test_server.py"
58
+ file_path.write_text(server_code)
59
+ return file_path
60
+
61
+
62
+ @pytest.fixture
63
+ def temp_env_file(tmp_path):
64
+ """Create a temporary .env file."""
65
+ env_content = """
66
+ TEST_VAR1=value1
67
+ TEST_VAR2=value2
68
+ """
69
+ env_path = tmp_path / ".env"
70
+ env_path.write_text(env_content)
71
+ return env_path
72
+
73
+
74
+ class TestHelperFunctions:
75
+ """Tests for helper functions in cli.py."""
76
+
77
+ def test_get_npx_command_unix(self):
78
+ """Test getting npx command on unix systems."""
79
+ with patch("sys.platform", "linux"):
80
+ with patch("subprocess.run") as mock_run:
81
+ mock_run.return_value = Mock(returncode=0)
82
+ assert cli._get_npx_command() == "npx"
83
+
84
+ def test_get_npx_command_windows(self):
85
+ """Test getting npx command on Windows."""
86
+ with patch("sys.platform", "win32"):
87
+ with patch("subprocess.run") as mock_run:
88
+ # First try fails, second succeeds
89
+ mock_run.side_effect = [
90
+ subprocess.CalledProcessError(1, "npx.cmd"),
91
+ Mock(returncode=0),
92
+ ]
93
+ assert cli._get_npx_command() == "npx.exe"
94
+
95
+ def test_get_npx_command_not_found(self):
96
+ """Test when npx command is not found."""
97
+ with patch("sys.platform", "win32"):
98
+ with patch("subprocess.run") as mock_run:
99
+ mock_run.side_effect = [
100
+ subprocess.CalledProcessError(1, "npx.cmd"),
101
+ subprocess.CalledProcessError(1, "npx.exe"),
102
+ subprocess.CalledProcessError(1, "npx"),
103
+ ]
104
+ assert cli._get_npx_command() is None
105
+
106
+ def test_parse_env_var_valid(self):
107
+ """Test parsing valid environment variables."""
108
+ assert cli._parse_env_var("KEY=VALUE") == ("KEY", "VALUE")
109
+ assert cli._parse_env_var("KEY=") == ("KEY", "")
110
+ assert cli._parse_env_var("KEY=VALUE=WITH=EQUALS") == ("KEY", "VALUE=WITH=EQUALS")
111
+ assert cli._parse_env_var(" KEY = VALUE ") == ("KEY", "VALUE")
112
+
113
+ def test_build_uv_command_basic(self):
114
+ """Test building basic uv command."""
115
+ cmd = cli._build_uv_command("file.py")
116
+ assert cmd == ["uv", "run", "--with", "fastmcp", "fastmcp", "run", "file.py"]
117
+
118
+ def test_build_uv_command_with_editable(self):
119
+ """Test building uv command with editable flag."""
120
+ cmd = cli._build_uv_command("file.py", with_editable=Path("/path/to/project"))
121
+ assert cmd == [
122
+ "uv", "run", "--with", "fastmcp",
123
+ "--with-editable", "/path/to/project",
124
+ "fastmcp", "run", "file.py"
125
+ ]
126
+
127
+ def test_build_uv_command_with_packages(self):
128
+ """Test building uv command with additional packages."""
129
+ cmd = cli._build_uv_command("file.py", with_packages=["pkg1", "pkg2"])
130
+ assert cmd == [
131
+ "uv", "run", "--with", "fastmcp",
132
+ "--with", "pkg1", "--with", "pkg2",
133
+ "fastmcp", "run", "file.py"
134
+ ]
135
+
136
+ def test_build_uv_command_full(self):
137
+ """Test building full uv command with all options."""
138
+ cmd = cli._build_uv_command(
139
+ "file.py:server",
140
+ with_editable=Path("/path/to/project"),
141
+ with_packages=["pkg1", "pkg2"],
142
+ )
143
+ assert cmd == [
144
+ "uv", "run", "--with", "fastmcp",
145
+ "--with-editable", "/path/to/project",
146
+ "--with", "pkg1", "--with", "pkg2",
147
+ "fastmcp", "run", "file.py:server"
148
+ ]
149
+
150
+ def test_parse_file_path_simple(self):
151
+ """Test parsing simple file path."""
152
+ with patch("pathlib.Path.exists") as mock_exists, \
153
+ patch("pathlib.Path.is_file") as mock_is_file, \
154
+ patch("pathlib.Path.expanduser") as mock_expanduser, \
155
+ patch("pathlib.Path.resolve") as mock_resolve:
156
+
157
+ mock_exists.return_value = True
158
+ mock_is_file.return_value = True
159
+ mock_expanduser.return_value = Path("file.py")
160
+ mock_resolve.return_value = Path("file.py")
161
+
162
+ path, obj = cli._parse_file_path("file.py")
163
+ assert path == Path("file.py")
164
+ assert obj is None
165
+
166
+ def test_parse_file_path_with_object(self):
167
+ """Test parsing file path with object."""
168
+ with patch("pathlib.Path.exists") as mock_exists, \
169
+ patch("pathlib.Path.is_file") as mock_is_file, \
170
+ patch("pathlib.Path.expanduser") as mock_expanduser, \
171
+ patch("pathlib.Path.resolve") as mock_resolve:
172
+
173
+ mock_exists.return_value = True
174
+ mock_is_file.return_value = True
175
+ mock_expanduser.return_value = Path("file.py")
176
+ mock_resolve.return_value = Path("file.py")
177
+
178
+ path, obj = cli._parse_file_path("file.py:server")
179
+ assert path == Path("file.py")
180
+ assert obj == "server"
181
+
182
+ def test_parse_file_path_windows(self):
183
+ """Test parsing Windows file path."""
184
+ with patch("pathlib.Path.exists") as mock_exists, \
185
+ patch("pathlib.Path.is_file") as mock_is_file, \
186
+ patch("pathlib.Path.expanduser") as mock_expanduser, \
187
+ patch("pathlib.Path.resolve") as mock_resolve:
188
+
189
+ mock_exists.return_value = True
190
+ mock_is_file.return_value = True
191
+ mock_expanduser.return_value = Path("C:/path/file.py")
192
+ mock_resolve.return_value = Path("C:/path/file.py")
193
+
194
+ path, obj = cli._parse_file_path("C:/path/file.py:server")
195
+ assert path == Path("C:/path/file.py")
196
+ assert obj == "server"
197
+
198
+ def test_parse_file_path_not_file(self, mock_exit, mock_logger):
199
+ """Test parsing path that is not a file."""
200
+ with patch("pathlib.Path.exists") as mock_exists, \
201
+ patch("pathlib.Path.is_file") as mock_is_file, \
202
+ patch("pathlib.Path.expanduser") as mock_expanduser, \
203
+ patch("pathlib.Path.resolve") as mock_resolve:
204
+
205
+ mock_exists.return_value = True
206
+ mock_is_file.return_value = False
207
+ mock_expanduser.return_value = Path("directory")
208
+ mock_resolve.return_value = Path("directory")
209
+
210
+ cli._parse_file_path("directory")
211
+ mock_logger.error.assert_called_once()
212
+ mock_exit.assert_called_once_with(1)
213
+
214
+
215
+ class TestVersionCommand:
216
+ """Tests for the version command."""
217
+
218
+ def test_version_early_exit_with_resilient_parsing(self):
219
+ """Test version command exits early with resilient parsing."""
220
+ ctx = MagicMock()
221
+ ctx.resilient_parsing = True
222
+ result = cli.version(ctx)
223
+ assert result is None
224
+
225
+
226
+ class TestDevCommand:
227
+ """Tests for the dev command."""
228
+
229
+ def test_dev_command_success(self, temp_python_file, mock_logger):
230
+ """Test successful dev command execution."""
231
+ with patch("fastmcp.cli.cli._parse_file_path") as mock_parse, \
232
+ patch("fastmcp.cli.cli._import_server") as mock_import, \
233
+ patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, \
234
+ patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, \
235
+ patch("subprocess.run") as mock_run:
236
+
237
+ mock_parse.return_value = (temp_python_file, None)
238
+ mock_server = MagicMock()
239
+ mock_server.dependencies = ["extra_dep"]
240
+ mock_import.return_value = mock_server
241
+ mock_get_npx.return_value = "npx"
242
+ mock_build_uv.return_value = ["uv", "command"]
243
+ mock_run.return_value = MagicMock(returncode=0)
244
+
245
+ result = runner.invoke(cli.app, ["dev", str(temp_python_file)])
246
+ assert result.exit_code == 0
247
+ mock_run.assert_called_once()
248
+
249
+ # Check dependencies were passed correctly
250
+ mock_build_uv.assert_called_once_with(
251
+ str(temp_python_file),
252
+ None,
253
+ ["extra_dep"]
254
+ )
255
+
256
+ def test_dev_command_with_ui_port(self, temp_python_file):
257
+ """Test dev command with UI port."""
258
+ with patch("fastmcp.cli.cli._parse_file_path") as mock_parse, \
259
+ patch("fastmcp.cli.cli._import_server") as mock_import, \
260
+ patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, \
261
+ patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, \
262
+ patch("subprocess.run") as mock_run:
263
+
264
+ mock_parse.return_value = (temp_python_file, None)
265
+ mock_import.return_value = MagicMock(dependencies=[])
266
+ mock_get_npx.return_value = "npx"
267
+ mock_build_uv.return_value = ["uv", "command"]
268
+ mock_run.return_value = MagicMock(returncode=0)
269
+
270
+ result = runner.invoke(cli.app, ["dev", str(temp_python_file), "--ui-port", "3000"])
271
+ assert result.exit_code == 0
272
+
273
+ # Check environment variables were set
274
+ env = mock_run.call_args[1]["env"]
275
+ assert "CLIENT_PORT" in env
276
+ assert env["CLIENT_PORT"] == "3000"
277
+
278
+ def test_dev_command_with_server_port(self, temp_python_file):
279
+ """Test dev command with server port."""
280
+ with patch("fastmcp.cli.cli._parse_file_path") as mock_parse, \
281
+ patch("fastmcp.cli.cli._import_server") as mock_import, \
282
+ patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, \
283
+ patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, \
284
+ patch("subprocess.run") as mock_run:
285
+
286
+ mock_parse.return_value = (temp_python_file, None)
287
+ mock_import.return_value = MagicMock(dependencies=[])
288
+ mock_get_npx.return_value = "npx"
289
+ mock_build_uv.return_value = ["uv", "command"]
290
+ mock_run.return_value = MagicMock(returncode=0)
291
+
292
+ result = runner.invoke(cli.app, ["dev", str(temp_python_file), "--server-port", "8080"])
293
+ assert result.exit_code == 0
294
+
295
+ # Check environment variables were set
296
+ env = mock_run.call_args[1]["env"]
297
+ assert "SERVER_PORT" in env
298
+ assert env["SERVER_PORT"] == "8080"
299
+
300
+ def test_dev_command_inspector_version(self, temp_python_file):
301
+ """Test dev command with specific inspector version."""
302
+ with patch("fastmcp.cli.cli._parse_file_path") as mock_parse, \
303
+ patch("fastmcp.cli.cli._import_server") as mock_import, \
304
+ patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, \
305
+ patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, \
306
+ patch("subprocess.run") as mock_run:
307
+
308
+ mock_parse.return_value = (temp_python_file, None)
309
+ mock_import.return_value = MagicMock(dependencies=[])
310
+ mock_get_npx.return_value = "npx"
311
+ mock_build_uv.return_value = ["uv", "command"]
312
+ mock_run.return_value = MagicMock(returncode=0)
313
+
314
+ result = runner.invoke(cli.app, [
315
+ "dev", str(temp_python_file),
316
+ "--inspector-version", "1.0.0"
317
+ ])
318
+ assert result.exit_code == 0
319
+
320
+ # Check inspector version was used
321
+ inspector_cmd = mock_run.call_args[0][0][1]
322
+ assert inspector_cmd == "@modelcontextprotocol/inspector@1.0.0"
323
+
324
+
325
+ class TestRunCommand:
326
+ """Tests for the run command."""
327
+
328
+ def test_run_command_success(self, temp_python_file, mock_logger):
329
+ """Test successful run command execution."""
330
+ with patch("fastmcp.cli.cli._parse_file_path") as mock_parse, \
331
+ patch("fastmcp.cli.cli._import_server") as mock_import:
332
+
333
+ mock_parse.return_value = (temp_python_file, None)
334
+ mock_server = MagicMock()
335
+ mock_server.name = "test_server"
336
+ mock_import.return_value = mock_server
337
+
338
+ result = runner.invoke(cli.app, ["run", str(temp_python_file)])
339
+ assert result.exit_code == 0
340
+ mock_server.run.assert_called_once_with()
341
+ mock_logger.info.assert_called_with(f'Found server "test_server" in {temp_python_file}')
342
+
343
+ def test_run_command_with_transport(self, temp_python_file):
344
+ """Test run command with transport option."""
345
+ with patch("fastmcp.cli.cli._parse_file_path") as mock_parse, \
346
+ patch("fastmcp.cli.cli._import_server") as mock_import:
347
+
348
+ mock_parse.return_value = (temp_python_file, None)
349
+ mock_server = MagicMock()
350
+ mock_server.name = "test_server"
351
+ mock_import.return_value = mock_server
352
+
353
+ result = runner.invoke(cli.app, ["run", str(temp_python_file), "--transport", "sse"])
354
+ assert result.exit_code == 0
355
+ mock_server.run.assert_called_once_with(transport="sse")
356
+
357
+ def test_run_command_with_host(self, temp_python_file):
358
+ """Test run command with host option."""
359
+ with patch("fastmcp.cli.cli._parse_file_path") as mock_parse, \
360
+ patch("fastmcp.cli.cli._import_server") as mock_import:
361
+
362
+ mock_parse.return_value = (temp_python_file, None)
363
+ mock_server = MagicMock()
364
+ mock_server.name = "test_server"
365
+ mock_import.return_value = mock_server
366
+
367
+ result = runner.invoke(cli.app, ["run", str(temp_python_file), "--host", "0.0.0.0"])
368
+ assert result.exit_code == 0
369
+ mock_server.run.assert_called_once_with(host="0.0.0.0")
370
+
371
+ def test_run_command_with_port(self, temp_python_file):
372
+ """Test run command with port option."""
373
+ with patch("fastmcp.cli.cli._parse_file_path") as mock_parse, \
374
+ patch("fastmcp.cli.cli._import_server") as mock_import:
375
+
376
+ mock_parse.return_value = (temp_python_file, None)
377
+ mock_server = MagicMock()
378
+ mock_server.name = "test_server"
379
+ mock_import.return_value = mock_server
380
+
381
+ result = runner.invoke(cli.app, ["run", str(temp_python_file), "--port", "8080"])
382
+ assert result.exit_code == 0
383
+ mock_server.run.assert_called_once_with(port=8080)
384
+
385
+ def test_run_command_with_log_level(self, temp_python_file):
386
+ """Test run command with log level option."""
387
+ with patch("fastmcp.cli.cli._parse_file_path") as mock_parse, \
388
+ patch("fastmcp.cli.cli._import_server") as mock_import:
389
+
390
+ mock_parse.return_value = (temp_python_file, None)
391
+ mock_server = MagicMock()
392
+ mock_server.name = "test_server"
393
+ mock_import.return_value = mock_server
394
+
395
+ result = runner.invoke(cli.app, ["run", str(temp_python_file), "--log-level", "DEBUG"])
396
+ assert result.exit_code == 0
397
+ mock_server.run.assert_called_once_with(log_level="DEBUG")
398
+
399
+ def test_run_command_with_multiple_options(self, temp_python_file):
400
+ """Test run command with multiple options."""
401
+ with patch("fastmcp.cli.cli._parse_file_path") as mock_parse, \
402
+ patch("fastmcp.cli.cli._import_server") as mock_import:
403
+
404
+ mock_parse.return_value = (temp_python_file, None)
405
+ mock_server = MagicMock()
406
+ mock_server.name = "test_server"
407
+ mock_import.return_value = mock_server
408
+
409
+ result = runner.invoke(cli.app, [
410
+ "run", str(temp_python_file),
411
+ "--transport", "sse",
412
+ "--host", "0.0.0.0",
413
+ "--port", "8080",
414
+ "--log-level", "DEBUG"
415
+ ])
416
+ assert result.exit_code == 0
417
+ mock_server.run.assert_called_once_with(
418
+ transport="sse",
419
+ host="0.0.0.0",
420
+ port=8080,
421
+ log_level="DEBUG"
422
+ )