mcp-caddy 0.1.0__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.
@@ -0,0 +1,44 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish-pypi:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v3
17
+ with:
18
+ python-version: "3.12"
19
+ - run: uv build
20
+ - uses: pypa/gh-action-pypi-publish@release/v1
21
+
22
+ publish-docker:
23
+ runs-on: ubuntu-latest
24
+ permissions:
25
+ contents: read
26
+ packages: write
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: docker/setup-buildx-action@v3
30
+ - uses: docker/login-action@v3
31
+ with:
32
+ registry: ghcr.io
33
+ username: ${{ github.actor }}
34
+ password: ${{ secrets.GITHUB_TOKEN }}
35
+ - name: Extract tag name
36
+ id: tag
37
+ run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
38
+ - uses: docker/build-push-action@v5
39
+ with:
40
+ context: .
41
+ push: true
42
+ tags: |
43
+ ghcr.io/aaronckj/mcp-caddy:latest
44
+ ghcr.io/aaronckj/mcp-caddy:${{ steps.tag.outputs.version }}
@@ -0,0 +1,16 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v3
13
+ with:
14
+ python-version: "3.12"
15
+ - run: uv sync --extra dev
16
+ - run: uv run pytest -v
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ .venv/
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
@@ -0,0 +1,12 @@
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN pip install uv
6
+
7
+ COPY pyproject.toml README.md ./
8
+ COPY src/ ./src/
9
+
10
+ RUN uv pip install --system .
11
+
12
+ ENTRYPOINT ["mcp-caddy"]
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-caddy
3
+ Version: 0.1.0
4
+ Summary: MCP server for Caddy web server management — routes, upstreams, certificates, reload
5
+ License: MIT
6
+ Keywords: caddy,homelab,mcp,reverse-proxy
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
13
+ Classifier: Topic :: System :: Systems Administration
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: mcp>=1.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
19
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # mcp-caddy
24
+
25
+ MCP server for [Caddy](https://caddyserver.com/) web server management. Exposes 7 tools for inspecting routes, upstream health, certificates, and reloading configuration via the Caddy admin API.
26
+
27
+ ## Quick Start
28
+
29
+ **With uvx (recommended):**
30
+ ```bash
31
+ CADDY_HOST=http://10.0.0.31:2019 uvx mcp-caddy
32
+ ```
33
+
34
+ **With Docker:**
35
+ ```bash
36
+ docker run -i \
37
+ -e CADDY_HOST=http://10.0.0.31:2019 \
38
+ ghcr.io/aaronckj/mcp-caddy:latest
39
+ ```
40
+
41
+ **Add to Claude Code:**
42
+ ```bash
43
+ claude mcp add caddy -s user -e CADDY_HOST=http://10.0.0.31:2019 -- uvx mcp-caddy
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ | Variable | Required | Default | Description |
49
+ |----------|----------|---------|-------------|
50
+ | `CADDY_HOST` | No | `http://localhost:2019` | Caddy admin API URL |
51
+ | `CADDY_TIMEOUT` | No | `30` | HTTP timeout in seconds |
52
+
53
+ ## Finding Your CADDY_HOST
54
+
55
+ The Caddy admin API binds to `localhost:2019` by default. Depending on your setup:
56
+
57
+ 1. **MCP server on same host as Caddy** → use the default `http://localhost:2019`
58
+ 2. **Caddy in Docker, MCP server on the same Docker host** → add `ports: ["127.0.0.1:2019:2019"]` to your Caddy compose service, then use `http://localhost:2019`
59
+ 3. **Remote Caddy host** → SSH tunnel: `ssh -L 2019:localhost:2019 user@caddy-host`, then use `http://localhost:2019`
60
+ 4. **Caddy exposes admin via reverse proxy** → set `CADDY_HOST=https://caddy-admin.example.com`
61
+
62
+ The Caddy admin API has no authentication by default. If you expose it beyond localhost, add Caddy basic auth to that route.
63
+
64
+ ## Tools
65
+
66
+ | Tool | Description |
67
+ |------|-------------|
68
+ | `server_info` | Caddy version and loaded modules |
69
+ | `get_config` | Full configuration as JSON |
70
+ | `list_routes` | All virtual hosts with upstream targets |
71
+ | `list_upstreams` | Upstream proxy health status |
72
+ | `get_certificates` | TLS automation policies and ACME issuers |
73
+ | `adapt_config` | Convert a Caddyfile snippet to JSON |
74
+ | `reload` | Reload config from a JSON string or file path |
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ git clone https://github.com/aaronckj/mcp-caddy
80
+ cd mcp-caddy
81
+ uv sync --extra dev
82
+ uv run pytest -v
83
+ ```
@@ -0,0 +1,61 @@
1
+ # mcp-caddy
2
+
3
+ MCP server for [Caddy](https://caddyserver.com/) web server management. Exposes 7 tools for inspecting routes, upstream health, certificates, and reloading configuration via the Caddy admin API.
4
+
5
+ ## Quick Start
6
+
7
+ **With uvx (recommended):**
8
+ ```bash
9
+ CADDY_HOST=http://10.0.0.31:2019 uvx mcp-caddy
10
+ ```
11
+
12
+ **With Docker:**
13
+ ```bash
14
+ docker run -i \
15
+ -e CADDY_HOST=http://10.0.0.31:2019 \
16
+ ghcr.io/aaronckj/mcp-caddy:latest
17
+ ```
18
+
19
+ **Add to Claude Code:**
20
+ ```bash
21
+ claude mcp add caddy -s user -e CADDY_HOST=http://10.0.0.31:2019 -- uvx mcp-caddy
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ | Variable | Required | Default | Description |
27
+ |----------|----------|---------|-------------|
28
+ | `CADDY_HOST` | No | `http://localhost:2019` | Caddy admin API URL |
29
+ | `CADDY_TIMEOUT` | No | `30` | HTTP timeout in seconds |
30
+
31
+ ## Finding Your CADDY_HOST
32
+
33
+ The Caddy admin API binds to `localhost:2019` by default. Depending on your setup:
34
+
35
+ 1. **MCP server on same host as Caddy** → use the default `http://localhost:2019`
36
+ 2. **Caddy in Docker, MCP server on the same Docker host** → add `ports: ["127.0.0.1:2019:2019"]` to your Caddy compose service, then use `http://localhost:2019`
37
+ 3. **Remote Caddy host** → SSH tunnel: `ssh -L 2019:localhost:2019 user@caddy-host`, then use `http://localhost:2019`
38
+ 4. **Caddy exposes admin via reverse proxy** → set `CADDY_HOST=https://caddy-admin.example.com`
39
+
40
+ The Caddy admin API has no authentication by default. If you expose it beyond localhost, add Caddy basic auth to that route.
41
+
42
+ ## Tools
43
+
44
+ | Tool | Description |
45
+ |------|-------------|
46
+ | `server_info` | Caddy version and loaded modules |
47
+ | `get_config` | Full configuration as JSON |
48
+ | `list_routes` | All virtual hosts with upstream targets |
49
+ | `list_upstreams` | Upstream proxy health status |
50
+ | `get_certificates` | TLS automation policies and ACME issuers |
51
+ | `adapt_config` | Convert a Caddyfile snippet to JSON |
52
+ | `reload` | Reload config from a JSON string or file path |
53
+
54
+ ## Development
55
+
56
+ ```bash
57
+ git clone https://github.com/aaronckj/mcp-caddy
58
+ cd mcp-caddy
59
+ uv sync --extra dev
60
+ uv run pytest -v
61
+ ```
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-caddy"
7
+ version = "0.1.0"
8
+ description = "MCP server for Caddy web server management — routes, upstreams, certificates, reload"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = {text = "MIT"}
12
+ keywords = ["mcp", "caddy", "homelab", "reverse-proxy"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.12",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
20
+ "Topic :: System :: Systems Administration",
21
+ ]
22
+ dependencies = [
23
+ "mcp>=1.0",
24
+ "httpx>=0.27",
25
+ ]
26
+
27
+ [project.scripts]
28
+ mcp-caddy = "mcp_caddy.server:main"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov>=4.0"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ asyncio_mode = "auto"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/mcp_caddy"]
File without changes
@@ -0,0 +1,191 @@
1
+ """mcp-caddy: Caddy web server management MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import httpx
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ mcp = FastMCP("caddy")
11
+
12
+ _DEFAULT_HOST = "http://localhost:2019"
13
+ _DEFAULT_TIMEOUT = 30.0
14
+
15
+
16
+ async def _request(method: str, path: str, **kwargs) -> httpx.Response:
17
+ """Make an HTTP request to Caddy admin API.
18
+
19
+ Args:
20
+ method: HTTP method (GET, POST, PUT, DELETE, etc.)
21
+ path: API path (e.g., "/config/")
22
+ **kwargs: Additional arguments to pass to httpx.AsyncClient.request()
23
+
24
+ Returns:
25
+ httpx.Response object
26
+
27
+ Environment variables:
28
+ CADDY_HOST: Caddy admin API host (default: http://localhost:2019)
29
+ CADDY_TIMEOUT: Request timeout in seconds (default: 30.0)
30
+ """
31
+ host = os.environ.get("CADDY_HOST", _DEFAULT_HOST)
32
+ timeout = float(os.environ.get("CADDY_TIMEOUT", str(_DEFAULT_TIMEOUT)))
33
+ async with httpx.AsyncClient(timeout=timeout) as client:
34
+ return await client.request(method, f"{host}{path}", **kwargs)
35
+
36
+
37
+ @mcp.tool()
38
+ async def server_info() -> dict:
39
+ """Get Caddy server version and list of loaded modules."""
40
+ try:
41
+ resp = await _request("GET", "/")
42
+ resp.raise_for_status()
43
+ return {"result": resp.json()}
44
+ except Exception as e:
45
+ return {"error": str(e), "tool": "server_info", "detail": type(e).__name__}
46
+
47
+
48
+ @mcp.tool()
49
+ async def get_config() -> dict:
50
+ """Get the full Caddy configuration as JSON."""
51
+ try:
52
+ resp = await _request("GET", "/config/")
53
+ resp.raise_for_status()
54
+ return {"result": resp.json()}
55
+ except Exception as e:
56
+ return {"error": str(e), "tool": "get_config", "detail": type(e).__name__}
57
+
58
+
59
+ def _extract_upstreams(handles: list) -> list[str]:
60
+ """Extract upstream dial addresses, recursing into subroute handlers."""
61
+ upstreams = []
62
+ for handle in handles:
63
+ if handle.get("handler") == "reverse_proxy":
64
+ for up in handle.get("upstreams", []):
65
+ if "dial" in up:
66
+ upstreams.append(up["dial"])
67
+ elif handle.get("handler") == "subroute":
68
+ for sub_route in handle.get("routes", []):
69
+ upstreams.extend(_extract_upstreams(sub_route.get("handle", [])))
70
+ return upstreams
71
+
72
+
73
+ @mcp.tool()
74
+ async def list_routes() -> dict:
75
+ """List all configured routes (virtual hosts) parsed from Caddy's HTTP app config."""
76
+ try:
77
+ resp = await _request("GET", "/config/")
78
+ resp.raise_for_status()
79
+ config = resp.json()
80
+
81
+ servers = config.get("apps", {}).get("http", {}).get("servers", {})
82
+ routes_out = []
83
+
84
+ for server_name, server in servers.items():
85
+ for route in server.get("routes", []):
86
+ hosts = []
87
+ for match in route.get("match", []):
88
+ hosts.extend(match.get("host", []))
89
+
90
+ handles = route.get("handle", [])
91
+ handler_type = handles[0].get("handler") if handles else None
92
+ upstreams = _extract_upstreams(handles)
93
+
94
+ routes_out.append({
95
+ "server": server_name,
96
+ "hosts": hosts,
97
+ "handler": handler_type,
98
+ "upstreams": upstreams,
99
+ })
100
+
101
+ return {"result": routes_out}
102
+ except Exception as e:
103
+ return {"error": str(e), "tool": "list_routes", "detail": type(e).__name__}
104
+
105
+
106
+ @mcp.tool()
107
+ async def list_upstreams() -> dict:
108
+ """List all reverse proxy upstreams with their health status and request counts."""
109
+ try:
110
+ resp = await _request("GET", "/reverse_proxy/upstreams")
111
+ resp.raise_for_status()
112
+ return {"result": resp.json()}
113
+ except Exception as e:
114
+ return {"error": str(e), "tool": "list_upstreams", "detail": type(e).__name__}
115
+
116
+
117
+ @mcp.tool()
118
+ async def get_certificates() -> dict:
119
+ """List TLS certificate automation policies: subjects, ACME issuers, and CAs."""
120
+ try:
121
+ resp = await _request("GET", "/config/")
122
+ resp.raise_for_status()
123
+ config = resp.json()
124
+
125
+ policies = (
126
+ config.get("apps", {})
127
+ .get("tls", {})
128
+ .get("automation", {})
129
+ .get("policies", [])
130
+ )
131
+
132
+ certs = []
133
+ for policy in policies:
134
+ issuers = []
135
+ for issuer in policy.get("issuers", []):
136
+ issuer_info: dict = {"module": issuer.get("module")}
137
+ if "ca" in issuer:
138
+ issuer_info["ca"] = issuer["ca"]
139
+ if "email" in issuer:
140
+ issuer_info["email"] = issuer["email"]
141
+ issuers.append(issuer_info)
142
+
143
+ certs.append({
144
+ "subjects": policy.get("subjects", []),
145
+ "issuers": issuers,
146
+ })
147
+
148
+ return {"result": certs}
149
+ except Exception as e:
150
+ return {"error": str(e), "tool": "get_certificates", "detail": type(e).__name__}
151
+
152
+
153
+ @mcp.tool()
154
+ async def adapt_config(caddyfile: str) -> dict:
155
+ """Convert a Caddyfile snippet to JSON config using Caddy's built-in adapter."""
156
+ try:
157
+ resp = await _request(
158
+ "POST",
159
+ "/adapt",
160
+ json={"adapter": "caddyfile", "body": caddyfile},
161
+ )
162
+ resp.raise_for_status()
163
+ return {"result": resp.json()}
164
+ except Exception as e:
165
+ return {"error": str(e), "tool": "adapt_config", "detail": type(e).__name__}
166
+
167
+
168
+ @mcp.tool()
169
+ async def reload(source: str) -> dict:
170
+ """Reload Caddy config. source: raw JSON string (starts with '{') or path to a JSON config file."""
171
+ try:
172
+ import json as _json
173
+ if source.lstrip().startswith("{"):
174
+ config_data = _json.loads(source)
175
+ else:
176
+ with open(source) as f:
177
+ config_data = _json.load(f)
178
+
179
+ resp = await _request("POST", "/load", json=config_data)
180
+ resp.raise_for_status()
181
+ return {"result": {"reloaded": True}}
182
+ except Exception as e:
183
+ return {"error": str(e), "tool": "reload", "detail": type(e).__name__}
184
+
185
+
186
+ def main() -> None:
187
+ mcp.run()
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
@@ -0,0 +1,482 @@
1
+ """Tests for mcp-caddy tools. All HTTP calls are mocked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import AsyncMock, MagicMock, patch
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # HTTP client layer tests
13
+ # ---------------------------------------------------------------------------
14
+
15
+ async def test_request_uses_caddy_host_env(monkeypatch):
16
+ """_request() builds URL from CADDY_HOST env var."""
17
+ monkeypatch.setenv("CADDY_HOST", "http://10.0.0.31:2019")
18
+
19
+ mock_resp = MagicMock()
20
+ mock_resp.status_code = 200
21
+
22
+ mock_client = AsyncMock()
23
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
24
+ mock_client.__aexit__ = AsyncMock(return_value=None)
25
+ mock_client.request = AsyncMock(return_value=mock_resp)
26
+
27
+ with patch("mcp_caddy.server.httpx.AsyncClient", return_value=mock_client):
28
+ import mcp_caddy.server as srv
29
+ resp = await srv._request("GET", "/config/")
30
+
31
+ assert resp.status_code == 200
32
+ mock_client.request.assert_called_once_with(
33
+ "GET",
34
+ "http://10.0.0.31:2019/config/",
35
+ )
36
+
37
+
38
+ async def test_request_uses_default_host(monkeypatch):
39
+ """_request() falls back to http://localhost:2019 when CADDY_HOST is not set."""
40
+ monkeypatch.delenv("CADDY_HOST", raising=False)
41
+
42
+ mock_resp = MagicMock()
43
+ mock_resp.status_code = 200
44
+
45
+ mock_client = AsyncMock()
46
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
47
+ mock_client.__aexit__ = AsyncMock(return_value=None)
48
+ mock_client.request = AsyncMock(return_value=mock_resp)
49
+
50
+ with patch("mcp_caddy.server.httpx.AsyncClient", return_value=mock_client):
51
+ import mcp_caddy.server as srv
52
+ resp = await srv._request("GET", "/")
53
+
54
+ mock_client.request.assert_called_once_with("GET", "http://localhost:2019/")
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Tool tests — helpers
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def make_response(status: int, data) -> httpx.Response:
62
+ """Build a real httpx.Response with JSON body (no live HTTP needed)."""
63
+ import json
64
+ mock_req = MagicMock()
65
+ resp = httpx.Response(
66
+ status,
67
+ content=json.dumps(data).encode(),
68
+ headers={"content-type": "application/json"},
69
+ )
70
+ resp._request = mock_req
71
+ return resp
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # server_info
76
+ # ---------------------------------------------------------------------------
77
+
78
+ async def test_server_info_success(monkeypatch):
79
+ payload = {"version": "v2.7.6", "modules": ["admin", "http", "tls"]}
80
+
81
+ async def fake_request(method, path, **kw):
82
+ assert method == "GET"
83
+ assert path == "/"
84
+ return make_response(200, payload)
85
+
86
+ import mcp_caddy.server as srv
87
+ monkeypatch.setattr(srv, "_request", fake_request)
88
+ result = await srv.server_info()
89
+
90
+ assert result["result"]["version"] == "v2.7.6"
91
+ assert "modules" in result["result"]
92
+
93
+
94
+ async def test_server_info_error(monkeypatch):
95
+ async def fake_request(method, path, **kw):
96
+ raise httpx.ConnectError("Connection refused")
97
+
98
+ import mcp_caddy.server as srv
99
+ monkeypatch.setattr(srv, "_request", fake_request)
100
+ result = await srv.server_info()
101
+
102
+ assert "error" in result
103
+ assert result["tool"] == "server_info"
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # get_config
108
+ # ---------------------------------------------------------------------------
109
+
110
+ async def test_get_config_success(monkeypatch):
111
+ payload = {
112
+ "admin": {"listen": "localhost:2019"},
113
+ "apps": {"http": {"servers": {}}, "tls": {}},
114
+ }
115
+
116
+ async def fake_request(method, path, **kw):
117
+ assert method == "GET"
118
+ assert path == "/config/"
119
+ return make_response(200, payload)
120
+
121
+ import mcp_caddy.server as srv
122
+ monkeypatch.setattr(srv, "_request", fake_request)
123
+ result = await srv.get_config()
124
+
125
+ assert "admin" in result["result"]
126
+ assert "apps" in result["result"]
127
+
128
+
129
+ async def test_get_config_error(monkeypatch):
130
+ async def fake_request(method, path, **kw):
131
+ raise httpx.ConnectError("Connection refused")
132
+
133
+ import mcp_caddy.server as srv
134
+ monkeypatch.setattr(srv, "_request", fake_request)
135
+ result = await srv.get_config()
136
+
137
+ assert "error" in result
138
+ assert result["tool"] == "get_config"
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # list_routes
143
+ # ---------------------------------------------------------------------------
144
+
145
+ _ROUTES_CONFIG = {
146
+ "apps": {
147
+ "http": {
148
+ "servers": {
149
+ "srv0": {
150
+ "listen": [":443"],
151
+ "routes": [
152
+ {
153
+ "match": [{"host": ["example.com"]}],
154
+ "handle": [
155
+ {
156
+ "handler": "reverse_proxy",
157
+ "upstreams": [{"dial": "10.0.0.5:8080"}],
158
+ }
159
+ ],
160
+ },
161
+ {
162
+ "match": [{"host": ["api.example.com"]}],
163
+ "handle": [
164
+ {
165
+ "handler": "subroute",
166
+ "routes": [
167
+ {
168
+ "handle": [
169
+ {
170
+ "handler": "reverse_proxy",
171
+ "upstreams": [{"dial": "10.0.0.6:9000"}],
172
+ }
173
+ ]
174
+ }
175
+ ],
176
+ }
177
+ ],
178
+ },
179
+ ],
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+
187
+ async def test_list_routes_success(monkeypatch):
188
+ async def fake_request(method, path, **kw):
189
+ return make_response(200, _ROUTES_CONFIG)
190
+
191
+ import mcp_caddy.server as srv
192
+ monkeypatch.setattr(srv, "_request", fake_request)
193
+ result = await srv.list_routes()
194
+
195
+ routes = result["result"]
196
+ assert isinstance(routes, list)
197
+ assert len(routes) == 2
198
+
199
+ first = routes[0]
200
+ assert first["hosts"] == ["example.com"]
201
+ assert first["upstreams"] == ["10.0.0.5:8080"]
202
+ assert first["handler"] == "reverse_proxy"
203
+ assert first["server"] == "srv0"
204
+
205
+
206
+ async def test_list_routes_subroute_upstreams(monkeypatch):
207
+ """Upstreams inside subroute handlers are extracted."""
208
+ async def fake_request(method, path, **kw):
209
+ return make_response(200, _ROUTES_CONFIG)
210
+
211
+ import mcp_caddy.server as srv
212
+ monkeypatch.setattr(srv, "_request", fake_request)
213
+ result = await srv.list_routes()
214
+
215
+ second = result["result"][1]
216
+ assert second["hosts"] == ["api.example.com"]
217
+ assert second["upstreams"] == ["10.0.0.6:9000"]
218
+ assert second["handler"] == "subroute"
219
+
220
+
221
+ async def test_list_routes_no_http_app(monkeypatch):
222
+ """Returns empty list when config has no http app."""
223
+ async def fake_request(method, path, **kw):
224
+ return make_response(200, {"apps": {}})
225
+
226
+ import mcp_caddy.server as srv
227
+ monkeypatch.setattr(srv, "_request", fake_request)
228
+ result = await srv.list_routes()
229
+
230
+ assert result["result"] == []
231
+
232
+
233
+ async def test_list_routes_error(monkeypatch):
234
+ async def fake_request(method, path, **kw):
235
+ raise httpx.ConnectError("Connection refused")
236
+
237
+ import mcp_caddy.server as srv
238
+ monkeypatch.setattr(srv, "_request", fake_request)
239
+ result = await srv.list_routes()
240
+
241
+ assert "error" in result
242
+ assert result["tool"] == "list_routes"
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # list_upstreams
247
+ # ---------------------------------------------------------------------------
248
+
249
+ async def test_list_upstreams_success(monkeypatch):
250
+ payload = [
251
+ {"address": "10.0.0.5:8080", "num_requests": 3, "fails": 0},
252
+ {"address": "10.0.0.6:9000", "num_requests": 0, "fails": 1},
253
+ ]
254
+
255
+ async def fake_request(method, path, **kw):
256
+ assert method == "GET"
257
+ assert path == "/reverse_proxy/upstreams"
258
+ return make_response(200, payload)
259
+
260
+ import mcp_caddy.server as srv
261
+ monkeypatch.setattr(srv, "_request", fake_request)
262
+ result = await srv.list_upstreams()
263
+
264
+ upstreams = result["result"]
265
+ assert isinstance(upstreams, list)
266
+ assert len(upstreams) == 2
267
+ assert upstreams[0]["address"] == "10.0.0.5:8080"
268
+ assert upstreams[1]["fails"] == 1
269
+
270
+
271
+ async def test_list_upstreams_empty(monkeypatch):
272
+ async def fake_request(method, path, **kw):
273
+ return make_response(200, [])
274
+
275
+ import mcp_caddy.server as srv
276
+ monkeypatch.setattr(srv, "_request", fake_request)
277
+ result = await srv.list_upstreams()
278
+
279
+ assert result["result"] == []
280
+
281
+
282
+ async def test_list_upstreams_error(monkeypatch):
283
+ async def fake_request(method, path, **kw):
284
+ raise httpx.ConnectError("Connection refused")
285
+
286
+ import mcp_caddy.server as srv
287
+ monkeypatch.setattr(srv, "_request", fake_request)
288
+ result = await srv.list_upstreams()
289
+
290
+ assert "error" in result
291
+ assert result["tool"] == "list_upstreams"
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # get_certificates
296
+ # ---------------------------------------------------------------------------
297
+
298
+ _TLS_CONFIG = {
299
+ "apps": {
300
+ "tls": {
301
+ "automation": {
302
+ "policies": [
303
+ {
304
+ "subjects": ["example.com", "www.example.com"],
305
+ "issuers": [
306
+ {
307
+ "module": "acme",
308
+ "ca": "https://acme-v02.api.letsencrypt.org/directory",
309
+ "email": "admin@example.com",
310
+ }
311
+ ],
312
+ },
313
+ {
314
+ "subjects": ["internal.example.com"],
315
+ "issuers": [{"module": "acme", "ca": "https://acme.zerossl.com/v2/DV90"}],
316
+ },
317
+ ]
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+
324
+ async def test_get_certificates_success(monkeypatch):
325
+ async def fake_request(method, path, **kw):
326
+ return make_response(200, _TLS_CONFIG)
327
+
328
+ import mcp_caddy.server as srv
329
+ monkeypatch.setattr(srv, "_request", fake_request)
330
+ result = await srv.get_certificates()
331
+
332
+ certs = result["result"]
333
+ assert isinstance(certs, list)
334
+ assert len(certs) == 2
335
+ assert certs[0]["subjects"] == ["example.com", "www.example.com"]
336
+ assert certs[0]["issuers"][0]["module"] == "acme"
337
+ assert certs[0]["issuers"][0]["ca"] == "https://acme-v02.api.letsencrypt.org/directory"
338
+ assert certs[0]["issuers"][0]["email"] == "admin@example.com"
339
+
340
+
341
+ async def test_get_certificates_no_tls_app(monkeypatch):
342
+ """Returns empty list when config has no tls app."""
343
+ async def fake_request(method, path, **kw):
344
+ return make_response(200, {"apps": {"http": {}}})
345
+
346
+ import mcp_caddy.server as srv
347
+ monkeypatch.setattr(srv, "_request", fake_request)
348
+ result = await srv.get_certificates()
349
+
350
+ assert result["result"] == []
351
+
352
+
353
+ async def test_get_certificates_no_policies(monkeypatch):
354
+ """Returns empty list when tls app has no automation policies."""
355
+ async def fake_request(method, path, **kw):
356
+ return make_response(200, {"apps": {"tls": {"automation": {}}}})
357
+
358
+ import mcp_caddy.server as srv
359
+ monkeypatch.setattr(srv, "_request", fake_request)
360
+ result = await srv.get_certificates()
361
+
362
+ assert result["result"] == []
363
+
364
+
365
+ async def test_get_certificates_error(monkeypatch):
366
+ async def fake_request(method, path, **kw):
367
+ raise httpx.ConnectError("Connection refused")
368
+
369
+ import mcp_caddy.server as srv
370
+ monkeypatch.setattr(srv, "_request", fake_request)
371
+ result = await srv.get_certificates()
372
+
373
+ assert "error" in result
374
+ assert result["tool"] == "get_certificates"
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # adapt_config
379
+ # ---------------------------------------------------------------------------
380
+
381
+ async def test_adapt_config_success(monkeypatch):
382
+ caddyfile = "example.com { reverse_proxy localhost:8080 }"
383
+ adapted = {"apps": {"http": {"servers": {"srv0": {"routes": []}}}}}
384
+
385
+ async def fake_request(method, path, **kw):
386
+ assert method == "POST"
387
+ assert path == "/adapt"
388
+ assert kw.get("json") == {"adapter": "caddyfile", "body": caddyfile}
389
+ return make_response(200, adapted)
390
+
391
+ import mcp_caddy.server as srv
392
+ monkeypatch.setattr(srv, "_request", fake_request)
393
+ result = await srv.adapt_config(caddyfile=caddyfile)
394
+
395
+ assert "apps" in result["result"]
396
+
397
+
398
+ async def test_adapt_config_syntax_error(monkeypatch):
399
+ async def fake_request(method, path, **kw):
400
+ return make_response(400, {"error": "Caddyfile syntax error"})
401
+
402
+ import mcp_caddy.server as srv
403
+ monkeypatch.setattr(srv, "_request", fake_request)
404
+ result = await srv.adapt_config(caddyfile="invalid {{{")
405
+
406
+ assert "error" in result
407
+ assert result["tool"] == "adapt_config"
408
+
409
+
410
+ async def test_adapt_config_network_error(monkeypatch):
411
+ async def fake_request(method, path, **kw):
412
+ raise httpx.ConnectError("Connection refused")
413
+
414
+ import mcp_caddy.server as srv
415
+ monkeypatch.setattr(srv, "_request", fake_request)
416
+ result = await srv.adapt_config(caddyfile="example.com {}")
417
+
418
+ assert "error" in result
419
+ assert result["tool"] == "adapt_config"
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # reload
424
+ # ---------------------------------------------------------------------------
425
+
426
+ async def test_reload_json_string(monkeypatch):
427
+ """reload() with a JSON string POSTs directly without file I/O."""
428
+ config = {"apps": {}}
429
+ config_str = '{"apps": {}}'
430
+
431
+ async def fake_request(method, path, **kw):
432
+ assert method == "POST"
433
+ assert path == "/load"
434
+ assert kw.get("json") == config
435
+ return make_response(200, {})
436
+
437
+ import mcp_caddy.server as srv
438
+ monkeypatch.setattr(srv, "_request", fake_request)
439
+ result = await srv.reload(source=config_str)
440
+
441
+ assert result["result"]["reloaded"] is True
442
+
443
+
444
+ async def test_reload_file_path(monkeypatch, tmp_path):
445
+ """reload() with a file path reads the file and POSTs its contents."""
446
+ import json
447
+ config = {"apps": {"http": {}}}
448
+ config_file = tmp_path / "caddy.json"
449
+ config_file.write_text(json.dumps(config))
450
+
451
+ async def fake_request(method, path, **kw):
452
+ assert method == "POST"
453
+ assert path == "/load"
454
+ assert kw.get("json") == config
455
+ return make_response(200, {})
456
+
457
+ import mcp_caddy.server as srv
458
+ monkeypatch.setattr(srv, "_request", fake_request)
459
+ result = await srv.reload(source=str(config_file))
460
+
461
+ assert result["result"]["reloaded"] is True
462
+
463
+
464
+ async def test_reload_invalid_json(monkeypatch):
465
+ """reload() returns error dict when source is not valid JSON and not a valid file."""
466
+ import mcp_caddy.server as srv
467
+ result = await srv.reload(source="not json at all")
468
+
469
+ assert "error" in result
470
+ assert result["tool"] == "reload"
471
+
472
+
473
+ async def test_reload_network_error(monkeypatch):
474
+ async def fake_request(method, path, **kw):
475
+ raise httpx.ConnectError("Connection refused")
476
+
477
+ import mcp_caddy.server as srv
478
+ monkeypatch.setattr(srv, "_request", fake_request)
479
+ result = await srv.reload(source='{"apps": {}}')
480
+
481
+ assert "error" in result
482
+ assert result["tool"] == "reload"