pythonanywhere-mcp-server 0.0.12__tar.gz → 0.0.13__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pythonanywhere_mcp_server-0.0.12/src/pythonanywhere_mcp_server.egg-info → pythonanywhere_mcp_server-0.0.13}/PKG-INFO +3 -3
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/pyproject.toml +3 -3
- pythonanywhere_mcp_server-0.0.13/src/pythonanywhere_mcp_server/__init__.py +1 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/server.py +2 -2
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/tools/file.py +13 -9
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/tools/schedule.py +8 -7
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/tools/webapp.py +20 -19
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/tools/website.py +8 -7
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13/src/pythonanywhere_mcp_server.egg-info}/PKG-INFO +3 -3
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server.egg-info/SOURCES.txt +1 -0
- pythonanywhere_mcp_server-0.0.13/src/pythonanywhere_mcp_server.egg-info/requires.txt +6 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/tests/test_file_tools.py +21 -10
- pythonanywhere_mcp_server-0.0.13/tests/test_mcp_integration.py +60 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/tests/test_schedule_tools.py +6 -5
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/tests/test_server.py +7 -7
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/tests/test_webapp_tools.py +4 -3
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/tests/test_website_tools.py +6 -5
- pythonanywhere_mcp_server-0.0.12/src/pythonanywhere_mcp_server/__init__.py +0 -1
- pythonanywhere_mcp_server-0.0.12/src/pythonanywhere_mcp_server.egg-info/requires.txt +0 -6
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/LICENSE +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/README.md +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/setup.cfg +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/__main__.py +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server/tools/__init__.py +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server.egg-info/dependency_links.txt +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server.egg-info/entry_points.txt +0 -0
- {pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/src/pythonanywhere_mcp_server.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythonanywhere-mcp-server
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.13
|
|
4
4
|
Summary: PythonAnywhere Model Context Protocol Server
|
|
5
5
|
Author-email: PythonAnywhere Developers <developers@pythonanywhere.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,8 +18,8 @@ Classifier: Topic :: Utilities
|
|
|
18
18
|
Requires-Python: >=3.13
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: pythonanywhere-core>=0.3.
|
|
22
|
-
Requires-Dist: mcp[cli]<2.
|
|
21
|
+
Requires-Dist: pythonanywhere-core>=0.3.2
|
|
22
|
+
Requires-Dist: mcp[cli]<3,>=2.2.0
|
|
23
23
|
Provides-Extra: test
|
|
24
24
|
Requires-Dist: pytest; extra == "test"
|
|
25
25
|
Requires-Dist: pytest-mock; extra == "test"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pythonanywhere-mcp-server"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.13"
|
|
8
8
|
description = "PythonAnywhere Model Context Protocol Server"
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "PythonAnywhere Developers", email = "developers@pythonanywhere.com"}
|
|
@@ -12,8 +12,8 @@ authors = [
|
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.13"
|
|
14
14
|
dependencies = [
|
|
15
|
-
"pythonanywhere-core>=0.3.
|
|
16
|
-
"mcp[cli]
|
|
15
|
+
"pythonanywhere-core>=0.3.2",
|
|
16
|
+
"mcp[cli]>=2.2.0,<3",
|
|
17
17
|
]
|
|
18
18
|
classifiers = [
|
|
19
19
|
"Development Status :: 2 - Pre-Alpha",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.13"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
2
|
|
|
3
|
-
from mcp.server
|
|
3
|
+
from mcp.server import MCPServer
|
|
4
4
|
from . import __version__
|
|
5
5
|
from .tools.file import register_file_tools
|
|
6
6
|
from .tools.webapp import register_webapp_tools
|
|
@@ -17,7 +17,7 @@ def create_server():
|
|
|
17
17
|
if not API_TOKEN:
|
|
18
18
|
raise RuntimeError("API_TOKEN environment variable must be set.")
|
|
19
19
|
|
|
20
|
-
mcp =
|
|
20
|
+
mcp = MCPServer("PythonAnywhere Model Context Protocol Server")
|
|
21
21
|
|
|
22
22
|
register_file_tools(mcp)
|
|
23
23
|
register_website_tools(mcp)
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from mcp.server import MCPServer
|
|
4
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
2
5
|
|
|
3
6
|
from pythonanywhere_core.files import Files
|
|
4
7
|
|
|
5
8
|
|
|
6
|
-
def register_file_tools(mcp:
|
|
9
|
+
def register_file_tools(mcp: MCPServer) -> None:
|
|
7
10
|
@mcp.tool()
|
|
8
11
|
def read_file_or_directory(path: str) -> str:
|
|
9
12
|
"""
|
|
10
13
|
Return the contents of a file or a directory listing.
|
|
11
14
|
|
|
12
15
|
If the given path is a file, returns its contents as a string.
|
|
13
|
-
If the path is a directory, returns a JSON string
|
|
16
|
+
If the path is a directory, returns a JSON string listing its immediate
|
|
17
|
+
children, without recursively listing subdirectory contents.
|
|
14
18
|
|
|
15
19
|
Args:
|
|
16
20
|
path (str): The absolute path to the file or directory.
|
|
@@ -22,9 +26,9 @@ def register_file_tools(mcp: FastMCP) -> None:
|
|
|
22
26
|
data = Files().path_get(path)
|
|
23
27
|
if isinstance(data, (bytes, bytearray)):
|
|
24
28
|
return data.decode()
|
|
25
|
-
return
|
|
29
|
+
return json.dumps(data)
|
|
26
30
|
except Exception as exc:
|
|
27
|
-
raise
|
|
31
|
+
raise ToolError(f"Failed to read file or directory: {str(exc)}") from exc
|
|
28
32
|
|
|
29
33
|
@mcp.tool()
|
|
30
34
|
def upload_text_file(dest_path: str, content: str) -> str:
|
|
@@ -42,7 +46,7 @@ def register_file_tools(mcp: FastMCP) -> None:
|
|
|
42
46
|
status = Files().path_post(dest_path, content.encode())
|
|
43
47
|
return f"Uploaded to {dest_path} (HTTP {status})."
|
|
44
48
|
except Exception as exc:
|
|
45
|
-
raise
|
|
49
|
+
raise ToolError(f"Failed to upload text file: {str(exc)}") from exc
|
|
46
50
|
|
|
47
51
|
@mcp.tool()
|
|
48
52
|
def upload_directory(local_dir_path: str, remote_dir_path: str) -> str:
|
|
@@ -63,7 +67,7 @@ def register_file_tools(mcp: FastMCP) -> None:
|
|
|
63
67
|
Files().tree_post(local_dir_path, remote_dir_path)
|
|
64
68
|
return f"Uploaded {local_dir_path} to {remote_dir_path}."
|
|
65
69
|
except Exception as exc:
|
|
66
|
-
raise
|
|
70
|
+
raise ToolError(f"Failed to upload directory: {str(exc)}") from exc
|
|
67
71
|
|
|
68
72
|
@mcp.tool()
|
|
69
73
|
def delete_path(path: str) -> str:
|
|
@@ -80,7 +84,7 @@ def register_file_tools(mcp: FastMCP) -> None:
|
|
|
80
84
|
Files().path_delete(path)
|
|
81
85
|
return f"Deleted {path}."
|
|
82
86
|
except Exception as exc:
|
|
83
|
-
raise
|
|
87
|
+
raise ToolError(f"Failed to delete path: {str(exc)}") from exc
|
|
84
88
|
|
|
85
89
|
@mcp.tool()
|
|
86
90
|
def tree(path: str) -> list[str]:
|
|
@@ -98,4 +102,4 @@ def register_file_tools(mcp: FastMCP) -> None:
|
|
|
98
102
|
listing = Files().tree_get(path)
|
|
99
103
|
return listing
|
|
100
104
|
except Exception as exc:
|
|
101
|
-
raise
|
|
105
|
+
raise ToolError(f"Failed to get directory tree: {str(exc)}") from exc
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
from mcp.server
|
|
1
|
+
from mcp.server import MCPServer
|
|
2
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
2
3
|
|
|
3
4
|
from pythonanywhere_core.schedule import Schedule
|
|
4
5
|
|
|
5
6
|
|
|
6
|
-
def register_schedule_tools(mcp:
|
|
7
|
+
def register_schedule_tools(mcp: MCPServer) -> None:
|
|
7
8
|
@mcp.tool()
|
|
8
9
|
def list_scheduled_tasks() -> list[dict]:
|
|
9
10
|
"""
|
|
@@ -17,7 +18,7 @@ def register_schedule_tools(mcp: FastMCP) -> None:
|
|
|
17
18
|
try:
|
|
18
19
|
return Schedule().get_list()
|
|
19
20
|
except Exception as exc:
|
|
20
|
-
raise
|
|
21
|
+
raise ToolError(f"Failed to list scheduled tasks: {str(exc)}") from exc
|
|
21
22
|
|
|
22
23
|
@mcp.tool()
|
|
23
24
|
def create_scheduled_task(params: dict) -> dict:
|
|
@@ -38,7 +39,7 @@ def register_schedule_tools(mcp: FastMCP) -> None:
|
|
|
38
39
|
try:
|
|
39
40
|
return Schedule().create(params)
|
|
40
41
|
except Exception as exc:
|
|
41
|
-
raise
|
|
42
|
+
raise ToolError(f"Failed to create scheduled task: {str(exc)}") from exc
|
|
42
43
|
|
|
43
44
|
@mcp.tool()
|
|
44
45
|
def delete_scheduled_task(task_id: int) -> bool:
|
|
@@ -54,7 +55,7 @@ def register_schedule_tools(mcp: FastMCP) -> None:
|
|
|
54
55
|
try:
|
|
55
56
|
return Schedule().delete(task_id)
|
|
56
57
|
except Exception as exc:
|
|
57
|
-
raise
|
|
58
|
+
raise ToolError(f"Failed to delete scheduled task: {str(exc)}") from exc
|
|
58
59
|
|
|
59
60
|
@mcp.tool()
|
|
60
61
|
def get_scheduled_task(task_id: int) -> dict:
|
|
@@ -70,7 +71,7 @@ def register_schedule_tools(mcp: FastMCP) -> None:
|
|
|
70
71
|
try:
|
|
71
72
|
return Schedule().get_specs(task_id)
|
|
72
73
|
except Exception as exc:
|
|
73
|
-
raise
|
|
74
|
+
raise ToolError(f"Failed to get scheduled task: {str(exc)}") from exc
|
|
74
75
|
|
|
75
76
|
@mcp.tool()
|
|
76
77
|
def update_scheduled_task(task_id: int, params: dict) -> dict:
|
|
@@ -88,4 +89,4 @@ def register_schedule_tools(mcp: FastMCP) -> None:
|
|
|
88
89
|
try:
|
|
89
90
|
return Schedule().update(task_id, params)
|
|
90
91
|
except Exception as exc:
|
|
91
|
-
raise
|
|
92
|
+
raise ToolError(f"Failed to update scheduled task: {str(exc)}") from exc
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
|
-
from mcp.server
|
|
3
|
+
from mcp.server import MCPServer
|
|
4
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
4
5
|
|
|
5
6
|
from pythonanywhere_core.webapp import Webapp
|
|
6
7
|
from pythonanywhere_core.base import AuthenticationError, NoTokenError
|
|
@@ -9,7 +10,7 @@ from pythonanywhere_core.exceptions import MissingCNAMEException
|
|
|
9
10
|
# ToDo: Add the log file functions once pythonanywhere-core webapp log file functions
|
|
10
11
|
# have been improved
|
|
11
12
|
|
|
12
|
-
def register_webapp_tools(mcp:
|
|
13
|
+
def register_webapp_tools(mcp: MCPServer) -> None:
|
|
13
14
|
@mcp.tool()
|
|
14
15
|
def reload_webapp(domain: str) -> str:
|
|
15
16
|
"""
|
|
@@ -32,9 +33,9 @@ def register_webapp_tools(mcp: FastMCP) -> None:
|
|
|
32
33
|
except MissingCNAMEException as exc:
|
|
33
34
|
return f"Webapp '{domain}' reloaded. Note: {str(exc)}"
|
|
34
35
|
except (AuthenticationError, NoTokenError):
|
|
35
|
-
raise
|
|
36
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
36
37
|
except Exception as exc:
|
|
37
|
-
raise
|
|
38
|
+
raise ToolError(str(exc)) from exc
|
|
38
39
|
|
|
39
40
|
@mcp.tool()
|
|
40
41
|
def create_webapp(domain: str, python_version: str, virtualenv_path: str, project_path: str) -> str:
|
|
@@ -64,7 +65,7 @@ def register_webapp_tools(mcp: FastMCP) -> None:
|
|
|
64
65
|
str: Status message indicating creation result.
|
|
65
66
|
|
|
66
67
|
Raises:
|
|
67
|
-
|
|
68
|
+
ToolError: If authentication fails, webapp already exists (and nuke=False),
|
|
68
69
|
or other API errors occur. If 403 error is raised, it may mean that there
|
|
69
70
|
is already a non-uwsgi-based website. `list_websites` tool can be used to check that.
|
|
70
71
|
"""
|
|
@@ -78,9 +79,9 @@ def register_webapp_tools(mcp: FastMCP) -> None:
|
|
|
78
79
|
)
|
|
79
80
|
return f"Webapp '{domain}' created successfully."
|
|
80
81
|
except (AuthenticationError, NoTokenError):
|
|
81
|
-
raise
|
|
82
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
82
83
|
except Exception as exc:
|
|
83
|
-
raise
|
|
84
|
+
raise ToolError(str(exc)) from exc
|
|
84
85
|
|
|
85
86
|
@mcp.tool()
|
|
86
87
|
def delete_webapp(domain: str) -> str:
|
|
@@ -99,15 +100,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
|
|
|
99
100
|
str: Status message indicating deletion result.
|
|
100
101
|
|
|
101
102
|
Raises:
|
|
102
|
-
|
|
103
|
+
ToolError: If authentication fails, webapp doesn't exist, or other API errors occur.
|
|
103
104
|
"""
|
|
104
105
|
try:
|
|
105
106
|
Webapp(domain).delete()
|
|
106
107
|
return f"Webapp '{domain}' deleted successfully."
|
|
107
108
|
except (AuthenticationError, NoTokenError):
|
|
108
|
-
raise
|
|
109
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
109
110
|
except Exception as exc:
|
|
110
|
-
raise
|
|
111
|
+
raise ToolError(str(exc)) from exc
|
|
111
112
|
|
|
112
113
|
@mcp.tool()
|
|
113
114
|
def patch_webapp(domain: str, data: dict) -> dict:
|
|
@@ -155,15 +156,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
|
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
Raises:
|
|
158
|
-
|
|
159
|
+
ToolError: If authentication fails, webapp doesn't exist, or other API errors occur.
|
|
159
160
|
"""
|
|
160
161
|
try:
|
|
161
162
|
result = Webapp(domain).patch(data)
|
|
162
163
|
return result
|
|
163
164
|
except (AuthenticationError, NoTokenError):
|
|
164
|
-
raise
|
|
165
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
165
166
|
except Exception as exc:
|
|
166
|
-
raise
|
|
167
|
+
raise ToolError(str(exc)) from exc
|
|
167
168
|
|
|
168
169
|
@mcp.tool()
|
|
169
170
|
def list_webapps() -> list:
|
|
@@ -186,15 +187,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
|
|
|
186
187
|
get_webapp_info: Get detailed information about a specific webapp.
|
|
187
188
|
|
|
188
189
|
Raises:
|
|
189
|
-
|
|
190
|
+
ToolError: If authentication fails or other API errors occur.
|
|
190
191
|
"""
|
|
191
192
|
try:
|
|
192
193
|
result = Webapp.list_webapps()
|
|
193
194
|
return result
|
|
194
195
|
except (AuthenticationError, NoTokenError):
|
|
195
|
-
raise
|
|
196
|
+
raise ToolError("Authentication failed — check API_TOKEN.")
|
|
196
197
|
except Exception as exc:
|
|
197
|
-
raise
|
|
198
|
+
raise ToolError(str(exc)) from exc
|
|
198
199
|
|
|
199
200
|
@mcp.tool()
|
|
200
201
|
def get_webapp_info(domain: str) -> dict:
|
|
@@ -226,15 +227,15 @@ def register_webapp_tools(mcp: FastMCP) -> None:
|
|
|
226
227
|
|
|
227
228
|
|
|
228
229
|
Raises:
|
|
229
|
-
|
|
230
|
+
ToolError: If authentication fails, webapp doesn't exist, or other API errors occur.
|
|
230
231
|
"""
|
|
231
232
|
try:
|
|
232
233
|
result = Webapp(domain).get()
|
|
233
234
|
return result
|
|
234
235
|
except (AuthenticationError, NoTokenError):
|
|
235
|
-
raise
|
|
236
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
236
237
|
except Exception as exc:
|
|
237
|
-
raise
|
|
238
|
+
raise ToolError(str(exc)) from exc
|
|
238
239
|
|
|
239
240
|
|
|
240
241
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
|
|
3
|
-
from mcp.server
|
|
3
|
+
from mcp.server import MCPServer
|
|
4
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
4
5
|
|
|
5
6
|
from pythonanywhere_core.website import Website
|
|
6
7
|
from pythonanywhere_core.base import AuthenticationError, NoTokenError
|
|
7
8
|
|
|
8
9
|
|
|
9
|
-
def register_website_tools(mcp:
|
|
10
|
+
def register_website_tools(mcp: MCPServer) -> None:
|
|
10
11
|
@mcp.tool()
|
|
11
12
|
def reload_website(domain: str) -> str:
|
|
12
13
|
"""
|
|
@@ -27,9 +28,9 @@ def register_website_tools(mcp: FastMCP) -> None:
|
|
|
27
28
|
Website().reload(domain)
|
|
28
29
|
return f"Website '{domain}' reloaded."
|
|
29
30
|
except (AuthenticationError, NoTokenError):
|
|
30
|
-
raise
|
|
31
|
+
raise ToolError("Authentication failed — check API_TOKEN and domain.")
|
|
31
32
|
except Exception as exc:
|
|
32
|
-
raise
|
|
33
|
+
raise ToolError(str(exc)) from exc
|
|
33
34
|
|
|
34
35
|
@mcp.tool()
|
|
35
36
|
def list_websites() -> list[dict[str, Any]]:
|
|
@@ -50,7 +51,7 @@ def register_website_tools(mcp: FastMCP) -> None:
|
|
|
50
51
|
try:
|
|
51
52
|
return Website().list()
|
|
52
53
|
except Exception as exc:
|
|
53
|
-
raise
|
|
54
|
+
raise ToolError(f"Failed to list websites: {str(exc)}") from exc
|
|
54
55
|
|
|
55
56
|
@mcp.tool()
|
|
56
57
|
def create_website(domain_name: str, command: str) -> dict:
|
|
@@ -88,7 +89,7 @@ def register_website_tools(mcp: FastMCP) -> None:
|
|
|
88
89
|
try:
|
|
89
90
|
return Website().create(domain_name, command)
|
|
90
91
|
except Exception as exc:
|
|
91
|
-
raise
|
|
92
|
+
raise ToolError(f"Failed to create website: {str(exc)}") from exc
|
|
92
93
|
|
|
93
94
|
@mcp.tool()
|
|
94
95
|
def delete_website(domain_name: str) -> dict:
|
|
@@ -104,4 +105,4 @@ def register_website_tools(mcp: FastMCP) -> None:
|
|
|
104
105
|
try:
|
|
105
106
|
return Website().delete(domain_name)
|
|
106
107
|
except Exception as exc:
|
|
107
|
-
raise
|
|
108
|
+
raise ToolError(f"Failed to delete website: {str(exc)}") from exc
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythonanywhere-mcp-server
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.13
|
|
4
4
|
Summary: PythonAnywhere Model Context Protocol Server
|
|
5
5
|
Author-email: PythonAnywhere Developers <developers@pythonanywhere.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,8 +18,8 @@ Classifier: Topic :: Utilities
|
|
|
18
18
|
Requires-Python: >=3.13
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: pythonanywhere-core>=0.3.
|
|
22
|
-
Requires-Dist: mcp[cli]<2.
|
|
21
|
+
Requires-Dist: pythonanywhere-core>=0.3.2
|
|
22
|
+
Requires-Dist: mcp[cli]<3,>=2.2.0
|
|
23
23
|
Provides-Extra: test
|
|
24
24
|
Requires-Dist: pytest; extra == "test"
|
|
25
25
|
Requires-Dist: pytest-mock; extra == "test"
|
|
@@ -16,6 +16,7 @@ src/pythonanywhere_mcp_server/tools/schedule.py
|
|
|
16
16
|
src/pythonanywhere_mcp_server/tools/webapp.py
|
|
17
17
|
src/pythonanywhere_mcp_server/tools/website.py
|
|
18
18
|
tests/test_file_tools.py
|
|
19
|
+
tests/test_mcp_integration.py
|
|
19
20
|
tests/test_schedule_tools.py
|
|
20
21
|
tests/test_server.py
|
|
21
22
|
tests/test_webapp_tools.py
|
{pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/tests/test_file_tools.py
RENAMED
|
@@ -1,22 +1,33 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
1
3
|
import pytest
|
|
4
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
2
5
|
|
|
3
6
|
import tools.file as file_tools
|
|
4
7
|
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
@pytest.mark.parametrize("content", ["file contents", 'Café says "hello"\n'])
|
|
10
|
+
def test_read_file_or_directory_file(mcp, mocker, content):
|
|
7
11
|
file_tools.register_file_tools(mcp)
|
|
8
12
|
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
9
|
-
mock_files.return_value.path_get.return_value =
|
|
13
|
+
mock_files.return_value.path_get.return_value = content.encode("utf-8")
|
|
10
14
|
result = mcp.call_tool("read_file_or_directory", {"path": "/some/file.txt"})
|
|
11
|
-
assert result ==
|
|
15
|
+
assert result == content
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
def test_read_file_or_directory_directory(mcp, mocker):
|
|
15
19
|
file_tools.register_file_tools(mcp)
|
|
16
20
|
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
17
|
-
|
|
21
|
+
listing = {
|
|
22
|
+
'a "quoted" file.txt': {"type": "file", "url": "/files/quoted"},
|
|
23
|
+
"café": {"type": "directory", "url": "/files/cafe"},
|
|
24
|
+
}
|
|
25
|
+
mock_files.return_value.path_get.return_value = listing
|
|
26
|
+
|
|
18
27
|
result = mcp.call_tool("read_file_or_directory", {"path": "/some/dir/"})
|
|
19
|
-
|
|
28
|
+
|
|
29
|
+
assert json.loads(result) == listing
|
|
30
|
+
mock_files.return_value.path_get.assert_called_once_with("/some/dir/")
|
|
20
31
|
|
|
21
32
|
|
|
22
33
|
def test_upload_text_file(mcp, mocker):
|
|
@@ -39,7 +50,7 @@ def test_read_file_or_directory_file_exception(mcp, mocker):
|
|
|
39
50
|
file_tools.register_file_tools(mcp)
|
|
40
51
|
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
41
52
|
mock_files.return_value.path_get.side_effect = Exception("read error")
|
|
42
|
-
with pytest.raises(
|
|
53
|
+
with pytest.raises(ToolError) as exc:
|
|
43
54
|
mcp.call_tool("read_file_or_directory", {"path": "/some/file.txt"})
|
|
44
55
|
assert "Failed to read file or directory: read error" in str(exc)
|
|
45
56
|
|
|
@@ -48,7 +59,7 @@ def test_upload_text_file_exception(mcp, mocker):
|
|
|
48
59
|
file_tools.register_file_tools(mcp)
|
|
49
60
|
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
50
61
|
mock_files.return_value.path_post.side_effect = Exception("upload error")
|
|
51
|
-
with pytest.raises(
|
|
62
|
+
with pytest.raises(ToolError) as exc:
|
|
52
63
|
mcp.call_tool("upload_text_file", {"dest_path": "/some/file.txt", "content": "hello"})
|
|
53
64
|
assert "Failed to upload text file: upload error" in str(exc)
|
|
54
65
|
|
|
@@ -57,7 +68,7 @@ def test_delete_path_exception(mcp, mocker):
|
|
|
57
68
|
file_tools.register_file_tools(mcp)
|
|
58
69
|
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
59
70
|
mock_files.return_value.path_delete.side_effect = Exception("delete error")
|
|
60
|
-
with pytest.raises(
|
|
71
|
+
with pytest.raises(ToolError) as exc:
|
|
61
72
|
mcp.call_tool("delete_path", {"path": "/some/file.txt"})
|
|
62
73
|
assert "Failed to delete path: delete error" in str(exc)
|
|
63
74
|
|
|
@@ -74,7 +85,7 @@ def test_upload_directory_exception(mcp, mocker):
|
|
|
74
85
|
file_tools.register_file_tools(mcp)
|
|
75
86
|
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
76
87
|
mock_files.return_value.tree_post.side_effect = Exception("upload dir error")
|
|
77
|
-
with pytest.raises(
|
|
88
|
+
with pytest.raises(ToolError) as exc:
|
|
78
89
|
mcp.call_tool("upload_directory", {"local_dir_path": "/local/myapp", "remote_dir_path": "/home/user/myapp"})
|
|
79
90
|
assert "Failed to upload directory: upload dir error" in str(exc)
|
|
80
91
|
|
|
@@ -91,6 +102,6 @@ def test_directory_tree_exception(mcp, mocker):
|
|
|
91
102
|
file_tools.register_file_tools(mcp)
|
|
92
103
|
mock_files = mocker.patch("tools.file.Files", autospec=True)
|
|
93
104
|
mock_files.return_value.tree_get.side_effect = Exception("tree error")
|
|
94
|
-
with pytest.raises(
|
|
105
|
+
with pytest.raises(ToolError) as exc:
|
|
95
106
|
mcp.call_tool("tree", {"path": "/some/dir/"})
|
|
96
107
|
assert "Failed to get directory tree: tree error" in str(exc)
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
)
|
{pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/tests/test_schedule_tools.py
RENAMED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pytest
|
|
2
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
2
3
|
|
|
3
4
|
import tools.schedule as schedule_tools
|
|
4
5
|
|
|
@@ -16,7 +17,7 @@ def test_list_scheduled_tasks_exception(mcp, mocker):
|
|
|
16
17
|
schedule_tools.register_schedule_tools(mcp)
|
|
17
18
|
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
18
19
|
mock_schedule.return_value.get_list.side_effect = Exception("list error")
|
|
19
|
-
with pytest.raises(
|
|
20
|
+
with pytest.raises(ToolError) as exc:
|
|
20
21
|
mcp.call_tool("list_scheduled_tasks", {})
|
|
21
22
|
assert "Failed to list scheduled tasks: list error" in str(exc)
|
|
22
23
|
|
|
@@ -35,7 +36,7 @@ def test_create_scheduled_task_exception(mcp, mocker):
|
|
|
35
36
|
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
36
37
|
mock_schedule.return_value.create.side_effect = Exception("create error")
|
|
37
38
|
params = {"command": "ls", "enabled": True, "interval": "hourly", "minute": 0}
|
|
38
|
-
with pytest.raises(
|
|
39
|
+
with pytest.raises(ToolError) as exc:
|
|
39
40
|
mcp.call_tool("create_scheduled_task", {"params": params})
|
|
40
41
|
assert "Failed to create scheduled task: create error" in str(exc)
|
|
41
42
|
|
|
@@ -53,7 +54,7 @@ def test_delete_scheduled_task_exception(mcp, mocker):
|
|
|
53
54
|
schedule_tools.register_schedule_tools(mcp)
|
|
54
55
|
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
55
56
|
mock_schedule.return_value.delete.side_effect = Exception("delete error")
|
|
56
|
-
with pytest.raises(
|
|
57
|
+
with pytest.raises(ToolError) as exc:
|
|
57
58
|
mcp.call_tool("delete_scheduled_task", {"task_id": 1})
|
|
58
59
|
assert "Failed to delete scheduled task: delete error" in str(exc)
|
|
59
60
|
|
|
@@ -70,7 +71,7 @@ def test_get_scheduled_task_exception(mcp, mocker):
|
|
|
70
71
|
schedule_tools.register_schedule_tools(mcp)
|
|
71
72
|
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
72
73
|
mock_schedule.return_value.get_specs.side_effect = Exception("get error")
|
|
73
|
-
with pytest.raises(
|
|
74
|
+
with pytest.raises(ToolError) as exc:
|
|
74
75
|
mcp.call_tool("get_scheduled_task", {"task_id": 1})
|
|
75
76
|
assert "Failed to get scheduled task: get error" in str(exc)
|
|
76
77
|
|
|
@@ -89,6 +90,6 @@ def test_update_scheduled_task_exception(mcp, mocker):
|
|
|
89
90
|
mock_schedule = mocker.patch("tools.schedule.Schedule", autospec=True)
|
|
90
91
|
mock_schedule.return_value.update.side_effect = Exception("update error")
|
|
91
92
|
params = {"command": "ls -l"}
|
|
92
|
-
with pytest.raises(
|
|
93
|
+
with pytest.raises(ToolError) as exc:
|
|
93
94
|
mcp.call_tool("update_scheduled_task", {"task_id": 1, "params": params})
|
|
94
95
|
assert "Failed to update scheduled task: update error" in str(exc)
|
|
@@ -4,15 +4,15 @@ from pythonanywhere_mcp_server import server
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
@pytest.fixture()
|
|
7
|
-
def
|
|
8
|
-
"""Mock the
|
|
9
|
-
return mocker.patch("pythonanywhere_mcp_server.server.
|
|
7
|
+
def mock_MCPServer(mocker):
|
|
8
|
+
"""Mock the MCPServer class to avoid actual server creation."""
|
|
9
|
+
return mocker.patch("pythonanywhere_mcp_server.server.MCPServer", autospec=True)
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def test_create_server(monkeypatch,
|
|
12
|
+
def test_create_server(monkeypatch, mock_MCPServer):
|
|
13
13
|
monkeypatch.setenv("API_TOKEN", "dummy-token")
|
|
14
14
|
server.create_server()
|
|
15
|
-
|
|
15
|
+
mock_MCPServer.assert_called_once_with("PythonAnywhere Model Context Protocol Server")
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@pytest.mark.parametrize(
|
|
@@ -23,14 +23,14 @@ def test_create_server(monkeypatch, mock_FastMCP):
|
|
|
23
23
|
"register_schedule_tools",
|
|
24
24
|
]
|
|
25
25
|
)
|
|
26
|
-
def test_register_tools(monkeypatch, mocker,
|
|
26
|
+
def test_register_tools(monkeypatch, mocker, mock_MCPServer, register_fn):
|
|
27
27
|
monkeypatch.setenv("API_TOKEN", "dummy-token")
|
|
28
28
|
mock_register = mocker.patch(
|
|
29
29
|
f"pythonanywhere_mcp_server.server.{register_fn}" ,
|
|
30
30
|
autospec=True
|
|
31
31
|
)
|
|
32
32
|
server.create_server()
|
|
33
|
-
mock_register.assert_called_once_with(
|
|
33
|
+
mock_register.assert_called_once_with(mock_MCPServer.return_value)
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def test_missing_api_token(monkeypatch):
|
{pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/tests/test_webapp_tools.py
RENAMED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pytest
|
|
2
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from pythonanywhere_core.base import AuthenticationError
|
|
4
5
|
from pythonanywhere_core.exceptions import MissingCNAMEException
|
|
@@ -50,7 +51,7 @@ def test_webapp_tools_errors(setup_webapp_tools, mocker, tool_name, method_name,
|
|
|
50
51
|
mock_webapp = mocker.patch("tools.webapp.Webapp", autospec=True)
|
|
51
52
|
getattr(mock_webapp.return_value, method_name).side_effect = side_effect
|
|
52
53
|
|
|
53
|
-
with pytest.raises(
|
|
54
|
+
with pytest.raises(ToolError) as exc:
|
|
54
55
|
setup_webapp_tools.call_tool(tool_name, params)
|
|
55
56
|
assert expected_error in str(exc)
|
|
56
57
|
|
|
@@ -63,7 +64,7 @@ def test_list_webapps_errors(setup_webapp_tools, mocker, side_effect, expected_e
|
|
|
63
64
|
mocker.patch("tools.webapp.Webapp", autospec=True)
|
|
64
65
|
mocker.patch("tools.webapp.Webapp.list_webapps", side_effect=side_effect)
|
|
65
66
|
|
|
66
|
-
with pytest.raises(
|
|
67
|
+
with pytest.raises(ToolError) as exc:
|
|
67
68
|
setup_webapp_tools.call_tool("list_webapps", {})
|
|
68
69
|
assert expected_error in str(exc)
|
|
69
70
|
|
|
@@ -108,7 +109,7 @@ def test_create_webapp_errors(setup_webapp_tools, mocker, side_effect, expected_
|
|
|
108
109
|
"virtualenv_path": "/test/venv/path",
|
|
109
110
|
"project_path": "/project/path",
|
|
110
111
|
}
|
|
111
|
-
with pytest.raises(
|
|
112
|
+
with pytest.raises(ToolError) as exc:
|
|
112
113
|
setup_webapp_tools.call_tool("create_webapp", params)
|
|
113
114
|
assert expected_error in str(exc)
|
|
114
115
|
|
{pythonanywhere_mcp_server-0.0.12 → pythonanywhere_mcp_server-0.0.13}/tests/test_website_tools.py
RENAMED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pytest
|
|
2
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
2
3
|
|
|
3
4
|
import tools.website as website_tools
|
|
4
5
|
|
|
@@ -17,7 +18,7 @@ def test_list_websites_exception(mcp, mocker):
|
|
|
17
18
|
website_tools.register_website_tools(mcp)
|
|
18
19
|
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
19
20
|
mock_website.return_value.list.side_effect = Exception("list error")
|
|
20
|
-
with pytest.raises(
|
|
21
|
+
with pytest.raises(ToolError) as exc:
|
|
21
22
|
mcp.call_tool("list_websites", {})
|
|
22
23
|
assert "Failed to list websites: list error" in str(exc)
|
|
23
24
|
|
|
@@ -36,7 +37,7 @@ def test_create_website_exception(mcp, mocker):
|
|
|
36
37
|
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
37
38
|
mock_website.return_value.create.side_effect = Exception("create error")
|
|
38
39
|
params = {"domain_name": "test.com", "command": "run.sh"}
|
|
39
|
-
with pytest.raises(
|
|
40
|
+
with pytest.raises(ToolError) as exc:
|
|
40
41
|
mcp.call_tool("create_website", params)
|
|
41
42
|
assert "Failed to create website: create error" in str(exc)
|
|
42
43
|
|
|
@@ -54,7 +55,7 @@ def test_delete_website_exception(mcp, mocker):
|
|
|
54
55
|
website_tools.register_website_tools(mcp)
|
|
55
56
|
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
56
57
|
mock_website.return_value.delete.side_effect = Exception("delete error")
|
|
57
|
-
with pytest.raises(
|
|
58
|
+
with pytest.raises(ToolError) as exc:
|
|
58
59
|
mcp.call_tool("delete_website", {"domain_name": "test.com"})
|
|
59
60
|
assert "Failed to delete website: delete error" in str(exc)
|
|
60
61
|
|
|
@@ -72,7 +73,7 @@ def test_reload_website_auth_error(mcp, mocker):
|
|
|
72
73
|
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
73
74
|
from pythonanywhere_core.base import AuthenticationError
|
|
74
75
|
mock_website.return_value.reload.side_effect = AuthenticationError()
|
|
75
|
-
with pytest.raises(
|
|
76
|
+
with pytest.raises(ToolError) as exc:
|
|
76
77
|
mcp.call_tool("reload_website", {"domain": "test.com"})
|
|
77
78
|
assert "Authentication failed" in str(exc)
|
|
78
79
|
|
|
@@ -81,6 +82,6 @@ def test_reload_website_other_error(mcp, mocker):
|
|
|
81
82
|
website_tools.register_website_tools(mcp)
|
|
82
83
|
mock_website = mocker.patch("tools.website.Website", autospec=True)
|
|
83
84
|
mock_website.return_value.reload.side_effect = Exception("website reload error")
|
|
84
|
-
with pytest.raises(
|
|
85
|
+
with pytest.raises(ToolError) as exc:
|
|
85
86
|
mcp.call_tool("reload_website", {"domain": "test.com"})
|
|
86
87
|
assert "website reload error" in str(exc)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.0.12"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|