mirror of
https://github.com/NewFuture/DDNS.git
synced 2026-10-20 00:16:45 +00:00
* feat(mcp): add Streamable HTTP transport Add a modern MCP 2026-07-28 HTTP endpoint for standalone and Web modes, with shared listener configuration, authentication, schema, tests, and bilingual documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): support Python 2 HTTP assertions Normalize response header names and call handler methods through their underlying functions so the HTTP tests behave consistently on Python 2.7 and Python 3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): harden HTTP request handling Validate mirrored request IDs, align Config Studio listener checks, bound socket reads, and accept the binary wrapper's documented SIGINT exit status. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): align HTTP negotiation validation Reject unavailable Accept media types by quality and keep schema host validation consistent with runtime listener checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): validate host schema behavior Assert accepted and rejected host samples instead of comparing Unicode escape representations that differ on Python 2.7. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): avoid cross-engine schema regex Keep the schema pattern contract without executing its ECMA-262 Unicode escape through Python 2's incompatible regex engine. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate HTTP request boundaries Reject empty tokens and malformed JSON-RPC request shapes, and keep Config Studio bracket validation aligned with runtime listener handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): tighten HTTP input validation Reject legacy notifications, nested host brackets, and document the required JSON content type so validation remains consistent across runtime, schema, and Config Studio. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate Origin before method errors Apply the existing Origin policy to non-POST MCP requests and return CORS headers for configured allowed origins. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs(mcp): clarify proxy Origin allowlist Explain that browser clients behind TLS termination must allowlist the public HTTPS origin even when the public page appears same-origin. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs(mcp): add standalone server guide Move MCP client setup, transports, tools, security, and protocol limits into dedicated Chinese and English pages with matching navigation and index links. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): reject duplicate routing headers Reject repeated MCP protocol, method, and name headers before body consistency checks across Python 2 and Python 3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate named request headers Validate Mcp-Name for tools/call, prompts/get, and resources/read before dispatching unsupported methods. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
262 lines
9.1 KiB
Python
262 lines
9.1 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Shared HTTP listener configuration for the Web and MCP servers."""
|
|
|
|
from __future__ import unicode_literals
|
|
|
|
import hmac
|
|
import socket
|
|
import struct
|
|
|
|
try:
|
|
from urllib.parse import urlparse
|
|
except ImportError: # Python 2
|
|
from urlparse import urlparse
|
|
|
|
try:
|
|
string_types = (basestring,) # type: ignore[name-defined]
|
|
except NameError:
|
|
string_types = (str,)
|
|
|
|
|
|
DEFAULT_HTTP_HOST = "127.0.0.1"
|
|
DEFAULT_HTTP_PORT = 9876
|
|
HTTP_CONNECTION_TIMEOUT = 15
|
|
|
|
|
|
class HttpConfigError(ValueError):
|
|
"""Raised when shared HTTP listener settings are invalid."""
|
|
|
|
|
|
def is_loopback_host(host):
|
|
# type: (str) -> bool
|
|
"""Return whether a bind host is unambiguously loopback-only."""
|
|
if not isinstance(host, string_types):
|
|
return False
|
|
normalized = host.strip().lower()
|
|
if normalized == "localhost":
|
|
return True
|
|
if normalized.startswith("[") and normalized.endswith("]"):
|
|
normalized = normalized[1:-1]
|
|
try:
|
|
packed = socket.inet_aton(normalized)
|
|
return struct.unpack("!I", packed)[0] >> 24 == 127
|
|
except (AttributeError, socket.error, struct.error):
|
|
pass
|
|
if normalized == "::1":
|
|
return True
|
|
if hasattr(socket, "inet_pton"):
|
|
try:
|
|
packed = socket.inet_pton(socket.AF_INET6, normalized)
|
|
return packed == (b"\x00" * 15 + b"\x01")
|
|
except (AttributeError, socket.error):
|
|
pass
|
|
return False
|
|
|
|
|
|
def _constant_time_equal(left, right):
|
|
# type: (str, str) -> bool
|
|
left_bytes = left.encode("utf-8")
|
|
right_bytes = right.encode("utf-8")
|
|
try:
|
|
return hmac.compare_digest(left_bytes, right_bytes)
|
|
except AttributeError: # Python 2.7 before compare_digest
|
|
result = len(left_bytes) ^ len(right_bytes)
|
|
for index in range(max(len(left_bytes), len(right_bytes))):
|
|
left_value = ord(left_bytes[index]) if index < len(left_bytes) else 0
|
|
right_value = ord(right_bytes[index]) if index < len(right_bytes) else 0
|
|
result |= left_value ^ right_value
|
|
return result == 0
|
|
|
|
|
|
def _bearer_token(value):
|
|
# type: (str | None) -> str | None
|
|
if not isinstance(value, string_types):
|
|
return None
|
|
parts = value.strip().split(None, 1)
|
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
return None
|
|
return parts[1]
|
|
|
|
|
|
def request_token_matches(headers, token, allow_dashboard_header=False):
|
|
# type: (object, str | None, bool) -> bool
|
|
"""Validate the shared HTTP token without reflecting it."""
|
|
if token is None:
|
|
return True
|
|
supplied = _bearer_token(headers.get("Authorization"))
|
|
if supplied is None and allow_dashboard_header:
|
|
supplied = headers.get("X-DDNS-Token")
|
|
if not isinstance(supplied, string_types):
|
|
return False
|
|
try:
|
|
return _constant_time_equal(supplied, token)
|
|
except (TypeError, UnicodeError):
|
|
return False
|
|
|
|
|
|
def _normalize_host(value):
|
|
# type: (object) -> str
|
|
if not isinstance(value, string_types):
|
|
raise HttpConfigError("HTTP host must be a string.")
|
|
host = value.strip()
|
|
if host.startswith("[") != host.endswith("]"):
|
|
raise HttpConfigError("HTTP host contains mismatched brackets.")
|
|
if host.startswith("["):
|
|
host = host[1:-1]
|
|
if (
|
|
not host
|
|
or any(character in host for character in ("\r", "\n", "\x00", "/", "\\", "[", "]"))
|
|
or any(character.isspace() for character in host)
|
|
):
|
|
raise HttpConfigError("HTTP host must be a non-empty bind address.")
|
|
return host
|
|
|
|
|
|
def _normalize_port(value):
|
|
# type: (object) -> int
|
|
if isinstance(value, (bool, float)):
|
|
raise HttpConfigError("HTTP port must be an integer from 0 to 65535.")
|
|
try:
|
|
port = int(value)
|
|
except (TypeError, ValueError):
|
|
raise HttpConfigError("HTTP port must be an integer from 0 to 65535.")
|
|
if port < 0 or port > 65535:
|
|
raise HttpConfigError("HTTP port must be an integer from 0 to 65535.")
|
|
return port
|
|
|
|
|
|
def _normalize_token(value):
|
|
# type: (object) -> str | None
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, string_types):
|
|
raise HttpConfigError("HTTP token must be a string or null.")
|
|
token = value
|
|
if not token:
|
|
raise HttpConfigError("HTTP token must be non-empty or null.")
|
|
if "\r" in token or "\n" in token or "\x00" in token:
|
|
raise HttpConfigError("HTTP token cannot contain control characters.")
|
|
try:
|
|
encoded = token.encode("ascii")
|
|
except (UnicodeDecodeError, UnicodeEncodeError):
|
|
raise HttpConfigError("HTTP token must contain visible ASCII characters only.")
|
|
if any(byte < 0x21 or byte > 0x7E for byte in bytearray(encoded)):
|
|
raise HttpConfigError("HTTP token must contain visible ASCII characters only.")
|
|
return token
|
|
|
|
|
|
def normalize_origin(value):
|
|
# type: (object) -> str
|
|
"""Validate and canonicalize one exact HTTP origin."""
|
|
if not isinstance(value, string_types):
|
|
raise HttpConfigError("HTTP origins must contain strings.")
|
|
origin = value.strip()
|
|
if any(character.isspace() or ord(character) < 0x20 for character in origin):
|
|
raise HttpConfigError("HTTP origin is invalid: {}.".format(value))
|
|
try:
|
|
parsed = urlparse(origin)
|
|
port = parsed.port
|
|
except (AttributeError, TypeError, ValueError):
|
|
raise HttpConfigError("HTTP origin is invalid: {}.".format(value))
|
|
if (
|
|
parsed.scheme.lower() not in ("http", "https")
|
|
or not parsed.hostname
|
|
or parsed.username is not None
|
|
or parsed.password is not None
|
|
or parsed.path not in ("", "/")
|
|
or parsed.params
|
|
or parsed.query
|
|
or parsed.fragment
|
|
):
|
|
raise HttpConfigError("HTTP origin must be an exact http(s) origin: {}.".format(value))
|
|
|
|
scheme = parsed.scheme.lower()
|
|
hostname = parsed.hostname.lower()
|
|
if ":" in hostname:
|
|
hostname = "[{}]".format(hostname)
|
|
default_port = 80 if scheme == "http" else 443
|
|
suffix = "" if port in (None, default_port) else ":{}".format(port)
|
|
return "{}://{}{}".format(scheme, hostname, suffix)
|
|
|
|
|
|
def _normalize_origins(value):
|
|
# type: (object) -> list[str]
|
|
if isinstance(value, string_types):
|
|
values = [item.strip() for item in value.replace(";", ",").split(",") if item.strip()]
|
|
elif isinstance(value, (list, tuple)):
|
|
values = list(value)
|
|
else:
|
|
raise HttpConfigError("HTTP origins must be an array.")
|
|
|
|
origins = []
|
|
for item in values:
|
|
origin = normalize_origin(item)
|
|
if origin not in origins:
|
|
origins.append(origin)
|
|
return origins
|
|
|
|
|
|
def normalize_http_settings(value=None, enforce_bind_auth=True):
|
|
# type: (dict | None, bool) -> dict
|
|
"""Normalize a complete or partial HTTP settings object."""
|
|
if value is None:
|
|
value = {}
|
|
if not isinstance(value, dict):
|
|
raise HttpConfigError("http must be an object.")
|
|
unknown = set(value) - {"host", "port", "token", "origins"}
|
|
if unknown:
|
|
raise HttpConfigError("Unsupported HTTP setting: {}.".format(sorted(unknown)[0]))
|
|
|
|
settings = {
|
|
"host": _normalize_host(value.get("host", DEFAULT_HTTP_HOST)),
|
|
"port": _normalize_port(value.get("port", DEFAULT_HTTP_PORT)),
|
|
"token": _normalize_token(value.get("token")),
|
|
"origins": _normalize_origins(value["origins"] if "origins" in value else []),
|
|
}
|
|
if enforce_bind_auth and not is_loopback_host(settings["host"]) and settings["token"] is None:
|
|
raise HttpConfigError("A non-empty HTTP token is required for non-loopback listeners.")
|
|
return settings
|
|
|
|
|
|
def resolve_http_settings(cli_config=None, document=None, env_config=None):
|
|
# type: (dict | None, dict | None, dict | None) -> dict
|
|
"""Resolve CLI > JSON > environment > defaults HTTP settings."""
|
|
cli_config = cli_config or {}
|
|
document = document or {}
|
|
env_config = env_config or {}
|
|
if not isinstance(document, dict):
|
|
raise HttpConfigError("Configuration root must be an object.")
|
|
|
|
merged = {}
|
|
env_keys = {"host": "http_host", "port": "http_port", "token": "http_token", "origins": "http_origins"}
|
|
for key, env_key in env_keys.items():
|
|
if env_key in env_config:
|
|
merged[key] = env_config[env_key]
|
|
|
|
if "http" in document:
|
|
json_settings = document["http"]
|
|
if not isinstance(json_settings, dict):
|
|
raise HttpConfigError("http must be an object.")
|
|
if "origins" in json_settings and not isinstance(json_settings["origins"], (list, tuple)):
|
|
raise HttpConfigError("HTTP origins must be an array.")
|
|
merged.update(json_settings)
|
|
|
|
cli_keys = {"host": "host", "port": "port", "token": "http_token", "origins": "http_origins"}
|
|
for key, cli_key in cli_keys.items():
|
|
if cli_config.get(cli_key) is not None:
|
|
merged[key] = cli_config[cli_key]
|
|
return normalize_http_settings(merged)
|
|
|
|
|
|
__all__ = [
|
|
"DEFAULT_HTTP_HOST",
|
|
"DEFAULT_HTTP_PORT",
|
|
"HTTP_CONNECTION_TIMEOUT",
|
|
"HttpConfigError",
|
|
"is_loopback_host",
|
|
"normalize_http_settings",
|
|
"normalize_origin",
|
|
"request_token_matches",
|
|
"resolve_http_settings",
|
|
]
|