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.
Files changed (27) hide show
  1. {pythonanywhere_mcp_server-0.0.13/src/pythonanywhere_mcp_server.egg-info → pythonanywhere_mcp_server-0.0.14}/PKG-INFO +2 -2
  2. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/pyproject.toml +2 -2
  3. pythonanywhere_mcp_server-0.0.14/src/pythonanywhere_mcp_server/__init__.py +1 -0
  4. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/webapp.py +18 -13
  5. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14/src/pythonanywhere_mcp_server.egg-info}/PKG-INFO +2 -2
  6. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/requires.txt +1 -1
  7. pythonanywhere_mcp_server-0.0.14/tests/test_mcp_integration.py +144 -0
  8. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_webapp_tools.py +62 -5
  9. pythonanywhere_mcp_server-0.0.13/src/pythonanywhere_mcp_server/__init__.py +0 -1
  10. pythonanywhere_mcp_server-0.0.13/tests/test_mcp_integration.py +0 -60
  11. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/LICENSE +0 -0
  12. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/README.md +0 -0
  13. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/setup.cfg +0 -0
  14. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/__main__.py +0 -0
  15. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/server.py +0 -0
  16. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/__init__.py +0 -0
  17. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/file.py +0 -0
  18. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/schedule.py +0 -0
  19. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server/tools/website.py +0 -0
  20. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/SOURCES.txt +0 -0
  21. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/dependency_links.txt +0 -0
  22. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/entry_points.txt +0 -0
  23. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/src/pythonanywhere_mcp_server.egg-info/top_level.txt +0 -0
  24. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_file_tools.py +0 -0
  25. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_schedule_tools.py +0 -0
  26. {pythonanywhere_mcp_server-0.0.13 → pythonanywhere_mcp_server-0.0.14}/tests/test_server.py +0 -0
  27. {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.13
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.2
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.13"
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.2",
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, it will fail unless
48
- 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.
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 (and nuke=False),
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 as exc:
167
- raise ToolError(str(exc)) from exc
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.13
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.2
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"
@@ -1,4 +1,4 @@
1
- pythonanywhere-core>=0.3.2
1
+ pythonanywhere-core>=0.3.3
2
2
  mcp[cli]<3,>=2.2.0
3
3
 
4
4
  [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
+ )
@@ -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"), "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
- 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):
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
- )