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.
- {pythonanywhere_mcp_server-0.0.12/src/pythonanywhere_mcp_server.egg-info → pythonanywhere_mcp_server-0.0.14}/PKG-INFO +3 -3
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/pyproject.toml +3 -3
- pythonanywhere_mcp_server-0.0.14/src/pythonanywhere_mcp_server/__init__.py +1 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/server.py +2 -2
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/file.py +13 -9
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/schedule.py +8 -7
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/webapp.py +36 -30
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/website.py +8 -7
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14/src/pythonanywhere_mcp_server.egg-info}/PKG-INFO +3 -3
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/SOURCES.txt +1 -0
- pythonanywhere_mcp_server-0.0.14/src/pythonanywhere_mcp_server.egg-info/requires.txt +6 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_file_tools.py +21 -10
- pythonanywhere_mcp_server-0.0.14/tests/test_mcp_integration.py +144 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_schedule_tools.py +6 -5
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_server.py +7 -7
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_webapp_tools.py +66 -8
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_website_tools.py +6 -5
- pythonanywhere_mcp_server-0.0.12/src/pythonanywhere_mcp_server/__init__.py +0 -1
- pythonanywhere_mcp_server-0.0.12/src/pythonanywhere_mcp_server.egg-info/requires.txt +0 -6
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/LICENSE +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/README.md +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/setup.cfg +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/__main__.py +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/__init__.py +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/dependency_links.txt +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
22
|
-
Requires-Dist: mcp[cli]<2.
|
|
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.
|
|
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.
|
|
16
|
-
"mcp[cli]
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
29
|
+
return json.dumps(data)
|
|
26
30
|
except Exception as exc:
|
|
27
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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
|
|
105
|
+
raise ToolError(f"Failed to get directory tree: {str(exc)}") from exc
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
from mcp.server
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
41
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
36
42
|
except Exception as exc:
|
|
37
|
-
raise
|
|
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,
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
87
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
82
88
|
except Exception as exc:
|
|
83
|
-
raise
|
|
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
|
-
|
|
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
|
|
114
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
109
115
|
except Exception as exc:
|
|
110
|
-
raise
|
|
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
|
-
|
|
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
|
|
165
|
-
except Exception
|
|
166
|
-
raise
|
|
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
|
-
|
|
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
|
|
202
|
+
raise ToolError("Authentication failed — check API_TOKEN.")
|
|
196
203
|
except Exception as exc:
|
|
197
|
-
raise
|
|
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
|
-
|
|
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
|
|
241
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
236
242
|
except Exception as exc:
|
|
237
|
-
raise
|
|
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
|
|
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:
|
|
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
|
|
31
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
31
32
|
except Exception as exc:
|
|
32
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
22
|
-
Requires-Dist: mcp[cli]<2.
|
|
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
|
{pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_file_tools.py
RENAMED
|
@@ -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
|
-
|
|
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 =
|
|
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 ==
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
)
|
{pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_schedule_tools.py
RENAMED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
8
|
-
"""Mock the
|
|
9
|
-
return mocker.patch("pythonanywhere_mcp_server.server.
|
|
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,
|
|
12
|
+
def test_create_server(monkeypatch, mock_MCPServer):
|
|
13
13
|
monkeypatch.setenv("API_TOKEN", "dummy-token")
|
|
14
14
|
server.create_server()
|
|
15
|
-
|
|
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,
|
|
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(
|
|
33
|
+
mock_register.assert_called_once_with(mock_MCPServer.return_value)
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def test_missing_api_token(monkeypatch):
|
{pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_webapp_tools.py
RENAMED
|
@@ -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"), "
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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"}]
|
{pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.14}/tests/test_website_tools.py
RENAMED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|