Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,9 @@ This file defines how coding agents should work in this repository.
dashboard/static fallback.
- Missing dashboard asset URLs, including raw, decoded, or repeatedly decoded
`GET`/`HEAD` requests under `/assets/*`, dot-segment aliases that normalize
into `/assets`, and extension-bearing static paths, must return structured
JSON `404` errors instead of the dashboard HTML shell; `HEAD` responses must
not include a body.
into `/assets`, and paths with recognized static-asset extension components,
must return structured JSON `404` errors instead of asset bytes or the
dashboard HTML shell; `HEAD` responses must not include a body.
- `HEAD` requests for GET-able dashboard/static paths such as `/` and concrete
static assets must return the same success headers as `GET` with no body,
while API/RPC `HEAD` requests keep the structured JSON 405/404 contract.
Expand Down
6 changes: 3 additions & 3 deletions docs/guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ Exact API collection routes such as `/api/gpus` and `/api/sessions` do not
accept query strings or path parameters unless documented; query-shaped
collection URLs return JSON `404` responses.
Missing packaged asset URLs, including raw, encoded, or repeatedly encoded
`/assets/*` aliases and dot-segment aliases that normalize into the packaged
asset directory, also return JSON `404` responses instead of the dashboard
shell.
`/assets/*` aliases, dot-segment aliases that normalize into the packaged asset
directory, and paths with recognized static-asset extension components, also
return JSON `404` responses instead of asset bytes or the dashboard shell.

## Start service

Expand Down
35 changes: 32 additions & 3 deletions src/keep_gpu/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
STATIC_DIR = Path(__file__).resolve().parent / "static"
MAX_JSON_BODY_BYTES = 1_000_000
ROUTE_PATH_DECODE_MAX_PASSES = 16
STATIC_ASSET_SUFFIXES = frozenset(
{".css", ".html", ".ico", ".js", ".json", ".map", ".png", ".svg"}
)
MCP_PROTOCOL_VERSION = "2025-06-18"
JSONRPC_PARSE_ERROR = -32700
JSONRPC_INVALID_REQUEST = -32600
Expand Down Expand Up @@ -1348,6 +1351,17 @@ def _serve_static(self, request_path: str, write_body: bool = True) -> None:
relative = request_path.lstrip("/")

decoded_relative = unquote(relative)
decoded_components = [
component for component in decoded_relative.split("/") if component
]
has_dot_segment = any(
component in (".", "..") for component in decoded_components
)
has_extension_component = any(
Path(component).suffix.lower() in STATIC_ASSET_SUFFIXES
for component in decoded_components
if component not in (".", "..")
)
Comment thread
Wangmerlyn marked this conversation as resolved.
requested = (STATIC_DIR / decoded_relative).resolve()
static_root = STATIC_DIR.resolve()
is_asset_route_alias = any(
Expand All @@ -1374,13 +1388,28 @@ def _serve_static(self, request_path: str, write_body: bool = True) -> None:
is_asset_prefix_request = (
decoded_relative == "assets" or decoded_relative.startswith("assets/")
)
is_canonical_asset_request = (
is_asset_prefix_request
and (relative == "assets" or relative.startswith("assets/"))
and not has_dot_segment
)
is_noncanonical_resolved_asset = (
is_resolved_asset_request
and is_asset_route_alias
and not is_canonical_asset_request
)
if is_noncanonical_resolved_asset:
self._json_response(
404,
{"error": {"message": "Static asset not found"}},
write_body=write_body,
)
return
is_asset_request = (
is_asset_prefix_request
or is_resolved_asset_request
or is_asset_route_alias
or (
bool(Path(decoded_relative).suffix) and decoded_relative != "index.html"
)
or (has_extension_component and decoded_relative != "index.html")
)
if (
is_asset_prefix_request
Expand Down
48 changes: 44 additions & 4 deletions tests/mcp/test_http_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1342,7 +1342,32 @@ class _Server(TCPServer):
thread.join(timeout=2)


@pytest.mark.parametrize("path", ["/assets/missing.js", "/missing.keepgpu-test.js"])
@pytest.mark.parametrize("path", ["/dashboard/settings", "/v1.0/dashboard"])
def test_http_dashboard_client_routes_fall_back_to_static_index(path):
server = make_server()
httpd, thread, base = _start_http_server(server)

try:
status, headers, body = _request_http_response("GET", f"{base}{path}")
finally:
httpd.shutdown()
httpd.server_close()
server.shutdown()
thread.join(timeout=2)

assert status == 200
assert headers.get_content_type() == "text/html"
assert b"KeepGPU Control Deck" in body


@pytest.mark.parametrize(
"path",
[
"/assets/missing.js",
"/missing.keepgpu-test.js",
"/missing.keepgpu-test.js/extra",
],
)
def test_http_missing_static_asset_returns_404_not_dashboard_index(path):
server = make_server()
httpd, thread, base = _start_http_server(server)
Expand Down Expand Up @@ -1390,7 +1415,10 @@ def test_http_normalized_asset_escape_returns_404_not_dashboard_index(target):
}


@pytest.mark.parametrize("target", ["/foo/../assets/missing", "/foo/../assets"])
@pytest.mark.parametrize(
"target",
["/foo/../assets/missing", "/foo/../assets", "/foo/../assets/index.css"],
)
def test_http_normalized_asset_alias_returns_404_not_dashboard_index(target):
server = make_server()
httpd, thread, _base = _start_http_server(server)
Expand Down Expand Up @@ -1418,6 +1446,7 @@ def test_http_normalized_asset_alias_returns_404_not_dashboard_index(target):
"/%252Fassets/missing",
"/%2Fassets/index.css",
"/%252Fassets/index.css",
"/assets%2Findex.css",
],
)
def test_http_encoded_leading_slash_asset_alias_returns_404_not_dashboard_index(
Expand All @@ -1442,7 +1471,14 @@ def test_http_encoded_leading_slash_asset_alias_returns_404_not_dashboard_index(
}


@pytest.mark.parametrize("path", ["/assets/missing.js", "/missing.keepgpu-test.js"])
@pytest.mark.parametrize(
"path",
[
"/assets/missing.js",
"/missing.keepgpu-test.js",
"/missing.keepgpu-test.js/extra",
],
)
def test_http_head_missing_static_asset_returns_json_404_without_body(path):
server = make_server()
httpd, thread, base = _start_http_server(server)
Expand Down Expand Up @@ -1485,7 +1521,10 @@ def test_http_head_normalized_asset_escape_returns_404_without_body(target):
assert body == b""


@pytest.mark.parametrize("target", ["/foo/../assets/missing", "/foo/../assets"])
@pytest.mark.parametrize(
"target",
["/foo/../assets/missing", "/foo/../assets", "/foo/../assets/index.css"],
)
def test_http_head_normalized_asset_alias_returns_404_without_body(target):
server = make_server()
httpd, thread, _base = _start_http_server(server)
Expand All @@ -1510,6 +1549,7 @@ def test_http_head_normalized_asset_alias_returns_404_without_body(target):
"/%252Fassets/missing",
"/%2Fassets/index.css",
"/%252Fassets/index.css",
"/assets%2Findex.css",
],
)
def test_http_head_encoded_leading_slash_asset_alias_returns_404_without_body(
Expand Down