Jeremiah Lowin commited on
Commit
7e4a5c7
·
1 Parent(s): ca11c0a

Add more tests

Browse files
tests/cli/test_cli.py CHANGED
@@ -1,5 +1,6 @@
1
  """Tests for the main CLI functionality."""
2
 
 
3
  from pathlib import Path
4
  from unittest.mock import Mock, patch
5
 
@@ -197,6 +198,114 @@ class TestRunCommand:
197
  assert exc_info.value.code == 1
198
 
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  class TestInspectCommand:
201
  """Test the inspect command."""
202
 
 
1
  """Tests for the main CLI functionality."""
2
 
3
+ import subprocess
4
  from pathlib import Path
5
  from unittest.mock import Mock, patch
6
 
 
198
  assert exc_info.value.code == 1
199
 
200
 
201
+ class TestWindowsSpecific:
202
+ """Test Windows-specific functionality."""
203
+
204
+ @patch("subprocess.run")
205
+ def test_get_npx_command_windows_cmd(self, mock_run):
206
+ """Test npx command detection on Windows with npx.cmd."""
207
+ from fastmcp.cli.cli import _get_npx_command
208
+
209
+ with patch("sys.platform", "win32"):
210
+ # First call succeeds with npx.cmd
211
+ mock_run.return_value = Mock(returncode=0)
212
+
213
+ result = _get_npx_command()
214
+
215
+ assert result == "npx.cmd"
216
+ mock_run.assert_called_once_with(
217
+ ["npx.cmd", "--version"],
218
+ check=True,
219
+ capture_output=True,
220
+ shell=True,
221
+ )
222
+
223
+ @patch("subprocess.run")
224
+ def test_get_npx_command_windows_exe(self, mock_run):
225
+ """Test npx command detection on Windows with npx.exe."""
226
+ from fastmcp.cli.cli import _get_npx_command
227
+
228
+ with patch("sys.platform", "win32"):
229
+ # First call fails, second succeeds
230
+ mock_run.side_effect = [
231
+ subprocess.CalledProcessError(1, "npx.cmd"),
232
+ Mock(returncode=0),
233
+ ]
234
+
235
+ result = _get_npx_command()
236
+
237
+ assert result == "npx.exe"
238
+ assert mock_run.call_count == 2
239
+
240
+ @patch("subprocess.run")
241
+ def test_get_npx_command_windows_fallback(self, mock_run):
242
+ """Test npx command detection on Windows with plain npx."""
243
+ from fastmcp.cli.cli import _get_npx_command
244
+
245
+ with patch("sys.platform", "win32"):
246
+ # First two calls fail, third succeeds
247
+ mock_run.side_effect = [
248
+ subprocess.CalledProcessError(1, "npx.cmd"),
249
+ subprocess.CalledProcessError(1, "npx.exe"),
250
+ Mock(returncode=0),
251
+ ]
252
+
253
+ result = _get_npx_command()
254
+
255
+ assert result == "npx"
256
+ assert mock_run.call_count == 3
257
+
258
+ @patch("subprocess.run")
259
+ def test_get_npx_command_windows_not_found(self, mock_run):
260
+ """Test npx command detection on Windows when npx is not found."""
261
+ from fastmcp.cli.cli import _get_npx_command
262
+
263
+ with patch("sys.platform", "win32"):
264
+ # All calls fail
265
+ mock_run.side_effect = subprocess.CalledProcessError(1, "npx")
266
+
267
+ result = _get_npx_command()
268
+
269
+ assert result is None
270
+ assert mock_run.call_count == 3
271
+
272
+ @patch("subprocess.run")
273
+ def test_get_npx_command_unix(self, mock_run):
274
+ """Test npx command detection on Unix systems."""
275
+ from fastmcp.cli.cli import _get_npx_command
276
+
277
+ with patch("sys.platform", "darwin"):
278
+ result = _get_npx_command()
279
+
280
+ assert result == "npx"
281
+ mock_run.assert_not_called()
282
+
283
+ def test_windows_path_parsing_with_colon(self):
284
+ """Test parsing Windows paths with drive letters and colons."""
285
+ from fastmcp.cli.run import parse_file_path
286
+
287
+ # We can't test actual Windows paths on non-Windows systems,
288
+ # but we can test the logic with mock paths
289
+ with patch("pathlib.Path.exists") as mock_exists:
290
+ with patch("pathlib.Path.is_file") as mock_is_file:
291
+ mock_exists.return_value = True
292
+ mock_is_file.return_value = True
293
+
294
+ # Test that C:\path\file.py is parsed correctly
295
+ with patch("pathlib.Path.resolve") as mock_resolve:
296
+ mock_resolve.return_value = Path("C:/path/file.py")
297
+
298
+ file_path, obj = parse_file_path("C:\\path\\file.py")
299
+ assert obj is None
300
+
301
+ # Test C:\path\file.py:object parsing
302
+ with patch("pathlib.Path.resolve") as mock_resolve:
303
+ mock_resolve.return_value = Path("C:/path/file.py")
304
+
305
+ file_path, obj = parse_file_path("C:\\path\\file.py:myapp")
306
+ assert obj == "myapp"
307
+
308
+
309
  class TestInspectCommand:
