pythonanywhere-mcp-server 0.0.12__tar.gz → 0.0.14__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.12/src/pythonanywhere_mcp_server.egg-info → pythonanywhere_mcp_server-0.0.14}/PKG-INFO +3 -3
  2. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/pyproject.toml +3 -3
  3. pythonanywhere_mcp_server-0.0.14/src/pythonanywhere_mcp_server/__init__.py +1 -0
  4. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/server.py +2 -2
  5. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/file.py +13 -9
  6. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/schedule.py +8 -7
  7. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/webapp.py +36 -30
  8. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/website.py +8 -7
  9. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14/src/pythonanywhere_mcp_server.egg-info}/PKG-INFO +3 -3
  10. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/SOURCES.txt +1 -0
  11. pythonanywhere_mcp_server-0.0.14/src/pythonanywhere_mcp_server.egg-info/requires.txt +6 -0
  12. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_file_tools.py +21 -10
  13. pythonanywhere_mcp_server-0.0.14/tests/test_mcp_integration.py +144 -0
  14. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_schedule_tools.py +6 -5
  15. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_server.py +7 -7
  16. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_webapp_tools.py +66 -8
  17. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_website_tools.py +6 -5
  18. pythonanywhere_mcp_server-0.0.12/src/pythonanywhere_mcp_server/__init__.py +0 -1
  19. pythonanywhere_mcp_server-0.0.12/src/pythonanywhere_mcp_server.egg-info/requires.txt +0 -6
  20. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/LICENSE +0 -0
  21. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/README.md +0 -0
  22. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/setup.cfg +0 -0
  23. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/__main__.py +0 -0
  24. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/__init__.py +0 -0
  25. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/dependency_links.txt +0 -0
  26. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/entry_points.txt +0 -0
  27. {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/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.12
3
+ Version: 0.0.14
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]<2.0.0
21
+ Requires-Dist: pythonanywhere-core>=0.3.3
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.12"
7
+ version = "0.0.14"
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]<2.0.0",
15
+ "pythonanywhere-core>=0.3.3",
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.14"
@@ -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,12 @@ 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 _without_password(config: dict) -> dict:
14
+ """Omit the basic-auth password from a successful configuration result."""
15
+ return {key: value for key, value in config.items() if key != "password_protection_password"}
16
+
17
+
18
+ def register_webapp_tools(mcp: MCPServer) -> None:
13
19
  @mcp.tool()
14
20
  def reload_webapp(domain: str) -> str:
15
21
  """
@@ -32,19 +38,19 @@ def register_webapp_tools(mcp: FastMCP) -> None:
32
38
  except MissingCNAMEException as exc:
33
39
  return f"Webapp '{domain}' reloaded. Note: {str(exc)}"
34
40
  except (AuthenticationError, NoTokenError):
35
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
41
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
36
42
  except Exception as exc:
37
- raise RuntimeError(str(exc)) from exc
43
+ raise ToolError(str(exc)) from exc
38
44
 
39
45
  @mcp.tool()
40
- def create_webapp(domain: str, python_version: str, virtualenv_path: str, project_path: str) -> str:
46
+ def create_webapp(domain: str, python_version: str, virtualenv_path: str | None, project_path: str) -> str:
41
47
  """
42
48
  Create a new uWSGI-based web application for the given domain.
43
49
 
44
50
  This creates a new webapp on PythonAnywhere with the specified configuration.
45
51
  The webapp will be created using the specified Python version, virtual environment,
46
- and project path. If a webapp already exists for this domain, it will fail unless
47
- the nuke parameter is set to True.
52
+ and project path. If a webapp already exists for this domain, creation fails;
53
+ this tool does not replace existing webapps.
48
54
 
49
55
  The WSGI configuration file can be found in /var/www. The file is of the format:
50
56
  domain.replace('.', '_') + '_wsgi.py' It would be automatically created as side effect of running this tool.
@@ -64,7 +70,7 @@ def register_webapp_tools(mcp: FastMCP) -> None:
64
70
  str: Status message indicating creation result.
65
71
 
66
72
  Raises:
67
- RuntimeError: If authentication fails, webapp already exists (and nuke=False),
73
+ ToolError: If authentication fails, webapp already exists,
68
74
  or other API errors occur. If 403 error is raised, it may mean that there
69
75
  is already a non-uwsgi-based website. `list_websites` tool can be used to check that.
