Files
DDNS/tests/test_config_cli_mcp.py
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

161 lines
6.4 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for the local MCP CLI command."""
from __future__ import unicode_literals
import sys
from __init__ import mock, patch, unittest
from ddns.config.cli import _handle_mcp_command, load_config
class TestMcpSubcommand(unittest.TestCase):
"""Test MCP command parsing and local configuration restrictions."""
def setUp(self):
"""Preserve command-line arguments between tests."""
self.original_argv = sys.argv[:]
def tearDown(self):
"""Restore command-line arguments after each test."""
sys.argv = self.original_argv
def test_mcp_subcommand_defaults(self):
"""Start MCP with the conventional local config resolution."""
sys.argv = ["ddns", "mcp"]
captured = [None]
with patch("ddns.config.cli._handle_mcp_command") as handler:
handler.side_effect = lambda args: captured.__setitem__(0, args)
with self.assertRaises(SystemExit) as context:
load_config("Test DDNS", "Test doc", "1.0.0", "2026-08-12")
self.assertEqual(context.exception.code, 0)
self.assertEqual(captured[0]["command"], "mcp")
self.assertIsNone(captured[0]["config"])
self.assertEqual(captured[0]["transport"], "stdio")
self.assertIsNone(captured[0]["host"])
self.assertIsNone(captured[0]["port"])
def test_mcp_subcommand_accepts_local_config(self):
"""Pass one explicit local configuration to the MCP handler."""
sys.argv = ["ddns", "mcp", "-c", "config.json"]
captured = [None]
with patch("ddns.config.cli._handle_mcp_command") as handler:
handler.side_effect = lambda args: captured.__setitem__(0, args)
with self.assertRaises(SystemExit):
load_config("Test DDNS", "Test doc", "1.0.0", "2026-08-12")
self.assertEqual(captured[0]["config"], "config.json")
def test_mcp_subcommand_rejects_multiple_configs(self):
"""Keep the MCP service bound to one deterministic local configuration."""
sys.argv = ["ddns", "mcp", "-c", "first.json", "-c", "second.json"]
with self.assertRaises(SystemExit) as context:
load_config("Test DDNS", "Test doc", "1.0.0", "2026-08-12")
self.assertEqual(context.exception.code, 2)
def test_mcp_subcommand_rejects_http_options_with_stdio(self):
"""Fail rather than silently applying HTTP options to stdio."""
sys.argv = ["ddns", "mcp", "--host", "127.0.0.1"]
with self.assertRaises(SystemExit) as context:
load_config("Test DDNS", "Test doc", "1.0.0", "2026-08-12")
self.assertEqual(context.exception.code, 2)
@patch("ddns.config.cli.basicConfig")
@patch("ddns.mcp.serve")
@patch("ddns.config.cli.load_env_config", return_value={})
def test_mcp_handler_starts_stdio_server(self, mock_load_env, mock_serve, mock_basic_config):
"""Pass the selected path to the stdio server."""
_handle_mcp_command({"config": "config.json"})
mock_basic_config.assert_called_once_with()
mock_load_env.assert_not_called()
mock_serve.assert_called_once_with(config_path="config.json")
@patch("ddns.config.cli.basicConfig")
@patch("ddns.mcp.serve")
@patch("ddns.config.cli.load_env_config", return_value={"config": "environment.json"})
def test_mcp_handler_uses_environment_config(self, mock_load_env, mock_serve, mock_basic_config):
"""Use DDNS_CONFIG when no explicit path is supplied."""
_handle_mcp_command({})
mock_basic_config.assert_called_once_with()
mock_load_env.assert_called_once_with()
mock_serve.assert_called_once_with(config_path="environment.json")
@patch("ddns.mcp.serve")
def test_mcp_handler_rejects_remote_config(self, mock_serve):
"""Do not let the local stdio server fetch remote credential documents."""
with self.assertRaises(SystemExit) as context:
_handle_mcp_command({"config": "https://example.com/config.json"})
self.assertEqual(context.exception.code, 2)
mock_serve.assert_not_called()
@patch("ddns.mcp.serve")
@patch("ddns.config.cli.load_env_config", return_value={"config": ["one.json", "two.json"]})
def test_mcp_handler_rejects_multiple_environment_configs(self, mock_load_env, mock_serve):
"""Reject ambiguous DDNS_CONFIG lists."""
with self.assertRaises(SystemExit) as context:
_handle_mcp_command({})
self.assertEqual(context.exception.code, 2)
mock_load_env.assert_called_once_with()
mock_serve.assert_not_called()
def test_mcp_subcommand_parses_http_transport_options(self):
"""Expose the shared listener controls on the HTTP transport."""
sys.argv = [
"ddns",
"mcp",
"--transport",
"http",
"--host",
"0.0.0.0",
"--port",
"8765",
"--http-token",
"x",
"--http-origin",
"https://client.example",
]
captured = [None]
with patch("ddns.config.cli._handle_mcp_command") as handler:
handler.side_effect = lambda args: captured.__setitem__(0, args)
with self.assertRaises(SystemExit) as context:
load_config("Test DDNS", "Test doc", "1.0.0", "2026-08-12")
self.assertEqual(context.exception.code, 0)
self.assertEqual(captured[0]["transport"], "http")
self.assertEqual(captured[0]["host"], "0.0.0.0")
self.assertEqual(captured[0]["port"], 8765)
self.assertEqual(captured[0]["http_token"], "x")
self.assertEqual(captured[0]["http_origins"], ["https://client.example"])
@patch("ddns.mcp_http.serve")
@patch("ddns.config.cli.load_env_config", return_value={"http_host": "0.0.0.0", "http_token": "environment-secret"})
@patch("ddns.config.cli.basicConfig")
def test_mcp_handler_starts_http_server(self, mock_basic_config, mock_load_env, mock_serve):
"""Resolve shared settings before starting standalone HTTP MCP."""
_handle_mcp_command({"transport": "http", "port": 0})
mock_basic_config.assert_called_once_with()
mock_load_env.assert_called_once_with()
mock_serve.assert_called_once_with(
config_path=mock.ANY,
settings={"host": "0.0.0.0", "port": 0, "token": "environment-secret", "origins": []},
logger=mock.ANY,
)
if __name__ == "__main__":
unittest.main()