pythonanywhere-mcp-server 0.0.1__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.1/LICENSE +22 -0
- pythonanywhere_mcp_server-0.0.1/PKG-INFO +139 -0
- pythonanywhere_mcp_server-0.0.1/README.md +113 -0
- pythonanywhere_mcp_server-0.0.1/pyproject.toml +44 -0
- pythonanywhere_mcp_server-0.0.1/setup.cfg +4 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server/__init__.py +0 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server/__main__.py +19 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server/server.py +23 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server/tools/__init__.py +1 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server/tools/file.py +80 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server/tools/schedule.py +91 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server/tools/webapp.py +30 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server/tools/website.py +104 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server.egg-info/PKG-INFO +139 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server.egg-info/SOURCES.txt +22 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server.egg-info/dependency_links.txt +1 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server.egg-info/entry_points.txt +2 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server.egg-info/requires.txt +6 -0
- pythonanywhere_mcp_server-0.0.1/src/pythonanywhere_mcp_server.egg-info/top_level.txt +1 -0
- pythonanywhere_mcp_server-0.0.1/tests/test_file_tools.py +79 -0
- pythonanywhere_mcp_server-0.0.1/tests/test_schedule_tools.py +94 -0
- pythonanywhere_mcp_server-0.0.1/tests/test_server.py +40 -0
- pythonanywhere_mcp_server-0.0.1/tests/test_webapp_tools.py +30 -0
- pythonanywhere_mcp_server-0.0.1/tests/test_website_tools.py +86 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 PythonAnywhere
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pythonanywhere-mcp-server
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: PythonAnywhere Model Context Protocol Server
|
|
5
|
+
Author-email: PythonAnywhere Developers <developers@pythonanywhere.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pythonanywhere/pythonanywhere-mcp-server
|
|
8
|
+
Project-URL: Repository, https://github.com/pythonanywhere/pythonanywhere-mcp-server
|
|
9
|
+
Project-URL: Documentation, https://github.com/pythonanywhere/pythonanywhere-mcp-server#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/pythonanywhere/pythonanywhere-mcp-server/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/pythonanywhere/pythonanywhere-mcp-server/releases
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pythonanywhere-core
|
|
21
|
+
Requires-Dist: mcp[cli]
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-mock; extra == "test"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# PythonAnywhere Model Context Protocol Server
|
|
28
|
+
|
|
29
|
+
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction)
|
|
30
|
+
server acts as a bridge between AI-powered tools and your
|
|
31
|
+
[PythonAnywhere](https://www.pythonanywhere.com/) account, enabling secure,
|
|
32
|
+
programmatic management of files, websites, webapps, and scheduled tasks. By
|
|
33
|
+
exposing a standardized interface, it allows language models and automation
|
|
34
|
+
clients to perform operations—such as editing files, deploying web apps, or
|
|
35
|
+
scheduling jobs—on your behalf, all while maintaining fine-grained control
|
|
36
|
+
and auditability.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
- **File management**: Read, upload, delete files and list directory trees.
|
|
40
|
+
_(also enables debugging with direct access to log files, which are just
|
|
41
|
+
files on PythonAnywhere)_
|
|
42
|
+
- **ASGI Web app management**: Create, delete, reload, and list.
|
|
43
|
+
_(as described in the [PythonAnywhere ASGI
|
|
44
|
+
documentation](https://help.pythonanywhere.com/pages/ASGICommandLine))_
|
|
45
|
+
- **WSGI Web app management**: Reload only _(at the moment)_.
|
|
46
|
+
- **Scheduled task management**: List, create, update, and delete.
|
|
47
|
+
_(Npote that it enables LLMs to execute arbitrary commands if a task is
|
|
48
|
+
scheduled to soon after creation and deleted after execution. For that we
|
|
49
|
+
would suggest running it with [mcp-server-time](https://pypi.org/project/mcp-server-time/)
|
|
50
|
+
as models easily get confused about time.)_
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
MCP protocol is well-defined and supported by various clients, but
|
|
54
|
+
installation is different depending on the client you are using. We will
|
|
55
|
+
cover cases that we tried and tested.
|
|
56
|
+
|
|
57
|
+
In all cases, you need to have `uv` installed and available in your `PATH`.
|
|
58
|
+
|
|
59
|
+
Have your PythonAnywhere API token and username ready. You can find (or
|
|
60
|
+
generate) your API token in the [API section of your PythonAnywhere
|
|
61
|
+
account](https://www.pythonanywhere.com/account/#api_token).
|
|
62
|
+
|
|
63
|
+
### Desktop Extension - works with Claude Desktop
|
|
64
|
+
Probably the most straightforward way to install the MCP server is to use
|
|
65
|
+
the [desktop extension](https://github.com/anthropics/dxt/) for Claude Desktop.
|
|
66
|
+
|
|
67
|
+
1. Open Claude Desktop.
|
|
68
|
+
2. **[Download the latest .dxt file](https://github.com/pythonanywhere/pythonanywhere-mcp-server/releases/latest/download/pythonanywhere-mcp-server.dxt)**.
|
|
69
|
+
3. Double-click on the downloaded .dxt file or drag the file into the window.
|
|
70
|
+
4. Configure your PythonAnywhere API token and username.
|
|
71
|
+
5. Restart Claude Desktop.
|
|
72
|
+
|
|
73
|
+
### Claude Code
|
|
74
|
+
Run:
|
|
75
|
+
```bash
|
|
76
|
+
claude mcp add pythonanywhere-mcp-server\
|
|
77
|
+
-e API_TOKEN=yourpythonanywhereapitoken\
|
|
78
|
+
-e LOGNAME=yourpythonanywhereusername\
|
|
79
|
+
-- uvx pythonanywhere-mcp-server
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### GitHub Copilot in PyCharm:
|
|
83
|
+
Add it to your `mcp.json`.
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"servers": {
|
|
88
|
+
"pythonanywhere-mcp-server": {
|
|
89
|
+
"type": "stdio",
|
|
90
|
+
"command": "uvx",
|
|
91
|
+
"args": ["pythonanywhere-mcp-server"],
|
|
92
|
+
"env": {
|
|
93
|
+
"API_TOKEN": "yourpythonanywhereapitoken",
|
|
94
|
+
"LOGNAME": "yourpythonanywhereusername"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Claude Desktop (manual setup) and Cursor:
|
|
102
|
+
Add it to `claude_desktop_config.json` (for Claude Desktop) or (`mcp.json`
|
|
103
|
+
for Cursor).
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"mcpServers": {
|
|
108
|
+
"pythonanywhere-mcp-server": {
|
|
109
|
+
"type": "stdio",
|
|
110
|
+
"command": "uvx",
|
|
111
|
+
"args": ["pythonanywhere-mcp-server"],
|
|
112
|
+
"env": {
|
|
113
|
+
"API_TOKEN": "yourpythonanywhereapitoken",
|
|
114
|
+
"LOGNAME": "yourpythonanywhereusername"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Caveats
|
|
122
|
+
|
|
123
|
+
Direct integration of an LLM with your PythonAnywhere account offers
|
|
124
|
+
significant capabilities, but also introduces risks. We strongly advise
|
|
125
|
+
maintaining human oversight, especially for sensitive actions such as
|
|
126
|
+
modifying or deleting files.
|
|
127
|
+
|
|
128
|
+
If you are running multiple MCP servers simultaneously, be
|
|
129
|
+
cautious—particularly if any server can access external resources you do not
|
|
130
|
+
control, such as GitHub issues. These can become attack vectors. For more
|
|
131
|
+
details, see [this story](https://simonwillison.net/2025/Jul/6/supabase-mcp-lethal-trifecta/).
|
|
132
|
+
|
|
133
|
+
## Implementation
|
|
134
|
+
|
|
135
|
+
Server uses [python mcp sdk](https://github.com/modelcontextprotocol/python-sdk)
|
|
136
|
+
in connection with [pythonanywhere-core](https://github.com/pythonanywhere/pythonanywhere-core)
|
|
137
|
+
package ([docs](https://core.pythonanywhere.com/)) that wraps subset of [PythonAnywhere
|
|
138
|
+
API](https://help.pythonanywhere.com/pages/API/) and would be expanded in
|
|
139
|
+
the future as needed.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# PythonAnywhere Model Context Protocol Server
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction)
|
|
4
|
+
server acts as a bridge between AI-powered tools and your
|
|
5
|
+
[PythonAnywhere](https://www.pythonanywhere.com/) account, enabling secure,
|
|
6
|
+
programmatic management of files, websites, webapps, and scheduled tasks. By
|
|
7
|
+
exposing a standardized interface, it allows language models and automation
|
|
8
|
+
clients to perform operations—such as editing files, deploying web apps, or
|
|
9
|
+
scheduling jobs—on your behalf, all while maintaining fine-grained control
|
|
10
|
+
and auditability.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
- **File management**: Read, upload, delete files and list directory trees.
|
|
14
|
+
_(also enables debugging with direct access to log files, which are just
|
|
15
|
+
files on PythonAnywhere)_
|
|
16
|
+
- **ASGI Web app management**: Create, delete, reload, and list.
|
|
17
|
+
_(as described in the [PythonAnywhere ASGI
|
|
18
|
+
documentation](https://help.pythonanywhere.com/pages/ASGICommandLine))_
|
|
19
|
+
- **WSGI Web app management**: Reload only _(at the moment)_.
|
|
20
|
+
- **Scheduled task management**: List, create, update, and delete.
|
|
21
|
+
_(Npote that it enables LLMs to execute arbitrary commands if a task is
|
|
22
|
+
scheduled to soon after creation and deleted after execution. For that we
|
|
23
|
+
would suggest running it with [mcp-server-time](https://pypi.org/project/mcp-server-time/)
|
|
24
|
+
as models easily get confused about time.)_
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
MCP protocol is well-defined and supported by various clients, but
|
|
28
|
+
installation is different depending on the client you are using. We will
|
|
29
|
+
cover cases that we tried and tested.
|
|
30
|
+
|
|
31
|
+
In all cases, you need to have `uv` installed and available in your `PATH`.
|
|
32
|
+
|
|
33
|
+
Have your PythonAnywhere API token and username ready. You can find (or
|
|
34
|
+
generate) your API token in the [API section of your PythonAnywhere
|
|
35
|
+
account](https://www.pythonanywhere.com/account/#api_token).
|
|
36
|
+
|
|
37
|
+
### Desktop Extension - works with Claude Desktop
|
|
38
|
+
Probably the most straightforward way to install the MCP server is to use
|
|
39
|
+
the [desktop extension](https://github.com/anthropics/dxt/) for Claude Desktop.
|
|
40
|
+
|
|
41
|
+
1. Open Claude Desktop.
|
|
42
|
+
2. **[Download the latest .dxt file](https://github.com/pythonanywhere/pythonanywhere-mcp-server/releases/latest/download/pythonanywhere-mcp-server.dxt)**.
|
|
43
|
+
3. Double-click on the downloaded .dxt file or drag the file into the window.
|
|
44
|
+
4. Configure your PythonAnywhere API token and username.
|
|
45
|
+
5. Restart Claude Desktop.
|
|
46
|
+
|
|
47
|
+
### Claude Code
|
|
48
|
+
Run:
|
|
49
|
+
```bash
|
|
50
|
+
claude mcp add pythonanywhere-mcp-server\
|
|
51
|
+
-e API_TOKEN=yourpythonanywhereapitoken\
|
|
52
|
+
-e LOGNAME=yourpythonanywhereusername\
|
|
53
|
+
-- uvx pythonanywhere-mcp-server
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### GitHub Copilot in PyCharm:
|
|
57
|
+
Add it to your `mcp.json`.
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"servers": {
|
|
62
|
+
"pythonanywhere-mcp-server": {
|
|
63
|
+
"type": "stdio",
|
|
64
|
+
"command": "uvx",
|
|
65
|
+
"args": ["pythonanywhere-mcp-server"],
|
|
66
|
+
"env": {
|
|
67
|
+
"API_TOKEN": "yourpythonanywhereapitoken",
|
|
68
|
+
"LOGNAME": "yourpythonanywhereusername"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Claude Desktop (manual setup) and Cursor:
|
|
76
|
+
Add it to `claude_desktop_config.json` (for Claude Desktop) or (`mcp.json`
|
|
77
|
+
for Cursor).
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"pythonanywhere-mcp-server": {
|
|
83
|
+
"type": "stdio",
|
|
84
|
+
"command": "uvx",
|
|
85
|
+
"args": ["pythonanywhere-mcp-server"],
|
|
86
|
+
"env": {
|
|
87
|
+
"API_TOKEN": "yourpythonanywhereapitoken",
|
|
88
|
+
"LOGNAME": "yourpythonanywhereusername"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Caveats
|
|
96
|
+
|
|
97
|
+
Direct integration of an LLM with your PythonAnywhere account offers
|
|
98
|
+
significant capabilities, but also introduces risks. We strongly advise
|
|
99
|
+
maintaining human oversight, especially for sensitive actions such as
|
|
100
|
+
modifying or deleting files.
|
|
101
|
+
|
|
102
|
+
If you are running multiple MCP servers simultaneously, be
|
|
103
|
+
cautious—particularly if any server can access external resources you do not
|
|
104
|
+
control, such as GitHub issues. These can become attack vectors. For more
|
|
105
|
+
details, see [this story](https://simonwillison.net/2025/Jul/6/supabase-mcp-lethal-trifecta/).
|
|
106
|
+
|
|
107
|
+
## Implementation
|
|
108
|
+
|
|
109
|
+
Server uses [python mcp sdk](https://github.com/modelcontextprotocol/python-sdk)
|
|
110
|
+
in connection with [pythonanywhere-core](https://github.com/pythonanywhere/pythonanywhere-core)
|
|
111
|
+
package ([docs](https://core.pythonanywhere.com/)) that wraps subset of [PythonAnywhere
|
|
112
|
+
API](https://help.pythonanywhere.com/pages/API/) and would be expanded in
|
|
113
|
+
the future as needed.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pythonanywhere-mcp-server"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "PythonAnywhere Model Context Protocol Server"
|
|
9
|
+
authors = [
|
|
10
|
+
{name = "PythonAnywhere Developers", email = "developers@pythonanywhere.com"}
|
|
11
|
+
]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.13"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pythonanywhere-core",
|
|
16
|
+
"mcp[cli]",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
]
|
|
25
|
+
license = "MIT"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = [
|
|
29
|
+
"pytest",
|
|
30
|
+
"pytest-mock",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
pythonanywhere-mcp-server = "pythonanywhere_mcp_server.__main__:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/pythonanywhere/pythonanywhere-mcp-server"
|
|
38
|
+
Repository = "https://github.com/pythonanywhere/pythonanywhere-mcp-server"
|
|
39
|
+
Documentation = "https://github.com/pythonanywhere/pythonanywhere-mcp-server#readme"
|
|
40
|
+
Issues = "https://github.com/pythonanywhere/pythonanywhere-mcp-server/issues"
|
|
41
|
+
Changelog = "https://github.com/pythonanywhere/pythonanywhere-mcp-server/releases"
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
pythonpath = ["src/pythonanywhere_mcp_server"]
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Entry point for the PythonAnywhere MCP server."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from .server import create_server
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
"""Main entry point for the MCP server."""
|
|
8
|
+
try:
|
|
9
|
+
mcp = create_server()
|
|
10
|
+
mcp.run()
|
|
11
|
+
except KeyboardInterrupt:
|
|
12
|
+
print("\nServer interrupted by user", file=sys.stderr)
|
|
13
|
+
sys.exit(0)
|
|
14
|
+
except Exception as e:
|
|
15
|
+
print(f"Error starting server: {e}", file=sys.stderr)
|
|
16
|
+
sys.exit(1)
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
from .tools.file import register_file_tools
|
|
5
|
+
from .tools.webapp import register_webapp_tools
|
|
6
|
+
from .tools.website import register_website_tools
|
|
7
|
+
from .tools.schedule import register_schedule_tools
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_server():
|
|
11
|
+
"""Create the MCP server."""
|
|
12
|
+
API_TOKEN = os.getenv("API_TOKEN")
|
|
13
|
+
if not API_TOKEN:
|
|
14
|
+
raise RuntimeError("API_TOKEN environment variable must be set.")
|
|
15
|
+
|
|
16
|
+
mcp = FastMCP("PythonAnywhere Model Context Protocol Server")
|
|
17
|
+
|
|
18
|
+
register_file_tools(mcp)
|
|
19
|
+
register_website_tools(mcp)
|
|
20
|
+
register_schedule_tools(mcp)
|
|
21
|
+
register_webapp_tools(mcp)
|
|
22
|
+
|
|
23
|
+
return mcp
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from mcp.server.fastmcp import FastMCP
|
|
2
|
+
|
|
3
|
+
from pythonanywhere_core.files import Files
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def register_file_tools(mcp: FastMCP) -> None:
|
|
7
|
+
@mcp.tool()
|
|
8
|
+
def read_file_or_directory(path: str) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Return the contents of a file or a directory listing.
|
|
11
|
+
|
|
12
|
+
If the given path is a file, returns its contents as a string.
|
|
13
|
+
If the path is a directory, returns a JSON string with its listing (recursively, up to 1000 entries).
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
path (str): The absolute path to the file or directory.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
str: File contents or JSON directory listing.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
data = Files().path_get(path)
|
|
23
|
+
if isinstance(data, (bytes, bytearray)):
|
|
24
|
+
return data.decode()
|
|
25
|
+
return str(data)
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
raise RuntimeError(f"Failed to read file or directory: {str(exc)}") from exc
|
|
28
|
+
|
|
29
|
+
@mcp.tool()
|
|
30
|
+
def upload_text_file(dest_path: str, content: str) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Create or replace a file with the given content (UTF-8 encoded).
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
dest_path (str): The absolute path where the file will be created or replaced.
|
|
36
|
+
content (str): The text content to write to the file.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
str: Status message indicating upload result.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
status = Files().path_post(dest_path, content.encode())
|
|
43
|
+
return f"Uploaded to {dest_path} (HTTP {status})."
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
raise RuntimeError(f"Failed to upload text file: {str(exc)}") from exc
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
def delete_path(path: str) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Permanently delete a file or directory (recursively if directory).
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
path (str): The absolute path to the file or directory to delete.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
str: Status message indicating deletion result.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
Files().path_delete(path)
|
|
60
|
+
return f"Deleted {path}."
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
raise RuntimeError(f"Failed to delete path: {str(exc)}") from exc
|
|
63
|
+
|
|
64
|
+
@mcp.tool()
|
|
65
|
+
def tree(path: str) -> list[str]:
|
|
66
|
+
"""
|
|
67
|
+
Return a list of absolute paths contained in the given directory.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
path (str): The absolute path to the directory.
|
|
71
|
+
Home directory path is `/home/<username>/` where <username> is your username.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List[str]: List of absolute paths contained in the directory.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
listing = Files().tree_get(path)
|
|
78
|
+
return listing
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
raise RuntimeError(f"Failed to get directory tree: {str(exc)}") from exc
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from mcp.server.fastmcp import FastMCP
|
|
2
|
+
|
|
3
|
+
from pythonanywhere_core.schedule import Schedule
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def register_schedule_tools(mcp: FastMCP) -> None:
|
|
7
|
+
@mcp.tool()
|
|
8
|
+
def list_scheduled_tasks() -> list[dict]:
|
|
9
|
+
"""
|
|
10
|
+
List all scheduled tasks for the current user. Empty list
|
|
11
|
+
means that there are no scheduled tasks deployed.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
list[dict]: List of dictionaries, each representing a scheduled task.
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
return Schedule().get_list()
|
|
19
|
+
except Exception as exc:
|
|
20
|
+
raise RuntimeError(f"Failed to list scheduled tasks: {str(exc)}") from exc
|
|
21
|
+
|
|
22
|
+
@mcp.tool()
|
|
23
|
+
def create_scheduled_task(params: dict) -> dict:
|
|
24
|
+
"""
|
|
25
|
+
Create a new scheduled task.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
params (dict): Dictionary with required scheduled task specs. Must include:
|
|
29
|
+
- command (str): The command to run.
|
|
30
|
+
- enabled (bool): Whether the task is enabled.
|
|
31
|
+
- interval (str): 'daily' or 'hourly'.
|
|
32
|
+
- hour (int, optional): Hour (24h format, for daily tasks).
|
|
33
|
+
- minute (int): Minute.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
dict: Dictionary with created task specs.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
return Schedule().create(params)
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
raise RuntimeError(f"Failed to create scheduled task: {str(exc)}") from exc
|
|
42
|
+
|
|
43
|
+
@mcp.tool()
|
|
44
|
+
def delete_scheduled_task(task_id: int) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Delete a scheduled task by its ID.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
task_id (int): The ID of the scheduled task to delete.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
bool: True if deletion was successful.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
return Schedule().delete(task_id)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
raise RuntimeError(f"Failed to delete scheduled task: {str(exc)}") from exc
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
def get_scheduled_task(task_id: int) -> dict:
|
|
61
|
+
"""
|
|
62
|
+
Get the specifications of a scheduled task by its ID.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
task_id (int): The ID of the scheduled task.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
dict: Dictionary of the task's specifications.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
return Schedule().get_specs(task_id)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
raise RuntimeError(f"Failed to get scheduled task: {str(exc)}") from exc
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
def update_scheduled_task(task_id: int, params: dict) -> dict:
|
|
77
|
+
"""
|
|
78
|
+
Update an existing scheduled task.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
task_id (int): The ID of the scheduled task to update.
|
|
82
|
+
params (dict): Dictionary of specs to update. Should include at least one of:
|
|
83
|
+
- command, enabled, interval, hour, minute.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
dict: Dictionary with updated task specs.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
return Schedule().update(task_id, params)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
raise RuntimeError(f"Failed to update scheduled task: {str(exc)}") from exc
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from mcp.server.fastmcp import FastMCP
|
|
2
|
+
|
|
3
|
+
from pythonanywhere_core.webapp import Webapp
|
|
4
|
+
from pythonanywhere_core.base import AuthenticationError, NoTokenError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def register_webapp_tools(mcp: FastMCP) -> None:
|
|
8
|
+
@mcp.tool()
|
|
9
|
+
def reload_webapp(domain: str) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Reload a uWSGI-based web application for the given domain.
|
|
12
|
+
|
|
13
|
+
This reloads uwsgi-based web applications on PythonAnywhere. For ASGI-based
|
|
14
|
+
web applications, use the `reload_website` tool instead. Any changes to the
|
|
15
|
+
code require a reload to take effect.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
domain (str): The domain name of the web application to reload
|
|
19
|
+
(e.g., 'alice.pythonanywhere.com').
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
str: Status message indicating reload's result.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
Webapp(domain).reload()
|
|
26
|
+
return f"Webapp '{domain}' reloaded."
|
|
27
|
+
except (AuthenticationError, NoTokenError):
|
|
28
|
+
raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
raise RuntimeError(str(exc)) from exc
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
|
|
5
|
+
from pythonanywhere_core.website import Website
|
|
6
|
+
from pythonanywhere_core.base import AuthenticationError, NoTokenError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_website_tools(mcp: FastMCP) -> None:
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
def reload_website(domain: str) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Reload an ASGI-based website for the given domain.
|
|
14
|
+
|
|
15
|
+
This is for ASGI-based web applications on PythonAnywhere. For uWSGI-based
|
|
16
|
+
web applications, use the `reload_webapp` tool instead. Any changes to the
|
|
17
|
+
code require a reload to take effect.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
domain (str): The domain name of the website to reload
|
|
21
|
+
(e.g., 'alice.pythonanywhere.com').
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
str: Status message indicating reload result.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
Website().reload(domain)
|
|
28
|
+
return f"Website '{domain}' reloaded."
|
|
29
|
+
except (AuthenticationError, NoTokenError):
|
|
30
|
+
raise RuntimeError("Authentication failed — check API_TOKEN and domain.")
|
|
31
|
+
except Exception as exc:
|
|
32
|
+
raise RuntimeError(str(exc)) from exc
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
def list_websites() -> list[dict[str, Any]]:
|
|
36
|
+
"""
|
|
37
|
+
Return info dictionaries for every ASGI website configured for the current
|
|
38
|
+
user. Empty list means that there are no websites deployed.
|
|
39
|
+
That would not include WSGI-based web applications,
|
|
40
|
+
which could be only reloaded with `reload_webapp` tool.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List[dict[str, Any]]: List of dictionaries with website information.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
return Website().list()
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
raise RuntimeError(f"Failed to list websites: {str(exc)}") from exc
|
|
51
|
+
|
|
52
|
+
@mcp.tool()
|
|
53
|
+
def create_website(domain_name: str, command: str) -> dict:
|
|
54
|
+
"""
|
|
55
|
+
Create a new website with the specified domain name and command.
|
|
56
|
+
|
|
57
|
+
This function is primarily used to create a new ASGI-based website on
|
|
58
|
+
PythonAnywhere, but it can also be used for any website that communicates via
|
|
59
|
+
a Unix domain socket. The provided command must use the `DOMAIN_SOCKET`
|
|
60
|
+
environment variable to bind to the socket.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
domain_name (str): The domain name for the new website
|
|
64
|
+
(e.g., `yourusername.pythonanywhere.com` or `www.mydomain.com`).
|
|
65
|
+
command (str): The command to run for the new website. Examples:
|
|
66
|
+
- For FastAPI (ASGI):
|
|
67
|
+
`/usr/local/bin/uvicorn myapp:app \
|
|
68
|
+
--app-dir '/home/yourusername/fastapi/' \
|
|
69
|
+
--uds ${DOMAIN_SOCKET}`
|
|
70
|
+
- For async Django:
|
|
71
|
+
`/home/yourusername/.virtualenvs/my_venv/bin/uvicorn asyncdjango.asgi:application \
|
|
72
|
+
--app-dir /home/yourusername/asyncdjango \
|
|
73
|
+
--uds ${DOMAIN_SOCKET} `
|
|
74
|
+
- For non-ASGI websites (e.g., Streamlit):
|
|
75
|
+
`/home/yourusername/.virtualenvs/my_venv/bin/streamlit \
|
|
76
|
+
run /home/yourusername/my_streamlit/streamlit_app.py \
|
|
77
|
+
--server.address "unix://${DOMAIN_SOCKET}" \
|
|
78
|
+
--server.enableCORS false \
|
|
79
|
+
--server.enableXsrfProtection false \
|
|
80
|
+
--server.enableWebsocketCompression false`
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
dict: A dictionary containing information about the created website.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
return Website().create(domain_name, command)
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
raise RuntimeError(f"Failed to create website: {str(exc)}") from exc
|
|
89
|
+
|
|
90
|
+
@mcp.tool()
|
|
91
|
+
def delete_website(domain_name: str) -> dict:
|
|
92
|
+
"""
|
|
93
|
+
Delete a website with the given domain name.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
domain_name (str): The domain name for the website to delete.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
dict: Empty dictionary on success.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
return Website().delete(domain_name)
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
raise RuntimeError(f"Failed to delete website: {str(exc)}") from exc
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pythonanywhere-mcp-server
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: PythonAnywhere Model Context Protocol Server
|
|
5
|
+
Author-email: PythonAnywhere Developers <developers@pythonanywhere.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pythonanywhere/pythonanywhere-mcp-server
|
|
8
|
+
Project-URL: Repository, https://github.com/pythonanywhere/pythonanywhere-mcp-server
|
|
9
|
+
Project-URL: Documentation, https://github.com/pythonanywhere/pythonanywhere-mcp-server#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/pythonanywhere/pythonanywhere-mcp-server/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/pythonanywhere/pythonanywhere-mcp-server/releases
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pythonanywhere-core
|
|
21
|
+
Requires-Dist: mcp[cli]
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-mock; extra == "test"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# PythonAnywhere Model Context Protocol Server
|
|
28
|
+
|
|
29
|
+
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction)
|
|
30
|
+
server acts as a bridge between AI-powered tools and your
|
|
31
|
+
[PythonAnywhere](https://www.pythonanywhere.com/) account, enabling secure,
|
|
32
|
+
programmatic management of files, websites, webapps, and scheduled tasks. By
|
|
33
|
+
exposing a standardized interface, it allows language models and automation
|
|
34
|
+
clients to perform operations—such as editing files, deploying web apps, or
|
|
35
|
+
scheduling jobs—on your behalf, all while maintaining fine-grained control
|
|
36
|
+
and auditability.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
- **File management**: Read, upload, delete files and list directory trees.
|
|
40
|
+
_(also enables debugging with direct access to log files, which are just
|
|
41
|
+
files on PythonAnywhere)_
|
|
42
|
+
- **ASGI Web app management**: Create, delete, reload, and list.
|
|
43
|
+
_(as described in the [PythonAnywhere ASGI
|
|
44
|
+
documentation](https://help.pythonanywhere.com/pages/ASGICommandLine))_
|
|
45
|
+
- **WSGI Web app management**: Reload only _(at the moment)_.
|
|
46
|
+
- **Scheduled task management**: List, create, update, and delete.
|
|
47
|
+
_(Npote that it enables LLMs to execute arbitrary commands if a task is
|
|
48
|
+
scheduled to soon after creation and deleted after execution. For that we
|
|
49
|
+
would suggest running it with [mcp-server-time](https://pypi.org/project/mcp-server-time/)
|
|
50
|
+
as models easily get confused about time.)_
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
MCP protocol is well-defined and supported by various clients, but
|
|
54
|
+
installation is different depending on the client you are using. We will
|
|
55
|
+
cover cases that we tried and tested.
|
|
56
|
+
|
|
57
|
+
In all cases, you need to have `uv` installed and available in your `PATH`.
|
|
58
|
+
|
|
59
|
+
Have your PythonAnywhere API token and username ready. You can find (or
|
|
60
|
+
generate) your API token in the [API section of your PythonAnywhere
|
|
61
|
+
account](https://www.pythonanywhere.com/account/#api_token).
|
|
62
|
+
|
|
63
|
+
### Desktop Extension - works with Claude Desktop
|
|
64
|
+
Probably the most straightforward way to install the MCP server is to use
|
|
65
|
+
the [desktop extension](https://github.com/anthropics/dxt/) for Claude Desktop.
|
|
66
|
+
|
|
67
|
+
1. Open Claude Desktop.
|
|
68
|
+
2. **[Download the latest .dxt file](https://github.com/pythonanywhere/pythonanywhere-mcp-server/releases/latest/download/pythonanywhere-mcp-server.dxt)**.
|
|
69
|
+
3. Double-click on the downloaded .dxt file or drag the file into the window.
|
|
70
|
+
4. Configure your PythonAnywhere API token and username.
|
|
71
|
+
5. Restart Claude Desktop.
|
|
72
|
+
|
|
73
|
+
### Claude Code
|
|
74
|
+
Run:
|
|
75
|
+
```bash
|
|
76
|
+
claude mcp add pythonanywhere-mcp-server\
|
|
77
|
+
-e API_TOKEN=yourpythonanywhereapitoken\
|
|
78
|
+
-e LOGNAME=yourpythonanywhereusername\
|
|
79
|
+
-- uvx pythonanywhere-mcp-server
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### GitHub Copilot in PyCharm:
|
|
83
|
+
Add it to your `mcp.json`.
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"servers": {
|
|
88
|
+
"pythonanywhere-mcp-server": {
|
|
89
|
+
"type": "stdio",
|
|
90
|
+
"command": "uvx",
|
|
91
|
+
"args": ["pythonanywhere-mcp-server"],
|
|
92
|
+
"env": {
|
|
93
|
+
"API_TOKEN": "yourpythonanywhereapitoken",
|
|
94
|
+
"LOGNAME": "yourpythonanywhereusername"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Claude Desktop (manual setup) and Cursor:
|
|
102
|
+
Add it to `claude_desktop_config.json` (for Claude Desktop) or (`mcp.json`
|
|
103
|
+
for Cursor).
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"mcpServers": {
|
|
108
|
+
"pythonanywhere-mcp-server": {
|
|
109
|
+
"type": "stdio",
|
|
110
|
+
"command": "uvx",
|
|
111
|
+
"args": ["pythonanywhere-mcp-server"],
|
|
112
|
+
"env": {
|
|
113
|
+
"API_TOKEN": "yourpythonanywhereapitoken",
|
|
114
|
+
"LOGNAME": "yourpythonanywhereusername"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Caveats
|
|
122
|
+
|
|
123
|
+
Direct integration of an LLM with your PythonAnywhere account offers
|
|
124
|
+
significant capabilities, but also introduces risks. We strongly advise
|
|
125
|
+
maintaining human oversight, especially for sensitive actions such as
|
|
126
|
+
modifying or deleting files.
|
|
127
|
+
|
|
128
|
+
If you are running multiple MCP servers simultaneously, be
|
|
129
|
+
cautious—particularly if any server can access external resources you do not
|
|
130
|
+
control, such as GitHub issues. These can become attack vectors. For more
|
|
131
|
+
details, see [this story](https://simonwillison.net/2025/Jul/6/supabase-mcp-lethal-trifecta/).
|
|
132
|
+
|
|
133
|
+
## Implementation
|
|
134
|
+
|
|
135
|
+
Server uses [python mcp sdk](https://github.com/modelcontextprotocol/python-sdk)
|
|
136
|
+
in connection with [pythonanywhere-core](https://github.com/pythonanywhere/pythonanywhere-core)
|
|
137
|
+
package ([docs](https://core.pythonanywhere.com/)) that wraps subset of [PythonAnywhere
|
|
138
|
+
API](https://help.pythonanywhere.com/pages/API/) and would be expanded in
|
|
139
|
+
the future as needed.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pythonanywhere_mcp_server/__init__.py
|
|
5
|
+
src/pythonanywhere_mcp_server/__main__.py
|
|
6
|
+
src/pythonanywhere_mcp_server/server.py
|
|
7
|
+
src/pythonanywhere_mcp_server.egg-info/PKG-INFO
|
|
8
|
+
src/pythonanywhere_mcp_server.egg-info/SOURCES.txt
|
|
9
|
+
src/pythonanywhere_mcp_server.egg-info/dependency_links.txt
|
|
10
|
+
src/pythonanywhere_mcp_server.egg-info/entry_points.txt
|
|
11
|
+
src/pythonanywhere_mcp_server.egg-info/requires.txt
|
|
12
|
+
src/pythonanywhere_mcp_server.egg-info/top_level.txt
|
|
13
|
+
src/pythonanywhere_mcp_server/tools/__init__.py
|
|
14
|
+
src/pythonanywhere_mcp_server/tools/file.py
|
|
15
|
+
src/pythonanywhere_mcp_server/tools/schedule.py
|
|
16
|
+
src/pythonanywhere_mcp_server/tools/webapp.py
|
|
17
|
+
src/pythonanywhere_mcp_server/tools/website.py
|
|
18
|
+
tests/test_file_tools.py
|
|
19
|
+
tests/test_schedule_tools.py
|
|
20
|
+
tests/test_server.py
|
|
21
|
+
tests/test_webapp_tools.py
|
|
22
|
+
tests/test_website_tools.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pythonanywhere_mcp_server
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import tools.file as file_tools
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_read_file_or_directory_file(mcp, mocker):
|
|
7
|
+
file_tools.register_file_tools(mcp)
|
|
8
|
+
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
9
|
+
mock_files.return_value.path_get.return_value = b"file contents"
|
|
10
|
+
result = mcp.call_tool("read_file_or_directory", {"path": "/some/file.txt"})
|
|
11
|
+
assert result == "file contents"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_read_file_or_directory_directory(mcp, mocker):
|
|
15
|
+
file_tools.register_file_tools(mcp)
|
|
16
|
+
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
17
|
+
mock_files.return_value.path_get.return_value = {"listing": ["a", "b"]}
|
|
18
|
+
result = mcp.call_tool("read_file_or_directory", {"path": "/some/dir/"})
|
|
19
|
+
assert result == str({"listing": ["a", "b"]})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_upload_text_file(mcp, mocker):
|
|
23
|
+
file_tools.register_file_tools(mcp)
|
|
24
|
+
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
25
|
+
mock_files.return_value.path_post.return_value = 201
|
|
26
|
+
result = mcp.call_tool("upload_text_file", {"dest_path": "/some/file.txt", "content": "hello"})
|
|
27
|
+
assert result == "Uploaded to /some/file.txt (HTTP 201)."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_delete_path(mcp, mocker):
|
|
31
|
+
file_tools.register_file_tools(mcp)
|
|
32
|
+
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
33
|
+
result = mcp.call_tool("delete_path", {"path": "/some/file.txt"})
|
|
34
|
+
mock_files.return_value.path_delete.assert_called_with("/some/file.txt")
|
|
35
|
+
assert result == "Deleted /some/file.txt."
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_read_file_or_directory_file_exception(mcp, mocker):
|
|
39
|
+
file_tools.register_file_tools(mcp)
|
|
40
|
+
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
41
|
+
mock_files.return_value.path_get.side_effect = Exception("read error")
|
|
42
|
+
with pytest.raises(RuntimeError) as exc:
|
|
43
|
+
mcp.call_tool("read_file_or_directory", {"path": "/some/file.txt"})
|
|
44
|
+
assert "Failed to read file or directory: read error" in str(exc)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_upload_text_file_exception(mcp, mocker):
|
|
48
|
+
file_tools.register_file_tools(mcp)
|
|
49
|
+
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
50
|
+
mock_files.return_value.path_post.side_effect = Exception("upload error")
|
|
51
|
+
with pytest.raises(RuntimeError) as exc:
|
|
52
|
+
mcp.call_tool("upload_text_file", {"dest_path": "/some/file.txt", "content": "hello"})
|
|
53
|
+
assert "Failed to upload text file: upload error" in str(exc)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_delete_path_exception(mcp, mocker):
|
|
57
|
+
file_tools.register_file_tools(mcp)
|
|
58
|
+
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
59
|
+
mock_files.return_value.path_delete.side_effect = Exception("delete error")
|
|
60
|
+
with pytest.raises(RuntimeError) as exc:
|
|
61
|
+
mcp.call_tool("delete_path", {"path": "/some/file.txt"})
|
|
62
|
+
assert "Failed to delete path: delete error" in str(exc)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_directory_tree(mcp, mocker):
|
|
66
|
+
file_tools.register_file_tools(mcp)
|
|
67
|
+
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
68
|
+
mock_files.return_value.tree_get.return_value = ["/a", "/b/c"]
|
|
69
|
+
result = mcp.call_tool("tree", {"path": "/some/dir/"})
|
|
70
|
+
assert result == ["/a", "/b/c"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_directory_tree_exception(mcp, mocker):
|
|
74
|
+
file_tools.register_file_tools(mcp)
|
|
75
|
+
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
76
|
+
mock_files.return_value.tree_get.side_effect = Exception("tree error")
|
|
77
|
+
with pytest.raises(RuntimeError) as exc:
|
|
78
|
+
mcp.call_tool("tree", {"path": "/some/dir/"})
|
|
79
|
+
assert "Failed to get directory tree: tree error" in str(exc)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import tools.schedule as schedule_tools
|
|
4
|
+
|
|
5
|
+
def test_list_scheduled_tasks(mcp, mocker):
|
|
6
|
+
schedule_tools.register_schedule_tools(mcp)
|
|
7
|
+
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
8
|
+
mock_schedule.return_value.get_list.return_value = [
|
|
9
|
+
{"id": 1, "command": "echo hi", "interval": "daily"}
|
|
10
|
+
]
|
|
11
|
+
result = mcp.call_tool("list_scheduled_tasks", {})
|
|
12
|
+
assert result == [{"id": 1, "command": "echo hi", "interval": "daily"}]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_list_scheduled_tasks_exception(mcp, mocker):
|
|
16
|
+
schedule_tools.register_schedule_tools(mcp)
|
|
17
|
+
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
18
|
+
mock_schedule.return_value.get_list.side_effect = Exception("list error")
|
|
19
|
+
with pytest.raises(RuntimeError) as exc:
|
|
20
|
+
mcp.call_tool("list_scheduled_tasks", {})
|
|
21
|
+
assert "Failed to list scheduled tasks: list error" in str(exc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_create_scheduled_task(mcp, mocker):
|
|
25
|
+
schedule_tools.register_schedule_tools(mcp)
|
|
26
|
+
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
27
|
+
mock_schedule.return_value.create.return_value = {"id": 2, "command": "ls", "interval": "hourly"}
|
|
28
|
+
params = {"command": "ls", "enabled": True, "interval": "hourly", "minute": 0}
|
|
29
|
+
result = mcp.call_tool("create_scheduled_task", {"params": params})
|
|
30
|
+
assert result == {"id": 2, "command": "ls", "interval": "hourly"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_create_scheduled_task_exception(mcp, mocker):
|
|
34
|
+
schedule_tools.register_schedule_tools(mcp)
|
|
35
|
+
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
36
|
+
mock_schedule.return_value.create.side_effect = Exception("create error")
|
|
37
|
+
params = {"command": "ls", "enabled": True, "interval": "hourly", "minute": 0}
|
|
38
|
+
with pytest.raises(RuntimeError) as exc:
|
|
39
|
+
mcp.call_tool("create_scheduled_task", {"params": params})
|
|
40
|
+
assert "Failed to create scheduled task: create error" in str(exc)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_delete_scheduled_task(mcp, mocker):
|
|
44
|
+
schedule_tools.register_schedule_tools(mcp)
|
|
45
|
+
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
46
|
+
mock_schedule.return_value.delete.return_value = True
|
|
47
|
+
result = mcp.call_tool("delete_scheduled_task", {"task_id": 1})
|
|
48
|
+
mock_schedule.return_value.delete.assert_called_with(1)
|
|
49
|
+
assert result is True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_delete_scheduled_task_exception(mcp, mocker):
|
|
53
|
+
schedule_tools.register_schedule_tools(mcp)
|
|
54
|
+
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
55
|
+
mock_schedule.return_value.delete.side_effect = Exception("delete error")
|
|
56
|
+
with pytest.raises(RuntimeError) as exc:
|
|
57
|
+
mcp.call_tool("delete_scheduled_task", {"task_id": 1})
|
|
58
|
+
assert "Failed to delete scheduled task: delete error" in str(exc)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_get_scheduled_task(mcp, mocker):
|
|
62
|
+
schedule_tools.register_schedule_tools(mcp)
|
|
63
|
+
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
64
|
+
mock_schedule.return_value.get_specs.return_value = {"id": 1, "command": "ls"}
|
|
65
|
+
result = mcp.call_tool("get_scheduled_task", {"task_id": 1})
|
|
66
|
+
assert result == {"id": 1, "command": "ls"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_get_scheduled_task_exception(mcp, mocker):
|
|
70
|
+
schedule_tools.register_schedule_tools(mcp)
|
|
71
|
+
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
72
|
+
mock_schedule.return_value.get_specs.side_effect = Exception("get error")
|
|
73
|
+
with pytest.raises(RuntimeError) as exc:
|
|
74
|
+
mcp.call_tool("get_scheduled_task", {"task_id": 1})
|
|
75
|
+
assert "Failed to get scheduled task: get error" in str(exc)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_update_scheduled_task(mcp, mocker):
|
|
79
|
+
schedule_tools.register_schedule_tools(mcp)
|
|
80
|
+
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
81
|
+
mock_schedule.return_value.update.return_value = {"id": 1, "command": "ls -l"}
|
|
82
|
+
params = {"command": "ls -l"}
|
|
83
|
+
result = mcp.call_tool("update_scheduled_task", {"task_id": 1, "params": params})
|
|
84
|
+
assert result == {"id": 1, "command": "ls -l"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_update_scheduled_task_exception(mcp, mocker):
|
|
88
|
+
schedule_tools.register_schedule_tools(mcp)
|
|
89
|
+
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
90
|
+
mock_schedule.return_value.update.side_effect = Exception("update error")
|
|
91
|
+
params = {"command": "ls -l"}
|
|
92
|
+
with pytest.raises(RuntimeError) as exc:
|
|
93
|
+
mcp.call_tool("update_scheduled_task", {"task_id": 1, "params": params})
|
|
94
|
+
assert "Failed to update scheduled task: update error" in str(exc)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from pythonanywhere_mcp_server import server
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture()
|
|
7
|
+
def mock_FastMCP(mocker):
|
|
8
|
+
"""Mock the FastMCP class to avoid actual server creation."""
|
|
9
|
+
return mocker.patch("pythonanywhere_mcp_server.server.FastMCP", autospec=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_create_server(monkeypatch, mock_FastMCP):
|
|
13
|
+
monkeypatch.setenv("API_TOKEN", "dummy-token")
|
|
14
|
+
server.create_server()
|
|
15
|
+
mock_FastMCP.assert_called_once_with("PythonAnywhere Model Context Protocol Server")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize(
|
|
19
|
+
"register_fn", [
|
|
20
|
+
"register_file_tools",
|
|
21
|
+
"register_webapp_tools",
|
|
22
|
+
"register_website_tools",
|
|
23
|
+
"register_schedule_tools",
|
|
24
|
+
]
|
|
25
|
+
)
|
|
26
|
+
def test_register_tools(monkeypatch, mocker, mock_FastMCP, register_fn):
|
|
27
|
+
monkeypatch.setenv("API_TOKEN", "dummy-token")
|
|
28
|
+
mock_register = mocker.patch(
|
|
29
|
+
f"pythonanywhere_mcp_server.server.{register_fn}" ,
|
|
30
|
+
autospec=True
|
|
31
|
+
)
|
|
32
|
+
server.create_server()
|
|
33
|
+
mock_register.assert_called_once_with(mock_FastMCP.return_value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_missing_api_token(monkeypatch):
|
|
37
|
+
monkeypatch.delenv("API_TOKEN", raising=False)
|
|
38
|
+
with pytest.raises(RuntimeError) as excinfo:
|
|
39
|
+
server.create_server()
|
|
40
|
+
assert "API_TOKEN environment variable must be set" in str(excinfo.value)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import tools.webapp as webapp_tools
|
|
4
|
+
|
|
5
|
+
def test_reload_webapp(mcp, mocker):
|
|
6
|
+
webapp_tools.register_webapp_tools(mcp)
|
|
7
|
+
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."
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_reload_webapp_auth_error(mcp, mocker):
|
|
15
|
+
webapp_tools.register_webapp_tools(mcp)
|
|
16
|
+
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()
|
|
19
|
+
with pytest.raises(RuntimeError) as exc:
|
|
20
|
+
mcp.call_tool("reload_webapp", {"domain": "test.com"})
|
|
21
|
+
assert "Authentication failed" in str(exc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_reload_webapp_other_error(mcp, mocker):
|
|
25
|
+
webapp_tools.register_webapp_tools(mcp)
|
|
26
|
+
mock_webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
|
|
27
|
+
mock_webapp.return_value.reload.side_effect = Exception("webapp reload error")
|
|
28
|
+
with pytest.raises(RuntimeError) as exc:
|
|
29
|
+
mcp.call_tool("reload_webapp", {"domain": "test.com"})
|
|
30
|
+
assert "webapp reload error" in str(exc)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import tools.website as website_tools
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_list_websites(mcp, mocker):
|
|
7
|
+
website_tools.register_website_tools(mcp)
|
|
8
|
+
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
9
|
+
mock_website.return_value.list.return_value = [
|
|
10
|
+
{"domain": "test.com", "status": "running"}
|
|
11
|
+
]
|
|
12
|
+
result = mcp.call_tool("list_websites", {})
|
|
13
|
+
assert result == [{"domain": "test.com", "status": "running"}]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_list_websites_exception(mcp, mocker):
|
|
17
|
+
website_tools.register_website_tools(mcp)
|
|
18
|
+
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
19
|
+
mock_website.return_value.list.side_effect = Exception("list error")
|
|
20
|
+
with pytest.raises(RuntimeError) as exc:
|
|
21
|
+
mcp.call_tool("list_websites", {})
|
|
22
|
+
assert "Failed to list websites: list error" in str(exc)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_create_website(mcp, mocker):
|
|
26
|
+
website_tools.register_website_tools(mcp)
|
|
27
|
+
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
28
|
+
mock_website.return_value.create.return_value = {"domain": "test.com", "status": "created"}
|
|
29
|
+
params = {"domain_name": "test.com", "command": "run.sh"}
|
|
30
|
+
result = mcp.call_tool("create_website", params)
|
|
31
|
+
assert result == {"domain": "test.com", "status": "created"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_create_website_exception(mcp, mocker):
|
|
35
|
+
website_tools.register_website_tools(mcp)
|
|
36
|
+
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
37
|
+
mock_website.return_value.create.side_effect = Exception("create error")
|
|
38
|
+
params = {"domain_name": "test.com", "command": "run.sh"}
|
|
39
|
+
with pytest.raises(RuntimeError) as exc:
|
|
40
|
+
mcp.call_tool("create_website", params)
|
|
41
|
+
assert "Failed to create website: create error" in str(exc)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_delete_website(mcp, mocker):
|
|
45
|
+
website_tools.register_website_tools(mcp)
|
|
46
|
+
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
47
|
+
mock_website.return_value.delete.return_value = True
|
|
48
|
+
result = mcp.call_tool("delete_website", {"domain_name": "test.com"})
|
|
49
|
+
mock_website.return_value.delete.assert_called_with("test.com")
|
|
50
|
+
assert result is True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_delete_website_exception(mcp, mocker):
|
|
54
|
+
website_tools.register_website_tools(mcp)
|
|
55
|
+
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
56
|
+
mock_website.return_value.delete.side_effect = Exception("delete error")
|
|
57
|
+
with pytest.raises(RuntimeError) as exc:
|
|
58
|
+
mcp.call_tool("delete_website", {"domain_name": "test.com"})
|
|
59
|
+
assert "Failed to delete website: delete error" in str(exc)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_reload_website(mcp, mocker):
|
|
63
|
+
website_tools.register_website_tools(mcp)
|
|
64
|
+
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
65
|
+
result = mcp.call_tool("reload_website", {"domain": "test.com"})
|
|
66
|
+
mock_website.return_value.reload.assert_called_with("test.com")
|
|
67
|
+
assert result == "Website 'test.com' reloaded."
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_reload_website_auth_error(mcp, mocker):
|
|
71
|
+
website_tools.register_website_tools(mcp)
|
|
72
|
+
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
73
|
+
from pythonanywhere_core.base import AuthenticationError
|
|
74
|
+
mock_website.return_value.reload.side_effect = AuthenticationError()
|
|
75
|
+
with pytest.raises(RuntimeError) as exc:
|
|
76
|
+
mcp.call_tool("reload_website", {"domain": "test.com"})
|
|
77
|
+
assert "Authentication failed" in str(exc)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_reload_website_other_error(mcp, mocker):
|
|
81
|
+
website_tools.register_website_tools(mcp)
|
|
82
|
+
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
83
|
+
mock_website.return_value.reload.side_effect = Exception("website reload error")
|
|
84
|
+
with pytest.raises(RuntimeError) as exc:
|
|
85
|
+
mcp.call_tool("reload_website", {"domain": "test.com"})
|
|
86
|
+
assert "website reload error" in str(exc)
|