MCP: serve tools from the API process over Streamable HTTP at /mcp

- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 21:52:08 +08:00
parent 918ece86c3
commit 52679a7cfc
150 changed files with 2367 additions and 2812 deletions

View file

@ -0,0 +1,189 @@
"""Real-TCP regression: /mcp must not emit TE:chunked with an unframed body."""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
import httpx
import pytest
ROOT = Path(__file__).resolve().parents[2]
PY = Path(os.environ.get("NETX_TEST_PYTHON") or sys.executable)
def _free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = int(s.getsockname()[1])
s.close()
return port
def _wait_health(port: int, timeout: float = 45.0) -> None:
deadline = time.time() + timeout
url = f"http://127.0.0.1:{port}/health"
last = ""
while time.time() < deadline:
try:
r = httpx.get(url, timeout=1.0, trust_env=False)
if r.status_code == 200:
return
last = f"status={r.status_code}"
except Exception as exc:
last = str(exc)
time.sleep(0.2)
raise TimeoutError(f"health not ready on {port}: {last}")
def _raw_post(port: int, path: str, body: bytes, headers: dict[str, str] | None = None) -> bytes:
hdrs = {
"Host": f"127.0.0.1:{port}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Connection": "close",
"Content-Length": str(len(body)),
}
if headers:
hdrs.update(headers)
lines = [f"POST {path} HTTP/1.1"] + [f"{k}: {v}" for k, v in hdrs.items()]
req = ("\r\n".join(lines) + "\r\n\r\n").encode() + body
s = socket.create_connection(("127.0.0.1", port), 10)
s.sendall(req)
s.settimeout(10)
data = b""
try:
while True:
chunk = s.recv(65536)
if not chunk:
break
data += chunk
except TimeoutError:
pass
finally:
s.close()
return data
def _assert_framed(raw: bytes) -> None:
head, _, rest = raw.partition(b"\r\n\r\n")
assert head.startswith(b"HTTP/1.1 "), head[:80]
hl = head.lower()
has_cl = b"content-length:" in hl
has_te = b"transfer-encoding: chunked" in hl
assert has_cl or has_te, f"missing framing headers: {head!r}"
if has_te and not has_cl:
first = rest.split(b"\r\n", 1)[0]
try:
int(first, 16)
except ValueError as exc:
raise AssertionError(
f"TE:chunked but body is not chunk-framed (first line={first!r})"
) from exc
assert b"\r\n0\r\n" in rest or rest.rstrip().endswith(b"0"), rest[-40:]
@pytest.fixture(scope="module")
def live_mcp_port():
port = _free_port()
tmp = Path(tempfile.mkdtemp(prefix="netx-mcp-tcp-"))
env = os.environ.copy()
env.update(
{
"NETX_DATABASE_URL": f"sqlite:///{(tmp / 'netx.sqlite').as_posix()}",
"NETX_AUTH_SECRET_FILE": str(tmp / "jwt_secret"),
"NETX_AUTH_MCP_TOKEN_FILE": str(tmp / "mcp_token"),
"NETX_CREDENTIAL_SECRET_FILE": str(tmp / "cred_secret"),
"NETX_NE_COLLECTION_DATA_DIR": str(tmp / "ne_collections"),
"NETX_BIZ_STATE_SPOOL_DIR": str(tmp / "biz_spool"),
"NETX_NE_EXEC_JOB_DIR": str(tmp / "ne_exec_jobs"),
"NETX_WEBCRT_DATA_DIR": str(tmp / "webcrt"),
"NETX_SCHEDULER_HEARTBEAT_PATH": str(tmp / "scheduler_heartbeat.json"),
"NETX_DOCS_ENABLED": "false",
"NETX_AUTH_ENABLED": "false",
"NETX_ALLOW_INSECURE_DEFAULTS": "true",
"NETX_RUN_INLINE_SCHEDULERS": "false",
"PYTHONUTF8": "1",
# Avoid Windows system proxy (httpx trust_env) returning 502 on loopback.
"NO_PROXY": "127.0.0.1,localhost",
"no_proxy": "127.0.0.1,localhost",
}
)
proc = subprocess.Popen(
[
str(PY),
"-m",
"uvicorn",
"netx_api.main:app",
"--host",
"127.0.0.1",
"--port",
str(port),
"--http",
"h11",
],
cwd=str(ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
try:
_wait_health(port)
yield port
finally:
proc.terminate()
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
def test_mcp_tcp_initialize_framing(live_mcp_port: int):
body = json.dumps(
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "tcp-framing", "version": "0"},
},
}
).encode()
raw = _raw_post(live_mcp_port, "/mcp", body)
_assert_framed(raw)
assert b'"serverInfo"' in raw or b"serverInfo" in raw or b"server_info" in raw
def test_mcp_tcp_httpx_initialize(live_mcp_port: int):
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "httpx-framing", "version": "0"},
},
}
r = httpx.post(
f"http://127.0.0.1:{live_mcp_port}/mcp",
json=body,
headers={
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": "2024-11-05",
},
timeout=30.0,
trust_env=False,
)
assert r.status_code == 200, (r.status_code, r.text[:300])
data = r.json()
assert data["result"]["serverInfo"]["name"] == "netx"