70
76
  """
@@ -72,15 +78,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
72
78
  webapp = Webapp(domain)
73
79
  webapp.create(
74
80
  python_version=python_version,
75
- virtualenv_path=Path(virtualenv_path),
81
+ virtualenv_path=Path(virtualenv_path) if virtualenv_path is not None else None,
76
82
  project_path=Path(project_path),
77
83
  nuke=False
78
84
  )
79
85
  return f"Webapp '{domain}' created successfully."
80
86
  except (AuthenticationError, NoTokenError):
81
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
87
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
82
88
  except Exception as exc:
83
- raise RuntimeError(str(exc)) from exc
89
+ raise ToolError(str(exc)) from exc
84
90
 
85
91
  @mcp.tool()
86
92
  def delete_webapp(domain: str) -> str:
@@ -99,15 +105,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
99
105
  str: Status message indicating deletion result.
100
106
 
101
107
  Raises:
102
- RuntimeError: If authentication fails, webapp doesn't exist, or other API errors occur.
108
+ ToolError: If authentication fails, webapp doesn't exist, or other API errors occur.
103
109
  """
104
110
  try:
105
111
  Webapp(domain).delete()
106
112
  return f"Webapp '{domain}' deleted successfully."
107
113
  except (AuthenticationError, NoTokenError):
108
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
114
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
109
115
  except Exception as exc:
110
- raise RuntimeError(str(exc)) from exc
116
+ raise ToolError(str(exc)) from exc
111
117
 
112
118
  @mcp.tool()
113
119
  def patch_webapp(domain: str, data: dict) -> dict:
@@ -150,20 +156,21 @@ def register_webapp_tools(mcp: FastMCP) -> None:
150
156
  "expiry": "2025-10-16",
151
157
  "force_https": False,
152
158
  "password_protection_enabled": False,
153
- "password_protection_username": "foo",
154
- "password_protection_password": "bar"
159
+ "password_protection_username": "foo"
155
160
  }
156
161
 
157
162
  Raises:
158
- RuntimeError: If authentication fails, webapp doesn't exist, or other API errors occur.
163
+ ToolError: If authentication fails, webapp doesn't exist, or other API errors occur.
159
164
  """
160
165
  try:
161
166
  result = Webapp(domain).patch(data)
162
- return result
167
+ return _without_password(result)
163
168
  except (AuthenticationError, NoTokenError):
164
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
165
- except Exception as exc:
166
- raise RuntimeError(str(exc)) from exc
169
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
170
+ except Exception:
171
+ raise ToolError(
172
+ "Webapp update failed; inspect the configuration before retrying."
173
+ ) from None
167
174
 
168
175
  @mcp.tool()
169
176
  def list_webapps() -> list:
@@ -186,15 +193,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
186
193
  get_webapp_info: Get detailed information about a specific webapp.
187
194
 
188
195
  Raises:
189
- RuntimeError: If authentication fails or other API errors occur.
196
+ ToolError: If authentication fails or other API errors occur.
190
197
  """
191
198
  try:
192
199
  result = Webapp.list_webapps()
193
- return result
200
+ return [_without_password(config) for config in result]
194
201
  except (AuthenticationError, NoTokenError):
195
- raise RuntimeError("Authentication failed — check API_TOKEN.")
202
+ raise ToolError("Authentication failed — check API_TOKEN.")
196
203
  except Exception as exc:
197
- raise RuntimeError(str(exc)) from exc
204
+ raise ToolError(str(exc)) from exc
198
205
 
199
206
  @mcp.tool()
200
207
  def get_webapp_info(domain: str) -> dict:
@@ -222,19 +229,18 @@ def register_webapp_tools(mcp: FastMCP) -> None:
222
229
  - "force_https": bool, # Whether HTTPS is enforced
223
230
  - "password_protection_enabled": bool, # Whether password protection is enabled
224
231
  - "password_protection_username": str, # Username for password-protected access
225
- - "password_protection_password": str # Password for password-protected access
226
232
 
227
233
 
228
234
  Raises:
