Files
3c8578e9f4 feat(mcp): add Streamable HTTP transport (#736)
* 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>
2026-08-23 21:36:26 +08:00

193 lines
7.4 KiB
Python

# -*- coding:utf-8 -*-
"""
Configuration file loader for DDNS. supports both JSON and AST parsing.
@author: NewFuture
"""
from ast import literal_eval
from json import loads as json_decode, dumps as json_encode
from sys import stderr, stdout
from ..util.comment import remove_comment
from ..util.http import request
from ..util.fileio import read_file, write_file
DEFAULT_CONFIG_PATHS = ("config.json", "~/.ddns/config.json", "~/.ddns.json", "/etc/ddns/config.json", "/etc/ddns.json")
def _process_multi_providers(config):
# type: (dict) -> list[dict]
"""Process v4.1 providers format and return list of configs."""
result = []
# 提取全局配置(除providers之外的所有配置)
global_config = _flatten_single_config(config, exclude_keys=["providers"], preserve_keys=["extra", "http"])
# 检查providers和dns字段不能同时使用
if global_config.get("dns"):
stderr.write("Error: 'providers' and 'dns' fields cannot be used simultaneously in config file!\n")
raise ValueError("providers and dns fields conflict")
# 为每个provider创建独立配置
for provider_config in config["providers"]:
# 验证provider必须有provider字段
if not provider_config.get("provider"):
stderr.write("Error: Each provider must have a 'provider' field!\n")
raise ValueError("provider missing provider field")
flat_config = global_config.copy() # 从全局配置开始
provider_flat = _flatten_single_config(provider_config, exclude_keys=["provider"], preserve_keys=["extra"])
flat_config["dns"] = provider_config.get("provider")
flat_config.update(provider_flat)
result.append(flat_config)
return result
def _flatten_single_config(config, exclude_keys=None, preserve_keys=None):
# type: (dict, list[str]|None, list[str]|None) -> dict
"""
Flatten a single config object with optional key exclusion and preservation.
Args:
config: Configuration dictionary to flatten
exclude_keys: Keys to completely exclude from result
preserve_keys: Keys to keep as nested dicts without flattening
"""
if exclude_keys is None:
exclude_keys = []
if preserve_keys is None:
preserve_keys = []
flat_config = {}
for k, v in config.items():
if k in exclude_keys:
continue
if k in preserve_keys:
# Keep as-is without flattening
flat_config[k] = v
elif isinstance(v, dict):
for subk, subv in v.items():
flat_config["{}_{}".format(k, subk)] = subv
else:
flat_config[k] = v
return flat_config
def load_config(config_path, proxy=None, ssl="auto", raw=False, timeout=None):
# type: (str, list[str] | None, bool | str, bool, float | None) -> dict|list[dict]
"""
加载配置文件并返回配置字典或配置字典数组。
支持本地文件和远程HTTP(S) URL。
对于单个对象返回dict,对于数组返回list[dict]。
优先尝试JSON解析,失败后尝试AST解析。
Args:
config_path (str): 配置文件路径或HTTP(S) URL
proxy (list[str] | None): 代理列表,仅用于HTTP或HTTPS请求
ssl (bool | str): SSL验证配置,仅用于HTTPS请求
raw (bool): 返回解析后的原始结构,不展开多服务商配置
timeout (float | None): 远程配置单次请求的超时秒数
Returns:
dict|list[dict]: 配置字典或配置字典数组
Raises:
Exception: 当配置文件加载失败时抛出异常
"""
try:
# 检查是否为远程URL
if "://" in config_path:
# 使用HTTP请求获取远程配置
request_options = {"proxies": proxy, "verify": ssl, "retries": 3}
if timeout is not None:
request_options["timeout"] = timeout
response = request("GET", config_path, **request_options)
if (response.status not in (200, None)) or not response.body:
stderr.write("Failed to load {}: HTTP {} {}\n".format(config_path, response.status, response.reason))
stderr.write("Response body: %s\n" % response.body)
raise Exception("HTTP {}: {}".format(response.status, response.reason))
content = response.body
else:
# 本地文件加载
content = read_file(config_path)
# 移除注释后尝试JSON解析
content_without_comments = remove_comment(content)
try:
config = json_decode(content_without_comments)
except (ValueError, SyntaxError) as json_error:
# JSON解析失败,尝试AST解析
try:
config = literal_eval(content)
stdout.write("Successfully loaded config file with AST parser: %s\n" % config_path)
except (ValueError, SyntaxError) as ast_error:
if config_path.endswith(".json"):
stderr.write("JSON parsing failed for %s\n" % (config_path))
raise json_error
stderr.write(
"Both JSON and AST parsing failed for %s\nJSON Error: %s\nAST Error: %s\n"
% (config_path, json_error, ast_error)
)
raise ast_error
except Exception as e:
stderr.write("Failed to load config file `%s`: %s\n" % (config_path, e))
raise
if raw:
return config
# 处理配置格式:v4.1 providers格式或单个对象
if "providers" in config and isinstance(config["providers"], list):
return _process_multi_providers(config)
else:
return _flatten_single_config(config, preserve_keys=["extra", "http"])
def save_config(config_path, config):
# type: (str, dict) -> bool
"""
保存配置到文件。
Args:
config_path (str): 配置文件路径
config (dict): 配置字典
Returns:
bool: 保存成功返回True
Raises:
Exception: 保存失败时抛出异常
"""
# 补全默认配置
config = {
"$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
"dns": config.get("dns", "debug"),
"id": config.get("id", "YOUR ID or EMAIL for DNS Provider"),
"token": config.get("token", "YOUR TOKEN or KEY for DNS Provider"),
"ipv4": config.get("ipv4", ["ddns.newfuture.cc"]),
"index4": config.get("index4", ["default"]),
"ipv6": config.get("ipv6", []),
"index6": config.get("index6", []),
"ttl": config.get("ttl", 600),
"line": config.get("line"),
"proxy": config.get("proxy", []),
"cache": config.get("cache", True),
"cache_max_age": config.get("cache_max_age", 259200),
"ssl": config.get("ssl", "auto"),
"log": {
"file": config.get("log_file"),
"level": config.get("log_level", "INFO"),
"format": config.get("log_format"),
"datefmt": config.get("log_datefmt"),
},
}
try:
content = json_encode(config, indent=2, ensure_ascii=False)
# Python 2 兼容性:检查是否需要解码
if hasattr(content, "decode"):
content = content.decode("utf-8") # type: ignore
write_file(config_path, content)
return True
except Exception:
stderr.write("Cannot open config file to write: `%s`!\n" % config_path)
raise