Skip to content

Commit ca139cd

Browse files
leec1979filiplajszczak
authored andcommitted
Adds more tools for handling WSGI web apps.
Co-authored-by: Piotr Kaznowski <[email protected]> Co-authored-by: Lee Cartwright <[email protected]> Added a test for the create_webapp functionality. Updated some dependencies, most importantly pythonanywhere-core. Added a bunch of webapp functionality.
1 parent 1fa2088 commit ca139cd

File tree

5 files changed

+462
-45
lines changed

5 files changed

+462
-45
lines changed

manifest.json

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,7 @@
88
"name": "PythonAnywhere Developers",
99
"email": "[email protected]"
1010
},
11-
"main": "pythonanywhere_mcp_server.py",
12-
"categories": [
13-
"developer-tools",
14-
"utilities"
15-
],
1611
"license": "MIT",
17-
"permissions": [
18-
"mcp",
19-
"filesystem"
20-
],
2112
"user_config": {
2213
"pa_api_token": {
2314
"type": "string",

src/pythonanywhere_mcp_server/tools/webapp.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
from pathlib import Path
2+
13
from mcp.server.fastmcp import FastMCP
24

35
from pythonanywhere_core.webapp import Webapp
46
from pythonanywhere_core.base import AuthenticationError, NoTokenError
57

8+
# ToDo: Add the log file functions once pythonanywhere-core webapp log file functions
9+
# have been improved
610

711
def register_webapp_tools(mcp: FastMCP) -> None:
812
@mcp.tool()
@@ -28,3 +32,206 @@ def reload_webapp(domain: str) -> str:
2832
raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
2933
except Exception as exc:
3034
raise RuntimeError(str(exc)) from exc
35+
36+
@mcp.tool()
37+
def create_webapp(domain: str, python_version: str, virtualenv_path: str, project_path: str) -> str:
38+
"""
39+
Create a new uWSGI-based web application for the given domain.
40+
41+
This creates a new webapp on PythonAnywhere with the specified configuration.
42+
The webapp will be created using the specified Python version, virtual environment,
43+
and project path. If a webapp already exists for this domain, it will fail unless
44+
the nuke parameter is set to True.
45+
46+
The WSGI configuration file can be found in /var/www. The file is of the format:
47+
domain.replace('.', '_') + '_wsgi.py' It would be automatically created as side effect of running this tool.
48+
49+
Note:
50+
Virtual environments should be configured in the PythonAnywhere web app
51+
configuration, not in the WSGI file itself.
52+
53+
Args:
54+
domain (str): The domain name for the new webapp (e.g., 'alice.pythonanywhere.com').
55+
python_version (str): Python version to use (e.g., '3.11', '3.10', '3.9').
56+
virtualenv_path (str | None): Path to the virtual environment to use. (or None for
57+
no virtualenv when using one of pre-installed Pythons with batteries included packages)
58+
project_path (str): Path to the project directory containing the webapp code.
59+
60+
Returns:
61+
str: Status message indicating creation result.
62+
63+
Raises:
64+
RuntimeError: If authentication fails, webapp already exists (and nuke=False),
65+
or other API errors occur. If 403 error is raised, it may mean that there
66+
is already a non-uwsgi-based website. `list_websites` tool can be used to check that.
67+
"""
68+
try:
69+
webapp = Webapp(domain)
70+
webapp.create(
71+
python_version=python_version,
72+
virtualenv_path=Path(virtualenv_path),
73+
project_path=Path(project_path),
74+
nuke=False
75+
)
76+
return f"Webapp '{domain}' created successfully."
77+
except (AuthenticationError, NoTokenError):
78+
raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
79+
except Exception as exc:
80+
raise RuntimeError(str(exc)) from exc
81+
82+
@mcp.tool()
83+
def delete_webapp(domain: str) -> str:
84+
"""
85+
Delete a uWSGI-based web application for the given domain.
86+
87+
This permanently deletes the webapp configuration from PythonAnywhere. The actual
88+
files in your file system are not deleted, only the webapp configuration that
89+
serves them. This action cannot be undone.
90+
91+
Args:
92+
domain (str): The domain name of the webapp to delete
93+
(e.g., 'alice.pythonanywhere.com').
94+
95+
Returns:
96+
str: Status message indicating deletion result.
97+
98+
Raises:
99+
RuntimeError: If authentication fails, webapp doesn't exist, or other API errors occur.
100+
"""
101+
try:
102+
Webapp(domain).delete()
103+
return f"Webapp '{domain}' deleted successfully."
104+
except (AuthenticationError, NoTokenError):
105+
raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
106+
except Exception as exc:
107+
raise RuntimeError(str(exc)) from exc
108+
109+
@mcp.tool()
110+
def patch_webapp(domain: str, data: dict) -> dict:
111+
"""
112+
Update configuration settings for a uWSGI-based web application.
113+
114+
This allows you to modify various webapp settings such as the Python version,
115+
virtual environment path, source directory, and other configuration options.
116+
Only the fields provided in the data dictionary will be updated.
117+
118+
In order for any changes to take effect you must reload the webapp.
119+
120+
If you provide an invalid Python version you will receive a 400 error with an error
121+
message that the version you used is not a valid choice. You will need to choose a
122+
different Python version if you get this error.
123+
124+
Args:
125+
domain (str): The domain name of the webapp to update
126+
(e.g., 'alice.pythonanywhere.com').
127+
data (dict): Dictionary containing the configuration updates. Supported keys are:
128+
- 'python_version': Python version (e.g., '3.11')
129+
- 'virtualenv_path': Path to virtual environment
130+
- 'source_directory': Path to source code directory
131+
- 'working_directory': Working directory for the webapp
132+
- 'force_https': Force the use of HTTPS when accessing the webapp
133+
- 'password_protection_enabled': Enable basic HTTP password to your webapp, provided via PythonAnywhere not in the webapp code
134+
- 'password_protection_username': The username used for HTTP password protection
135+
- 'password_protection_password': The password used for HTTP password protection
136+
137+
Returns:
138+
dict: Updated webapp configuration information.
139+
Example: {
140+
"id": 2097234,
141+
"user": username,
142+
"domain_name": domain,
143+
"python_version": "3.10",
144+
"source_directory": f"/home/{username}/mysite",
145+
"working_directory": f"/home/{username}/",
146+
"virtualenv_path": "",
147+
"expiry": "2025-10-16",
148+
"force_https": False,
149+
"password_protection_enabled": False,
150+
"password_protection_username": "foo",
151+
"password_protection_password": "bar"
152+
}
153+
154+
Raises:
155+
RuntimeError: If authentication fails, webapp doesn't exist, or other API errors occur.
156+
"""
157+
try:
158+
result = Webapp(domain).patch(data)
159+
return result
160+
except (AuthenticationError, NoTokenError):
161+
raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
162+
except Exception as exc:
163+
raise RuntimeError(str(exc)) from exc
164+
165+
@mcp.tool()
166+
def list_webapps() -> list:
167+
"""
168+
List all uWSGI-based web applications for the current user.
169+
170+
This retrieves information about all webapps configured in your PythonAnywhere
171+
account. The returned list contains dictionaries with detailed information about
172+
each webapp including domain, Python version, paths, and status.
173+
174+
On PythonAnywhere one may also have non-uWSGI-based websites (usually ASGI-based),
175+
which are not included in this list. For those, use the `list_websites` tool.
176+
177+
Returns:
178+
list: List of dictionaries containing webapp information. Empty list means
179+
no WSGI-based webapps are deployed. That still could mean that there are
180+
non-WSGI-based apps that can be listed with the `list_websites` tool.
181+
182+
See also:
183+
get_webapp_info: Get detailed information about a specific webapp.
184+
185+
Raises:
186+
RuntimeError: If authentication fails or other API errors occur.
187+
"""
188+
try:
189+
result = Webapp.list_webapps()
190+
return result
191+
except (AuthenticationError, NoTokenError):
192+
raise RuntimeError("Authentication failed — check API_TOKEN.")
193+
except Exception as exc:
194+
raise RuntimeError(str(exc)) from exc
195+
196+
@mcp.tool()
197+
def get_webapp_info(domain: str) -> dict:
198+
"""
199+
Get detailed information about a specific uWSGI-based web application.
200+
201+
This retrieves comprehensive configuration and status information for the
202+
specified webapp, including paths, Python version, enabled status, and
203+
other configuration details.
204+
205+
Args:
206+
domain (str): The domain name of the webapp to get information for
207+
(e.g., 'alice.pythonanywhere.com').
208+
209+
Returns:
210+
dict: Dictionary containing detailed webapp information including:
211+
- "id": int, # Unique identifier for the site or user session
212+
- "user": str, # Username associated with the deployment
213+
- "domain_name": str, # Domain name for the deployed site
214+
- "python_version": str, # Python version used, e.g., "3.10"
215+
- "source_directory": str, # Absolute path to the site's source directory
216+
- "working_directory": str, # Absolute path to the working directory
217+
- "virtualenv_path": str, # Path to the Python virtual environment (can be empty)
218+
- "expiry": str, # Expiration date in ISO format, e.g., "2025-10-16"
219+
- "force_https": bool, # Whether HTTPS is enforced
220+
- "password_protection_enabled": bool, # Whether password protection is enabled
221+
- "password_protection_username": str, # Username for password-protected access
222+
- "password_protection_password": str # Password for password-protected access
223+
224+
225+
Raises:
226+
RuntimeError: If authentication fails, webapp doesn't exist, or other API errors occur.
227+
"""
228+
try:
229+
result = Webapp(domain).get()
230+
return result
231+
except (AuthenticationError, NoTokenError):
232+
raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
233+
except Exception as exc:
234+
raise RuntimeError(str(exc)) from exc
235+
236+
237+

src/pythonanywhere_mcp_server/tools/website.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,14 @@ def list_websites() -> list[dict[str, Any]]:
3737
Return info dictionaries for every ASGI website configured for the current
3838
user. Empty list means that there are no websites deployed.
3939
That would not include WSGI-based web applications,
40-
which could be only reloaded with `reload_webapp` tool.
40+
which could be only listed with the `list_webapps` tool.
4141
4242
4343
Returns:
4444
List[dict[str, Any]]: List of dictionaries with website information.
45+
Empty list if no websites are configured for the user, but there could be
46+
still WSGI-based web applications configured that qould be listed with
47+
the `list_webapps` tool.
4548
4649
"""
4750
try:

tests/test_webapp_tools.py

Lines changed: 106 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,120 @@
11
import pytest
2+
from pathlib import Path
3+
from pythonanywhere_core.base import AuthenticationError
24

35
import tools.webapp as webapp_tools
46

5-
def test_reload_webapp(mcp, mocker):
7+
8+
@pytest.fixture
9+
def setup_webapp_tools(mcp):
610
webapp_tools.register_webapp_tools(mcp)
11+
return mcp
12+
13+
14+
@pytest.mark.parametrize("tool_name,method_name,params,expected_params,expected_result", [
15+
("reload_webapp", "reload", {"domain": "test.com"}, {}, "Webapp 'test.com' reloaded."),
16+
("delete_webapp", "delete", {"domain": "test.com"}, {}, "Webapp 'test.com' deleted successfully."),
17+
("get_webapp_info", "get", {"domain": "test.com"}, {}, {"domain_name": "test.com", "python_version": "3.10"}),
18+
("patch_webapp", "patch", {"domain": "test.com", "data": {"python_version": "3.10"}}, {"python_version": "3.10"}, {"domain_name": "test.com", "python_version": "3.10"}),
19+
])
20+
def test_webapp_tools_success(setup_webapp_tools, mocker, tool_name, method_name, params, expected_params, expected_result):
721
mock_webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
8-
result = mcp.call_tool("reload_webapp", {"domain": "test.com"})
9-
mock_webapp.assert_called_with("test.com")
10-
mock_webapp.return_value.reload.assert_called_once()
11-
assert result == "Webapp 'test.com' reloaded."
22+
23+
if tool_name == "get_webapp_info" or tool_name == "patch_webapp":
24+
getattr(mock_webapp.return_value, method_name).return_value = expected_result
25+
26+
result = setup_webapp_tools.call_tool(tool_name, params)
27+
28+
if "domain" in params:
29+
mock_webapp.assert_called_with(params["domain"])
30+
if expected_params:
31+
getattr(mock_webapp.return_value, method_name).assert_called_with(expected_params)
32+
else:
33+
getattr(mock_webapp.return_value, method_name).assert_called_once()
34+
35+
assert result == expected_result
1236

1337

14-
def test_reload_webapp_auth_error(mcp, mocker):
15-
webapp_tools.register_webapp_tools(mcp)
38+
@pytest.mark.parametrize("tool_name,method_name,params,side_effect,expected_error", [
39+
("reload_webapp", "reload", {"domain": "test.com"}, AuthenticationError(), "Authentication failed"),
40+
("reload_webapp", "reload", {"domain": "test.com"}, Exception("webapp reload error"), "webapp reload error"),
41+
("delete_webapp", "delete", {"domain": "test.com"}, AuthenticationError(), "Authentication failed"),
42+
("delete_webapp", "delete", {"domain": "test.com"}, Exception("delete error"), "delete error"),
43+
("get_webapp_info", "get", {"domain": "test.com"}, AuthenticationError(), "Authentication failed"),
44+
("get_webapp_info", "get", {"domain": "test.com"}, Exception("info error"), "info error"),
45+
("patch_webapp", "patch", {"domain": "test.com", "data": {"python_version": "3.10"}}, AuthenticationError(), "Authentication failed"),
46+
("patch_webapp", "patch", {"domain": "test.com", "data": {"python_version": "3.10"}}, Exception("patch error"), "patch error"),
47+
])
48+
def test_webapp_tools_errors(setup_webapp_tools, mocker, tool_name, method_name, params, side_effect, expected_error):
1649
mock_webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
17-
from pythonanywhere_core.base import AuthenticationError
18-
mock_webapp.return_value.reload.side_effect = AuthenticationError()
50+
getattr(mock_webapp.return_value, method_name).side_effect = side_effect
51+
1952
with pytest.raises(RuntimeError) as exc:
20-
mcp.call_tool("reload_webapp", {"domain": "test.com"})
21-
assert "Authentication failed" in str(exc)
53+
setup_webapp_tools.call_tool(tool_name, params)
54+
assert expected_error in str(exc)
2255

2356

24-
def test_reload_webapp_other_error(mcp, mocker):
25-
webapp_tools.register_webapp_tools(mcp)
57+
@pytest.mark.parametrize("side_effect,expected_error", [
58+
(AuthenticationError(), "Authentication failed"),
59+
(Exception("list error"), "list error"),
60+
])
61+
def test_list_webapps_errors(setup_webapp_tools, mocker, side_effect, expected_error):
62+
mocker.patch("tools.webapp.Webapp", autospec=True)
63+
mocker.patch("tools.webapp.Webapp.list_webapps", side_effect=side_effect)
64+
65+
with pytest.raises(RuntimeError) as exc:
66+
setup_webapp_tools.call_tool("list_webapps", {})
67+
assert expected_error in str(exc)
68+
69+
70+
def test_create_webapp(setup_webapp_tools, mocker):
71+
webapp_domain = 'test.com'
72+
python_version = '3.10'
73+
virtualenv_path = Path('/test/venv/path')
74+
project_path = Path('/project/path')
75+
2676
mock_webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
27-
mock_webapp.return_value.reload.side_effect = Exception("webapp reload error")
77+
result = setup_webapp_tools.call_tool(
78+
"create_webapp",
79+
{
80+
"domain": webapp_domain,
81+
"python_version": python_version,
82+
"virtualenv_path": virtualenv_path,
83+
"project_path": project_path,
84+
}
85+
)
86+
mock_webapp.assert_called_with(webapp_domain)
87+
mock_webapp.return_value.create.assert_called_with(
88+
python_version=python_version,
89+
virtualenv_path=virtualenv_path,
90+
project_path=project_path,
91+
nuke=False
92+
)
93+
mock_webapp.return_value.create.assert_called_once()
94+
assert result == f"Webapp 'test.com' created successfully."
95+
96+
97+
@pytest.mark.parametrize("side_effect,expected_error", [
98+
(AuthenticationError(), "Authentication failed"),
99+
(Exception("webapp create error"), "webapp create error"),
100+
])
101+
def test_create_webapp_errors(setup_webapp_tools, mocker, side_effect, expected_error):
102+
mock_webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
103+
mock_webapp.return_value.create.side_effect = side_effect
104+
params = {
105+
"domain": "test.com",
106+
"python_version": "3.10",
107+
"virtualenv_path": "/test/venv/path",
108+
"project_path": "/project/path",
109+
}
28110
with pytest.raises(RuntimeError) as exc:
29-
mcp.call_tool("reload_webapp", {"domain": "test.com"})
30-
assert "webapp reload error" in str(exc)
111+
setup_webapp_tools.call_tool("create_webapp", params)
112+
assert expected_error in str(exc)
113+
114+
115+
def test_list_webapps(setup_webapp_tools, mocker):
116+
mocker.patch("tools.webapp.Webapp", autospec=True)
117+
expected = [{"domain_name": "test.com", "python_version": "3.10"}]
118+
mocker.patch("tools.webapp.Webapp.list_webapps", return_value=expected)
119+
result = setup_webapp_tools.call_tool("list_webapps", {})
120+
assert result == expected

0 commit comments

Comments
 (0)