229
- RuntimeError: If authentication fails, webapp doesn't exist, or other API errors occur.
235
+ ToolError: If authentication fails, webapp doesn't exist, or other API errors occur.
230
236
  """
231
237
  try:
232
238
  result = Webapp(domain).get()
233
- return result
239
+ return _without_password(result)
234
240
  except (AuthenticationError, NoTokenError):
235
- raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
241
+ raise ToolError("Authentication failed — check API_TOKEN and domain.")
236
242
  except Exception as exc:
237
- raise RuntimeError(str(exc)) from exc
243
+ raise ToolError(str(exc)) from exc
238
244
 
239
245
 
240
246
 
@@ -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.12
3
+ Version: 0.0.14
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]<2.0.0
21
+ Requires-Dist: pythonanywhere-core>=0.3.3
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.3
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,144 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from mcp import Client
6
+ from pythonanywhere_core.exceptions import PythonAnywhereApiException
7
+
8
+ from pythonanywhere_mcp_server.server import create_server
9
+
10
+
11
+ EXPECTED_TOOLS = {
12
+ "create_scheduled_task",
13
+ "create_webapp",
14
+ "create_website",
15
+ "delete_path",
16
+ "delete_scheduled_task",
17
+ "delete_webapp",
18
+ "delete_website",
19
+ "get_scheduled_task",
20
+ "get_webapp_info",
21
+ "list_scheduled_tasks",
22
+ "list_webapps",
23
+ "list_websites",
24
+ "patch_webapp",
25
+ "read_file_or_directory",
26
+ "reload_webapp",
27
+ "reload_website",
28
+ "tree",
29
+ "update_scheduled_task",
30
+ "upload_directory",
31
+ "upload_text_file",
32
+ }
33
+
34
+
35
+ @pytest.fixture
36
+ def server(monkeypatch):
37
+ monkeypatch.setenv("API_TOKEN", "dummy-token")
38
+ return create_server()
39
+
40
+
41
+ @pytest.mark.anyio
42
+ async def test_server_exposes_expected_tools(server):
43
+ async with Client(server) as client:
44
+ result = await client.list_tools()
45
+
46
+ assert {tool.name for tool in result.tools} == EXPECTED_TOOLS
47
+
48
+
49
+ @pytest.mark.anyio
50
+ @pytest.mark.parametrize("tool_name,method_name,arguments", [
51
+ ("get_webapp_info", "get", {"domain": "test.com"}),
52
+ ("list_webapps", "list_webapps", {}),
53
+ ("patch_webapp", "patch", {"domain": "test.com", "data": {"force_https": True}}),
54
+ ])
55
+ async def test_webapp_password_is_absent_from_wire_result(
56
+ server, mocker, tool_name, method_name, arguments
57
+ ):
58
+ config = {"domain_name": "test.com", "password_protection_password": "sentinel-secret"}
59
+ mocker.patch(
60
+ f"pythonanywhere_mcp_server.tools.webapp.Webapp.{method_name}",
61
+ return_value=[config] if tool_name == "list_webapps" else config,
62
+ )
63
+
64
+ async with Client(server) as client:
65
+ result = await client.call_tool(tool_name, arguments)
66
+
67
+ assert not result.is_error
68
+ serialised = result.model_dump_json()
69
+ assert "test.com" in serialised
70
+ assert "sentinel-secret" not in serialised
71
+ assert "password_protection_password" not in serialised
72
+
73
+
74
+ @pytest.mark.anyio
75
+ async def test_create_webapp_schema_requires_nullable_virtualenv(server):
76
+ async with Client(server) as client:
77
+ result = await client.list_tools()
78
+
79
+ tool = next(tool for tool in result.tools if tool.name == "create_webapp")
80
+ schema = tool.input_schema
81
+ assert "virtualenv_path" in schema["required"]
82
+ assert {option["type"] for option in schema["properties"]["virtualenv_path"]["anyOf"]} == {
83
+ "string", "null"
84
+ }
85
+ assert "nuke" not in tool.description
86
+
87
+
88
+ @pytest.mark.anyio
89
+ @pytest.mark.parametrize("virtualenv_path", [None, "/home/test/venv"])
90
+ async def test_create_webapp_accepts_nullable_virtualenv(server, mocker, virtualenv_path):
91
+ create = mocker.patch("pythonanywhere_mcp_server.tools.webapp.Webapp.create")
92
+ async with Client(server) as client:
93
+ result = await client.call_tool("create_webapp", {
94
+ "domain": "test.com",
95
+ "python_version": "3.10",
96
+ "virtualenv_path": virtualenv_path,
97
+ "project_path": "/home/test/project",
98
+ })
99
+
100
+ assert not result.is_error
101
+ create.assert_called_once_with(
102
+ python_version="3.10",
103
+ virtualenv_path=Path(virtualenv_path) if virtualenv_path is not None else None,
104
+ project_path=Path("/home/test/project"),
105
+ nuke=False,
106
+ )
107
+
108
+
109
+ @pytest.mark.anyio
110
+ @pytest.mark.parametrize("error_type", [PythonAnywhereApiException, ValueError])
111
+ async def test_patch_error_wire_result_contains_no_secrets(server, mocker, error_type):
112
+ patch = mocker.patch(
113
+ "pythonanywhere_mcp_server.tools.webapp.Webapp.patch",
114
+ side_effect=error_type("sentinel-password sentinel-response"),
115
+ )
116
+ data = {"password_protection_password": "sentinel-password"}
117
+
118
+ async with Client(server) as client:
119
+ result = await client.call_tool("patch_webapp", {"domain": "sentinel-domain", "data": data})
120
+
121
+ assert result.is_error
122
+ assert "sentinel" not in result.model_dump_json()
123
+ assert result.content[0].text == (
124
+ "Error executing tool patch_webapp: "
125
+ "Webapp update failed; inspect the configuration before retrying."
126
+ )
127
+ patch.assert_called_once_with(data)
128
+
129
+
130
+ @pytest.mark.anyio
131
+ async def test_api_failure_is_returned_as_actionable_tool_error(server, mocker):
132
+ mocker.patch(
133
+ "pythonanywhere_mcp_server.tools.schedule.Schedule.get_list",
134
+ side_effect=Exception("list error"),
135
+ )
136
+
137
+ async with Client(server) as client:
138
+ result = await client.call_tool("list_scheduled_tasks")
139
+
140
+ assert result.is_error
141
+ assert result.content[0].text == (
142
+ "Error executing tool list_scheduled_tasks: "
143
+ "Failed to list scheduled tasks: list error"
144
+ )
@@ -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,7 +1,8 @@
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
- from pythonanywhere_core.exceptions import MissingCNAMEException
5
+ from pythonanywhere_core.exceptions import MissingCNAMEException, PythonAnywhereApiException
5
6
 
6
7
  import tools.webapp as webapp_tools
7
8
 
@@ -44,17 +45,34 @@ def test_webapp_tools_success(setup_webapp_tools, mocker, tool_name, method_name
44
45
  ("get_webapp_info", "get", {"domain": "test.com"}, AuthenticationError(), "Authentication failed"),
45
46
  ("get_webapp_info", "get", {"domain": "test.com"}, Exception("info error"), "info error"),
46
47
  ("patch_webapp", "patch", {"domain": "test.com", "data": {"python_version": "3.10"}}, AuthenticationError(), "Authentication failed"),
47
- ("patch_webapp", "patch", {"domain": "test.com", "data": {"python_version": "3.10"}}, Exception("patch error"), "patch error"),
48
+ ("patch_webapp", "patch", {"domain": "test.com", "data": {"python_version": "3.10"}}, Exception("patch error"), "Webapp update failed; inspect the configuration before retrying."),
48
49
  ])
49
50
  def test_webapp_tools_errors(setup_webapp_tools, mocker, tool_name, method_name, params, side_effect, expected_error):
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
 
57
58
 
59
+ @pytest.mark.parametrize("error_type", [PythonAnywhereApiException, ValueError])
60
+ def test_patch_error_does_not_expose_secrets(setup_webapp_tools, mocker, error_type):
61
+ webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
62
+ webapp.return_value.patch.side_effect = error_type("sentinel-password sentinel-response")
63
+ data = {"password_protection_password": "sentinel-password"}
64
+
65
+ with pytest.raises(ToolError) as exc:
66
+ setup_webapp_tools.call_tool("patch_webapp", {"domain": "sentinel-domain", "data": data})
67
+
68
+ assert str(exc.value) == "Webapp update failed; inspect the configuration before retrying."
69
+ assert exc.value.__cause__ is None
70
+ assert exc.value.__suppress_context__
71
+ webapp.assert_called_once_with("sentinel-domain")
72
+ webapp.return_value.patch.assert_called_once_with({"password_protection_password": "sentinel-password"})
73
+ assert data == {"password_protection_password": "sentinel-password"}
74
+
75
+
58
76
  @pytest.mark.parametrize("side_effect,expected_error", [
59
77
  (AuthenticationError(), "Authentication failed"),
60
78
  (Exception("list error"), "list error"),
@@ -63,15 +81,15 @@ def test_list_webapps_errors(setup_webapp_tools, mocker, side_effect, expected_e
63
81
  mocker.patch("tools.webapp.Webapp", autospec=True)
64
82
  mocker.patch("tools.webapp.Webapp.list_webapps", side_effect=side_effect)
65
83
 
66
- with pytest.raises(RuntimeError) as exc:
84
+ with pytest.raises(ToolError) as exc:
67
85
  setup_webapp_tools.call_tool("list_webapps", {})
68
86
  assert expected_error in str(exc)
69
87
 
70
88
 
71
- def test_create_webapp(setup_webapp_tools, mocker):
89
+ @pytest.mark.parametrize("virtualenv_path", [None, "/test/venv/path"])
90
+ def test_create_webapp(setup_webapp_tools, mocker, virtualenv_path):
72
91
  webapp_domain = 'test.com'
73
92
  python_version = '3.10'
74
- virtualenv_path = Path('/test/venv/path')
75
93
  project_path = Path('/project/path')
76
94
 
77
95
  mock_webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
@@ -87,7 +105,7 @@ def test_create_webapp(setup_webapp_tools, mocker):
87
105
  mock_webapp.assert_called_with(webapp_domain)
88
106
  mock_webapp.return_value.create.assert_called_with(
89
107
  python_version=python_version,
90
- virtualenv_path=virtualenv_path,
108
+ virtualenv_path=Path(virtualenv_path) if virtualenv_path is not None else None,
91
109
  project_path=project_path,
92
110
  nuke=False
93
111
  )
@@ -108,11 +126,51 @@ def test_create_webapp_errors(setup_webapp_tools, mocker, side_effect, expected_
108
126
  "virtualenv_path": "/test/venv/path",
109
127
  "project_path": "/project/path",
110
128
  }
111
- with pytest.raises(RuntimeError) as exc:
129
+ with pytest.raises(ToolError) as exc:
112
130
  setup_webapp_tools.call_tool("create_webapp", params)
113
131
  assert expected_error in str(exc)
114
132
 
115
133
 
134
+ @pytest.mark.parametrize("tool_name,method_name", [
135
+ ("get_webapp_info", "get"),
136
+ ("list_webapps", "list_webapps"),
137
+ ("patch_webapp", "patch"),
138
+ ])
139
+ @pytest.mark.parametrize("has_password", [False, True])
140
+ def test_webapp_results_omit_password_without_mutating_input(
141
+ setup_webapp_tools, mocker, tool_name, method_name, has_password
142
+ ):
143
+ safe = {"domain_name": "test.com", "password_protection_enabled": True}
144
+ original = dict(safe)
145
+ if has_password:
146
+ original["password_protection_password"] = "sentinel-secret"
147
+ snapshot = dict(original)
148
+ webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
149
+ is_list = tool_name == "list_webapps"
150
+ method = getattr(webapp if is_list else webapp.return_value, method_name)
151
+ method.return_value = [original, dict(original)] if is_list else original
152
+ params = {} if is_list else {"domain": "test.com"}
153
+ patch_data = {"password_protection_password": "new-secret"}
154
+ if tool_name == "patch_webapp":
155
+ params["data"] = patch_data
156
+
157
+ result = setup_webapp_tools.call_tool(tool_name, params)
158
+
159
+ assert result == ([safe, safe] if is_list else safe)
160
+ assert original == snapshot
161
+ if is_list:
162
+ assert method.return_value == [snapshot, snapshot]
163
+ if tool_name == "patch_webapp":
164
+ method.assert_called_once_with({"password_protection_password": "new-secret"})
165
+ assert patch_data == {"password_protection_password": "new-secret"}
166
+
167
+
168
+ def test_list_webapps_empty(setup_webapp_tools, mocker):
169
+ mocker.patch("tools.webapp.Webapp.list_webapps", return_value=[])
170
+
171
+ assert setup_webapp_tools.call_tool("list_webapps", {}) == []
172
+
173
+
116
174
  def test_list_webapps(setup_webapp_tools, mocker):
117
175
  mocker.patch("tools.webapp.Webapp", autospec=True)
118
176
  expected = [{"domain_name": "test.com", "python_version": "3.10"}]
@@ -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.12"
@@ -1,6 +0,0 @@
1
- pythonanywhere-core>=0.3.0
2
- mcp[cli]<2.0.0
3
-
4
- [test]
5
- pytest
6
- pytest-mock