1+ #!/usr/bin/env python3
2+ # Copyright (c) 2025 Scott Wilcox
3+ # SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+ """
6+ HTTP Compliance Report Generator
7+
8+ This script runs the full compliance test suite against an HTTP-based MCP server.
9+ It uses the same comprehensive test cases as the STDIO compliance report but adapts
10+ them to use HTTP transport.
11+ """
12+
13+ import argparse
14+ import asyncio
15+ import json
16+ import os
17+ import sys
18+ import time
19+ from datetime import datetime
20+ from pathlib import Path
21+
22+ # Add the parent directory to the Python path
23+ parent_dir = Path (__file__ ).resolve ().parent .parent .parent
24+ sys .path .append (str (parent_dir ))
25+
26+ from mcp_testing .utils .runner import run_tests
27+ from mcp_testing .utils .reporter import results_to_markdown , extract_server_name , generate_markdown_report
28+ from mcp_testing .tests .base_protocol .test_initialization import TEST_CASES as INIT_TEST_CASES
29+ from mcp_testing .tests .features .test_tools import TEST_CASES as TOOLS_TEST_CASES
30+ from mcp_testing .tests .features .test_async_tools import TEST_CASES as ASYNC_TOOLS_TEST_CASES
31+ from mcp_testing .tests .features .dynamic_tool_tester import TEST_CASES as DYNAMIC_TOOL_TEST_CASES
32+ from mcp_testing .tests .features .dynamic_async_tools import TEST_CASES as DYNAMIC_ASYNC_TEST_CASES
33+ from mcp_testing .tests .specification_coverage import TEST_CASES as SPEC_COVERAGE_TEST_CASES
34+ from mcp_testing .transports .http import HttpTransportAdapter
35+
36+ def log_with_timestamp (message ):
37+ """Log a message with a timestamp prefix."""
38+ timestamp = datetime .now ().strftime ("%Y-%m-%d %H:%M:%S" )
39+ print (f"[{ timestamp } ] { message } " )
40+
41+ async def main ():
42+ """Run the compliance tests using HTTP transport and generate a report."""
43+ parser = argparse .ArgumentParser (description = "Generate an MCP HTTP server compliance report" )
44+
45+ # Server configuration
46+ parser .add_argument ("--server-url" , required = True , help = "URL of the HTTP server" )
47+ parser .add_argument ("--protocol-version" , choices = ["2024-11-05" , "2025-03-26" ],
48+ default = "2025-03-26" , help = "Protocol version to use" )
49+
50+ # Output options
51+ parser .add_argument ("--output-dir" , default = "reports" , help = "Directory to store the report files" )
52+ parser .add_argument ("--report-prefix" , default = "cr" , help = "Prefix for report filenames (default: 'cr')" )
53+ parser .add_argument ("--json" , action = "store_true" , help = "Generate a JSON report" )
54+
55+ # Debug and control options
56+ parser .add_argument ("--debug" , action = "store_true" , help = "Enable debug output" )
57+ parser .add_argument ("--skip-async" , action = "store_true" , help = "Skip async tests" )
58+ parser .add_argument ("--skip-tests" , help = "Comma-separated list of test names to skip" )
59+ parser .add_argument ("--dynamic-only" , action = "store_true" , help = "Only run dynamic tools tests" )
60+ parser .add_argument ("--test-mode" , choices = ["all" , "core" , "tools" , "async" , "spec" ], default = "all" ,
61+ help = "Test mode: all, core, tools, async, or spec" )
62+ parser .add_argument ("--spec-coverage-only" , action = "store_true" ,
63+ help = "Only run tests for spec coverage" )
64+ parser .add_argument ("--test-timeout" , type = int , default = 30 ,
65+ help = "Timeout in seconds for individual tests" )
66+ parser .add_argument ("--tools-timeout" , type = int , default = 30 ,
67+ help = "Timeout in seconds for tools tests" )
68+ parser .add_argument ("--verbose" , action = "store_true" , help = "Enable verbose output" )
69+
70+ args = parser .parse_args ()
71+
72+ # Parse tests to skip
73+ skip_tests = []
74+ if args .skip_tests :
75+ skip_tests = [t .strip () for t in args .skip_tests .split (',' )]
76+
77+ if skip_tests :
78+ log_with_timestamp (f"Skipping tests: { ', ' .join (skip_tests )} " )
79+
80+ # Ensure output directory exists
81+ output_dir = os .path .join (parent_dir , args .output_dir )
82+ os .makedirs (output_dir , exist_ok = True )
83+
84+ # Generate timestamp for report filenames
85+ timestamp = datetime .now ().strftime ("%Y%m%d_%H%M%S" )
86+
87+ # Collect test cases based on test mode and flags
88+ tests = []
89+
90+ if args .dynamic_only :
91+ log_with_timestamp ("Running in dynamic-only mode - tests will adapt to the server's capabilities" )
92+ tests .extend (INIT_TEST_CASES ) # Always include initialization tests
93+ tests .extend (DYNAMIC_TOOL_TEST_CASES )
94+
95+ if args .protocol_version == "2025-03-26" and not args .skip_async :
96+ tests .extend (DYNAMIC_ASYNC_TEST_CASES )
97+
98+ elif args .spec_coverage_only :
99+ log_with_timestamp ("Running specification coverage tests only" )
100+ tests .extend (SPEC_COVERAGE_TEST_CASES )
101+
102+ else :
103+ # Normal mode - collect tests based on test_mode
104+ if args .test_mode in ["all" , "core" ]:
105+ tests .extend (INIT_TEST_CASES )
106+
107+ if args .test_mode in ["all" , "tools" ]:
108+ tests .extend (TOOLS_TEST_CASES )
109+
110+ if args .test_mode in ["all" , "async" ] and args .protocol_version == "2025-03-26" and not args .skip_async :
111+ tests .extend (ASYNC_TOOLS_TEST_CASES )
112+
113+ if args .test_mode in ["all" , "spec" ]:
114+ tests .extend (SPEC_COVERAGE_TEST_CASES )
115+
116+ # Filter out tests to skip
117+ if skip_tests :
118+ original_count = len (tests )
119+ tests = [(func , name ) for func , name in tests if name not in skip_tests ]
120+ skipped_count = original_count - len (tests )
121+ if skipped_count > 0 :
122+ log_with_timestamp (f"Skipped { skipped_count } tests based on configuration" )
123+
124+ log_with_timestamp (f"Running compliance tests for protocol { args .protocol_version } ..." )
125+ log_with_timestamp (f"Server URL: { args .server_url } " )
126+ log_with_timestamp (f"Test mode: { args .test_mode } " )
127+ log_with_timestamp (f"Total tests to run: { len (tests )} " )
128+
129+ # Create HTTP transport adapter
130+ transport = HttpTransportAdapter (
131+ server_url = args .server_url ,
132+ debug = args .debug ,
133+ timeout = args .test_timeout
134+ )
135+
136+ # Run the tests
137+ start_time = time .time ()
138+
139+ # Set timeouts based on arguments
140+ test_timeout = args .test_timeout
141+ tools_timeout = args .tools_timeout
142+
143+ # Group tests by type and run with appropriate timeouts
144+ tool_tests = [(func , name ) for func , name in tests if name .startswith ("test_tool_" ) or name .startswith ("test_tools_" )]
145+ non_tool_tests = [(func , name ) for func , name in tests if not (name .startswith ("test_tool_" ) or name .startswith ("test_tools_" ))]
146+
147+ results = {
148+ "results" : [],
149+ "total" : len (tests ),
150+ "passed" : 0 ,
151+ "failed" : 0 ,
152+ "skipped" : 0 ,
153+ "timeouts" : 0
154+ }
155+
156+ # Run non-tool tests first with standard timeout
157+ if non_tool_tests :
158+ log_with_timestamp (f"Running { len (non_tool_tests )} non-tool tests with { test_timeout } s timeout" )
159+ non_tool_results = await run_tests (
160+ non_tool_tests ,
161+ protocol = args .protocol_version ,
162+ transport = "http" , # Use HTTP transport
163+ server_command = args .server_url , # Pass server URL as the command
164+ env_vars = {}, # No environment variables needed for HTTP
165+ debug = args .debug ,
166+ timeout = test_timeout
167+ )
168+ results ["results" ].extend (non_tool_results ["results" ])
169+ results ["passed" ] += non_tool_results ["passed" ]
170+ results ["failed" ] += non_tool_results ["failed" ]
171+ results ["skipped" ] += non_tool_results .get ("skipped" , 0 )
172+ results ["timeouts" ] += non_tool_results .get ("timeouts" , 0 )
173+
174+ # Run tool tests with extended timeout
175+ if tool_tests :
176+ log_with_timestamp (f"Running { len (tool_tests )} tool tests with { tools_timeout } s timeout" )
177+ tool_results = await run_tests (
178+ tool_tests ,
179+ protocol = args .protocol_version ,
180+ transport = "http" , # Use HTTP transport
181+ server_command = args .server_url , # Pass server URL as the command
182+ env_vars = {}, # No environment variables needed for HTTP
183+ debug = args .debug ,
184+ timeout = tools_timeout
185+ )
186+ results ["results" ].extend (tool_results ["results" ])
187+ results ["passed" ] += tool_results ["passed" ]
188+ results ["failed" ] += tool_results ["failed" ]
189+ results ["skipped" ] += tool_results .get ("skipped" , 0 )
190+ results ["timeouts" ] += tool_results .get ("timeouts" , 0 )
191+
192+ # Calculate compliance percentage
193+ total_tests = results ["total" ] - results ["skipped" ]
194+ compliance_percentage = (results ["passed" ] / total_tests ) * 100 if total_tests > 0 else 0
195+
196+ # Determine compliance status
197+ if compliance_percentage == 100 :
198+ compliance_status = "✅ Fully Compliant"
199+ elif compliance_percentage >= 80 :
200+ compliance_status = "⚠️ Mostly Compliant"
201+ else :
202+ compliance_status = "❌ Non-Compliant"
203+
204+ log_with_timestamp ("\n Compliance Test Results:" )
205+ log_with_timestamp (f"Total tests: { results ['total' ]} " )
206+ log_with_timestamp (f"Passed: { results ['passed' ]} " )
207+ log_with_timestamp (f"Failed: { results ['failed' ]} " )
208+ log_with_timestamp (f"Skipped: { results ['skipped' ]} " )
209+ log_with_timestamp (f"Compliance Status: { compliance_status } ({ compliance_percentage :.1f} %)" )
210+
211+ # Generate report filename
212+ server_name = "HTTP MCP Server" # Generic name for HTTP server
213+ report_basename = f"cr_{ server_name } _{ args .protocol_version } _{ timestamp } "
214+
215+ # Generate markdown report
216+ markdown_lines = [
217+ f"# { server_name } MCP Compliance Report" ,
218+ "" ,
219+ "## Server Information" ,
220+ "" ,
221+ f"- **Server URL**: `{ args .server_url } `" ,
222+ f"- **Protocol Version**: { args .protocol_version } " ,
223+ f"- **Test Date**: { datetime .now ().strftime ('%Y-%m-%d %H:%M:%S' )} " ,
224+ "" ,
225+ "## Summary" ,
226+ "" ,
227+ f"- **Total Tests**: { results ['total' ]} " ,
228+ f"- **Passed**: { results ['passed' ]} ({ (results ['passed' ] / total_tests * 100 ) if total_tests > 0 else 0 :.1f} %)" ,
229+ f"- **Failed**: { results ['failed' ]} ({ (results ['failed' ] / total_tests * 100 ) if total_tests > 0 else 0 :.1f} %)" ,
230+ f"- **Skipped**: { results ['skipped' ]} " ,
231+ "" ,
232+ f"**Compliance Status**: { compliance_status } ({ compliance_percentage :.1f} %)" ,
233+ "" ,
234+ "## Detailed Results" ,
235+ "" ,
236+ "### Passed Tests" ,
237+ ""
238+ ]
239+
240+ # Add passed tests
241+ passed_tests = [r for r in results ["results" ] if r .get ("passed" , False )]
242+ if passed_tests :
243+ markdown_lines .append ("| Test | Duration | Message |" )
244+ markdown_lines .append ("|------|----------|---------|" )
245+ for test in passed_tests :
246+ test_name = test .get ("name" , "" ).replace ("test_" , "" ).replace ("_" , " " ).title ()
247+ duration = f"{ test .get ('duration' , 0 ):.2f} s"
248+ message = test .get ("message" , "" )
249+ markdown_lines .append (f"| { test_name } | { duration } | { message } |" )
250+ else :
251+ markdown_lines .append ("No tests passed." )
252+
253+ markdown_lines .extend ([
254+ "" ,
255+ "### Failed Tests" ,
256+ ""
257+ ])
258+
259+ # Add failed tests
260+ failed_tests = [r for r in results ["results" ] if not r .get ("passed" , False )]
261+ if failed_tests :
262+ markdown_lines .append ("| Test | Duration | Error Message |" )
263+ markdown_lines .append ("|------|----------|--------------|" )
264+ for test in failed_tests :
265+ test_name = test .get ("name" , "" ).replace ("test_" , "" ).replace ("_" , " " ).title ()
266+ duration = f"{ test .get ('duration' , 0 ):.2f} s"
267+ message = test .get ("message" , "" )
268+ markdown_lines .append (f"| { test_name } | { duration } | { message } |" )
269+ else :
270+ markdown_lines .append ("All tests passed! 🎉" )
271+
272+ # Write markdown report
273+ markdown_content = "\n " .join (markdown_lines )
274+ markdown_report_path = os .path .join (output_dir , f"{ report_basename } .md" )
275+ with open (markdown_report_path , "w" ) as f :
276+ f .write (markdown_content )
277+
278+ log_with_timestamp (f"Markdown compliance report generated: { markdown_report_path } " )
279+
280+ # Generate JSON report if requested
281+ if args .json :
282+ json_report = {
283+ "server" : server_name ,
284+ "server_url" : args .server_url ,
285+ "protocol_version" : args .protocol_version ,
286+ "timestamp" : timestamp ,
287+ "total_tests" : results ["total" ],
288+ "passed_tests" : results ["passed" ],
289+ "failed_tests" : results ["failed" ],
290+ "skipped_tests" : results ["skipped" ],
291+ "compliance_percentage" : compliance_percentage ,
292+ "compliance_status" : compliance_status ,
293+ "results" : results ["results" ]
294+ }
295+
296+ json_report_path = os .path .join (output_dir , f"{ report_basename } .json" )
297+ with open (json_report_path , "w" ) as f :
298+ json .dump (json_report , f , indent = 2 )
299+
300+ log_with_timestamp (f"JSON report saved to: { json_report_path } " )
301+
302+ # Return success if fully compliant
303+ return 0 if compliance_percentage == 100 else 1
304+
305+ if __name__ == "__main__" :
306+ sys .exit (asyncio .run (main ()))
0 commit comments