310
  """Test the inspect command."""
311
 
tests/cli/test_cursor.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Cursor integration functionality."""
2
+
3
+ import base64
4
+ import json
5
+ from pathlib import Path
6
+ from unittest.mock import Mock, patch
7
+
8
+ import pytest
9
+
10
+ from fastmcp.cli.install.cursor import (
11
+ cursor_command,
12
+ generate_cursor_deeplink,
13
+ install_cursor,
14
+ open_deeplink,
15
+ )
16
+ from fastmcp.mcp_config import StdioMCPServer
17
+
18
+
19
+ class TestCursorDeeplinkGeneration:
20
+ """Test cursor deeplink generation functionality."""
21
+
22
+ def test_generate_deeplink_basic(self):
23
+ """Test basic deeplink generation."""
24
+ server_config = StdioMCPServer(
25
+ command="uv",
26
+ args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"],
27
+ )
28
+
29
+ deeplink = generate_cursor_deeplink("test-server", server_config)
30
+
31
+ assert deeplink.startswith("cursor://anysphere.cursor-deeplink/mcp/install?")
32
+ assert "name=test-server" in deeplink
33
+ assert "config=" in deeplink
34
+
35
+ # Verify base64 encoding
36
+ config_part = deeplink.split("config=")[1]
37
+ decoded = base64.urlsafe_b64decode(config_part).decode()
38
+ config_data = json.loads(decoded)
39
+
40
+ assert config_data["command"] == "uv"
41
+ assert config_data["args"] == [
42
+ "run",
43
+ "--with",
44
+ "fastmcp",
45
+ "fastmcp",
46
+ "run",
47
+ "server.py",
48
+ ]
49
+
50
+ def test_generate_deeplink_with_env_vars(self):
51
+ """Test deeplink generation with environment variables."""
52
+ server_config = StdioMCPServer(
53
+ command="uv",
54
+ args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"],
55
+ env={"API_KEY": "secret123", "DEBUG": "true"},
56
+ )
57
+
58
+ deeplink = generate_cursor_deeplink("my-server", server_config)
59
+
60
+ # Decode and verify
61
+ config_part = deeplink.split("config=")[1]
62
+ decoded = base64.urlsafe_b64decode(config_part).decode()
63
+ config_data = json.loads(decoded)
64
+
65
+ assert config_data["env"] == {"API_KEY": "secret123", "DEBUG": "true"}
66
+
67
+ def test_generate_deeplink_special_characters(self):
68
+ """Test deeplink generation with special characters in server name."""
69
+ server_config = StdioMCPServer(
70
+ command="uv",
71
+ args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"],
72
+ )
73
+
74
+ # Test with spaces and special chars in name
75
+ deeplink = generate_cursor_deeplink("my server (test)", server_config)
76
+
77
+ assert (
78
+ "name=my%20server%20%28test%29" in deeplink
79
+ or "name=my server (test)" in deeplink
80
+ )
81
+
82
+ def test_generate_deeplink_empty_config(self):
83
+ """Test deeplink generation with minimal config."""
84
+ server_config = StdioMCPServer(command="python", args=["server.py"])
85
+
86
+ deeplink = generate_cursor_deeplink("minimal", server_config)
87
+
88
+ config_part = deeplink.split("config=")[1]
89
+ decoded = base64.urlsafe_b64decode(config_part).decode()
90
+ config_data = json.loads(decoded)
91
+
92
+ assert config_data["command"] == "python"
93
+ assert config_data["args"] == ["server.py"]
94
+ assert config_data["env"] == {} # Empty env dict is included
95
+
96
+ def test_generate_deeplink_complex_args(self):
97
+ """Test deeplink generation with complex arguments."""
98
+ server_config = StdioMCPServer(
99
+ command="uv",
100
+ args=[
101
+ "run",
102
+ "--with",
103
+ "fastmcp",
104
+ "--with",
105
+ "numpy>=1.20",
106
+ "--with-editable",
107
+ "/path/to/local/package",
108
+ "fastmcp",
109
+ "run",
110
+ "server.py:CustomServer",
111
+ ],
112
+ )
113
+
114
+ deeplink = generate_cursor_deeplink("complex-server", server_config)
115
+
116
+ config_part = deeplink.split("config=")[1]
117
+ decoded = base64.urlsafe_b64decode(config_part).decode()
118
+ config_data = json.loads(decoded)
119
+
120
+ assert "--with-editable" in config_data["args"]
121
+ assert "server.py:CustomServer" in config_data["args"]
122
+
123
+
124
+ class TestOpenDeeplink:
125
+ """Test deeplink opening functionality."""
126
+
127
+ @patch("subprocess.run")
128
+ def test_open_deeplink_macos(self, mock_run):
129
+ """Test opening deeplink on macOS."""
130
+ with patch("sys.platform", "darwin"):
131
+ mock_run.return_value = Mock(returncode=0)
132
+
133
+ result = open_deeplink("cursor://test")
134
+
135
+ assert result is True
136
+ mock_run.assert_called_once_with(
137
+ ["open", "cursor://test"], check=True, capture_output=True
138
+ )
139
+
140
+ @patch("subprocess.run")
141
+ def test_open_deeplink_windows(self, mock_run):
142
+ """Test opening deeplink on Windows."""
143
+ with patch("sys.platform", "win32"):
144
+ mock_run.return_value = Mock(returncode=0)
145
+
146
+ result = open_deeplink("cursor://test")
147
+
148
+ assert result is True
149
+ mock_run.assert_called_once_with(
150
+ ["start", "cursor://test"], shell=True, check=True, capture_output=True
151
+ )
152
+
153
+ @patch("subprocess.run")
154
+ def test_open_deeplink_linux(self, mock_run):
155
+ """Test opening deeplink on Linux."""
156
+ with patch("sys.platform", "linux"):
157
+ mock_run.return_value = Mock(returncode=0)
158
+
159
+ result = open_deeplink("cursor://test")
160
+
161
+ assert result is True
162
+ mock_run.assert_called_once_with(
163
+ ["xdg-open", "cursor://test"], check=True, capture_output=True
164
+ )
165
+
166
+ @patch("subprocess.run")
167
+ def test_open_deeplink_failure(self, mock_run):
168
+ """Test handling of deeplink opening failure."""
169
+ import subprocess
170
+
171
+ mock_run.side_effect = subprocess.CalledProcessError(1, ["open"])
172
+
173
+ result = open_deeplink("cursor://test")
174
+
175
+ assert result is False
176
+
177
+ @patch("subprocess.run")
178
+ def test_open_deeplink_command_not_found(self, mock_run):
179
+ """Test handling when open command is not found."""
180
+ mock_run.side_effect = FileNotFoundError()
181
+
182
+ result = open_deeplink("cursor://test")
183
+
184
+ assert result is False
185
+
186
+
187
+ class TestInstallCursor:
188
+ """Test cursor installation functionality."""
189
+
190
+ @patch("fastmcp.cli.install.cursor.open_deeplink")
191
+ @patch("fastmcp.cli.install.cursor.print")
192
+ def test_install_cursor_success(self, mock_print, mock_open_deeplink):
193
+ """Test successful cursor installation."""
194
+ mock_open_deeplink.return_value = True
195
+
196
+ result = install_cursor(
197
+ file=Path("/path/to/server.py"),
198
+ server_object=None,
199
+ name="test-server",
200
+ )
201
+
202
+ assert result is True
203
+ mock_open_deeplink.assert_called_once()
204
+ # Verify the deeplink was generated correctly
205
+ call_args = mock_open_deeplink.call_args[0][0]
206
+ assert call_args.startswith("cursor://anysphere.cursor-deeplink/mcp/install?")
207
+ assert "name=test-server" in call_args
208
+
209
+ @patch("fastmcp.cli.install.cursor.open_deeplink")
210
+ @patch("fastmcp.cli.install.cursor.print")
211
+ def test_install_cursor_with_packages(self, mock_print, mock_open_deeplink):
212
+ """Test cursor installation with additional packages."""
213
+ mock_open_deeplink.return_value = True
214
+
215
+ result = install_cursor(
216
+ file=Path("/path/to/server.py"),
217
+ server_object="app",
218
+ name="test-server",
219
+ with_packages=["numpy", "pandas"],
220
+ env_vars={"API_KEY": "test"},
221
+ )
222
+
223
+ assert result is True
224
+ call_args = mock_open_deeplink.call_args[0][0]
225
+
226
+ # Decode the config to verify packages
227
+ config_part = call_args.split("config=")[1]
228
+ decoded = base64.urlsafe_b64decode(config_part).decode()
229
+ config_data = json.loads(decoded)
230
+
231
+ # Check that all packages are included
232
+ assert "--with" in config_data["args"]
233
+ assert "numpy" in config_data["args"]
234
+ assert "pandas" in config_data["args"]
235
+ assert "fastmcp" in config_data["args"]
236
+ assert config_data["env"] == {"API_KEY": "test"}
237
+
238
+ @patch("fastmcp.cli.install.cursor.open_deeplink")
239
+ @patch("fastmcp.cli.install.cursor.print")
240
+ def test_install_cursor_with_editable(self, mock_print, mock_open_deeplink):
241
+ """Test cursor installation with editable package."""
242
+ mock_open_deeplink.return_value = True
243
+
244
+ result = install_cursor(
245
+ file=Path("/path/to/server.py"),
246
+ server_object="custom_app",
247
+ name="test-server",
248
+ with_editable=Path("/local/package"),
249
+ )
250
+
251
+ assert result is True
252
+ call_args = mock_open_deeplink.call_args[0][0]
253
+
254
+ # Decode and verify editable path
255
+ config_part = call_args.split("config=")[1]
256
+ decoded = base64.urlsafe_b64decode(config_part).decode()
257
+ config_data = json.loads(decoded)
258
+
259
+ assert "--with-editable" in config_data["args"]
260
+ assert "/local/package" in config_data["args"]
261
+ assert "server.py:custom_app" in " ".join(config_data["args"])
262
+
263
+ @patch("fastmcp.cli.install.cursor.open_deeplink")
264
+ @patch("fastmcp.cli.install.cursor.print")
265
+ def test_install_cursor_failure(self, mock_print, mock_open_deeplink):
266
+ """Test cursor installation when deeplink fails to open."""
267
+ mock_open_deeplink.return_value = False
268
+
269
+ result = install_cursor(
270
+ file=Path("/path/to/server.py"),
271
+ server_object=None,
272
+ name="test-server",
273
+ )
274
+
275
+ assert result is False
276
+ # Verify failure message was printed
277
+ mock_print.assert_called()
278
+
279
+ def test_install_cursor_deduplicate_packages(self):
280
+ """Test that duplicate packages are deduplicated."""
281
+ with patch("fastmcp.cli.install.cursor.open_deeplink") as mock_open:
282
+ mock_open.return_value = True
283
+
284
+ install_cursor(
285
+ file=Path("/path/to/server.py"),
286
+ server_object=None,
287
+ name="test-server",
288
+ with_packages=["numpy", "fastmcp", "numpy", "pandas", "fastmcp"],
289
+ )
290
+
291
+ call_args = mock_open.call_args[0][0]
292
+ config_part = call_args.split("config=")[1]
293
+ decoded = base64.urlsafe_b64decode(config_part).decode()
294
+ config_data = json.loads(decoded)
295
+
296
+ # Count occurrences of each package
297
+ args_str = " ".join(config_data["args"])
298
+ assert args_str.count("numpy") == 1
299
+ assert args_str.count("pandas") == 1
300
+ # fastmcp appears twice: once as --with fastmcp and once as the command
301
+ assert args_str.count("fastmcp") == 2
302
+
303
+
304
+ class TestCursorCommand:
305
+ """Test the cursor CLI command."""
306
+
307
+ @patch("fastmcp.cli.install.cursor.install_cursor")
308
+ @patch("fastmcp.cli.install.cursor.process_common_args")
309
+ def test_cursor_command_basic(self, mock_process_args, mock_install):
310
+ """Test basic cursor command execution."""
311
+ mock_process_args.return_value = (
312
+ Path("server.py"),
313
+ None,
314
+ "test-server",
315
+ [],
316
+ {},
317
+ )
318
+ mock_install.return_value = True
319
+
320
+ with patch("sys.exit") as mock_exit:
321
+ cursor_command("server.py")
322
+
323
+ mock_install.assert_called_once_with(
324
+ file=Path("server.py"),
325
+ server_object=None,
326
+ name="test-server",
327
+ with_editable=None,
328
+ with_packages=[],
329
+ env_vars={},
330
+ )
331
+ mock_exit.assert_not_called()
332
+
333
+ @patch("fastmcp.cli.install.cursor.install_cursor")
334
+ @patch("fastmcp.cli.install.cursor.process_common_args")
335
+ def test_cursor_command_failure(self, mock_process_args, mock_install):
336
+ """Test cursor command when installation fails."""
337
+ mock_process_args.return_value = (
338
+ Path("server.py"),
339
+ None,
340
+ "test-server",
341
+ [],
342
+ {},
343
+ )
344
+ mock_install.return_value = False
345
+
346
+ with pytest.raises(SystemExit) as exc_info:
347
+ cursor_command("server.py")
348
+
349
+ assert exc_info.value.code == 1
tests/cli/test_shared.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for shared CLI functionality."""
2
+
3
+ from fastmcp.cli.cli import _parse_env_var
4
+
5
+
6
+ class TestEnvVarParsing:
7
+ """Test environment variable parsing functionality."""
8
+
9
+ def test_parse_env_var_simple(self):
10
+ """Test parsing simple environment variable."""
11
+ key, value = _parse_env_var("API_KEY=secret123")
12
+ assert key == "API_KEY"
13
+ assert value == "secret123"
14
+
15
+ def test_parse_env_var_with_equals_in_value(self):
16
+ """Test parsing env var with equals signs in the value."""
17
+ key, value = _parse_env_var("DATABASE_URL=postgresql://user:pass@host:5432/db")
18
+ assert key == "DATABASE_URL"
19
+ assert value == "postgresql://user:pass@host:5432/db"
20
+
21
+ def test_parse_env_var_with_spaces(self):
22
+ """Test parsing env var with spaces (should be stripped)."""
23
+ key, value = _parse_env_var(" API_KEY = secret with spaces ")
24
+ assert key == "API_KEY"
25
+ assert value == "secret with spaces"
26
+
27
+ def test_parse_env_var_empty_value(self):
28
+ """Test parsing env var with empty value."""
29
+ key, value = _parse_env_var("EMPTY_VAR=")
30
+ assert key == "EMPTY_VAR"
31
+ assert value == ""