项目文件夹

文件
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

328 行
11 KiB
Python

# tests/test_mcp_http_transport.py
"""
Tests for the opt-in HTTP transport added for #1801.
These exercise the *production* server built by
``mempalace.mcp_server._build_http_server`` over a real loopback socket on an
ephemeral port — the earlier version of this file reimplemented the endpoint in
Starlette and guarded on ``pytest.importorskip("starlette")``/``uvicorn``,
neither of which is a project dependency, so it was silently skipped in CI and
the real ``_serve_http`` handler had zero coverage.
Design constraints
------------------
* Real sockets, but bound to ``127.0.0.1:0`` (OS-assigned port) so there is no
port conflict on any CI runner.
* Pure stdlib (``http.client``, ``threading``) — no third-party deps.
* Server runs in a daemon thread and is shut down in fixture teardown.
"""
import http.client
import json
import threading
import pytest
from mempalace import mcp_server as mcp
def _post(port, path, body, headers=None, host_header=None):
"""Raw POST with full control over Host / Origin / Authorization headers."""
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
try:
raw = body if isinstance(body, (bytes, bytearray)) else json.dumps(body).encode("utf-8")
headers = headers or {}
conn.putrequest("POST", path, skip_host=(host_header is not None))
if host_header is not None:
conn.putheader("Host", host_header)
conn.putheader("Content-Type", "application/json")
# Let a caller override Content-Length (used to fake an oversized body)
# instead of emitting a second, conflicting header.
if not any(k.lower() == "content-length" for k in headers):
conn.putheader("Content-Length", str(len(raw)))
for k, v in headers.items():
conn.putheader(k, v)
conn.endheaders()
conn.send(raw)
resp = conn.getresponse()
return resp.status, resp.read()
finally:
conn.close()
def _get(port, path, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
try:
conn.request("GET", path, headers=headers or {})
resp = conn.getresponse()
return resp.status, resp.read()
finally:
conn.close()
@pytest.fixture
def http_server():
"""A running production MCP HTTP server on an ephemeral loopback port."""
httpd = mcp._build_http_server("127.0.0.1", 0)
port = httpd.server_address[1]
thread = threading.Thread(
target=httpd.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True
)
thread.start()
try:
yield port, httpd
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=5)
def test_post_dispatches_to_handle_request(http_server):
"""A real POST to /mcp reaches handle_request and returns its JSON-RPC reply."""
port, _ = http_server
status, body = _post(port, "/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert status == 200
payload = json.loads(body)
assert payload["id"] == 1
names = {t["name"] for t in payload["result"]["tools"]}
assert "mempalace_search" in names
def test_initialize_reports_server_info(http_server):
port, _ = http_server
status, body = _post(port, "/mcp", {"jsonrpc": "2.0", "id": 7, "method": "initialize"})
assert status == 200
assert json.loads(body)["result"]["serverInfo"]["name"] == "mempalace"
def test_healthz_ok(http_server):
port, _ = http_server
status, body = _get(port, "/healthz")
assert status == 200
assert body == b"ok\n"
def test_unknown_path_404(http_server):
port, _ = http_server
assert _post(port, "/nope", {"jsonrpc": "2.0", "id": 1, "method": "ping"})[0] == 404
assert _get(port, "/nope")[0] == 404
def test_invalid_json_returns_parse_error(http_server):
port, _ = http_server
status, body = _post(port, "/mcp", b"{not valid json")
assert status == 400
assert json.loads(body)["error"]["code"] == -32700
def test_oversized_request_rejected_413(http_server):
"""A declared Content-Length over the cap is rejected before the body is read."""
port, _ = http_server
# Lie about the length: the handler checks the header and returns 413 before
# reading the (tiny) body, so we never have to ship 16 MiB.
status, body = _post(
port,
"/mcp",
b"{}",
headers={"Content-Length": str(mcp._HTTP_MAX_REQUEST_BYTES + 1)},
)
assert status == 413
assert json.loads(body)["error"]["code"] == -32600
def test_notification_returns_202_no_body(http_server):
port, _ = http_server
status, body = _post(port, "/mcp", {"jsonrpc": "2.0", "method": "notifications/initialized"})
assert status == 202
assert body == b""
def test_rejects_foreign_host_header(http_server):
"""DNS-rebinding guard: a request carrying an attacker domain in Host is 403."""
port, _ = http_server
status, _ = _post(
port,
"/mcp",
{"jsonrpc": "2.0", "id": 1, "method": "ping"},
host_header="evil.example.com",
)
assert status == 403
def test_rejects_cross_origin(http_server):
"""A browser Origin from a non-loopback page is 403 (rebinding/SSRF guard)."""
port, _ = http_server
status, _ = _post(
port,
"/mcp",
{"jsonrpc": "2.0", "id": 1, "method": "ping"},
headers={"Origin": "https://evil.example"},
)
assert status == 403
def test_allows_loopback_origin(http_server):
port, _ = http_server
status, _ = _post(
port,
"/mcp",
{"jsonrpc": "2.0", "id": 1, "method": "ping"},
headers={"Origin": "http://localhost:5173"},
)
assert status == 200
def test_bearer_token_enforced_when_configured(monkeypatch):
"""With MEMPALACE_MCP_HTTP_TOKEN set, /mcp requires a matching bearer token."""
monkeypatch.setenv("MEMPALACE_MCP_HTTP_TOKEN", "s3cret")
httpd = mcp._build_http_server("127.0.0.1", 0)
port = httpd.server_address[1]
thread = threading.Thread(
target=httpd.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True
)
thread.start()
try:
ping = {"jsonrpc": "2.0", "id": 1, "method": "ping"}
# No token → 401.
assert _post(port, "/mcp", ping)[0] == 401
# Wrong token → 401.
assert _post(port, "/mcp", ping, headers={"Authorization": "Bearer nope"})[0] == 401
# Correct token → 200.
assert _post(port, "/mcp", ping, headers={"Authorization": "Bearer s3cret"})[0] == 200
# /healthz never requires the token (orchestrator liveness probes).
assert _get(port, "/healthz")[0] == 200
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=5)
def test_read_only_hides_and_refuses_mutating_tools(http_server, monkeypatch):
"""Read-only mode (#1877): mutating tools are hidden from tools/list AND
refused at dispatch with -32003, while read tools still work."""
monkeypatch.setattr(mcp, "_READ_ONLY", True)
port, _ = http_server
status, body = _post(port, "/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert status == 200
names = {t["name"] for t in json.loads(body)["result"]["tools"]}
assert "mempalace_search" in names # read tool stays
assert "mempalace_add_drawer" not in names # mutating tool hidden
assert names.isdisjoint(mcp._MUTATING_TOOLS)
status, body = _post(
port,
"/mcp",
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "mempalace_add_drawer", "arguments": {"content": "x"}},
},
)
assert status == 200
assert json.loads(body)["error"]["code"] == -32003
def test_read_only_off_exposes_mutating_tools(http_server):
"""Sanity: without read-only, mutating tools are present (guards the test above)."""
port, _ = http_server
status, body = _post(port, "/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
names = {t["name"] for t in json.loads(body)["result"]["tools"]}
assert "mempalace_add_drawer" in names
def _make_self_signed_cert(tmp_path):
"""Write a throwaway self-signed cert/key via openssl; skip if unavailable."""
import shutil
import subprocess
if shutil.which("openssl") is None:
pytest.skip("openssl not available to generate a test certificate")
cert = tmp_path / "cert.pem"
key = tmp_path / "key.pem"
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
str(key),
"-out",
str(cert),
"-days",
"1",
"-nodes",
"-subj",
"/CN=localhost",
],
check=True,
capture_output=True,
)
return cert, key
def test_tls_serves_https(tmp_path, monkeypatch):
"""With --tls-cert/--tls-key (via env), the server speaks TLS: a plain HTTP
client cannot read it, and an HTTPS client trusting the cert can."""
import ssl
cert, key = _make_self_signed_cert(tmp_path)
monkeypatch.setenv("MEMPALACE_MCP_TLS_CERT", str(cert))
monkeypatch.setenv("MEMPALACE_MCP_TLS_KEY", str(key))
httpd = mcp._build_http_server("127.0.0.1", 0)
assert getattr(httpd, "scheme", "http") == "https"
port = httpd.server_address[1]
thread = threading.Thread(
target=httpd.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True
)
thread.start()
try:
# Full verification on: trust the self-signed cert as the CA and dial
# "localhost" (the cert CN, resolves to 127.0.0.1) so hostname checking
# passes without being disabled.
ctx = ssl.create_default_context(cafile=str(cert))
conn = http.client.HTTPSConnection("localhost", port, context=ctx, timeout=5)
try:
conn.request("GET", "/healthz")
resp = conn.getresponse()
assert resp.status == 200
assert resp.read() == b"ok\n"
finally:
conn.close()
# A plaintext HTTP client must NOT be able to talk to the TLS socket.
with pytest.raises(Exception):
plain = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
plain.request("GET", "/healthz")
plain.getresponse()
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=5)
def test_tls_requires_both_cert_and_key(tmp_path, monkeypatch):
"""A cert without a key (or vice versa) is a startup error, not a silent skip."""
cert, _key = _make_self_signed_cert(tmp_path)
monkeypatch.setenv("MEMPALACE_MCP_TLS_CERT", str(cert))
monkeypatch.delenv("MEMPALACE_MCP_TLS_KEY", raising=False)
with pytest.raises(ValueError, match="both"):
mcp._build_http_server("127.0.0.1", 0)
def test_loopback_and_origin_helpers():
assert mcp._http_is_loopback("127.0.0.1")
assert mcp._http_is_loopback("localhost")
assert not mcp._http_is_loopback("0.0.0.0")
assert not mcp._http_is_loopback("192.168.1.10")
assert mcp._http_origin_allowed("http://127.0.0.1:8765")
assert mcp._http_origin_allowed("http://localhost")
assert not mcp._http_origin_allowed("https://evil.example")
assert not mcp._http_origin_allowed("garbage")
allowed = mcp._http_allowed_host_values("127.0.0.1", 8765)
assert "127.0.0.1:8765" in allowed and "localhost" in allowed