diff --git a/docker/Dockerfile.nginx-simple b/docker/Dockerfile.nginx-simple new file mode 100644 index 0000000..9b137f0 --- /dev/null +++ b/docker/Dockerfile.nginx-simple @@ -0,0 +1,42 @@ +# Single image with WebCat + nginx auth proxy +# Based on existing WebCat Dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# Install nginx and supervisor +RUN apt-get update && \ + apt-get install -y nginx supervisor && \ + rm -rf /var/lib/apt/lists/* + +# Copy project metadata and structure +COPY pyproject.toml README.md LICENSE /app/ +COPY docker/ /app/docker/ +COPY examples/ /app/examples/ + +# Install package with dependencies +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir /app + +# Create log directory +RUN mkdir -p /var/log/webcat && chmod 755 /var/log/webcat + +# Copy nginx and supervisor configs +COPY docker/nginx-single-image.conf /etc/nginx/nginx.conf +COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY docker/entrypoint-nginx.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Expose port (nginx listens on 8000, proxies to webcat on 4000) +EXPOSE 8000 + +# Environment variables +ENV PYTHONUNBUFFERED=1 +ENV PORT=4000 +ENV LOG_LEVEL=INFO +ENV LOG_DIR=/var/log/webcat +ENV SERPER_API_KEY="" +ENV WEBCAT_API_KEY="" + +# Use entrypoint to configure auth before starting +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/docker-compose-nginx.yml b/docker/docker-compose-nginx.yml new file mode 100644 index 0000000..795d7e3 --- /dev/null +++ b/docker/docker-compose-nginx.yml @@ -0,0 +1,39 @@ +# Docker Compose setup with nginx reverse proxy for authentication +# +# USAGE: +# 1. Set SERPER_API_KEY and WEBCAT_API_KEY in your environment or .env file +# 2. Run: docker-compose -f docker-compose-nginx.yml up -d +# 3. Access WebCat at: https://localhost:4000/mcp +# 4. Include header: Authorization: Bearer +# +# NOTE: WebCat runs on internal network without auth +# Nginx validates bearer token before proxying to WebCat + +version: '3.8' + +services: + webcat: + image: tmfrisinger/webcat:latest + environment: + - SERPER_API_KEY=${SERPER_API_KEY} + # DON'T set WEBCAT_API_KEY - nginx handles auth + networks: + - internal + # Not exposed externally - only nginx can reach it + + nginx: + image: nginx:alpine + ports: + - "4000:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + environment: + - WEBCAT_API_KEY=${WEBCAT_API_KEY} # For config templating + networks: + - internal + depends_on: + - webcat + +networks: + internal: + driver: bridge diff --git a/docker/entrypoint-nginx.sh b/docker/entrypoint-nginx.sh new file mode 100755 index 0000000..69033db --- /dev/null +++ b/docker/entrypoint-nginx.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Replace __WEBCAT_API_KEY__ placeholder with actual value from env + +if [ -n "$WEBCAT_API_KEY" ]; then + echo "✅ Auth enabled with WEBCAT_API_KEY" + # Replace map placeholder and add auth check + sed -i "s|__WEBCAT_API_KEY__|$WEBCAT_API_KEY|g" /etc/nginx/nginx.conf + sed -i '/# AUTH_CHECK_PLACEHOLDER/c\ if ($auth_valid = 0) { return 401 '"'"'{"error": "Unauthorized"}'"'"'; }' /etc/nginx/nginx.conf +else + echo "✅ No WEBCAT_API_KEY set - auth disabled" + # Remove auth check line entirely + sed -i '/# AUTH_CHECK_PLACEHOLDER/d' /etc/nginx/nginx.conf +fi + +# Start supervisor +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/docker/mcp_server.py b/docker/mcp_server.py index 34e2284..9e6cdca 100644 --- a/docker/mcp_server.py +++ b/docker/mcp_server.py @@ -62,7 +62,8 @@ async def search_tool() -> dict: f"SERPER API key: {'Set' if SERPER_API_KEY else 'Not set (using DuckDuckGo fallback)'}" ) -# Create FastMCP instance (no authentication required) + +# Create FastMCP instance mcp_server = FastMCP("WebCat Search") # Register tools with MCP server @@ -78,9 +79,12 @@ async def search_tool() -> dict: if __name__ == "__main__": port = int(os.environ.get("PORT", 8000)) - logging.info(f"Starting FastMCP server on port {port}") + logging.info( + f"Starting FastMCP server on port {port} (no auth - use nginx proxy for auth)" + ) # Run the server with modern HTTP transport (Streamable HTTP with JSON-RPC 2.0) + # NOTE: Authentication should be handled by reverse proxy (nginx) not in-app mcp_server.run( transport="http", host="0.0.0.0", diff --git a/docker/nginx-auth.conf b/docker/nginx-auth.conf new file mode 100644 index 0000000..e315d5c --- /dev/null +++ b/docker/nginx-auth.conf @@ -0,0 +1,56 @@ +# Nginx config for WebCat with Bearer token auth +# +# SETUP: +# 1. Replace YOUR_SECRET_TOKEN_HERE with your actual API key +# 2. Replace your-domain.com with your domain +# 3. Update SSL certificate paths +# 4. Place in /etc/nginx/sites-available/webcat +# 5. Symlink to sites-enabled: ln -s /etc/nginx/sites-available/webcat /etc/nginx/sites-enabled/ +# 6. Test: nginx -t +# 7. Reload: systemctl reload nginx +# +# ALTERNATIVE for Docker Compose: +# Use docker-compose-nginx.yml instead of deploying nginx separately + +upstream webcat_backend { + server localhost:4000; # WebCat server without auth +} + +server { + listen 443 ssl http2; + server_name your-domain.com; + + # SSL certificates + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + # Bearer token validation + location /mcp { + # Check Authorization header + set $auth_valid 0; + + if ($http_authorization = "Bearer YOUR_SECRET_TOKEN_HERE") { + set $auth_valid 1; + } + + if ($auth_valid = 0) { + return 401 '{"error": "Unauthorized: Invalid or missing bearer token"}'; + } + + # Forward to WebCat + proxy_pass http://webcat_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE support + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +} diff --git a/docker/nginx-single-image.conf b/docker/nginx-single-image.conf new file mode 100644 index 0000000..f3ed0c6 --- /dev/null +++ b/docker/nginx-single-image.conf @@ -0,0 +1,45 @@ +events { + worker_connections 1024; +} + +http { + # WebCat backend (running locally in same container) + upstream webcat_backend { + server 127.0.0.1:4000; + } + + # Map to dynamically check bearer token from env var + map $http_authorization $auth_valid { + default 0; + # This will be replaced by entrypoint script with actual WEBCAT_API_KEY + "~^Bearer\s+__WEBCAT_API_KEY__$" 1; + } + + server { + listen 8000; + server_name _; + + # MCP endpoint with optional auth + location /mcp { + # AUTH_CHECK_PLACEHOLDER - replaced by entrypoint script + + # Proxy to WebCat + proxy_pass http://webcat_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # SSE/Streamable HTTP support + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + # Health check (no auth) + location /health { + proxy_pass http://webcat_backend/health; + } + } +} diff --git a/docker/supervisord.conf b/docker/supervisord.conf new file mode 100644 index 0000000..fc80a51 --- /dev/null +++ b/docker/supervisord.conf @@ -0,0 +1,23 @@ +[supervisord] +nodaemon=true +user=root + +[program:webcat] +command=python3.11 mcp_server.py +directory=/app/docker +environment=PORT=4000,PYTHONPATH=/app/docker,SERPER_API_KEY="%(ENV_SERPER_API_KEY)s" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=nginx -g "daemon off;" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/test_mcp_no_auth.py b/docker/test_mcp_no_auth.py new file mode 100644 index 0000000..4739ac8 --- /dev/null +++ b/docker/test_mcp_no_auth.py @@ -0,0 +1,105 @@ +# Copyright (c) 2024 Travis Frisinger +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +"""Test MCP server without auth - verify max_results parameter works.""" + +import json + +import requests + +base_url = "http://localhost:8000/mcp" # nginx proxy + +# Create session with required headers +session = requests.Session() +session.headers.update({"Accept": "application/json, text/event-stream"}) + +# Step 1: Initialize +print("1. Initializing MCP session...") +init_payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"}, + }, +} + +init_resp = session.post(base_url, json=init_payload) +print(f"Initialize status: {init_resp.status_code}") + +if init_resp.status_code != 200: + print(f"Initialize failed: {init_resp.text}") + exit(1) + +# Get session ID +session_id = init_resp.headers.get("mcp-session-id") +print(f"Session ID: {session_id}") + +if not session_id: + print("No session ID in response!") + exit(1) + +# Step 2: Send initialized notification +print("\n2. Sending initialized notification...") +initialized_payload = {"jsonrpc": "2.0", "method": "notifications/initialized"} + +notif_resp = session.post( + base_url, json=initialized_payload, headers={"mcp-session-id": session_id} +) +print(f"Initialized notification status: {notif_resp.status_code}") + +# Step 3: Call search tool with max_results=2 +print("\n3. Calling search tool with max_results=2...") +search_payload = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "search", + "arguments": {"query": "Model Context Protocol", "max_results": 2}, + }, +} + +search_resp = session.post( + base_url, json=search_payload, headers={"mcp-session-id": session_id}, timeout=30 +) + +print(f"Search status: {search_resp.status_code}") +print("Content-Type:", search_resp.headers.get("content-type")) +print("\nResponse (first 2000 chars):") +print(search_resp.text[:2000]) + +# Parse SSE response +if search_resp.status_code == 200: + # Extract JSON from SSE format + lines = search_resp.text.strip().split("\n") + for line in lines: + if line.startswith("data: "): + data_json = line[6:] # Remove "data: " prefix + result = json.loads(data_json) + + if "result" in result and "content" in result["result"]: + content = result["result"]["content"] + if isinstance(content, list) and len(content) > 0: + text_content = content[0].get("text", "") + # Parse the JSON string inside text + search_result = json.loads(text_content) + + result_count = len(search_result.get("results", [])) + print(f"\n✅ Found {result_count} results (expected 2)") + print(f"Search source: {search_result.get('search_source')}") + + for i, res in enumerate(search_result.get("results", []), 1): + print(f"\n{i}. {res.get('title')}") + print(f" URL: {res.get('url')}") + print(f" Snippet: {res.get('snippet')[:100]}...") + + if result_count == 2: + print("\n✅ max_results parameter works correctly!") + else: + print(f"\n⚠️ Expected 2 results but got {result_count}") + break diff --git a/docker/test_nginx_auth.py b/docker/test_nginx_auth.py new file mode 100644 index 0000000..0cacc4d --- /dev/null +++ b/docker/test_nginx_auth.py @@ -0,0 +1,116 @@ +# Copyright (c) 2024 Travis Frisinger +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +"""Test nginx auth proxy - both with and without bearer token.""" + + +import requests + +base_url = "http://localhost:8000/mcp" + +# Load API key from .env +with open(".env") as f: + for line in f: + if line.startswith("WEBCAT_API_KEY="): + api_key = line.split("=", 1)[1].strip() + break + +print("=" * 60) +print("Test 1: Without Authorization header (should fail)") +print("=" * 60) + +session = requests.Session() +session.headers.update({"Accept": "application/json, text/event-stream"}) + +init_payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"}, + }, +} + +try: + resp = session.post(base_url, json=init_payload, timeout=5) + print(f"Status: {resp.status_code}") + print(f"Response: {resp.text[:200]}") + if resp.status_code == 401: + print("✅ Auth correctly blocked request without token") + else: + print(f"⚠️ Expected 401 but got {resp.status_code}") +except Exception as e: + print(f"❌ Error: {e}") + +print("\n" + "=" * 60) +print("Test 2: With valid Authorization header (should succeed)") +print("=" * 60) + +session2 = requests.Session() +session2.headers.update( + { + "Accept": "application/json, text/event-stream", + "Authorization": f"Bearer {api_key}", + } +) + +try: + resp = session2.post(base_url, json=init_payload, timeout=5) + print(f"Status: {resp.status_code}") + if resp.status_code == 200: + print("✅ Auth correctly allowed request with valid token") + session_id = resp.headers.get("mcp-session-id") + print(f"Session ID: {session_id}") + + # Try a search + print("\nTest 3: Real search with max_results=1") + notif_resp = session2.post( + base_url, + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + headers={"mcp-session-id": session_id}, + timeout=5, + ) + + search_resp = session2.post( + base_url, + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "search", + "arguments": {"query": "MCP protocol", "max_results": 1}, + }, + }, + headers={"mcp-session-id": session_id}, + timeout=30, + ) + + if search_resp.status_code == 200: + print(f"Search status: {search_resp.status_code}") + # Parse SSE + import json + + for line in search_resp.text.strip().split("\n"): + if line.startswith("data: "): + data = json.loads(line[6:]) + if "result" in data: + content_text = data["result"]["content"][0]["text"] + search_data = json.loads(content_text) + results = search_data.get("results", []) + print(f"✅ Got {len(results)} result(s) with max_results=1") + if results: + print(f" Title: {results[0]['title']}") + print(f" URL: {results[0]['url'][:50]}...") + break + else: + print(f"⚠️ Search failed: {search_resp.status_code}") + else: + print(f"⚠️ Expected 200 but got {resp.status_code}") + print(f"Response: {resp.text[:200]}") +except Exception as e: + print(f"❌ Error: {e}") diff --git a/docker/tests/unit/tools/test_search_tool.py b/docker/tests/unit/tools/test_search_tool.py index c02df89..dc7871f 100644 --- a/docker/tests/unit/tools/test_search_tool.py +++ b/docker/tests/unit/tools/test_search_tool.py @@ -18,14 +18,10 @@ class TestSearchTool: """Tests for search tool.""" @pytest.mark.asyncio - @patch("tools.search_tool.validate_bearer_token") @patch("tools.search_tool.process_search_results") @patch("tools.search_tool.fetch_with_fallback") - async def test_returns_search_results( - self, mock_fetch, mock_process, mock_validate - ): + async def test_returns_search_results(self, mock_fetch, mock_process): # Arrange - mock_validate.return_value = (True, None) api_results = [an_api_search_result().build()] processed = [ SearchResult( @@ -39,7 +35,7 @@ async def test_returns_search_results( mock_process.return_value = processed # Act - result = await search_tool("test query") + result = await search_tool("test query", max_results=5) # Assert assert result["query"] == "test query" @@ -48,15 +44,13 @@ async def test_returns_search_results( assert result["results"][0]["title"] == "Test" @pytest.mark.asyncio - @patch("tools.search_tool.validate_bearer_token") @patch("tools.search_tool.fetch_with_fallback") - async def test_returns_error_when_no_results(self, mock_fetch, mock_validate): + async def test_returns_error_when_no_results(self, mock_fetch): # Arrange - mock_validate.return_value = (True, None) mock_fetch.return_value = ([], "DuckDuckGo (free fallback)") # Act - result = await search_tool("test query") + result = await search_tool("test query", max_results=5) # Assert assert result["query"] == "test query" @@ -64,14 +58,10 @@ async def test_returns_error_when_no_results(self, mock_fetch, mock_validate): assert len(result["results"]) == 0 @pytest.mark.asyncio - @patch("tools.search_tool.validate_bearer_token") @patch("tools.search_tool.process_search_results") @patch("tools.search_tool.fetch_with_fallback") - async def test_processes_results_correctly( - self, mock_fetch, mock_process, mock_validate - ): + async def test_processes_results_correctly(self, mock_fetch, mock_process): # Arrange - mock_validate.return_value = (True, None) api_results = [ an_api_search_result() .with_title("T") @@ -83,35 +73,33 @@ async def test_processes_results_correctly( mock_process.return_value = [] # Act - await search_tool("query") + await search_tool("query", max_results=5) # Assert mock_process.assert_called_once_with(api_results) @pytest.mark.asyncio - @patch("tools.search_tool.validate_bearer_token") - async def test_returns_error_when_authentication_fails(self, mock_validate): + @patch("tools.search_tool.fetch_with_fallback") + async def test_respects_max_results_parameter(self, mock_fetch): # Arrange - mock_validate.return_value = (False, "Invalid bearer token") + api_results = [an_api_search_result().build()] + mock_fetch.return_value = (api_results, "Serper API") # Act - result = await search_tool("test query") + await search_tool("test query", max_results=10) # Assert - assert result["query"] == "test query" - assert result["error"] == "Invalid bearer token" - assert result["search_source"] == "none" - assert len(result["results"]) == 0 + mock_fetch.assert_called_once_with("test query", "", 10) @pytest.mark.asyncio - @patch("tools.search_tool.validate_bearer_token") - async def test_passes_context_to_authentication(self, mock_validate): + @patch("tools.search_tool.fetch_with_fallback") + async def test_uses_default_max_results(self, mock_fetch): # Arrange - mock_validate.return_value = (False, "Auth error") - ctx = {"headers": {"Authorization": "Bearer test"}} + api_results = [an_api_search_result().build()] + mock_fetch.return_value = (api_results, "Serper API") # Act - await search_tool("query", ctx=ctx) + await search_tool("test query") # Assert - mock_validate.assert_called_once_with(ctx) + mock_fetch.assert_called_once_with("test query", "", 5) diff --git a/docker/tools/search_tool.py b/docker/tools/search_tool.py index 50c6932..2792042 100644 --- a/docker/tools/search_tool.py +++ b/docker/tools/search_tool.py @@ -13,7 +13,6 @@ from models.search_result import SearchResult from services.search_processor import process_search_results from services.search_service import fetch_with_fallback -from utils.auth import validate_bearer_token logger = logging.getLogger(__name__) @@ -21,7 +20,7 @@ SERPER_API_KEY = os.environ.get("SERPER_API_KEY", "") -async def search_tool(query: str, ctx=None, max_results: int = 5) -> dict: +async def search_tool(query: str, max_results: int = 5) -> dict: """Search the web for information on a given query. This MCP tool searches the web using Serper API (premium) or DuckDuckGo @@ -29,7 +28,6 @@ async def search_tool(query: str, ctx=None, max_results: int = 5) -> dict: Args: query: The search query string - ctx: Optional MCP context (may contain authentication headers) max_results: Maximum number of results to return (default: 5) Returns: @@ -37,18 +35,6 @@ async def search_tool(query: str, ctx=None, max_results: int = 5) -> dict: """ logger.info(f"Processing search request: {query} (max {max_results} results)") - # Validate authentication if WEBCAT_API_KEY is set - is_valid, error_msg = validate_bearer_token(ctx) - if not is_valid: - logger.warning(f"Authentication failed: {error_msg}") - response = SearchResponse( - query=query, - search_source="none", - results=[], - error=error_msg, - ) - return response.model_dump() - # Fetch results with automatic fallback api_results, search_source = fetch_with_fallback(query, SERPER_API_KEY, max_results) diff --git a/docker/utils/auth.py b/docker/utils/auth.py index 7ddaae1..c51a2a4 100644 --- a/docker/utils/auth.py +++ b/docker/utils/auth.py @@ -20,6 +20,7 @@ Context = None # type: ignore logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) def validate_bearer_token(ctx: Optional[Any] = None) -> tuple[bool, Optional[str]]: @@ -45,6 +46,12 @@ def validate_bearer_token(ctx: Optional[Any] = None) -> tuple[bool, Optional[str logger.warning("Authentication required but no context provided") return False, "Authentication required: missing bearer token" + # Debug: log context type and attributes + logger.debug(f"Context type: {type(ctx)}") + logger.debug( + f"Context attributes: {dir(ctx) if hasattr(ctx, '__dir__') else 'N/A'}" + ) + # Try to extract Authorization header from context # FastMCP provides Context object with get_http_request() method headers = None @@ -53,8 +60,10 @@ def validate_bearer_token(ctx: Optional[Any] = None) -> tuple[bool, Optional[str if Context and isinstance(ctx, Context): try: request = ctx.get_http_request() + logger.debug(f"HTTP request: {request}") if request and hasattr(request, "headers"): headers = dict(request.headers) + logger.debug(f"Extracted headers: {headers}") except Exception as e: logger.warning(f"Failed to get HTTP request from context: {e}") diff --git a/test_port_4001.py b/test_port_4001.py new file mode 100755 index 0000000..23fcf34 --- /dev/null +++ b/test_port_4001.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# Copyright (c) 2024 Travis Frisinger +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +import json + +import requests + +session = requests.Session() +base_url = "http://localhost:4001/mcp" + +print("=== TESTING PORT 4001 WITH DEBUG LOGGING ===\n") + +# Initialize +init_resp = session.post( + base_url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0"}, + }, + }, + stream=True, +) + +session_id = init_resp.headers.get("mcp-session-id") +print(f"Session: {session_id}\n") + +# Send initialized +session.post( + base_url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "mcp-session-id": session_id, + }, + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, +) + +# Call search +resp = session.post( + base_url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "Authorization": "Bearer sk-abc-123-def-456", + "mcp-session-id": session_id, + }, + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "search", + "arguments": {"query": "Python", "max_results": 2}, + }, + }, + stream=True, + timeout=90, +) + +print(f"Status: {resp.status_code}\n") + +for line in resp.iter_lines(): + if line and line.decode("utf-8").startswith("data: "): + data = json.loads(line.decode("utf-8")[6:]) + if "result" in data: + result = json.loads(data["result"]["content"][0]["text"]) + print(f"✅ Query: {result['query']}") + print(f" Source: {result['search_source']}") + print(f" Results: {len(result['results'])}") + if result.get("error"): + print(f" Error: {result['error']}") + break diff --git a/test_real_mcp_server.py b/test_real_mcp_server.py new file mode 100755 index 0000000..c5d8a7d --- /dev/null +++ b/test_real_mcp_server.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# Copyright (c) 2024 Travis Frisinger +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +"""Test real MCP server with full handshake.""" +import json + +import requests + +session = requests.Session() +base_url = "http://localhost:4000/mcp" + +print("=== TESTING REAL MCP SERVER ON PORT 4000 ===\n") + +# Step 1: Initialize +print("1. Initialize...") +init_resp = session.post( + base_url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0"}, + }, + }, + stream=True, +) + +session_id = init_resp.headers.get("mcp-session-id") +print(f" Session: {session_id}") + +# Step 2: Send initialized notification +print("2. Send initialized notification...") +notif_resp = session.post( + base_url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "mcp-session-id": session_id, + }, + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, +) +print(f" Status: {notif_resp.status_code}") + +# Step 3: Call search +print("3. Call search with max_results=2...") +resp = session.post( + base_url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "Authorization": "Bearer sk-abc-123-def-456", + "mcp-session-id": session_id, + }, + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "search", + "arguments": {"query": "Python programming", "max_results": 2}, + }, + }, + stream=True, + timeout=90, +) + +print(f" Status: {resp.status_code}\n") + +for line in resp.iter_lines(): + if line: + line_str = line.decode("utf-8") + if line_str.startswith("data: "): + data = json.loads(line_str[6:]) + + if "error" in data: + print("❌ Error:", data["error"]) + break + elif "result" in data: + result = json.loads(data["result"]["content"][0]["text"]) + + print("✅ SUCCESS - REAL MCP SERVER!") + print("\nQuery:", result["query"]) + print("Source:", result["search_source"]) + print("Results:", len(result["results"])) + + if len(result["results"]) == 2: + print("✅ max_results=2 WORKS!") + else: + print("❌ Expected 2 results") + + for i, r in enumerate(result["results"], 1): + print(f"{i}. {r['title']}") + print(f" {r['url']}") + break