pythonanywhere-mcp-server 0.0.11__tar.gz → 0.0.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {pythonanywhere_mcp_server-0.0.11/src/pythonanywhere_mcp_server.egg-info → pythonanywhere_mcp_server-0.0.13}/PKG-INFO +3 -3
  2. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/pyproject.toml +3 -3
  3. pythonanywhere_mcp_server-0.0.13/src/pythonanywhere_mcp_server/__init__.py +1 -0
  4. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/server.py +2 -2
  5. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/tools/file.py +13 -9
  6. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/tools/schedule.py +8 -7
  7. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/tools/webapp.py +20 -19
  8. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/tools/website.py +8 -7
  9. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13/src/pythonanywhere_mcp_server.egg-info}/PKG-INFO +3 -3
  10. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server.egg-info/SOURCES.txt +1 -0
  11. pythonanywhere_mcp_server-0.0.13/src/pythonanywhere_mcp_server.egg-info/requires.txt +6 -0
  12. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/tests/test_file_tools.py +21 -10
  13. pythonanywhere_mcp_server-0.0.13/tests/test_mcp_integration.py +60 -0
  14. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/tests/test_schedule_tools.py +6 -5
  15. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/tests/test_server.py +7 -7
  16. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/tests/test_webapp_tools.py +4 -3
  17. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/tests/test_website_tools.py +6 -5
  18. pythonanywhere_mcp_server-0.0.11/src/pythonanywhere_mcp_server/__init__.py +0 -1
  19. pythonanywhere_mcp_server-0.0.11/src/pythonanywhere_mcp_server.egg-info/requires.txt +0 -6
  20. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/LICENSE +0 -0
  21. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/README.md +0 -0
  22. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/setup.cfg +0 -0
  23. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/__main__.py +0 -0
  24. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/tools/__init__.py +0 -0
  25. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server.egg-info/dependency_links.txt +0 -0
  26. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server.egg-info/entry_points.txt +0 -0
  27. {pythonanywhere_mcp_server-0.0.11 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonanywhere-mcp-server
3
- Version: 0.0.11
3
+ Version: 0.0.13
4
4
  Summary: PythonAnywhere Model Context Protocol Server
5
5
  Author-email: PythonAnywhere Developers <developers@pythonanywhere.com>
6
6
  License-Expression: MIT
@@ -18,8 +18,8 @@ Classifier: Topic :: Utilities
18
18
  Requires-Python: >=3.13
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
- Requires-Dist: pythonanywhere-core>=0.3.0
22
- Requires-Dist: mcp[cli]
21
+ Requires-Dist: pythonanywhere-core>=0.3.2
22
+ Requires-Dist: mcp[cli]<3,>=2.2.0
23
23
  Provides-Extra: test
24
24
  Requires-Dist: pytest; extra == "test"
25
25
  Requires-Dist: pytest-mock; extra == "test"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythonanywhere-mcp-server"
7
- version = "0.0.11"
7
+ version = "0.0.13"
8
8
  description = "PythonAnywhere Model Context Protocol Server"
9
9
  authors = [
10
10
  {name = "PythonAnywhere Developers", email = "developers@pythonanywhere.com"}
@@ -12,8 +12,8 @@ authors = [
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.13"
14
14
  dependencies = [
15
- "pythonanywhere-core>=0.3.0",
16
- "mcp[cli]",
15
+ "pythonanywhere-core>=0.3.2",
16
+ "mcp[cli]>=2.2.0,<3",
17
17
  ]
18
18
  classifiers = [
19
19
  "Development Status :: 2 - Pre-Alpha",
@@ -0,0 +1 @@
1
+ __version__ = "0.0.13"
@@ -1,6 +1,6 @@
1
1
  import os
2
2
 
3
- from mcp.server.fastmcp import FastMCP
3
+ from mcp.server import MCPServer
4
4
  from . import __version__
5
5
  from .tools.file import register_file_tools
6
6
  from .tools.webapp import register_webapp_tools
@@ -17,7 +17,7 @@ def create_server():
17
17
  if not API_TOKEN:
18
18
  raise RuntimeError("API_TOKEN environment variable must be set.")
19
19
 
20
- mcp = FastMCP("PythonAnywhere Model Context Protocol Server")
20
+ mcp = MCPServer("PythonAnywhere Model Context Protocol Server")
21
21
 
22
22
  register_file_tools(mcp)
23
23
  register_website_tools(mcp)
@@ -1,16 +1,20 @@
1
- from mcp.server.fastmcp import FastMCP
1
+ import json
2
+
3
+ from mcp.server import MCPServer
4
+ from mcp.server.mcpserver.exceptions import ToolError
2
5
 
3
6
  from pythonanywhere_core.files import Files
4
7
 
5
8
 
6
- def register_file_tools(mcp: FastMCP) -> None:
9
+ def register_file_tools(mcp: MCPServer) -> None:
7
10
  @mcp.tool()
8
11
  def read_file_or_directory(path: str) -> str:
9
12
  """
10
13
  Return the contents of a file or a directory listing.
11
14
 
12
15
  If the given path is a file, returns its contents as a string.
13
- If the path is a directory, returns a JSON string with its listing (recursively, up to 1000 entries).
16
+ If the path is a directory, returns a JSON string listing its immediate
17
+ children, without recursively listing subdirectory contents.
14
18
 
15
19
  Args:
16
20
  path (str): The absolute path to the file or directory.
@@ -22,9 +26,9 @@ def register_file_tools(mcp: FastMCP) -> None:
22
26
  data = Files().path_get(path)
23
27
  if isinstance(data, (bytes, bytearray)):
24
28
  return data.decode()
25
- return str(data)
29
+ return json.dumps(data)
26
30
  except Exception as exc:
27
- raise RuntimeError(f"Failed to read file or directory: {str(exc)}") from exc
31
+ raise ToolError(f"Failed to read file or directory: {str(exc)}") from exc
28
32
 
29
33
  @mcp.tool()
30
34
  def upload_text_file(dest_path: str, content: str) -> str:
@@ -42,7 +46,7 @@ def register_file_tools(mcp: FastMCP) -> None:
42
46
  status = Files().path_post(dest_path, content.encode())
43
47
  return f"Uploaded to {dest_path} (HTTP {status})."
44
48
  except Exception as exc:
45
- raise RuntimeError(f"Failed to upload text file: {str(exc)}") from exc
49
+ raise ToolError(f"Failed to upload text file: {str(exc)}") from exc
46
50
 
47
51
  @mcp.tool()
48
52
  def upload_directory(local_dir_path: str, remote_dir_path: str) -> str:
@@ -63,7 +67,7 @@ def register_file_tools(mcp: FastMCP) -> None:
63
67
  Files().tree_post(local_dir_path, remote_dir_path)
64
68
  return f"Uploaded {local_dir_path} to {remote_dir_path}."
65
69
  except Exception as exc:
66
- raise RuntimeError(f"Failed to upload directory: {str(exc)}") from exc
70
+ raise ToolError(f"Failed to upload directory: {str(exc)}") from exc
67
71
 
68
72
  @mcp.tool()
69
73
  def delete_path(path: str) -> str:
@@ -80,7 +84,7 @@ def register_file_tools(mcp: FastMCP) -> None:
80
84
  Files().path_delete(path)
81
85
  return f"Deleted {path}."
82
86
  except Exception as exc:
83
- raise RuntimeError(f"Failed to delete path: {str(exc)}") from exc
87
+ raise ToolError(f"Failed to delete path: {str(exc)}") from exc
84
88
 
85
89
  @mcp.tool()
86
90
  def tree(path: str) -> list[str]:
@@ -98,4 +102,4 @@ def register_file_tools(mcp: FastMCP) -> None:
98
102
  listing = Files().tree_get(path)
99
103
  return listing
100
104
  except Exception as exc:
101
- raise RuntimeError(f"Failed to get directory tree: {str(exc)}") from exc
105
+ raise ToolError(f"Failed to get directory tree: {str(exc)}") from exc
@@ -1,9 +1,10 @@
1
- from mcp.server.fastmcp import FastMCP
1
+ from mcp.server import MCPServer
2
+ from mcp.server.mcpserver.exceptions import ToolError
2
3
 
3
4
  from pythonanywhere_core.schedule import Schedule
4
5
 
5
6
 
6
- def register_schedule_tools(mcp: FastMCP) -> None:
7
+ def register_schedule_tools(mcp: MCPServer) -> None:
7
8
  @mcp.tool()
8
9
  def list_scheduled_tasks() -> list[dict]:
9
10
  """
@@ -17,7 +18,7 @@ def register_schedule_tools(mcp: FastMCP) -> None:
17
18
  try:
18
19
  return Schedule().get_list()
19
20
  except Exception as exc:
20
- raise RuntimeError(f"Failed to list scheduled tasks: {str(exc)}") from exc
21
+ raise ToolError(f"Failed to list scheduled tasks: {str(exc)}") from exc
21
22
 
22
23
  @mcp.tool()
23
24
  def create_scheduled_task(params: dict) -> dict:
@@ -38,7 +39,7 @@ def register_schedule_tools(mcp: FastMCP) -> None:
38
39
  try:
39
40
  return Schedule().create(params)
40
41
  except Exception as exc:
41
- raise RuntimeError(f"Failed to create scheduled task: {str(exc)}") from exc
42
+ raise ToolError(f"Failed to create scheduled task: {str(exc)}") from exc
42
43
 
43
44
  @mcp.tool()
44
45
  def delete_scheduled_task(task_id: int) -> bool:
@@ -54,7 +55,7 @@ def register_schedule_tools(mcp: FastMCP) -> None:
54
55
  try:
55
56
  return Schedule().delete(task_id)
56
57
  except Exception as exc:
57
- raise RuntimeError(f"Failed to delete scheduled task: {str(exc)}") from exc
58
+ raise ToolError(f"Failed to delete scheduled task: {str(exc)}") from exc
58
59
 
59
60
  @mcp.tool()
60
61
  def get_scheduled_task(task_id: int) -> dict:
@@ -70,7 +71,7 @@ def register_schedule_tools(mcp: FastMCP) -> None:
70
71
  try:
71
72
  return Schedule().get_specs(task_id)
72
73
  except Exception as exc:
73
- raise RuntimeError(f"Failed to get scheduled task: {str(exc)}") from exc
74
+ raise ToolError(f"Failed to get scheduled task: {str(exc)}") from exc
74
75
 
75
76
  @mcp.tool()
76
77
  def update_scheduled_task(task_id: int, params: dict) -> dict:
@@ -88,4 +89,4 @@ def register_schedule_tools(mcp: FastMCP) -> None:
88
89
  try:
89
90
  return Schedule().update(task_id, params)
90
91
  except Exception as exc:
91
- raise RuntimeError(f"Failed to update scheduled task: {str(exc)}") from exc
92
+ raise ToolError(f"Failed to update scheduled task: {str(exc)}") from exc
@@ -1,6 +1,7 @@
1
1
  from pathlib import Path
2
2
 
3
- from mcp.server.fastmcp import FastMCP
3
+ from mcp.server import MCPServer
4
+ from mcp.server.mcpserver.exceptions import ToolError
4
5
 
5
6
  from pythonanywhere_core.webapp import Webapp
6
7
  from pythonanywhere_core.base import AuthenticationError, NoTokenError
@@ -9,7 +10,7 @@ from pythonanywhere_core.exceptions import MissingCNAMEException
9
10
  # ToDo: Add the log file functions once pythonanywhere-core webapp log file functions
10
11
  # have been improved
11
12
 
12
- def register_webapp_tools(mcp: FastMCP) -> None:
13
+ def register_webapp_tools(mcp: MCPServer) -> None:
13
14
  @mcp.tool()
14
15
  def reload_webapp(domain: str) -> str:
15
16
  """
@@ -32,9 +33,9 @@ def register_webapp_tools(mcp: FastMCP) -> None:
32
33
  except MissingCNAMEException as exc:
33
34
  return f"Webapp '{domain}' reloaded. Note: {str(exc)}"
34
35
  except (AuthenticationError, NoTokenError):
35
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
36
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
36
37
  except Exception as exc:
37
- raise RuntimeError(str(exc)) from exc
38
+ raise ToolError(str(exc)) from exc
38
39
 
39
40
  @mcp.tool()
40
41
  def create_webapp(domain: str, python_version: str, virtualenv_path: str, project_path: str) -> str:
@@ -64,7 +65,7 @@ def register_webapp_tools(mcp: FastMCP) -> None:
64
65
  str: Status message indicating creation result.
65
66
 
66
67
  Raises:
67
- RuntimeError: If authentication fails, webapp already exists (and nuke=False),
68
+ ToolError: If authentication fails, webapp already exists (and nuke=False),
68
69
  or other API errors occur. If 403 error is raised, it may mean that there
69
70
  is already a non-uwsgi-based website. `list_websites` tool can be used to check that.
70
71
  """
@@ -78,9 +79,9 @@ def register_webapp_tools(mcp: FastMCP) -> None:
78
79
  )
79
80
  return f"Webapp '{domain}' created successfully."
80
81
  except (AuthenticationError, NoTokenError):
81
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
82
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
82
83
  except Exception as exc:
83
- raise RuntimeError(str(exc)) from exc
84
+ raise ToolError(str(exc)) from exc
84
85
 
85
86
  @mcp.tool()
86
87
  def delete_webapp(domain: str) -> str:
@@ -99,15 +100,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
99
100
  str: Status message indicating deletion result.
100
101
 
101
102
  Raises:
102
- RuntimeError: If authentication fails, webapp doesn't exist, or other API errors occur.
103
+ ToolError: If authentication fails, webapp doesn't exist, or other API errors occur.
103
104
  """
104
105
  try:
105
106
  Webapp(domain).delete()
106
107
  return f"Webapp '{domain}' deleted successfully."
107
108
  except (AuthenticationError, NoTokenError):
108
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
109
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
109
110
  except Exception as exc:
110
- raise RuntimeError(str(exc)) from exc
111
+ raise ToolError(str(exc)) from exc
111
112
 
112
113
  @mcp.tool()
113
114
  def patch_webapp(domain: str, data: dict) -> dict:
@@ -155,15 +156,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
155
156
  }
156
157
 
157
158
  Raises:
158
- RuntimeError: If authentication fails, webapp doesn't exist, or other API errors occur.
159
+ ToolError: If authentication fails, webapp doesn't exist, or other API errors occur.
159
160
  """
160
161
  try:
161
162
  result = Webapp(domain).patch(data)
162
163
  return result
163
164
  except (AuthenticationError, NoTokenError):
164
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
165
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
165
166
  except Exception as exc:
166
- raise RuntimeError(str(exc)) from exc
167
+ raise ToolError(str(exc)) from exc
167
168
 
168
169
  @mcp.tool()
169
170
  def list_webapps() -> list:
@@ -186,15 +187,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
186
187
  get_webapp_info: Get detailed information about a specific webapp.
187
188
 
188
189
  Raises:
189
- RuntimeError: If authentication fails or other API errors occur.
190
+ ToolError: If authentication fails or other API errors occur.
190
191
  """
191
192
  try:
192
193
  result = Webapp.list_webapps()
193
194
  return result
194
195
  except (AuthenticationError, NoTokenError):
195
- raise RuntimeError("Authentication failed — check API_TOKEN.")
196
+ raise ToolError("Authentication failed — check API_TOKEN.")
196
197
  except Exception as exc:
197
- raise RuntimeError(str(exc)) from exc
198
+ raise ToolError(str(exc)) from exc
198
199
 
199
200
  @mcp.tool()
200
201
  def get_webapp_info(domain: str) -> dict:
@@ -226,15 +227,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
226
227
 
227
228
 
228
229
  Raises:
229
- RuntimeError: If authentication fails, webapp doesn't exist, or other API errors occur.
230
+ ToolError: If authentication fails, webapp doesn't exist, or other API errors occur.
230
231
  """
231
232
  try:
232
233
  result = Webapp(domain).get()
233
234
  return result
234
235
  except (AuthenticationError, NoTokenError):
235
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
236
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
236
237
  except Exception as exc:
237
- raise RuntimeError(str(exc)) from exc
238
+ raise ToolError(str(exc)) from exc
238
239
 
239
240
 
240
241
 
@@ -1,12 +1,13 @@
1
1
  from typing import Any
2
2
 
3
- from mcp.server.fastmcp import FastMCP
3
+ from mcp.server import MCPServer
4
+ from mcp.server.mcpserver.exceptions import ToolError
4
5
 
5
6
  from pythonanywhere_core.website import Website
6
7
  from pythonanywhere_core.base import AuthenticationError, NoTokenError
7
8
 
8
9
 
9
- def register_website_tools(mcp: FastMCP) -> None:
10
+ def register_website_tools(mcp: MCPServer) -> None:
10
11
  @mcp.tool()
11
12
  def reload_website(domain: str) -> str:
12
13
  """
@@ -27,9 +28,9 @@ def register_website_tools(mcp: FastMCP) -> None:
27
28
  Website().reload(domain)
28
29
  return f"Website '{domain}' reloaded."
29
30
  except (AuthenticationError, NoTokenError):
30
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
31
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
31
32
  except Exception as exc:
32
- raise RuntimeError(str(exc)) from exc
33
+ raise ToolError(str(exc)) from exc
33
34
 
34
35
  @mcp.tool()
35
36
  def list_websites() -> list[dict[str, Any]]:
@@ -50,7 +51,7 @@ def register_website_tools(mcp: FastMCP) -> None:
50
51
  try:
51
52
  return Website().list()
52
53
  except Exception as exc:
53
- raise RuntimeError(f"Failed to list websites: {str(exc)}") from exc
54
+ raise ToolError(f"Failed to list websites: {str(exc)}") from exc
54
55
 
55
56
  @mcp.tool()
56
57
  def create_website(domain_name: str, command: str) -> dict:
@@ -88,7 +89,7 @@ def register_website_tools(mcp: FastMCP) -> None:
88
89
  try:
89
90
  return Website().create(domain_name, command)
90
91
  except Exception as exc:
91
- raise RuntimeError(f"Failed to create website: {str(exc)}") from exc
92
+ raise ToolError(f"Failed to create website: {str(exc)}") from exc
92
93
 
93
94
  @mcp.tool()
94
95
  def delete_website(domain_name: str) -> dict:
@@ -104,4 +105,4 @@ def register_website_tools(mcp: FastMCP) -> None:
104
105
  try:
105
106
  return Website().delete(domain_name)
106
107
  except Exception as exc:
107
- raise RuntimeError(f"Failed to delete website: {str(exc)}") from exc
108
+ raise ToolError(f"Failed to delete website: {str(exc)}") from exc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonanywhere-mcp-server
3
- Version: 0.0.11
3
+ Version: 0.0.13
4
4
  Summary: PythonAnywhere Model Context Protocol Server
5
5
  Author-email: PythonAnywhere Developers <developers@pythonanywhere.com>
6
6
  License-Expression: MIT
@@ -18,8 +18,8 @@ Classifier: Topic :: Utilities
18
18
  Requires-Python: >=3.13
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
- Requires-Dist: pythonanywhere-core>=0.3.0
22
- Requires-Dist: mcp[cli]
21
+ Requires-Dist: pythonanywhere-core>=0.3.2
22
+ Requires-Dist: mcp[cli]<3,>=2.2.0
23
23
  Provides-Extra: test
24
24
  Requires-Dist: pytest; extra == "test"
25
25
  Requires-Dist: pytest-mock; extra == "test"
@@ -16,6 +16,7 @@ src/pythonanywhere_mcp_server/tools/schedule.py
16
16
  src/pythonanywhere_mcp_server/tools/webapp.py
17
17
  src/pythonanywhere_mcp_server/tools/website.py
18
18
  tests/test_file_tools.py
19
+ tests/test_mcp_integration.py
19
20
  tests/test_schedule_tools.py
20
21
  tests/test_server.py
21
22
  tests/test_webapp_tools.py
@@ -0,0 +1,6 @@
1
+ pythonanywhere-core>=0.3.2
2
+ mcp[cli]<3,>=2.2.0
3
+
4
+ [test]
5
+ pytest
6
+ pytest-mock
@@ -1,22 +1,33 @@
1
+ import json
2
+
1
3
  import pytest
4
+ from mcp.server.mcpserver.exceptions import ToolError
2
5
 
3
6
  import tools.file as file_tools
4
7
 
5
8
 
6
- def test_read_file_or_directory_file(mcp, mocker):
9
+ @pytest.mark.parametrize("content", ["file contents", 'Café says "hello"\n'])
10
+ def test_read_file_or_directory_file(mcp, mocker, content):
7
11
  file_tools.register_file_tools(mcp)
8
12
  mock_files = mocker.patch("tools.file.Files", autospec=True)
9
- mock_files.return_value.path_get.return_value = b"file contents"
13
+ mock_files.return_value.path_get.return_value = content.encode("utf-8")
10
14
  result = mcp.call_tool("read_file_or_directory", {"path": "/some/file.txt"})
11
- assert result == "file contents"
15
+ assert result == content
12
16
 
13
17
 
14
18
  def test_read_file_or_directory_directory(mcp, mocker):
15
19
  file_tools.register_file_tools(mcp)
16
20
  mock_files = mocker.patch("tools.file.Files", autospec=True)
17
- mock_files.return_value.path_get.return_value = {"listing": ["a", "b"]}
21
+ listing = {
22
+ 'a "quoted" file.txt': {"type": "file", "url": "/files/quoted"},
23
+ "café": {"type": "directory", "url": "/files/cafe"},
24
+ }
25
+ mock_files.return_value.path_get.return_value = listing
26
+
18
27
  result = mcp.call_tool("read_file_or_directory", {"path": "/some/dir/"})
19
- assert result == str({"listing": ["a", "b"]})
28
+
29
+ assert json.loads(result) == listing
30
+ mock_files.return_value.path_get.assert_called_once_with("/some/dir/")
20
31
 
21
32
 
22
33
  def test_upload_text_file(mcp, mocker):
@@ -39,7 +50,7 @@ def test_read_file_or_directory_file_exception(mcp, mocker):
39
50
  file_tools.register_file_tools(mcp)
40
51
  mock_files = mocker.patch("tools.file.Files", autospec=True)
41
52
  mock_files.return_value.path_get.side_effect = Exception("read error")
42
- with pytest.raises(RuntimeError) as exc:
53
+ with pytest.raises(ToolError) as exc:
43
54
  mcp.call_tool("read_file_or_directory", {"path": "/some/file.txt"})
44
55
  assert "Failed to read file or directory: read error" in str(exc)
45
56
 
@@ -48,7 +59,7 @@ def test_upload_text_file_exception(mcp, mocker):
48
59
  file_tools.register_file_tools(mcp)
49
60
  mock_files = mocker.patch("tools.file.Files", autospec=True)
50
61
  mock_files.return_value.path_post.side_effect = Exception("upload error")
51
- with pytest.raises(RuntimeError) as exc:
62
+ with pytest.raises(ToolError) as exc:
52
63
  mcp.call_tool("upload_text_file", {"dest_path": "/some/file.txt", "content": "hello"})
53
64
  assert "Failed to upload text file: upload error" in str(exc)
54
65
 
@@ -57,7 +68,7 @@ def test_delete_path_exception(mcp, mocker):
57
68
  file_tools.register_file_tools(mcp)
58
69
  mock_files = mocker.patch("tools.file.Files", autospec=True)
59
70
  mock_files.return_value.path_delete.side_effect = Exception("delete error")
60
- with pytest.raises(RuntimeError) as exc:
71
+ with pytest.raises(ToolError) as exc:
61
72
  mcp.call_tool("delete_path", {"path": "/some/file.txt"})
62
73
  assert "Failed to delete path: delete error" in str(exc)
63
74
 
@@ -74,7 +85,7 @@ def test_upload_directory_exception(mcp, mocker):
74
85
  file_tools.register_file_tools(mcp)
75
86
  mock_files = mocker.patch("tools.file.Files", autospec=True)
76
87
  mock_files.return_value.tree_post.side_effect = Exception("upload dir error")
77
- with pytest.raises(RuntimeError) as exc:
88
+ with pytest.raises(ToolError) as exc:
78
89
  mcp.call_tool("upload_directory", {"local_dir_path": "/local/myapp", "remote_dir_path": "/home/user/myapp"})
79
90
  assert "Failed to upload directory: upload dir error" in str(exc)
80
91
 
@@ -91,6 +102,6 @@ def test_directory_tree_exception(mcp, mocker):
91
102
  file_tools.register_file_tools(mcp)
92
103
  mock_files = mocker.patch("tools.file.Files", autospec=True)
93
104
  mock_files.return_value.tree_get.side_effect = Exception("tree error")
94
- with pytest.raises(RuntimeError) as exc:
105
+ with pytest.raises(ToolError) as exc:
95
106
  mcp.call_tool("tree", {"path": "/some/dir/"})
96
107
  assert "Failed to get directory tree: tree error" in str(exc)
@@ -0,0 +1,60 @@
1
+ import pytest
2
+
3
+ from mcp import Client
4
+
5
+ from pythonanywhere_mcp_server.server import create_server
6
+
7
+
8
+ EXPECTED_TOOLS = {
9
+ "create_scheduled_task",
10
+ "create_webapp",
11
+ "create_website",
12
+ "delete_path",
13
+ "delete_scheduled_task",
14
+ "delete_webapp",
15
+ "delete_website",
16
+ "get_scheduled_task",
17
+ "get_webapp_info",
18
+ "list_scheduled_tasks",
19
+ "list_webapps",
20
+ "list_websites",
21
+ "patch_webapp",
22
+ "read_file_or_directory",
23
+ "reload_webapp",
24
+ "reload_website",
25
+ "tree",
26
+ "update_scheduled_task",
27
+ "upload_directory",
28
+ "upload_text_file",
29
+ }
30
+
31
+
32
+ @pytest.fixture
33
+ def server(monkeypatch):
34
+ monkeypatch.setenv("API_TOKEN", "dummy-token")
35
+ return create_server()
36
+
37
+
38
+ @pytest.mark.anyio
39
+ async def test_server_exposes_expected_tools(server):
40
+ async with Client(server) as client:
41
+ result = await client.list_tools()
42
+
43
+ assert {tool.name for tool in result.tools} == EXPECTED_TOOLS
44
+
45
+
46
+ @pytest.mark.anyio
47
+ async def test_api_failure_is_returned_as_actionable_tool_error(server, mocker):
48
+ mocker.patch(
49
+ "pythonanywhere_mcp_server.tools.schedule.Schedule.get_list",
50
+ side_effect=Exception("list error"),
51
+ )
52
+
53
+ async with Client(server) as client:
54
+ result = await client.call_tool("list_scheduled_tasks")
55
+
56
+ assert result.is_error
57
+ assert result.content[0].text == (
58
+ "Error executing tool list_scheduled_tasks: "
59
+ "Failed to list scheduled tasks: list error"
60
+ )
@@ -1,4 +1,5 @@
1
1
  import pytest
2
+ from mcp.server.mcpserver.exceptions import ToolError
2
3
 
3
4
  import tools.schedule as schedule_tools
4
5
 
@@ -16,7 +17,7 @@ def test_list_scheduled_tasks_exception(mcp, mocker):
16
17
  schedule_tools.register_schedule_tools(mcp)
17
18
  mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
18
19
  mock_schedule.return_value.get_list.side_effect = Exception("list error")
19
- with pytest.raises(RuntimeError) as exc:
20
+ with pytest.raises(ToolError) as exc:
20
21
  mcp.call_tool("list_scheduled_tasks", {})
21
22
  assert "Failed to list scheduled tasks: list error" in str(exc)
22
23
 
@@ -35,7 +36,7 @@ def test_create_scheduled_task_exception(mcp, mocker):
35
36
  mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
36
37
  mock_schedule.return_value.create.side_effect = Exception("create error")
37
38
  params = {"command": "ls", "enabled": True, "interval": "hourly", "minute": 0}
38
- with pytest.raises(RuntimeError) as exc:
39
+ with pytest.raises(ToolError) as exc:
39
40
  mcp.call_tool("create_scheduled_task", {"params": params})
40
41
  assert "Failed to create scheduled task: create error" in str(exc)
41
42
 
@@ -53,7 +54,7 @@ def test_delete_scheduled_task_exception(mcp, mocker):
53
54
  schedule_tools.register_schedule_tools(mcp)
54
55
  mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
55
56
  mock_schedule.return_value.delete.side_effect = Exception("delete error")
56
- with pytest.raises(RuntimeError) as exc:
57
+ with pytest.raises(ToolError) as exc:
57
58
  mcp.call_tool("delete_scheduled_task", {"task_id": 1})
58
59
  assert "Failed to delete scheduled task: delete error" in str(exc)
59
60
 
@@ -70,7 +71,7 @@ def test_get_scheduled_task_exception(mcp, mocker):
70
71
  schedule_tools.register_schedule_tools(mcp)
71
72
  mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
72
73
  mock_schedule.return_value.get_specs.side_effect = Exception("get error")
73
- with pytest.raises(RuntimeError) as exc:
74
+ with pytest.raises(ToolError) as exc:
74
75
  mcp.call_tool("get_scheduled_task", {"task_id": 1})
75
76
  assert "Failed to get scheduled task: get error" in str(exc)
76
77
 
@@ -89,6 +90,6 @@ def test_update_scheduled_task_exception(mcp, mocker):
89
90
  mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
90
91
  mock_schedule.return_value.update.side_effect = Exception("update error")
91
92
  params = {"command": "ls -l"}
92
- with pytest.raises(RuntimeError) as exc:
93
+ with pytest.raises(ToolError) as exc:
93
94
  mcp.call_tool("update_scheduled_task", {"task_id": 1, "params": params})
94
95
  assert "Failed to update scheduled task: update error" in str(exc)
@@ -4,15 +4,15 @@ from pythonanywhere_mcp_server import server
4
4
 
5
5
 
6
6
  @pytest.fixture()
7
- def mock_FastMCP(mocker):
8
- """Mock the FastMCP class to avoid actual server creation."""
9
- return mocker.patch("pythonanywhere_mcp_server.server.FastMCP", autospec=True)
7
+ def mock_MCPServer(mocker):
8
+ """Mock the MCPServer class to avoid actual server creation."""
9
+ return mocker.patch("pythonanywhere_mcp_server.server.MCPServer", autospec=True)
10
10
 
11
11
 
12
- def test_create_server(monkeypatch, mock_FastMCP):
12
+ def test_create_server(monkeypatch, mock_MCPServer):
13
13
  monkeypatch.setenv("API_TOKEN", "dummy-token")
14
14
  server.create_server()
15
- mock_FastMCP.assert_called_once_with("PythonAnywhere Model Context Protocol Server")
15
+ mock_MCPServer.assert_called_once_with("PythonAnywhere Model Context Protocol Server")
16
16
 
17
17
 
18
18
  @pytest.mark.parametrize(
@@ -23,14 +23,14 @@ def test_create_server(monkeypatch, mock_FastMCP):
23
23
  "register_schedule_tools",
24
24
  ]
25
25
  )
26
- def test_register_tools(monkeypatch, mocker, mock_FastMCP, register_fn):
26
+ def test_register_tools(monkeypatch, mocker, mock_MCPServer, register_fn):
27
27
  monkeypatch.setenv("API_TOKEN", "dummy-token")
28
28
  mock_register = mocker.patch(
29
29
  f"pythonanywhere_mcp_server.server.{register_fn}" ,
30
30
  autospec=True
31
31
  )
32
32
  server.create_server()
33
- mock_register.assert_called_once_with(mock_FastMCP.return_value)
33
+ mock_register.assert_called_once_with(mock_MCPServer.return_value)
34
34
 
35
35
 
36
36
  def test_missing_api_token(monkeypatch):
@@ -1,4 +1,5 @@
1
1
  import pytest
2
+ from mcp.server.mcpserver.exceptions import ToolError
2
3
  from pathlib import Path
3
4
  from pythonanywhere_core.base import AuthenticationError
4
5
  from pythonanywhere_core.exceptions import MissingCNAMEException
@@ -50,7 +51,7 @@ def test_webapp_tools_errors(setup_webapp_tools, mocker, tool_name, method_name,
50
51
  mock_webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
51
52
  getattr(mock_webapp.return_value, method_name).side_effect = side_effect
52
53
 
53
- with pytest.raises(RuntimeError) as exc:
54
+ with pytest.raises(ToolError) as exc:
54
55
  setup_webapp_tools.call_tool(tool_name, params)
55
56
  assert expected_error in str(exc)
56
57
 
@@ -63,7 +64,7 @@ def test_list_webapps_errors(setup_webapp_tools, mocker, side_effect, expected_e
63
64
  mocker.patch("tools.webapp.Webapp", autospec=True)
64
65
  mocker.patch("tools.webapp.Webapp.list_webapps", side_effect=side_effect)
65
66
 
66
- with pytest.raises(RuntimeError) as exc:
67
+ with pytest.raises(ToolError) as exc:
67
68
  setup_webapp_tools.call_tool("list_webapps", {})
68
69
  assert expected_error in str(exc)
69
70
 
@@ -108,7 +109,7 @@ def test_create_webapp_errors(setup_webapp_tools, mocker, side_effect, expected_
108
109
  "virtualenv_path": "/test/venv/path",
109
110
  "project_path": "/project/path",
110
111
  }
111
- with pytest.raises(RuntimeError) as exc:
112
+ with pytest.raises(ToolError) as exc:
112
113
  setup_webapp_tools.call_tool("create_webapp", params)
113
114
  assert expected_error in str(exc)
114
115
 
@@ -1,4 +1,5 @@
1
1
  import pytest
2
+ from mcp.server.mcpserver.exceptions import ToolError
2
3
 
3
4
  import tools.website as website_tools
4
5
 
@@ -17,7 +18,7 @@ def test_list_websites_exception(mcp, mocker):
17
18
  website_tools.register_website_tools(mcp)
18
19
  mock_website = mocker.patch("tools.website.Website", autospec=True)
19
20
  mock_website.return_value.list.side_effect = Exception("list error")
20
- with pytest.raises(RuntimeError) as exc:
21
+ with pytest.raises(ToolError) as exc:
21
22
  mcp.call_tool("list_websites", {})
22
23
  assert "Failed to list websites: list error" in str(exc)
23
24
 
@@ -36,7 +37,7 @@ def test_create_website_exception(mcp, mocker):
36
37
  mock_website = mocker.patch("tools.website.Website", autospec=True)
37
38
  mock_website.return_value.create.side_effect = Exception("create error")
38
39
  params = {"domain_name": "test.com", "command": "run.sh"}
39
- with pytest.raises(RuntimeError) as exc:
40
+ with pytest.raises(ToolError) as exc:
40
41
  mcp.call_tool("create_website", params)
41
42
  assert "Failed to create website: create error" in str(exc)
42
43
 
@@ -54,7 +55,7 @@ def test_delete_website_exception(mcp, mocker):
54
55
  website_tools.register_website_tools(mcp)
55
56
  mock_website = mocker.patch("tools.website.Website", autospec=True)
56
57
  mock_website.return_value.delete.side_effect = Exception("delete error")
57
- with pytest.raises(RuntimeError) as exc:
58
+ with pytest.raises(ToolError) as exc:
58
59
  mcp.call_tool("delete_website", {"domain_name": "test.com"})
59
60
  assert "Failed to delete website: delete error" in str(exc)
60
61
 
@@ -72,7 +73,7 @@ def test_reload_website_auth_error(mcp, mocker):
72
73
  mock_website = mocker.patch("tools.website.Website", autospec=True)
73
74
  from pythonanywhere_core.base import AuthenticationError
74
75
  mock_website.return_value.reload.side_effect = AuthenticationError()
75
- with pytest.raises(RuntimeError) as exc:
76
+ with pytest.raises(ToolError) as exc:
76
77
  mcp.call_tool("reload_website", {"domain": "test.com"})
77
78
  assert "Authentication failed" in str(exc)
78
79
 
@@ -81,6 +82,6 @@ def test_reload_website_other_error(mcp, mocker):
81
82
  website_tools.register_website_tools(mcp)
82
83
  mock_website = mocker.patch("tools.website.Website", autospec=True)
83
84
  mock_website.return_value.reload.side_effect = Exception("website reload error")
84
- with pytest.raises(RuntimeError) as exc:
85
+ with pytest.raises(ToolError) as exc:
85
86
  mcp.call_tool("reload_website", {"domain": "test.com"})
86
87
  assert "website reload error" in str(exc)
@@ -1 +0,0 @@
1
- __version__ = "0.0.11"
@@ -1,6 +0,0 @@
1
- pythonanywhere-core>=0.3.0
2
- mcp[cli]
3
-
4
- [test]
5
- pytest
6
- pytest-mock