=== NEW SERVERS ADDED (7) === - servers/closebot — 119 tools, 14 modules, 4,656 lines TS (Stage 7) - servers/google-console — Google Search Console MCP (Stage 7) - servers/meta-ads — Meta/Facebook Ads MCP (Stage 8) - servers/twilio — Twilio communications MCP (Stage 8) - servers/competitor-research — Competitive intel MCP (Stage 6) - servers/n8n-apps — n8n workflow MCP apps (Stage 6) - servers/reonomy — Commercial real estate MCP (Stage 1) === FACTORY INFRASTRUCTURE ADDED === - infra/factory-tools — mcp-jest, mcp-validator, mcp-add, MCP Inspector - 60 test configs, 702 auto-generated test cases - All 30 servers score 100/100 protocol compliance - infra/command-center — Pipeline state, operator playbook, dashboard config - infra/factory-reviews — Automated eval reports === DOCS ADDED === - docs/MCP-FACTORY.md — Factory overview - docs/reports/ — 5 pipeline evaluation reports - docs/research/ — Browser MCP research === RULES ESTABLISHED === - CONTRIBUTING.md — All MCP work MUST go in this repo - README.md — Full inventory of 37 servers + infra docs - .gitignore — Updated for Python venvs TOTAL: 37 MCP servers + full factory pipeline in one repo. This is now the single source of truth for all MCP work.
1910 lines
77 KiB
Python
1910 lines
77 KiB
Python
# Copyright (c) 2025 Scott Wilcox
|
||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
||
"""
|
||
MCP HTTP Server Tester
|
||
|
||
A class for testing MCP HTTP server implementations.
|
||
"""
|
||
|
||
import json
|
||
import uuid
|
||
import requests
|
||
import socket
|
||
import time
|
||
import base64
|
||
import hashlib
|
||
import secrets
|
||
import urllib.parse
|
||
from urllib.parse import urlparse, urljoin, parse_qs
|
||
|
||
class MCPHttpTester:
|
||
"""Class to test an MCP HTTP server implementation."""
|
||
|
||
def __init__(self, url, debug=False):
|
||
"""
|
||
Initialize the tester with the server URL.
|
||
|
||
Args:
|
||
url: The URL of the MCP server
|
||
debug: Whether to print debug information
|
||
"""
|
||
self.url = url
|
||
self.debug = debug
|
||
|
||
# Parse the URL
|
||
parsed_url = urlparse(url)
|
||
self.host = parsed_url.netloc
|
||
self.path = parsed_url.path or "/"
|
||
self.base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||
|
||
# Session information
|
||
self.session_id = None
|
||
self.initialized = False
|
||
|
||
# OAuth information
|
||
self.oauth_server_metadata = None
|
||
self.bearer_token = None
|
||
|
||
# Protocol information
|
||
self.protocol_version = "2025-03-26"
|
||
|
||
# Create a persistent session for all requests
|
||
self.request_session = requests.Session()
|
||
self.request_session.headers.update({
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json, text/event-stream"
|
||
})
|
||
|
||
self.log(f"MCP HTTP Tester initialized for {url}")
|
||
self.log(f"Host: {self.host}, Path: {self.path}")
|
||
|
||
def log(self, message):
|
||
"""Print a log message if debug is enabled."""
|
||
if self.debug:
|
||
print(f"[DEBUG] {message}")
|
||
|
||
def fetch_oauth_server_metadata(self):
|
||
"""
|
||
Fetch OAuth server metadata from .well-known/oauth-authorization-server.
|
||
|
||
Returns:
|
||
dict: OAuth server metadata or None if not available
|
||
"""
|
||
try:
|
||
well_known_url = urljoin(self.base_url, "/.well-known/oauth-authorization-server")
|
||
self.log(f"Fetching OAuth server metadata from: {well_known_url}")
|
||
|
||
response = self.request_session.get(well_known_url, timeout=5)
|
||
if response.status_code == 200:
|
||
metadata = response.json()
|
||
self.oauth_server_metadata = metadata
|
||
self.log(f"OAuth server metadata retrieved: {metadata}")
|
||
return metadata
|
||
else:
|
||
self.log(f"OAuth server metadata not available, status: {response.status_code}")
|
||
return None
|
||
except Exception as e:
|
||
self.log(f"Failed to fetch OAuth server metadata: {str(e)}")
|
||
return None
|
||
|
||
def generate_pkce_challenge(self):
|
||
"""
|
||
Generate PKCE code verifier and challenge.
|
||
|
||
Returns:
|
||
tuple: (code_verifier, code_challenge)
|
||
"""
|
||
# Generate code verifier (43-128 characters)
|
||
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
|
||
|
||
# Generate code challenge (SHA256 hash of verifier)
|
||
code_challenge = base64.urlsafe_b64encode(
|
||
hashlib.sha256(code_verifier.encode('utf-8')).digest()
|
||
).decode('utf-8').rstrip('=')
|
||
|
||
return code_verifier, code_challenge
|
||
|
||
def test_oauth_authorization_code_flow(self, metadata):
|
||
"""
|
||
Test OAuth 2.1 authorization code flow with PKCE.
|
||
|
||
Args:
|
||
metadata: OAuth server metadata
|
||
|
||
Returns:
|
||
dict: Test results and token information
|
||
"""
|
||
print("Testing OAuth 2.1 authorization code flow...")
|
||
|
||
result = {
|
||
"flow_supported": False,
|
||
"pkce_supported": False,
|
||
"token_obtained": False,
|
||
"refresh_supported": False,
|
||
"errors": []
|
||
}
|
||
|
||
try:
|
||
# Check if authorization code flow is supported
|
||
supported_flows = metadata.get("response_types_supported", [])
|
||
if "code" not in supported_flows:
|
||
result["errors"].append("Authorization code flow not supported")
|
||
return result
|
||
|
||
result["flow_supported"] = True
|
||
print("✅ Authorization code flow supported")
|
||
|
||
# Check PKCE support
|
||
pkce_methods = metadata.get("code_challenge_methods_supported", [])
|
||
if "S256" not in pkce_methods:
|
||
result["errors"].append("PKCE S256 method not supported")
|
||
else:
|
||
result["pkce_supported"] = True
|
||
print("✅ PKCE S256 method supported")
|
||
|
||
# Generate PKCE parameters
|
||
code_verifier, code_challenge = self.generate_pkce_challenge()
|
||
|
||
# Build authorization URL
|
||
auth_endpoint = metadata.get("authorization_endpoint")
|
||
if not auth_endpoint:
|
||
result["errors"].append("No authorization endpoint found")
|
||
return result
|
||
|
||
auth_params = {
|
||
"response_type": "code",
|
||
"client_id": "mcp-validator-test",
|
||
"redirect_uri": "http://localhost:8080/callback",
|
||
"scope": "mcp:read mcp:write",
|
||
"state": str(uuid.uuid4()),
|
||
"code_challenge": code_challenge,
|
||
"code_challenge_method": "S256"
|
||
}
|
||
|
||
# Add resource indicator if supported (RFC 8707)
|
||
if "resource_indicators_supported" in metadata:
|
||
auth_params["resource"] = self.base_url
|
||
|
||
auth_url = f"{auth_endpoint}?{urllib.parse.urlencode(auth_params)}"
|
||
|
||
print(f"ℹ️ Authorization URL constructed: {auth_url}")
|
||
print("ℹ️ In a real implementation, user would be redirected to this URL")
|
||
|
||
# Simulate authorization code receipt (in real implementation, this would come from redirect)
|
||
# For testing purposes, we'll simulate the flow
|
||
simulated_code = "test_authorization_code_123"
|
||
|
||
# Test token exchange
|
||
token_endpoint = metadata.get("token_endpoint")
|
||
if not token_endpoint:
|
||
result["errors"].append("No token endpoint found")
|
||
return result
|
||
|
||
token_params = {
|
||
"grant_type": "authorization_code",
|
||
"code": simulated_code,
|
||
"redirect_uri": auth_params["redirect_uri"],
|
||
"client_id": auth_params["client_id"],
|
||
"code_verifier": code_verifier
|
||
}
|
||
|
||
print("ℹ️ Testing token exchange endpoint...")
|
||
|
||
# Note: In a real test, we would actually call the token endpoint
|
||
# For this implementation, we simulate the response
|
||
result["token_obtained"] = True
|
||
print("✅ Token exchange flow validated")
|
||
|
||
# Test refresh token support
|
||
if "refresh_token" in metadata.get("grant_types_supported", []):
|
||
result["refresh_supported"] = True
|
||
print("✅ Refresh token flow supported")
|
||
else:
|
||
print("ℹ️ Refresh token flow not supported")
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
result["errors"].append(f"OAuth flow test failed: {str(e)}")
|
||
return result
|
||
|
||
def test_oauth_error_scenarios(self, metadata):
|
||
"""
|
||
Test various OAuth error scenarios.
|
||
|
||
Args:
|
||
metadata: OAuth server metadata
|
||
|
||
Returns:
|
||
dict: Test results for error scenarios
|
||
"""
|
||
print("Testing OAuth error scenarios...")
|
||
|
||
result = {
|
||
"invalid_client_handled": False,
|
||
"invalid_grant_handled": False,
|
||
"invalid_scope_handled": False,
|
||
"errors": []
|
||
}
|
||
|
||
try:
|
||
token_endpoint = metadata.get("token_endpoint")
|
||
if not token_endpoint:
|
||
result["errors"].append("No token endpoint for error testing")
|
||
return result
|
||
|
||
# Test invalid client
|
||
print("Testing invalid client error...")
|
||
invalid_client_params = {
|
||
"grant_type": "authorization_code",
|
||
"code": "valid_code",
|
||
"client_id": "invalid_client_id",
|
||
"redirect_uri": "http://localhost:8080/callback"
|
||
}
|
||
|
||
try:
|
||
response = self.request_session.post(token_endpoint, data=invalid_client_params, timeout=5)
|
||
if response.status_code == 401 and "invalid_client" in response.text:
|
||
result["invalid_client_handled"] = True
|
||
print("✅ Invalid client error properly handled")
|
||
else:
|
||
print(f"⚠️ Invalid client error response: {response.status_code}")
|
||
except Exception as e:
|
||
print(f"ℹ️ Invalid client test: {str(e)}")
|
||
|
||
# Test invalid grant
|
||
print("Testing invalid grant error...")
|
||
invalid_grant_params = {
|
||
"grant_type": "authorization_code",
|
||
"code": "invalid_authorization_code",
|
||
"client_id": "mcp-validator-test",
|
||
"redirect_uri": "http://localhost:8080/callback"
|
||
}
|
||
|
||
try:
|
||
response = self.request_session.post(token_endpoint, data=invalid_grant_params, timeout=5)
|
||
if response.status_code == 400 and "invalid_grant" in response.text:
|
||
result["invalid_grant_handled"] = True
|
||
print("✅ Invalid grant error properly handled")
|
||
else:
|
||
print(f"⚠️ Invalid grant error response: {response.status_code}")
|
||
except Exception as e:
|
||
print(f"ℹ️ Invalid grant test: {str(e)}")
|
||
|
||
# Test invalid scope
|
||
print("Testing invalid scope error...")
|
||
invalid_scope_params = {
|
||
"grant_type": "authorization_code",
|
||
"code": "valid_code",
|
||
"client_id": "mcp-validator-test",
|
||
"redirect_uri": "http://localhost:8080/callback",
|
||
"scope": "invalid:scope"
|
||
}
|
||
|
||
try:
|
||
response = self.request_session.post(token_endpoint, data=invalid_scope_params, timeout=5)
|
||
if response.status_code == 400 and "invalid_scope" in response.text:
|
||
result["invalid_scope_handled"] = True
|
||
print("✅ Invalid scope error properly handled")
|
||
else:
|
||
print(f"⚠️ Invalid scope error response: {response.status_code}")
|
||
except Exception as e:
|
||
print(f"ℹ️ Invalid scope test: {str(e)}")
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
result["errors"].append(f"OAuth error scenario testing failed: {str(e)}")
|
||
return result
|
||
|
||
def test_token_audience_validation(self, token_info=None):
|
||
"""
|
||
Test token audience claim validation (prevent confused deputy attacks).
|
||
|
||
Args:
|
||
token_info: Token information for testing
|
||
|
||
Returns:
|
||
dict: Test results for audience validation
|
||
"""
|
||
print("Testing token audience validation...")
|
||
|
||
result = {
|
||
"audience_validated": False,
|
||
"confused_deputy_prevented": False,
|
||
"errors": []
|
||
}
|
||
|
||
try:
|
||
# Test with token intended for different resource
|
||
print("Testing confused deputy attack prevention...")
|
||
|
||
# Create a token intended for a different resource
|
||
wrong_audience_headers = {
|
||
"Authorization": "Bearer token_for_different_resource",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
test_request = {
|
||
"jsonrpc": "2.0",
|
||
"method": "ping",
|
||
"id": str(uuid.uuid4())
|
||
}
|
||
|
||
response = self.request_session.post(
|
||
self.url,
|
||
json=test_request,
|
||
headers=wrong_audience_headers,
|
||
timeout=5
|
||
)
|
||
|
||
# Should return 403 Forbidden or 401 Unauthorized
|
||
if response.status_code in [401, 403]:
|
||
result["confused_deputy_prevented"] = True
|
||
print("✅ Confused deputy attack prevented")
|
||
|
||
# Check for proper error message
|
||
if response.status_code == 403:
|
||
result["audience_validated"] = True
|
||
print("✅ Token audience properly validated")
|
||
else:
|
||
result["errors"].append(f"Server accepted token for wrong audience: {response.status_code}")
|
||
print(f"❌ Server accepted token for wrong audience: {response.status_code}")
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
result["errors"].append(f"Token audience validation failed: {str(e)}")
|
||
return result
|
||
|
||
def test_resource_indicators(self, metadata):
|
||
"""
|
||
Test RFC 8707 Resource Indicators support.
|
||
|
||
Args:
|
||
metadata: OAuth server metadata
|
||
|
||
Returns:
|
||
dict: Test results for resource indicators
|
||
"""
|
||
print("Testing RFC 8707 Resource Indicators...")
|
||
|
||
result = {
|
||
"resource_indicators_supported": False,
|
||
"resource_parameter_accepted": False,
|
||
"errors": []
|
||
}
|
||
|
||
try:
|
||
# Check if resource indicators are supported
|
||
if metadata.get("resource_indicators_supported"):
|
||
result["resource_indicators_supported"] = True
|
||
print("✅ Resource indicators supported by server")
|
||
|
||
# Test resource parameter in authorization request
|
||
auth_endpoint = metadata.get("authorization_endpoint")
|
||
if auth_endpoint:
|
||
auth_params = {
|
||
"response_type": "code",
|
||
"client_id": "mcp-validator-test",
|
||
"resource": self.base_url, # RFC 8707 resource parameter
|
||
"scope": "mcp:read"
|
||
}
|
||
|
||
auth_url = f"{auth_endpoint}?{urllib.parse.urlencode(auth_params)}"
|
||
print(f"ℹ️ Resource indicator URL: {auth_url}")
|
||
|
||
result["resource_parameter_accepted"] = True
|
||
print("✅ Resource parameter properly formatted")
|
||
else:
|
||
result["errors"].append("No authorization endpoint for resource indicator testing")
|
||
else:
|
||
print("ℹ️ Resource indicators not supported")
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
result["errors"].append(f"Resource indicator testing failed: {str(e)}")
|
||
return result
|
||
|
||
def test_scope_validation(self, metadata):
|
||
"""
|
||
Test OAuth scope validation.
|
||
|
||
Args:
|
||
metadata: OAuth server metadata
|
||
|
||
Returns:
|
||
dict: Test results for scope validation
|
||
"""
|
||
print("Testing OAuth scope validation...")
|
||
|
||
result = {
|
||
"mcp_scopes_supported": False,
|
||
"scope_enforcement": False,
|
||
"errors": []
|
||
}
|
||
|
||
try:
|
||
# Check supported scopes
|
||
supported_scopes = metadata.get("scopes_supported", [])
|
||
mcp_scopes = ["mcp:read", "mcp:write", "mcp:admin"]
|
||
|
||
found_mcp_scopes = [scope for scope in mcp_scopes if scope in supported_scopes]
|
||
if found_mcp_scopes:
|
||
result["mcp_scopes_supported"] = True
|
||
print(f"✅ MCP scopes supported: {found_mcp_scopes}")
|
||
else:
|
||
print("ℹ️ No explicit MCP scopes found in metadata")
|
||
|
||
# Test scope enforcement
|
||
print("Testing scope enforcement...")
|
||
|
||
# Test with insufficient scope
|
||
insufficient_scope_headers = {
|
||
"Authorization": "Bearer token_with_read_only_scope",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
# Try to call a write operation with read-only token
|
||
test_request = {
|
||
"jsonrpc": "2.0",
|
||
"method": "tools/call",
|
||
"id": str(uuid.uuid4()),
|
||
"params": {
|
||
"name": "write_operation",
|
||
"arguments": {}
|
||
}
|
||
}
|
||
|
||
response = self.request_session.post(
|
||
self.url,
|
||
json=test_request,
|
||
headers=insufficient_scope_headers,
|
||
timeout=5
|
||
)
|
||
|
||
# Should return 403 Forbidden for insufficient scope
|
||
if response.status_code == 403:
|
||
result["scope_enforcement"] = True
|
||
print("✅ Scope enforcement working properly")
|
||
else:
|
||
print(f"ℹ️ Scope enforcement test: {response.status_code}")
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
result["errors"].append(f"Scope validation testing failed: {str(e)}")
|
||
return result
|
||
|
||
def handle_401_response(self, response_headers, response_body):
|
||
"""
|
||
Handle 401 Unauthorized responses according to OAuth 2.1 spec.
|
||
|
||
Args:
|
||
response_headers: Response headers from the 401 response
|
||
response_body: Response body from the 401 response
|
||
|
||
Returns:
|
||
dict: Information about the OAuth challenge and next steps
|
||
"""
|
||
oauth_info = {
|
||
"requires_auth": True,
|
||
"www_authenticate": None,
|
||
"oauth_metadata": None,
|
||
"next_steps": []
|
||
}
|
||
|
||
# Parse WWW-Authenticate header
|
||
www_authenticate = response_headers.get("WWW-Authenticate") or response_headers.get("www-authenticate")
|
||
if www_authenticate:
|
||
oauth_info["www_authenticate"] = www_authenticate
|
||
self.log(f"Found WWW-Authenticate header: {www_authenticate}")
|
||
|
||
# Parse Bearer challenge
|
||
if "Bearer" in www_authenticate:
|
||
oauth_info["scheme"] = "Bearer"
|
||
# Extract realm, scope, etc.
|
||
challenge_params = {}
|
||
parts = www_authenticate.replace("Bearer", "").strip().split(",")
|
||
for part in parts:
|
||
if "=" in part:
|
||
key, value = part.strip().split("=", 1)
|
||
challenge_params[key.strip()] = value.strip().strip('"')
|
||
oauth_info["challenge_params"] = challenge_params
|
||
oauth_info["next_steps"].append("Parse Bearer challenge parameters")
|
||
|
||
# Try to fetch OAuth server metadata
|
||
if not self.oauth_server_metadata:
|
||
metadata = self.fetch_oauth_server_metadata()
|
||
if metadata:
|
||
oauth_info["oauth_metadata"] = metadata
|
||
oauth_info["next_steps"].append("Fetch OAuth server metadata")
|
||
|
||
# In a real implementation, you would:
|
||
# 1. Redirect user to authorization endpoint
|
||
# 2. Handle authorization code
|
||
# 3. Exchange code for access token
|
||
# 4. Retry request with Bearer token
|
||
|
||
return oauth_info
|
||
|
||
def test_oauth_flow(self):
|
||
"""
|
||
Test OAuth 2.1 authorization flow compliance.
|
||
|
||
Returns:
|
||
bool: True if OAuth flow is properly implemented
|
||
"""
|
||
print("Testing OAuth 2.1 authorization flow...")
|
||
|
||
oauth_results = {
|
||
"basic_flow": False,
|
||
"authorization_code_flow": False,
|
||
"error_scenarios": False,
|
||
"audience_validation": False,
|
||
"resource_indicators": False,
|
||
"scope_validation": False
|
||
}
|
||
|
||
# Send a request without authentication to trigger 401
|
||
try:
|
||
test_request = {
|
||
"jsonrpc": "2.0",
|
||
"method": "ping",
|
||
"id": str(uuid.uuid4())
|
||
}
|
||
|
||
response = self.request_session.post(
|
||
self.url,
|
||
json=test_request,
|
||
timeout=5
|
||
)
|
||
|
||
if response.status_code == 401:
|
||
print("✅ Server properly returns 401 for unauthenticated requests")
|
||
|
||
# Handle the 401 response
|
||
oauth_info = self.handle_401_response(dict(response.headers), response.text)
|
||
|
||
# Check WWW-Authenticate header
|
||
if oauth_info["www_authenticate"]:
|
||
print("✅ Server provides WWW-Authenticate header")
|
||
|
||
# Check for Bearer scheme
|
||
if oauth_info.get("scheme") == "Bearer":
|
||
print("✅ Server uses Bearer authentication scheme")
|
||
oauth_results["basic_flow"] = True
|
||
else:
|
||
print("⚠️ Server doesn't use Bearer authentication scheme")
|
||
else:
|
||
print("⚠️ Server doesn't provide WWW-Authenticate header (will become optional)")
|
||
# Still considered valid since WWW-Authenticate is becoming optional
|
||
oauth_results["basic_flow"] = True
|
||
|
||
# Check for OAuth server metadata
|
||
if oauth_info["oauth_metadata"]:
|
||
print("✅ OAuth server metadata available")
|
||
metadata = oauth_info["oauth_metadata"]
|
||
|
||
required_fields = ["authorization_endpoint", "token_endpoint", "issuer"]
|
||
|
||
for field in required_fields:
|
||
if field in metadata:
|
||
print(f"✅ OAuth metadata contains {field}")
|
||
else:
|
||
print(f"❌ OAuth metadata missing {field}")
|
||
return False
|
||
|
||
# Run comprehensive OAuth tests
|
||
print("\n--- Comprehensive OAuth 2.1 Testing ---")
|
||
|
||
# Test authorization code flow
|
||
auth_flow_result = self.test_oauth_authorization_code_flow(metadata)
|
||
oauth_results["authorization_code_flow"] = auth_flow_result.get("flow_supported", False)
|
||
|
||
# Test error scenarios
|
||
error_result = self.test_oauth_error_scenarios(metadata)
|
||
oauth_results["error_scenarios"] = len(error_result.get("errors", [])) == 0
|
||
|
||
# Test audience validation
|
||
audience_result = self.test_token_audience_validation()
|
||
oauth_results["audience_validation"] = audience_result.get("confused_deputy_prevented", False)
|
||
|
||
# Test resource indicators
|
||
resource_result = self.test_resource_indicators(metadata)
|
||
oauth_results["resource_indicators"] = resource_result.get("resource_indicators_supported", False)
|
||
|
||
# Test scope validation
|
||
scope_result = self.test_scope_validation(metadata)
|
||
oauth_results["scope_validation"] = scope_result.get("mcp_scopes_supported", False)
|
||
|
||
else:
|
||
print("⚠️ OAuth server metadata not available")
|
||
# Basic flow still valid even without metadata
|
||
oauth_results["basic_flow"] = True
|
||
|
||
# Summary
|
||
print(f"\n--- OAuth 2.1 Test Summary ---")
|
||
total_tests = len(oauth_results)
|
||
passed_tests = sum(1 for result in oauth_results.values() if result)
|
||
print(f"Passed: {passed_tests}/{total_tests} OAuth tests")
|
||
|
||
# Consider it successful if basic flow works
|
||
return oauth_results["basic_flow"]
|
||
|
||
elif response.status_code == 200:
|
||
print("ℹ️ Server doesn't require authentication")
|
||
return True
|
||
else:
|
||
print(f"❌ Unexpected status code: {response.status_code}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
print(f"❌ OAuth flow test failed: {str(e)}")
|
||
return False
|
||
|
||
def test_structured_tool_output(self):
|
||
"""
|
||
Test MCP 2025-06-18 structured tool output compliance.
|
||
|
||
Returns:
|
||
bool: True if structured output is properly implemented
|
||
"""
|
||
print("Testing MCP 2025-06-18 structured tool output...")
|
||
|
||
if self.protocol_version != "2025-06-18":
|
||
print("ℹ️ Skipping structured output test (not 2025-06-18 protocol)")
|
||
return True
|
||
|
||
try:
|
||
# Initialize first if needed
|
||
if not self.initialized:
|
||
if not self.initialize():
|
||
print("❌ Failed to initialize for structured output test")
|
||
return False
|
||
|
||
# List tools to find one to test
|
||
if not self.list_tools():
|
||
print("❌ Failed to list tools for structured output test")
|
||
return False
|
||
|
||
if not hasattr(self, 'available_tools') or not self.available_tools:
|
||
print("ℹ️ No tools available for structured output test")
|
||
return True
|
||
|
||
# Test with first available tool
|
||
tool = self.available_tools[0]
|
||
tool_name = tool.get('name')
|
||
|
||
print(f"Testing structured output with tool: {tool_name}")
|
||
|
||
# Call the tool
|
||
params = {
|
||
"name": tool_name,
|
||
"arguments": {}
|
||
}
|
||
|
||
status, _, body = self.send_request("tools/call", params)
|
||
|
||
if status != 200:
|
||
print(f"⚠️ Tool call failed with status {status}")
|
||
return True # Not a structured output issue
|
||
|
||
if not isinstance(body, dict) or 'result' not in body:
|
||
print("❌ Invalid response format")
|
||
return False
|
||
|
||
result = body['result']
|
||
|
||
# Check for structured output format (2025-06-18)
|
||
required_fields = ['content', 'isError']
|
||
optional_fields = ['structuredContent', 'metadata']
|
||
|
||
for field in required_fields:
|
||
if field not in result:
|
||
print(f"❌ Missing required field: {field}")
|
||
return False
|
||
else:
|
||
print(f"✅ Found required field: {field}")
|
||
|
||
# Check content structure
|
||
content = result.get('content', [])
|
||
if not isinstance(content, list):
|
||
print("❌ Content field must be an array")
|
||
return False
|
||
|
||
# Check content items
|
||
for item in content:
|
||
if not isinstance(item, dict):
|
||
print("❌ Content items must be objects")
|
||
return False
|
||
|
||
if 'type' not in item:
|
||
print("❌ Content items must have 'type' field")
|
||
return False
|
||
|
||
# Check for valid content types
|
||
valid_types = ['text', 'image', 'resource']
|
||
if item['type'] not in valid_types:
|
||
print(f"⚠️ Unknown content type: {item['type']}")
|
||
|
||
print("✅ Content structure is valid")
|
||
|
||
# Check isError field
|
||
is_error = result.get('isError')
|
||
if not isinstance(is_error, bool):
|
||
print("❌ isError field must be boolean")
|
||
return False
|
||
|
||
print("✅ isError field is valid")
|
||
|
||
# Check optional structured content
|
||
if 'structuredContent' in result:
|
||
structured_content = result['structuredContent']
|
||
if isinstance(structured_content, dict):
|
||
print("✅ structuredContent is properly formatted")
|
||
else:
|
||
print("⚠️ structuredContent should be an object")
|
||
|
||
print("✅ Structured tool output compliance verified")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Structured output test failed: {str(e)}")
|
||
return False
|
||
|
||
def test_batch_request_rejection(self):
|
||
"""
|
||
Test that server properly rejects batch requests (removed in 2025-06-18).
|
||
|
||
Returns:
|
||
bool: True if batch requests are properly rejected
|
||
"""
|
||
print("Testing batch request rejection...")
|
||
|
||
if self.protocol_version != "2025-06-18":
|
||
print("ℹ️ Skipping batch rejection test (not 2025-06-18 protocol)")
|
||
return True
|
||
|
||
try:
|
||
# Send a batch request (array of requests)
|
||
batch_request = [
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"method": "ping",
|
||
"id": "1"
|
||
},
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"method": "ping",
|
||
"id": "2"
|
||
}
|
||
]
|
||
|
||
response = self.request_session.post(
|
||
self.url,
|
||
json=batch_request,
|
||
timeout=5
|
||
)
|
||
|
||
# Should return 400 Bad Request for batch requests
|
||
if response.status_code == 400:
|
||
print("✅ Batch requests properly rejected")
|
||
return True
|
||
else:
|
||
print(f"❌ Batch request not rejected, status: {response.status_code}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
print(f"❌ Batch rejection test failed: {str(e)}")
|
||
return False
|
||
|
||
def test_elicitation_support(self):
|
||
"""
|
||
Test MCP 2025-06-18 elicitation support.
|
||
|
||
Returns:
|
||
bool: True if elicitation is properly supported
|
||
"""
|
||
print("Testing MCP 2025-06-18 elicitation support...")
|
||
|
||
if self.protocol_version != "2025-06-18":
|
||
print("ℹ️ Skipping elicitation test (not 2025-06-18 protocol)")
|
||
return True
|
||
|
||
try:
|
||
# Initialize first if needed
|
||
if not self.initialized:
|
||
if not self.initialize():
|
||
print("❌ Failed to initialize for elicitation test")
|
||
return False
|
||
|
||
# Check if server declares elicitation capability
|
||
# This would be checked during initialization
|
||
print("ℹ️ Elicitation support is declarative (checked during initialization)")
|
||
|
||
# Test elicitation in tool calls
|
||
if hasattr(self, 'available_tools') and self.available_tools:
|
||
tool = self.available_tools[0]
|
||
tool_name = tool.get('name')
|
||
|
||
# Test tool call with elicitation request
|
||
params = {
|
||
"name": tool_name,
|
||
"arguments": {},
|
||
"elicit": True # Request elicitation
|
||
}
|
||
|
||
status, _, body = self.send_request("tools/call", params)
|
||
|
||
if status == 200:
|
||
print("✅ Elicitation parameter accepted")
|
||
|
||
# Check for elicitation in response
|
||
if isinstance(body, dict) and 'result' in body:
|
||
result = body['result']
|
||
if 'elicitationContext' in result:
|
||
print("✅ Elicitation context provided in response")
|
||
else:
|
||
print("ℹ️ No elicitation context in response")
|
||
|
||
return True
|
||
else:
|
||
print(f"ℹ️ Tool call with elicitation failed: {status}")
|
||
return True # Not necessarily an error
|
||
|
||
print("✅ Elicitation support validated")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Elicitation test failed: {str(e)}")
|
||
return False
|
||
|
||
def test_www_authenticate_flexibility(self):
|
||
"""
|
||
Test the WWW-Authenticate header requirement for 2025-06-18.
|
||
|
||
Returns:
|
||
bool: True if server handles WWW-Authenticate appropriately
|
||
"""
|
||
print("Testing WWW-Authenticate header compliance...")
|
||
|
||
try:
|
||
# Send request without authentication
|
||
test_request = {
|
||
"jsonrpc": "2.0",
|
||
"method": "ping",
|
||
"id": str(uuid.uuid4())
|
||
}
|
||
|
||
response = self.request_session.post(
|
||
self.url,
|
||
json=test_request,
|
||
timeout=5
|
||
)
|
||
|
||
if response.status_code == 401:
|
||
www_authenticate = response.headers.get("WWW-Authenticate") or response.headers.get("www-authenticate")
|
||
|
||
if www_authenticate:
|
||
print("✅ Server provides WWW-Authenticate header")
|
||
|
||
# For 2025-06-18, WWW-Authenticate header is MUST when returning 401
|
||
if self.protocol_version == "2025-06-18":
|
||
# Validate header format according to OAuth 2.1 spec
|
||
if "Bearer" in www_authenticate:
|
||
print("✅ WWW-Authenticate header properly formatted for OAuth 2.1")
|
||
else:
|
||
print("❌ WWW-Authenticate header doesn't specify Bearer scheme (required for 2025-06-18)")
|
||
return False
|
||
else:
|
||
# For older versions, just check if it's properly formatted
|
||
if "Bearer" in www_authenticate:
|
||
print("✅ WWW-Authenticate header properly formatted")
|
||
else:
|
||
print("⚠️ WWW-Authenticate header doesn't specify Bearer scheme")
|
||
|
||
else:
|
||
if self.protocol_version == "2025-06-18":
|
||
print("❌ Server doesn't provide WWW-Authenticate header (MUST for 2025-06-18)")
|
||
return False
|
||
else:
|
||
print("ℹ️ Server doesn't provide WWW-Authenticate header (acceptable for older versions)")
|
||
|
||
return True
|
||
|
||
elif response.status_code == 200:
|
||
print("ℹ️ Server doesn't require authentication")
|
||
return True
|
||
else:
|
||
print(f"ℹ️ Unexpected status code: {response.status_code}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ WWW-Authenticate header test failed: {str(e)}")
|
||
return False
|
||
|
||
def test_status_codes(self):
|
||
"""Test HTTP status code scenarios that are actually specified in MCP."""
|
||
print("\n=== Testing HTTP Status Codes (MCP Specification Only) ===")
|
||
|
||
tests = [
|
||
{
|
||
"name": "invalid_json",
|
||
"payload": "{bad json}",
|
||
"expected_codes": [400],
|
||
"headers": None,
|
||
"test_description": "Invalid JSON should return 400 (HTTP standard)"
|
||
},
|
||
{
|
||
"name": "missing_method",
|
||
"payload": {"jsonrpc": "2.0", "id": 1},
|
||
"expected_codes": [400],
|
||
"headers": None,
|
||
"test_description": "Missing method field should return 400 (JSON-RPC requirement)"
|
||
}
|
||
]
|
||
|
||
success = True
|
||
for test in tests:
|
||
try:
|
||
if isinstance(test["payload"], str):
|
||
# Send raw string for invalid JSON test
|
||
response = self.request_session.post(
|
||
self.url,
|
||
data=test["payload"],
|
||
headers={"Content-Type": "application/json"},
|
||
timeout=5
|
||
)
|
||
else:
|
||
response = self.request_session.post(
|
||
self.url,
|
||
json=test["payload"],
|
||
headers=test["headers"] if test["headers"] else {},
|
||
timeout=5
|
||
)
|
||
|
||
if response.status_code in test["expected_codes"]:
|
||
print(f"✅ {test['name']}: Got expected status code {response.status_code}")
|
||
print(f" {test['test_description']}")
|
||
else:
|
||
print(f"❌ {test['name']}: Expected {test['expected_codes']}, got {response.status_code}")
|
||
success = False
|
||
|
||
except Exception as e:
|
||
print(f"❌ {test['name']}: Test failed with error: {str(e)}")
|
||
success = False
|
||
|
||
print("\nNote: Tests for unknown_method and invalid_session have been removed")
|
||
print("because they test unspecified HTTP implementation details, not MCP specification compliance.")
|
||
return success
|
||
|
||
def test_headers(self):
|
||
"""Test HTTP header handling."""
|
||
print("\n=== Testing HTTP Headers ===")
|
||
|
||
# Initialize first to get a valid session
|
||
if not self.initialize():
|
||
print("❌ Failed to initialize for header tests")
|
||
return False
|
||
|
||
tests = [
|
||
{
|
||
"name": "content_type",
|
||
"required_headers": {"Content-Type": "application/json"},
|
||
"method": "server/info"
|
||
},
|
||
{
|
||
"name": "session_id_present",
|
||
"required_headers": {"Mcp-Session-Id": None}, # None means just check presence
|
||
"method": "server/info"
|
||
}
|
||
]
|
||
|
||
if self.protocol_version == "2025-06-18":
|
||
tests.append({
|
||
"name": "protocol_version",
|
||
"required_headers": {"MCP-Protocol-Version": "2025-06-18"},
|
||
"method": "server/info"
|
||
})
|
||
|
||
success = True
|
||
for test in tests:
|
||
try:
|
||
status, headers, _ = self.send_request(test["method"])
|
||
|
||
if status != 200:
|
||
print(f"❌ {test['name']}: Request failed with status {status}")
|
||
success = False
|
||
continue
|
||
|
||
# Check required headers
|
||
headers_valid = True
|
||
for header, expected_value in test["required_headers"].items():
|
||
header_present = False
|
||
for response_header in headers:
|
||
if response_header.lower() == header.lower():
|
||
header_present = True
|
||
if expected_value is not None and headers[response_header] != expected_value:
|
||
print(f"❌ {test['name']}: Expected {header}={expected_value}, got {headers[response_header]}")
|
||
headers_valid = False
|
||
break
|
||
|
||
if not header_present:
|
||
print(f"❌ {test['name']}: Missing required header {header}")
|
||
headers_valid = False
|
||
|
||
if headers_valid:
|
||
print(f"✅ {test['name']}: All required headers present and valid")
|
||
else:
|
||
success = False
|
||
|
||
except Exception as e:
|
||
print(f"❌ {test['name']}: Test failed with error: {str(e)}")
|
||
success = False
|
||
|
||
return success
|
||
|
||
def test_protocol_versions(self):
|
||
"""Test protocol version negotiation."""
|
||
print("\n=== Testing Protocol Version Negotiation ===")
|
||
|
||
versions = ["2024-11-05", "2025-03-26", "2025-06-18"]
|
||
success = True
|
||
|
||
for version in versions:
|
||
try:
|
||
# Reset state for each version test
|
||
self.session_id = None
|
||
self.initialized = False
|
||
|
||
params = {
|
||
"clientInfo": { # Use camelCase as per MCP specification
|
||
"name": "MCP HTTP Tester",
|
||
"version": "1.0.0"
|
||
},
|
||
"clientCapabilities": { # Use camelCase as per MCP specification
|
||
"protocol_versions": [version]
|
||
}
|
||
}
|
||
|
||
headers = {"MCP-Protocol-Version": version} if version == "2025-06-18" else {}
|
||
status, response_headers, body = self.send_request("initialize", params, headers)
|
||
|
||
if status == 401:
|
||
# Handle OAuth requirement
|
||
oauth_info = self.handle_401_response(response_headers, body)
|
||
print(f"✅ Version {version}: Server requires authentication (OAuth 2.1)")
|
||
continue
|
||
elif status != 200:
|
||
print(f"❌ Version {version}: Initialize failed with status {status}")
|
||
success = False
|
||
continue
|
||
|
||
if not isinstance(body, dict) or "result" not in body:
|
||
print(f"❌ Version {version}: Invalid response format")
|
||
success = False
|
||
continue
|
||
|
||
result = body["result"]
|
||
|
||
# Check for protocol version in camelCase (per spec) or snake_case (for compatibility)
|
||
server_version = result.get("protocolVersion") or result.get("protocol_version")
|
||
|
||
if not server_version:
|
||
print(f"❌ Version {version}: Missing protocolVersion in response")
|
||
success = False
|
||
continue
|
||
|
||
if server_version != version:
|
||
print(f"⚠️ Version {version}: Server responded with different version {server_version}")
|
||
# This might be acceptable depending on server implementation
|
||
|
||
print(f"✅ Version {version}: Successfully negotiated (server: {server_version})")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Version {version}: Test failed with error: {str(e)}")
|
||
success = False
|
||
|
||
return success
|
||
|
||
def run_comprehensive_tests(self):
|
||
"""Run all tests including OAuth flow testing."""
|
||
try:
|
||
print("=== MCP HTTP Server Comprehensive Test Suite ===")
|
||
print(f"Protocol Version: {self.protocol_version}")
|
||
|
||
# Test OAuth flow first
|
||
oauth_passed = self.test_oauth_flow()
|
||
if not oauth_passed:
|
||
print("⚠️ OAuth flow test had issues, continuing with other tests")
|
||
|
||
# Test WWW-Authenticate header flexibility
|
||
www_auth_passed = self.test_www_authenticate_flexibility()
|
||
|
||
# Reset server state
|
||
if not self.reset_server():
|
||
print("WARNING: Failed to reset server state, tests may fail")
|
||
|
||
# Run existing tests
|
||
options_passed = self.options_request()
|
||
if not options_passed:
|
||
print("⚠️ OPTIONS request had issues, continuing with other tests")
|
||
|
||
if not self.initialize():
|
||
print("❌ Basic initialization failed")
|
||
return False
|
||
|
||
if not self.list_tools():
|
||
print("❌ Tool listing failed")
|
||
return False
|
||
|
||
# Test async tools for 2025-03-26
|
||
async_passed = True
|
||
if self.protocol_version == "2025-03-26" and self.get_tool_by_name("sleep"):
|
||
async_passed = self.test_async_sleep_tool()
|
||
if not async_passed:
|
||
print("❌ Async sleep tool test failed")
|
||
|
||
tools_passed = self.test_available_tools()
|
||
if not tools_passed:
|
||
print("❌ Available tools test failed")
|
||
return False
|
||
|
||
# Run protocol compliance tests
|
||
print("\n=== Protocol Compliance Testing ===")
|
||
|
||
# Test structured tool output (2025-06-18)
|
||
structured_output_passed = self.test_structured_tool_output()
|
||
|
||
# Test batch request rejection (2025-06-18)
|
||
batch_rejection_passed = self.test_batch_request_rejection()
|
||
|
||
# Test elicitation support (2025-06-18)
|
||
elicitation_passed = self.test_elicitation_support()
|
||
|
||
# Run comprehensive HTTP tests
|
||
print("\n=== HTTP Protocol Testing ===")
|
||
|
||
status_codes_passed = self.test_status_codes()
|
||
if not status_codes_passed:
|
||
print("❌ Status codes test failed")
|
||
return False
|
||
|
||
headers_passed = self.test_headers()
|
||
if not headers_passed:
|
||
print("❌ Headers test failed")
|
||
return False
|
||
|
||
protocol_versions_passed = self.test_protocol_versions()
|
||
if not protocol_versions_passed:
|
||
print("❌ Protocol versions test failed")
|
||
return False
|
||
|
||
# Calculate overall results
|
||
test_results = {
|
||
"OAuth Flow": oauth_passed,
|
||
"WWW-Authenticate Flexibility": www_auth_passed,
|
||
"OPTIONS Request": options_passed,
|
||
"Initialization": True, # Must pass to get here
|
||
"Tool Listing": True, # Must pass to get here
|
||
"Async Tools": async_passed,
|
||
"Available Tools": tools_passed,
|
||
"Structured Output": structured_output_passed,
|
||
"Batch Rejection": batch_rejection_passed,
|
||
"Elicitation Support": elicitation_passed,
|
||
"Status Codes": status_codes_passed,
|
||
"Headers": headers_passed,
|
||
"Protocol Versions": protocol_versions_passed,
|
||
}
|
||
|
||
# Print comprehensive results
|
||
print("\n" + "="*60)
|
||
print("COMPREHENSIVE TEST RESULTS")
|
||
print("="*60)
|
||
|
||
passed_tests = 0
|
||
total_tests = 0
|
||
|
||
for test_name, passed in test_results.items():
|
||
total_tests += 1
|
||
if passed:
|
||
passed_tests += 1
|
||
print(f"✅ {test_name}")
|
||
else:
|
||
print(f"❌ {test_name}")
|
||
|
||
success_rate = (passed_tests / total_tests) * 100
|
||
print(f"\nOVERALL SUCCESS RATE: {success_rate:.1f}% ({passed_tests}/{total_tests})")
|
||
|
||
# Determine overall success
|
||
# Core tests that must pass
|
||
core_tests = ["Initialization", "Tool Listing", "Available Tools", "Status Codes", "Headers"]
|
||
core_passed = all(test_results[test] for test in core_tests if test in test_results)
|
||
|
||
# Protocol-specific tests
|
||
protocol_tests_passed = True
|
||
if self.protocol_version == "2025-06-18":
|
||
protocol_specific = ["Structured Output", "Batch Rejection", "Elicitation Support"]
|
||
protocol_tests_passed = all(test_results[test] for test in protocol_specific if test in test_results)
|
||
|
||
if core_passed and protocol_tests_passed:
|
||
print("\n🎉 SERVER IS FULLY COMPLIANT WITH MCP SPECIFICATION")
|
||
print(" All core functionality and protocol-specific features validated")
|
||
|
||
# Additional compliance notes
|
||
if oauth_passed:
|
||
print(" ✅ OAuth 2.1 authentication flow validated")
|
||
if www_auth_passed:
|
||
print(" ✅ WWW-Authenticate header handling compliant")
|
||
if self.protocol_version == "2025-06-18":
|
||
print(" ✅ MCP 2025-06-18 specific features validated")
|
||
|
||
print("="*60)
|
||
return True
|
||
else:
|
||
print("\n⚠️ SERVER HAS SOME COMPLIANCE ISSUES")
|
||
print(" Core functionality works but some features need attention")
|
||
|
||
if not core_passed:
|
||
failed_core = [test for test in core_tests if test in test_results and not test_results[test]]
|
||
print(f" Core issues: {', '.join(failed_core)}")
|
||
|
||
if not protocol_tests_passed:
|
||
failed_protocol = [test for test in protocol_specific if test in test_results and not test_results[test]]
|
||
print(f" Protocol issues: {', '.join(failed_protocol)}")
|
||
|
||
print("="*60)
|
||
return False
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error during comprehensive test execution: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
def send_request(self, method, json_data=None, headers=None, request_method="POST"):
|
||
"""
|
||
Send a JSON-RPC request to the server using the requests library.
|
||
|
||
Args:
|
||
method: The JSON-RPC method to call
|
||
json_data: Additional JSON data to include (optional)
|
||
headers: Additional headers to include (optional)
|
||
request_method: The HTTP method to use (default: POST)
|
||
|
||
Returns:
|
||
Tuple of (status_code, headers, body)
|
||
"""
|
||
# Build the request
|
||
if json_data is None:
|
||
json_data = {}
|
||
|
||
# For OPTIONS requests, we don't send a JSON-RPC request
|
||
if request_method == "OPTIONS":
|
||
try:
|
||
response = self.request_session.options(self.url, timeout=5)
|
||
self.log(f"OPTIONS Response Status: {response.status_code}")
|
||
self.log(f"OPTIONS Response Headers: {dict(response.headers)}")
|
||
return response.status_code, dict(response.headers), None
|
||
except requests.RequestException as e:
|
||
self.log(f"OPTIONS request failed: {str(e)}")
|
||
raise
|
||
|
||
# For other requests, build a JSON-RPC request
|
||
request = {
|
||
"jsonrpc": "2.0",
|
||
"method": method,
|
||
"id": str(uuid.uuid4())
|
||
}
|
||
|
||
# Add params if provided
|
||
if json_data:
|
||
request["params"] = json_data
|
||
|
||
# Convert to JSON
|
||
json_str = json.dumps(request)
|
||
self.log(f"Request: {json_str}")
|
||
|
||
# Set up headers
|
||
request_headers = {}
|
||
|
||
# Always include a session ID, either the existing one or a new one
|
||
if not self.session_id:
|
||
self.session_id = str(uuid.uuid4())
|
||
self.log(f"Created new session ID: {self.session_id}")
|
||
|
||
# Add session ID to headers
|
||
request_headers["Mcp-Session-Id"] = self.session_id
|
||
self.log(f"Using session ID in request: {self.session_id}")
|
||
|
||
# Add protocol version header for 2025-06-18
|
||
if self.protocol_version == "2025-06-18":
|
||
request_headers["MCP-Protocol-Version"] = self.protocol_version
|
||
|
||
# Add any additional headers
|
||
if headers:
|
||
request_headers.update(headers)
|
||
|
||
try:
|
||
# Build the URL with session ID for non-initialization requests
|
||
request_url = self.url
|
||
if method != "initialize" and self.session_id:
|
||
# Add session ID as URL parameter
|
||
separator = "&" if "?" in request_url else "?"
|
||
request_url = f"{request_url}{separator}session_id={self.session_id}"
|
||
self.log(f"Using URL with session ID: {request_url}")
|
||
|
||
# Send the request
|
||
response = self.request_session.post(
|
||
request_url,
|
||
json=request,
|
||
headers=request_headers,
|
||
timeout=5 # 5 second timeout
|
||
)
|
||
|
||
status = response.status_code
|
||
headers = dict(response.headers)
|
||
|
||
self.log(f"Response Status: {status}")
|
||
self.log(f"Response Headers: {headers}")
|
||
|
||
# Parse JSON response first
|
||
try:
|
||
body = response.json()
|
||
self.log(f"Response Body: {json.dumps(body)}")
|
||
except ValueError:
|
||
body = response.text
|
||
self.log(f"Response Body (text): {body}")
|
||
|
||
# If this is a successful initialize response, check for session ID in body first, then headers
|
||
if method == "initialize" and status == 200:
|
||
# First try to get session ID from response body (server-provided session ID)
|
||
try:
|
||
if isinstance(body, dict) and 'result' in body:
|
||
result = body['result']
|
||
if 'session_id' in result:
|
||
self.session_id = result['session_id']
|
||
self.log(f"Captured session ID from response body: {self.session_id}")
|
||
elif 'sessionId' in result:
|
||
self.session_id = result['sessionId']
|
||
self.log(f"Captured session ID from response body (camelCase): {self.session_id}")
|
||
else:
|
||
# Fall back to headers
|
||
if "mcp-session-id" in headers:
|
||
self.session_id = headers["mcp-session-id"]
|
||
self.log(f"Captured session ID from headers: {self.session_id}")
|
||
else:
|
||
# Try case-insensitive match
|
||
for header_key in headers:
|
||
if header_key.lower() == "mcp-session-id":
|
||
self.session_id = headers[header_key]
|
||
self.log(f"Captured session ID from headers (case insensitive): {self.session_id}")
|
||
break
|
||
else:
|
||
# Fall back to headers
|
||
if "mcp-session-id" in headers:
|
||
self.session_id = headers["mcp-session-id"]
|
||
self.log(f"Captured session ID from headers: {self.session_id}")
|
||
else:
|
||
# Try case-insensitive match
|
||
for header_key in headers:
|
||
if header_key.lower() == "mcp-session-id":
|
||
self.session_id = headers[header_key]
|
||
self.log(f"Captured session ID from headers (case insensitive): {self.session_id}")
|
||
break
|
||
except:
|
||
# Fall back to headers
|
||
if "mcp-session-id" in headers:
|
||
self.session_id = headers["mcp-session-id"]
|
||
self.log(f"Captured session ID from headers: {self.session_id}")
|
||
else:
|
||
# Try case-insensitive match
|
||
for header_key in headers:
|
||
if header_key.lower() == "mcp-session-id":
|
||
self.session_id = headers[header_key]
|
||
self.log(f"Captured session ID from headers (case insensitive): {self.session_id}")
|
||
break
|
||
|
||
return status, headers, body
|
||
|
||
except requests.RequestException as e:
|
||
self.log(f"Request failed: {str(e)}")
|
||
raise
|
||
|
||
def options_request(self):
|
||
"""Send an OPTIONS request to check server CORS support."""
|
||
print("Testing OPTIONS request...")
|
||
|
||
try:
|
||
# Create a direct request with short timeout
|
||
options_response = requests.options(self.url, timeout=2)
|
||
|
||
# Check status code
|
||
if options_response.status_code != 200:
|
||
print(f"WARNING: OPTIONS request returned status {options_response.status_code}")
|
||
# Continue even if not 200
|
||
else:
|
||
print("OPTIONS request successful")
|
||
|
||
# Check CORS headers
|
||
headers = options_response.headers
|
||
missing_headers = []
|
||
|
||
if 'access-control-allow-origin' not in headers:
|
||
missing_headers.append('Access-Control-Allow-Origin')
|
||
|
||
if 'access-control-allow-methods' not in headers:
|
||
missing_headers.append('Access-Control-Allow-Methods')
|
||
|
||
if 'access-control-allow-headers' not in headers:
|
||
missing_headers.append('Access-Control-Allow-Headers')
|
||
|
||
if missing_headers:
|
||
print(f"WARNING: Missing CORS headers: {', '.join(missing_headers)}")
|
||
# Continue even with missing headers
|
||
else:
|
||
print("All required CORS headers present")
|
||
|
||
# Return true regardless of minor issues to keep tests running
|
||
return True
|
||
|
||
except requests.RequestException as e:
|
||
print(f"WARNING: OPTIONS request failed with exception: {str(e)}")
|
||
print("This may not be critical. Continuing with other tests...")
|
||
# Don't fail the overall test for OPTIONS issues
|
||
return True
|
||
|
||
def initialize(self):
|
||
"""Initialize the server and store the session ID."""
|
||
print("Testing server initialization...")
|
||
|
||
# First ensure we're not using any session ID
|
||
self.session_id = None
|
||
|
||
# Use different parameter names based on protocol version
|
||
if self.protocol_version == "2025-06-18":
|
||
params = {
|
||
"clientInfo": { # Use camelCase as per MCP specification
|
||
"name": "MCP HTTP Tester",
|
||
"version": "1.0.0"
|
||
},
|
||
"clientCapabilities": { # Use camelCase as per MCP specification
|
||
"protocol_versions": [self.protocol_version],
|
||
"tools": {"asyncSupported": True},
|
||
"resources": True
|
||
}
|
||
}
|
||
else:
|
||
params = {
|
||
"protocolVersion": self.protocol_version,
|
||
"clientInfo": { # Use camelCase as per MCP specification
|
||
"name": "MCP HTTP Tester",
|
||
"version": "1.0.0"
|
||
},
|
||
"capabilities": {
|
||
"tools": {"asyncSupported": True},
|
||
"resources": True
|
||
}
|
||
}
|
||
|
||
try:
|
||
status, headers, body = self.send_request("initialize", params)
|
||
|
||
# Check for server already initialized error
|
||
if isinstance(body, dict) and 'error' in body:
|
||
error = body['error']
|
||
if error.get('code') == -32803 and "already initialized" in error.get('message', ''):
|
||
print("Server already initialized, we need to reset the server first...")
|
||
|
||
# Reset the server
|
||
if self.reset_server():
|
||
# Try initialization again after reset
|
||
status, headers, body = self.send_request("initialize", params)
|
||
|
||
# If still getting "already initialized" error, we have a problem
|
||
if isinstance(body, dict) and 'error' in body:
|
||
error = body['error']
|
||
if error.get('code') == -32803 and "already initialized" in error.get('message', ''):
|
||
print("ERROR: Server is still in initialized state after reset attempt.")
|
||
print("Please manually restart the server before running tests.")
|
||
return False
|
||
else:
|
||
print("ERROR: Failed to reset server state.")
|
||
return False
|
||
else:
|
||
print(f"ERROR: Server returned error: {error}")
|
||
return False
|
||
|
||
# Normal initialization flow
|
||
if status != 200:
|
||
print(f"ERROR: Initialize request failed with status {status}")
|
||
return False
|
||
|
||
# Check for session ID in body first (server-provided session ID), then headers
|
||
if isinstance(body, dict) and 'result' in body:
|
||
result = body['result']
|
||
if 'session_id' in result:
|
||
self.session_id = result['session_id']
|
||
print(f"Received session ID from response body: {self.session_id}")
|
||
elif 'sessionId' in result:
|
||
self.session_id = result['sessionId']
|
||
print(f"Received session ID from response body (camelCase): {self.session_id}")
|
||
elif isinstance(result, dict) and 'session' in result and 'id' in result['session']:
|
||
self.session_id = result['session']['id']
|
||
print(f"Received session ID from nested session object: {self.session_id}")
|
||
# Fall back to headers if not found in body
|
||
elif 'mcp-session-id' in headers:
|
||
self.session_id = headers['mcp-session-id']
|
||
print(f"Received session ID from headers: {self.session_id}")
|
||
# Check for lowercase variant
|
||
elif any(key.lower() == 'mcp-session-id' for key in headers):
|
||
key = next(key for key in headers if key.lower() == 'mcp-session-id')
|
||
self.session_id = headers[key]
|
||
print(f"Received session ID from headers (case insensitive): {self.session_id}")
|
||
else:
|
||
print("WARNING: No session ID found in response. Some servers may not require one.")
|
||
else:
|
||
print("WARNING: No session ID found in response. Some servers may not require one.")
|
||
|
||
# Verify other parts of the response body
|
||
if not isinstance(body, dict):
|
||
print("ERROR: Response body is not a JSON object")
|
||
return False
|
||
|
||
if 'result' not in body:
|
||
print("ERROR: Response missing 'result' field")
|
||
return False
|
||
|
||
result = body['result']
|
||
|
||
# Check for required fields in result (handle both formats)
|
||
if 'protocolVersion' not in result and 'protocol_version' not in result:
|
||
print("ERROR: Missing protocolVersion/protocol_version in result")
|
||
return False
|
||
|
||
if 'serverInfo' not in result:
|
||
print("WARNING: Missing serverInfo in result. Continuing anyway.")
|
||
|
||
if 'capabilities' not in result:
|
||
print("WARNING: Missing capabilities in result. Continuing anyway.")
|
||
|
||
print("Server initialization successful")
|
||
self.initialized = True
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"ERROR: Initialize request raised exception: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
def list_tools(self):
|
||
"""Test the tools/list endpoint."""
|
||
if not self.initialized:
|
||
print("ERROR: Server not initialized, cannot list tools")
|
||
return False
|
||
|
||
print("Testing tools/list endpoint...")
|
||
|
||
status, _, body = self.send_request("tools/list")
|
||
|
||
if status != 200:
|
||
print(f"ERROR: tools/list request failed with status {status}")
|
||
return False
|
||
|
||
# Verify response body
|
||
if not isinstance(body, dict):
|
||
print("ERROR: Response body is not a JSON object")
|
||
return False
|
||
|
||
if 'result' not in body:
|
||
print("ERROR: Response missing 'result' field")
|
||
return False
|
||
|
||
result = body['result']
|
||
|
||
# Check for tools array
|
||
if 'tools' not in result or not isinstance(result['tools'], list):
|
||
print("ERROR: Response missing 'tools' array")
|
||
return False
|
||
|
||
tools = result['tools']
|
||
print(f"Server returned {len(tools)} tools")
|
||
|
||
# Store the tools for later dynamic testing
|
||
self.available_tools = tools
|
||
|
||
return True
|
||
|
||
def get_tool_by_name(self, name):
|
||
"""Get a tool definition by name."""
|
||
if not hasattr(self, 'available_tools'):
|
||
print("ERROR: Tools have not been listed yet")
|
||
return None
|
||
|
||
for tool in self.available_tools:
|
||
if tool.get('name') == name:
|
||
return tool
|
||
|
||
return None
|
||
|
||
def test_tool(self, tool_name, test_parameters=None):
|
||
"""Test a tool dynamically."""
|
||
if not self.initialized:
|
||
print(f"ERROR: Server not initialized, cannot call tool {tool_name}")
|
||
return False
|
||
|
||
tool = self.get_tool_by_name(tool_name)
|
||
if not tool:
|
||
print(f"WARNING: Tool '{tool_name}' not found, skipping test")
|
||
return True # Not a failure if the tool doesn't exist
|
||
|
||
print(f"Testing tools/call with {tool_name} tool...")
|
||
|
||
# If test parameters weren't provided, create default ones based on the tool schema
|
||
if test_parameters is None:
|
||
parameters = {}
|
||
# Tools have inputSchema instead of parameters
|
||
input_schema = tool.get('inputSchema', {})
|
||
properties = input_schema.get('properties', {})
|
||
required_params = input_schema.get('required', [])
|
||
|
||
for param_name, param_def in properties.items():
|
||
# Create appropriate default values based on parameter name and type
|
||
param_type = param_def.get('type')
|
||
|
||
if param_name == 'message':
|
||
parameters[param_name] = "Hello from MCP validator!"
|
||
elif param_name == 'seconds':
|
||
parameters[param_name] = 1
|
||
elif param_name in ['a', 'b']:
|
||
parameters[param_name] = 5 if param_name == 'a' else 3
|
||
elif param_type == 'string':
|
||
parameters[param_name] = f"test_{param_name}"
|
||
elif param_type in ['number', 'integer']:
|
||
parameters[param_name] = 42
|
||
elif param_type == 'boolean':
|
||
parameters[param_name] = True
|
||
# Add more types as needed
|
||
|
||
# Make sure we have all required parameters
|
||
for required_param in required_params:
|
||
if required_param not in parameters:
|
||
# Add a generic value for required params we missed
|
||
prop_def = properties.get(required_param, {})
|
||
param_type = prop_def.get('type', 'string')
|
||
if param_type == 'string':
|
||
parameters[required_param] = f"required_{required_param}"
|
||
elif param_type in ['number', 'integer']:
|
||
parameters[required_param] = 1
|
||
elif param_type == 'boolean':
|
||
parameters[required_param] = True
|
||
else:
|
||
parameters = test_parameters
|
||
|
||
# Use different parameter names based on protocol version
|
||
if self.protocol_version == "2025-06-18":
|
||
params = {
|
||
"name": tool_name,
|
||
"arguments": parameters
|
||
}
|
||
else:
|
||
params = {
|
||
"name": tool_name,
|
||
"parameters": parameters
|
||
}
|
||
|
||
status, _, body = self.send_request("tools/call", params)
|
||
|
||
if status != 200:
|
||
print(f"ERROR: tools/call request failed with status {status}")
|
||
return False
|
||
|
||
# Verify response body
|
||
if not isinstance(body, dict):
|
||
print(f"ERROR: Response body for {tool_name} is not a JSON object")
|
||
return False
|
||
|
||
if 'result' not in body and 'error' not in body:
|
||
print(f"ERROR: Response for {tool_name} missing both 'result' and 'error' fields")
|
||
return False
|
||
|
||
if 'error' in body:
|
||
print(f"ERROR: Tool {tool_name} returned an error: {body['error']}")
|
||
return False
|
||
|
||
result = body['result']
|
||
print(f"{tool_name} tool test successful, returned: {result}")
|
||
|
||
return True
|
||
|
||
def test_available_tools(self):
|
||
"""Test all available tools dynamically."""
|
||
if not hasattr(self, 'available_tools'):
|
||
print("ERROR: Tools have not been listed yet")
|
||
return False
|
||
|
||
all_success = True
|
||
|
||
for tool in self.available_tools:
|
||
tool_name = tool.get('name')
|
||
if tool_name:
|
||
# Skip testing async tools with this method
|
||
if tool_name == 'sleep' and self.protocol_version == "2025-03-26":
|
||
print(f"Skipping '{tool_name}' tool as it's tested separately")
|
||
continue
|
||
|
||
result = self.test_tool(tool_name)
|
||
if not result:
|
||
all_success = False
|
||
|
||
return all_success
|
||
|
||
def test_async_sleep_tool(self):
|
||
"""Test the async sleep tool functionality."""
|
||
if not self.initialized:
|
||
print("ERROR: Server not initialized, cannot call tools")
|
||
return False
|
||
|
||
print("Testing tools/call-async with sleep tool...")
|
||
|
||
# Only run this test for 2025-03-26 protocol
|
||
if self.protocol_version != "2025-03-26":
|
||
print("Skipping async test for older protocol versions")
|
||
return True
|
||
|
||
sleep_time = 3 # seconds
|
||
|
||
# Use different parameter names based on protocol version
|
||
if self.protocol_version == "2025-06-18":
|
||
params = {
|
||
"name": "sleep",
|
||
"arguments": {
|
||
"seconds": sleep_time
|
||
}
|
||
}
|
||
else:
|
||
params = {
|
||
"name": "sleep",
|
||
"parameters": {
|
||
"seconds": sleep_time
|
||
}
|
||
}
|
||
|
||
status, _, body = self.send_request("tools/call-async", params)
|
||
|
||
if status != 200:
|
||
print(f"ERROR: tools/call-async request failed with status {status}")
|
||
return False
|
||
|
||
# Verify response body
|
||
if not isinstance(body, dict):
|
||
print("ERROR: Response body is not a JSON object")
|
||
return False
|
||
|
||
if 'result' not in body:
|
||
print("ERROR: Response missing 'result' field")
|
||
return False
|
||
|
||
result = body['result']
|
||
|
||
# Check for task ID
|
||
if 'id' not in result:
|
||
print("ERROR: Missing task ID in result")
|
||
return False
|
||
|
||
task_id = result['id']
|
||
print(f"Started async task with ID: {task_id}")
|
||
|
||
# Poll for result
|
||
max_attempts = 10
|
||
attempt = 0
|
||
completed = False
|
||
|
||
print(f"Waiting for async task to complete (max {max_attempts} attempts)...")
|
||
|
||
while attempt < max_attempts:
|
||
time.sleep(1)
|
||
attempt += 1
|
||
|
||
params = {
|
||
"id": task_id
|
||
}
|
||
|
||
status, _, body = self.send_request("tools/result", params)
|
||
|
||
if status != 200:
|
||
print(f"ERROR: tools/result request failed with status {status}")
|
||
return False
|
||
|
||
if not isinstance(body, dict) or 'result' not in body:
|
||
print("ERROR: Invalid response format")
|
||
return False
|
||
|
||
result = body['result']
|
||
|
||
if 'status' not in result:
|
||
print("ERROR: Missing status in result")
|
||
return False
|
||
|
||
if result['status'] == 'completed':
|
||
completed = True
|
||
print(f"Async task completed after {attempt} attempts")
|
||
break
|
||
|
||
print(f"Attempt {attempt}: Task status = {result['status']}")
|
||
|
||
if not completed:
|
||
print("ERROR: Async task did not complete in time")
|
||
return False
|
||
|
||
print("Async sleep tool test successful")
|
||
return True
|
||
|
||
def reset_server(self):
|
||
"""Attempt to reset the server state by terminating any existing session."""
|
||
print("Attempting to reset server state...")
|
||
|
||
# First try a shutdown request without session ID to see if the server allows it
|
||
try:
|
||
request = {
|
||
"jsonrpc": "2.0",
|
||
"method": "shutdown",
|
||
"id": str(uuid.uuid4())
|
||
}
|
||
|
||
self.log("Sending shutdown request without session ID")
|
||
response = self.request_session.post(
|
||
self.url,
|
||
json=request,
|
||
timeout=5
|
||
)
|
||
|
||
# Check if this was successful
|
||
if response.status_code == 200:
|
||
print("Server shutdown successful, waiting for restart...")
|
||
time.sleep(2) # Wait for server to restart or reset state
|
||
self.session_id = None
|
||
self.initialized = False
|
||
return True
|
||
except Exception as e:
|
||
self.log(f"Shutdown without session ID failed: {str(e)}")
|
||
|
||
# If we have a session ID from previous run, try to use it
|
||
if self.session_id:
|
||
try:
|
||
self.log(f"Sending shutdown request with existing session ID: {self.session_id}")
|
||
|
||
headers = {"Mcp-Session-Id": self.session_id}
|
||
request = {
|
||
"jsonrpc": "2.0",
|
||
"method": "shutdown",
|
||
"id": str(uuid.uuid4())
|
||
}
|
||
|
||
response = self.request_session.post(
|
||
self.url,
|
||
json=request,
|
||
headers=headers,
|
||
timeout=5
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
print("Server reset with existing session successful")
|
||
time.sleep(2) # Wait for server to process shutdown
|
||
self.session_id = None
|
||
self.initialized = False
|
||
return True
|
||
except Exception as e:
|
||
self.log(f"Shutdown with existing session ID failed: {str(e)}")
|
||
|
||
# If we tried our best but failed, tell the user and continue anyway
|
||
print("Server reset attempted, continuing with tests")
|
||
|
||
# Reset our state even if the server didn't reset
|
||
self.session_id = None
|
||
self.initialized = False
|
||
return True |