pythonanywhere-mcp-server 0.0.13__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.13/src/pythonanywhere_mcp_server.egg-info → pythonanywhere_mcp_server-0.0.14}/PKG-INFO +2 -2
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/pyproject.toml +2 -2
- pythonanywhere_mcp_server-0.0.14/src/pythonanywhere_mcp_server/__init__.py +1 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/webapp.py +18 -13
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14/src/pythonanywhere_mcp_server.egg-info}/PKG-INFO +2 -2
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/requires.txt +1 -1
- pythonanywhere_mcp_server-0.0.14/tests/test_mcp_integration.py +144 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_webapp_tools.py +62 -5
- pythonanywhere_mcp_server-0.0.13/src/pythonanywhere_mcp_server/__init__.py +0 -1
- pythonanywhere_mcp_server-0.0.13/tests/test_mcp_integration.py +0 -60
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/LICENSE +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/README.md +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/setup.cfg +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/__main__.py +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/server.py +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/__init__.py +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/file.py +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/schedule.py +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/website.py +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/SOURCES.txt +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/dependency_links.txt +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/entry_points.txt +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/top_level.txt +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_file_tools.py +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_schedule_tools.py +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_server.py +0 -0
- {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_website_tools.py +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,7 +18,7 @@ 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.
|
|
21
|
+
Requires-Dist: pythonanywhere-core>=0.3.3
|
|
22
22
|
Requires-Dist: mcp[cli]<3,>=2.2.0
|
|
23
23
|
Provides-Extra: test
|
|
24
24
|
Requires-Dist: pytest; 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,7 +12,7 @@ authors = [
|
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.13"
|
|
14
14
|
dependencies = [
|
|
15
|
-
"pythonanywhere-core>=0.3.
|
|
15
|
+
"pythonanywhere-core>=0.3.3",
|
|
16
16
|
"mcp[cli]>=2.2.0,<3",
|
|
17
17
|
]
|
|
18
18
|
classifiers = [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.14"
|
|
@@ -10,6 +10,11 @@ from pythonanywhere_core.exceptions import MissingCNAMEException
|
|
|
10
10
|
# ToDo: Add the log file functions once pythonanywhere-core webapp log file functions
|
|
11
11
|
# have been improved
|
|
12
12
|
|
|
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
|
+
|
|
13
18
|
def register_webapp_tools(mcp: MCPServer) -> None:
|
|
14
19
|
@mcp.tool()
|
|
15
20
|
def reload_webapp(domain: str) -> str:
|
|
@@ -38,14 +43,14 @@ def register_webapp_tools(mcp: MCPServer) -> None:
|
|
|
38
43
|
raise ToolError(str(exc)) from exc
|
|
39
44
|
|
|
40
45
|
@mcp.tool()
|
|
41
|
-
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:
|
|
42
47
|
"""
|
|
43
48
|
Create a new uWSGI-based web application for the given domain.
|
|
44
49
|
|
|
45
50
|
This creates a new webapp on PythonAnywhere with the specified configuration.
|
|
46
51
|
The webapp will be created using the specified Python version, virtual environment,
|
|
47
|
-
and project path. If a webapp already exists for this domain,
|
|
48
|
-
|
|
52
|
+
and project path. If a webapp already exists for this domain, creation fails;
|
|
53
|
+
this tool does not replace existing webapps.
|
|
49
54
|
|
|
50
55
|
The WSGI configuration file can be found in /var/www. The file is of the format:
|
|
51
56
|
domain.replace('.', '_') + '_wsgi.py' It would be automatically created as side effect of running this tool.
|
|
@@ -65,7 +70,7 @@ def register_webapp_tools(mcp: MCPServer) -> None:
|
|
|
65
70
|
str: Status message indicating creation result.
|
|
66
71
|
|
|
67
72
|
Raises:
|
|
68
|
-
ToolError: If authentication fails, webapp already exists
|
|
73
|
+
ToolError: If authentication fails, webapp already exists,
|
|
69
74
|
or other API errors occur. If 403 error is raised, it may mean that there
|
|
70
75
|
is already a non-uwsgi-based website. `list_websites` tool can be used to check that.
|
|
71
76
|
"""
|
|
@@ -73,7 +78,7 @@ def register_webapp_tools(mcp: MCPServer) -> None:
|
|
|
73
78
|
webapp = Webapp(domain)
|
|
74
79
|
webapp.create(
|
|
75
80
|
python_version=python_version,
|
|
76
|
-
virtualenv_path=Path(virtualenv_path),
|
|
81
|
+
virtualenv_path=Path(virtualenv_path) if virtualenv_path is not None else None,
|
|
77
82
|
project_path=Path(project_path),
|
|
78
83
|
nuke=False
|
|
79
84
|
)
|
|
@@ -151,8 +156,7 @@ def register_webapp_tools(mcp: MCPServer) -> None:
|
|
|
151
156
|
"expiry": "2025-10-16",
|
|
152
157
|
"force_https": False,
|
|
153
158
|
"password_protection_enabled": False,
|
|
154
|
-
"password_protection_username": "foo"
|
|
155
|
-
"password_protection_password": "bar"
|
|
159
|
+
"password_protection_username": "foo"
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
Raises:
|
|
@@ -160,11 +164,13 @@ def register_webapp_tools(mcp: MCPServer) -> None:
|
|
|
160
164
|
"""
|
|
161
165
|
try:
|
|
162
166
|
result = Webapp(domain).patch(data)
|
|
163
|
-
return result
|
|
167
|
+
return _without_password(result)
|
|
164
168
|
except (AuthenticationError, NoTokenError):
|
|
165
169
|
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
166
|
-
except Exception
|
|
167
|
-
raise ToolError(
|
|
170
|
+
except Exception:
|
|
171
|
+
raise ToolError(
|
|
172
|
+
"Webapp update failed; inspect the configuration before retrying."
|
|
173
|
+
) from None
|
|
168
174
|
|
|
169
175
|
@mcp.tool()
|
|
170
176
|
def list_webapps() -> list:
|
|
@@ -191,7 +197,7 @@ def register_webapp_tools(mcp: MCPServer) -> None:
|
|
|
191
197
|
"""
|
|
192
198
|
try:
|
|
193
199
|
result = Webapp.list_webapps()
|
|
194
|
-
return result
|
|
200
|
+
return [_without_password(config) for config in result]
|
|
195
201
|
except (AuthenticationError, NoTokenError):
|
|
196
202
|
raise ToolError("Authentication failed — check API_TOKEN.")
|
|
197
203
|
except Exception as exc:
|
|
@@ -223,7 +229,6 @@ def register_webapp_tools(mcp: MCPServer) -> None:
|
|
|
223
229
|
- "force_https": bool, # Whether HTTPS is enforced
|
|
224
230
|
- "password_protection_enabled": bool, # Whether password protection is enabled
|
|
225
231
|
- "password_protection_username": str, # Username for password-protected access
|
|
226
|
-
- "password_protection_password": str # Password for password-protected access
|
|
227
232
|
|
|
228
233
|
|
|
229
234
|
Raises:
|
|
@@ -231,7 +236,7 @@ def register_webapp_tools(mcp: MCPServer) -> None:
|
|
|
231
236
|
"""
|
|
232
237
|
try:
|
|
233
238
|
result = Webapp(domain).get()
|
|
234
|
-
return result
|
|
239
|
+
return _without_password(result)
|
|
235
240
|
except (AuthenticationError, NoTokenError):
|
|
236
241
|
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
237
242
|
except Exception as 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,7 +18,7 @@ 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.
|
|
21
|
+
Requires-Dist: pythonanywhere-core>=0.3.3
|
|
22
22
|
Requires-Dist: mcp[cli]<3,>=2.2.0
|
|
23
23
|
Provides-Extra: test
|
|
24
24
|
Requires-Dist: pytest; extra == "test"
|
|
@@ -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.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_webapp_tools.py
RENAMED
|
@@ -2,7 +2,7 @@ import pytest
|
|
|
2
2
|
from mcp.server.mcpserver.exceptions import ToolError
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from pythonanywhere_core.base import AuthenticationError
|
|
5
|
-
from pythonanywhere_core.exceptions import MissingCNAMEException
|
|
5
|
+
from pythonanywhere_core.exceptions import MissingCNAMEException, PythonAnywhereApiException
|
|
6
6
|
|
|
7
7
|
import tools.webapp as webapp_tools
|
|
8
8
|
|
|
@@ -45,7 +45,7 @@ def test_webapp_tools_success(setup_webapp_tools, mocker, tool_name, method_name
|
|
|
45
45
|
("get_webapp_info", "get", {"domain": "test.com"}, AuthenticationError(), "Authentication failed"),
|
|
46
46
|
("get_webapp_info", "get", {"domain": "test.com"}, Exception("info error"), "info error"),
|
|
47
47
|
("patch_webapp", "patch", {"domain": "test.com", "data": {"python_version": "3.10"}}, AuthenticationError(), "Authentication failed"),
|
|
48
|
-
("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."),
|
|
49
49
|
])
|
|
50
50
|
def test_webapp_tools_errors(setup_webapp_tools, mocker, tool_name, method_name, params, side_effect, expected_error):
|
|
51
51
|
mock_webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
|
|
@@ -56,6 +56,23 @@ def test_webapp_tools_errors(setup_webapp_tools, mocker, tool_name, method_name,
|
|
|
56
56
|
assert expected_error in str(exc)
|
|
57
57
|
|
|
58
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
|
+
|
|
59
76
|
@pytest.mark.parametrize("side_effect,expected_error", [
|
|
60
77
|
(AuthenticationError(), "Authentication failed"),
|
|
61
78
|
(Exception("list error"), "list error"),
|
|
@@ -69,10 +86,10 @@ def test_list_webapps_errors(setup_webapp_tools, mocker, side_effect, expected_e
|
|
|
69
86
|
assert expected_error in str(exc)
|
|
70
87
|
|
|
71
88
|
|
|
72
|
-
|
|
89
|
+
@pytest.mark.parametrize("virtualenv_path", [None, "/test/venv/path"])
|
|
90
|
+
def test_create_webapp(setup_webapp_tools, mocker, virtualenv_path):
|
|
73
91
|
webapp_domain = 'test.com'
|
|
74
92
|
python_version = '3.10'
|
|
75
|
-
virtualenv_path = Path('/test/venv/path')
|
|
76
93
|
project_path = Path('/project/path')
|
|
77
94
|
|
|
78
95
|
mock_webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
|
|
@@ -88,7 +105,7 @@ def test_create_webapp(setup_webapp_tools, mocker):
|
|
|
88
105
|
mock_webapp.assert_called_with(webapp_domain)
|
|
89
106
|
mock_webapp.return_value.create.assert_called_with(
|
|
90
107
|
python_version=python_version,
|
|
91
|
-
virtualenv_path=virtualenv_path,
|
|
108
|
+
virtualenv_path=Path(virtualenv_path) if virtualenv_path is not None else None,
|
|
92
109
|
project_path=project_path,
|
|
93
110
|
nuke=False
|
|
94
111
|
)
|
|
@@ -114,6 +131,46 @@ def test_create_webapp_errors(setup_webapp_tools, mocker, side_effect, expected_
|
|
|
114
131
|
assert expected_error in str(exc)
|
|
115
132
|
|
|
116
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
|
+
|
|
117
174
|
def test_list_webapps(setup_webapp_tools, mocker):
|
|
118
175
|
mocker.patch("tools.webapp.Webapp", autospec=True)
|
|
119
176
|
expected = [{"domain_name": "test.com", "python_version": "3.10"}]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.0.13"
|
|
@@ -1,60 +0,0 @@
|
|
|
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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_file_tools.py
RENAMED
|
File without changes
|
{pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_schedule_tools.py
RENAMED
|
File without changes
|
|
File without changes
|
{pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_website_tools.py
RENAMED
|
File without changes
|