From 1096115208cfa0dfcf5de2e3349b79a8287218c5 Mon Sep 17 00:00:00 2001 From: Jarian Cottingham Date: Fri, 3 Jul 2026 01:06:24 +0000 Subject: [PATCH] initial commit --- SEARCH_SKILL.md | 57 +++++ USAGE.md | 219 ++++++++++++++++++ docker-compose.yml | 48 ++++ duckduckgo-mcp/Dockerfile | 18 ++ duckduckgo-mcp/__init__.py | 0 duckduckgo-mcp/server.py | 198 ++++++++++++++++ google-mcp/Dockerfile | 18 ++ google-mcp/__init__.py | 0 google-mcp/server.py | 198 ++++++++++++++++ integration_test.py | 164 +++++++++++++ lib/__init__.py | 11 + lib/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 431 bytes .../duckduckgo_search.cpython-313.pyc | Bin 0 -> 4604 bytes lib/__pycache__/google_search.cpython-313.pyc | Bin 0 -> 7741 bytes .../playwright_manager.cpython-313.pyc | Bin 0 -> 6616 bytes lib/__pycache__/rate_limiter.cpython-313.pyc | Bin 0 -> 2040 bytes lib/duckduckgo_search.py | 79 +++++++ lib/google_search.py | 155 +++++++++++++ lib/playwright_manager.py | 114 +++++++++ lib/rate_limiter.py | 32 +++ requirements.txt | 2 + searxng-settings.yml | 88 +++++++ test_client.py | 173 ++++++++++++++ tests/__init__.py | 0 tests/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 149 bytes ...egration_test.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 12945 bytes ...t_integration.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 12945 bytes ..._rate_limiter.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 8136 bytes ...earch_parsing.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 9686 bytes tests/test_rate_limiter.py | 84 +++++++ tests/test_search_parsing.py | 73 ++++++ 31 files changed, 1731 insertions(+) create mode 100644 SEARCH_SKILL.md create mode 100644 USAGE.md create mode 100644 docker-compose.yml create mode 100644 duckduckgo-mcp/Dockerfile create mode 100644 duckduckgo-mcp/__init__.py create mode 100644 duckduckgo-mcp/server.py create mode 100644 google-mcp/Dockerfile create mode 100644 google-mcp/__init__.py create mode 100644 google-mcp/server.py create mode 100644 integration_test.py create mode 100644 lib/__init__.py create mode 100644 lib/__pycache__/__init__.cpython-313.pyc create mode 100644 lib/__pycache__/duckduckgo_search.cpython-313.pyc create mode 100644 lib/__pycache__/google_search.cpython-313.pyc create mode 100644 lib/__pycache__/playwright_manager.cpython-313.pyc create mode 100644 lib/__pycache__/rate_limiter.cpython-313.pyc create mode 100644 lib/duckduckgo_search.py create mode 100644 lib/google_search.py create mode 100644 lib/playwright_manager.py create mode 100644 lib/rate_limiter.py create mode 100644 requirements.txt create mode 100644 searxng-settings.yml create mode 100644 test_client.py create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-313.pyc create mode 100644 tests/__pycache__/integration_test.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_integration.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_rate_limiter.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_search_parsing.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/test_rate_limiter.py create mode 100644 tests/test_search_parsing.py diff --git a/SEARCH_SKILL.md b/SEARCH_SKILL.md new file mode 100644 index 0000000..36a9924 --- /dev/null +++ b/SEARCH_SKILL.md @@ -0,0 +1,57 @@ +# Search API + +Simple HTTP search endpoints. Returns JSON. + +## Endpoints + +| Service | URL | +|---------|-----| +| Google | `http://192.168.8.128:3001/search` | +| DuckDuckGo | `http://192.168.8.128:3002/search` | + +## Usage + +``` +GET /search?q=your+query&num=5 +``` + +**Params:** +- `q` (required) — search query +- `num` (optional) — max results, default 10, max 20 + +## Example + +```bash +curl "http://192.168.8.128:3001/search?q=Python+async+best+practices&num=5" +``` + +**Response:** +```json +{ + "query": "Python async best practices", + "results": [ + { + "title": "Python asyncio documentation", + "url": "https://docs.python.org/3/library/asyncio.html", + "snippet": "The asyncio library provides infrastructure for writing concurrent code..." + }, + { + "title": "Best Practices for asyncio - Real Python", + "url": "https://realpython.com/async-io-python/", + "snippet": "When working with asyncio, follow these patterns for clean concurrent code..." + } + ] +} +``` + +## Rate Limits + +- Google: 5s between requests +- DuckDuckGo: 3s between requests + +## Tips + +- Use Google (`:3001`) for general web search +- Use DuckDuckGo (`:3002`) for different ranking or privacy-focused results +- Keep queries concise (2-5 words) +- If one engine returns poor results, try the other \ No newline at end of file diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..e02b826 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,219 @@ +# MCP Search Services + +MCP (Model Context Protocol) servers that expose Google and DuckDuckGo search as tools for LLMs. + +## Quick Start + +```bash +cd google-mcp +docker compose up -d --build +``` + +This starts 3 containers: + +| Service | Port | Purpose | +|---------|------|---------| +| `google-mcp` | 3001 | MCP server with `google_search` tool | +| `duckduckgo-mcp` | 3002 | MCP server with `duckduckgo_search` tool | +| `searxng` | 8080 | Internal metasearch engine | + +## Architecture + +``` +LLM/MCP Client + │ + ├── http://host:3001/sse ──→ google-mcp ──→ searxng ──→ Google + └── http://host:3002/sse ──→ duckduckgo-mcp ──→ searxng ──→ DuckDuckGo +``` + +SearXNG handles all the actual search work behind the scenes. It aggregates from Google, Brave, Startpage, Wikipedia, and other engines with rotating user agents, so there's no single browser fingerprint to detect. + +## Connecting an LLM Client + +### Claude Desktop + +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "google-search": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-remote", "http://localhost:3001/sse"] + }, + "duckduckgo-search": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-remote", "http://localhost:3002/sse"] + } + } +} +``` + +### Cursor / Windsurf + +In your IDE's MCP settings, add: + +**Google Search** +- Type: SSE +- URL: `http://localhost:3001/sse` + +**DuckDuckGo Search** +- Type: SSE +- URL: `http://localhost:3002/sse` + +### VS Code with MCP extension + +Install the MCP extension, then add to workspace settings: + +```json +{ + "mcp": { + "servers": { + "google-search": { + "transport": { + "type": "sse", + "url": "http://localhost:3001/sse" + } + }, + "duckduckgo-search": { + "transport": { + "type": "sse", + "url": "http://localhost:3002/sse" + } + } + } + } +} +``` + +### Remote hosting + +If your machine has a public IP or domain, replace `localhost` with your host: + +``` +http://your-server-ip:3001/sse +http://your-server-ip:3002/sse +``` + +## Available Tools + +### `google_search` + +Searches via Google engine through SearXNG. + +```json +{ + "name": "google_search", + "arguments": { + "query": "Python programming language", + "num_results": 5 + } +} +``` + +### `duckduckgo_search` + +Searches via DuckDuckGo engine through SearXNG. + +```json +{ + "name": "duckduckgo_search", + "arguments": { + "query": "Python programming language", + "num_results": 5 + } +} +``` + +Both tools return formatted text with numbered results containing title, URL, and snippet. + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `RATE_LIMIT_SECONDS` | 5 (Google) / 3 (DDG) | Minimum seconds between search requests | +| `MCP_PORT` | 3001 / 3002 | Port the MCP server listens on | +| `SEARXNG_URL` | `http://searxng:8080` | URL of the SearXNG instance | + +To change rate limits, edit `docker-compose.yml` and restart. + +## Management + +```bash +# Start all services +docker compose up -d + +# Stop all services +docker compose down + +# View logs +docker compose logs -f google-mcp +docker compose logs -f duckduckgo-mcp + +# Rebuild after code changes +docker compose up -d --build + +# Run integration test +python3 integration_test.py +``` + +## Testing + +```bash +# Unit tests +PYTHONPATH=. python3 -m pytest tests/ -v + +# Full integration test (requires running containers) +python3 integration_test.py +``` + +## Troubleshooting + +**No results returned** — SearXNG may be blocked on your network. Check which engines are working: +```bash +curl "http://localhost:8080/search?q=test&format=json" | python3 -m json.tool +``` + +**Services not starting** — Check for port conflicts: +```bash +docker compose ps +docker compose logs +``` + +**Rate limiting too aggressive** — Lower `RATE_LIMIT_SECONDS` in docker-compose.yml. + +**Want more search engines?** — Edit `searxng-settings.yml` to enable/disable engines, then restart SearXNG: +```bash +docker compose restart searxng +``` + +## Project Structure + +``` +google-mcp/ +├── docker-compose.yml # All 3 services +├── searxng-settings.yml # SearXNG engine config +├── google-mcp/ +│ ├── Dockerfile +│ └── server.py # Google MCP server +├── duckduckgo-mcp/ +│ ├── Dockerfile +│ └── server.py # DuckDuckGo MCP server +├── lib/ +│ ├── playwright_manager.py # (legacy, not used) +│ ├── rate_limiter.py # Per-service rate limiter +│ ├── google_search.py # (legacy, not used) +│ └── duckduckgo_search.py # (legacy, not used) +├── tests/ +│ ├── test_rate_limiter.py +│ └── test_search_parsing.py +├── integration_test.py # End-to-end MCP test +└── requirements.txt +``` + +## Notes + +- **No Playwright in production** — The original design used Playwright headless browsers, but Google actively blocks automated browsers. SearXNG is the working solution: it's a self-hosted metasearch aggregator that handles browser rotation internally. +- **CloakBrowser / playwright-cli** — If you need custom browser automation beyond search, those tools would pair well with this setup. SearXNG already solves the search detection problem. +- **Rate limiting** — Each MCP server has its own rate limiter to avoid overwhelming SearXNG or the upstream engines. +- **SearXNG as single point** — Both MCP servers share one SearXNG instance. This is fine for moderate usage. For heavy load, run multiple SearXNG instances behind a load balancer. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4e291f4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +services: + searxng: + image: searxng/searxng:latest + container_name: searxng + ports: + - "8080:8080" + volumes: + - ./searxng-settings.yml:/etc/searxng/settings.yml:ro + environment: + - SEARXNG_BASE_URL=http://localhost:8080/ + - UWSGI_WORKERS=4 + - UWSGI_THREADS=4 + restart: unless-stopped + stop_grace_period: 5s + + google-search: + build: + context: . + dockerfile: google-mcp/Dockerfile + container_name: google-mcp + ports: + - "3001:3001" + environment: + - RATE_LIMIT_SECONDS=5 + - MCP_PORT=3001 + - SEARXNG_URL=http://searxng:8080 + - PYTHONUNBUFFERED=1 + depends_on: + - searxng + restart: unless-stopped + stop_grace_period: 5s + + duckduckgo-search: + build: + context: . + dockerfile: duckduckgo-mcp/Dockerfile + container_name: duckduckgo-mcp + ports: + - "3002:3002" + environment: + - RATE_LIMIT_SECONDS=3 + - MCP_PORT=3002 + - SEARXNG_URL=http://searxng:8080 + - PYTHONUNBUFFERED=1 + depends_on: + - searxng + restart: unless-stopped + stop_grace_period: 5s \ No newline at end of file diff --git a/duckduckgo-mcp/Dockerfile b/duckduckgo-mcp/Dockerfile new file mode 100644 index 0000000..80dc06e --- /dev/null +++ b/duckduckgo-mcp/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +ENV DEBIAN_FRONTEND=noninteractive + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY lib/ ./lib/ +COPY duckduckgo-mcp/ ./duckduckgo-mcp/ + +ENV PYTHONPATH=/app +ENV RATE_LIMIT_SECONDS=3 +ENV SEARXNG_URL=http://searxng:8080 +ENV MCP_PORT=3002 + +CMD ["python", "-m", "duckduckgo-mcp.server"] \ No newline at end of file diff --git a/duckduckgo-mcp/__init__.py b/duckduckgo-mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/duckduckgo-mcp/server.py b/duckduckgo-mcp/server.py new file mode 100644 index 0000000..9271e77 --- /dev/null +++ b/duckduckgo-mcp/server.py @@ -0,0 +1,198 @@ +import asyncio +import json +import logging +import os +import signal +import sys + +import httpx + +from mcp.server import Server +from mcp.server.sse import SseServerTransport +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Mount, Route +from mcp.types import Tool, TextContent + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger("duckduckgo-mcp") + +app = Server("duckduckgo-search") +PORT = int(os.environ.get("MCP_PORT", "3002")) +SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080") +RATE_LIMIT_SECONDS = float(os.environ.get("RATE_LIMIT_SECONDS", "3")) + + +class RateLimiter: + def __init__(self, min_interval: float): + self.min_interval = min_interval + self._last_request = None + self._lock = asyncio.Lock() + + async def acquire(self): + async with self._lock: + now = asyncio.get_event_loop().time() + if self._last_request: + elapsed = now - self._last_request + wait = self.min_interval - elapsed + if wait > 0: + logger.info(f"Rate limit: waiting {wait:.1f}s") + await asyncio.sleep(wait) + self._last_request = asyncio.get_event_loop().time() + + +rate_limiter = RateLimiter(RATE_LIMIT_SECONDS) + + +async def do_search(query: str, num_results: int, engine: str): + """Shared search logic.""" + await rate_limiter.acquire() + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + f"{SEARXNG_URL}/search", + params={ + "q": query, + "format": "json", + "engines": engine, + "categories": "general", + "language": "en", + }, + ) + resp.raise_for_status() + data = resp.json() + + results = data.get("results", [])[:num_results] + return [ + { + "title": r.get("title", ""), + "url": r.get("url", ""), + "snippet": r.get("content", "")[:200], + } + for r in results + ] + + +# --- HTTP endpoint --- + +async def search_http(request: Request): + query = request.query_params.get("q", "") + if not query: + return JSONResponse({"error": "'q' parameter required"}, status_code=400) + + num = min(int(request.query_params.get("num", "10")), 20) + try: + results = await do_search(query, num, "duckduckgo") + return JSONResponse({"query": query, "results": results}) + except Exception as e: + logger.exception("Search failed") + return JSONResponse({"error": str(e)}, status_code=500) + + +# --- MCP tool handlers --- + +@app.list_tools() +async def list_tools() -> list[Tool]: + return [ + Tool( + name="duckduckgo_search", + description="Search DuckDuckGo and return results with titles, URLs, and snippets.", + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query."}, + "num_results": { + "type": "integer", + "description": "Maximum number of results (default 10).", + "default": 10, + }, + }, + "required": ["query"], + }, + ) + ] + + +@app.call_tool() +async def call_tool(name: str, arguments: dict) -> list[TextContent]: + if name != "duckduckgo_search": + raise ValueError(f"Unknown tool: {name}") + + query = arguments.get("query", "") + if not query: + return [TextContent(type="text", text="Error: 'query' is required.")] + + num_results = min(int(arguments.get("num_results", 10)), 20) + + try: + results = await do_search(query, num_results, "duckduckgo") + except Exception as e: + return [TextContent(type="text", text=f"Search failed: {e}")] + + if not results: + return [TextContent(type="text", text=f"No results found for: {query}")] + + lines = [f"Search results for: {query}\n"] + for i, r in enumerate(results, 1): + lines.append(f"{i}. {r['title']}") + lines.append(f" URL: {r['url']}") + if r["snippet"]: + lines.append(f" {r['snippet']}") + lines.append("") + + return [TextContent(type="text", text="\n".join(lines))] + + +# --- SSE MCP transport --- + +sse = SseServerTransport("/messages/") + + +async def handle_sse(request): + async with sse.connect_sse( + request.scope, request.receive, request._send + ) as (read_stream, write_stream): + await app.run( + read_stream, + write_stream, + app.create_initialization_options(), + ) + return Response() + + +starlette_app = Starlette( + debug=True, + routes=[ + Route("/search", endpoint=search_http), + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=sse.handle_post_message), + ], +) + + +async def main(): + logger.info(f"DuckDuckGo MCP server ready on port {PORT}") + + import uvicorn + + config = uvicorn.Config(starlette_app, host="0.0.0.0", port=PORT, log_level="info") + server = uvicorn.Server(config) + + loop = asyncio.get_running_loop() + + def handle_signal(): + server.should_exit = True + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, handle_signal) + + await server.serve() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/google-mcp/Dockerfile b/google-mcp/Dockerfile new file mode 100644 index 0000000..8294cc6 --- /dev/null +++ b/google-mcp/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +ENV DEBIAN_FRONTEND=noninteractive + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY lib/ ./lib/ +COPY google-mcp/ ./google-mcp/ + +ENV PYTHONPATH=/app +ENV RATE_LIMIT_SECONDS=5 +ENV SEARXNG_URL=http://searxng:8080 +ENV MCP_PORT=3001 + +CMD ["python", "-m", "google-mcp.server"] \ No newline at end of file diff --git a/google-mcp/__init__.py b/google-mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/google-mcp/server.py b/google-mcp/server.py new file mode 100644 index 0000000..964df5d --- /dev/null +++ b/google-mcp/server.py @@ -0,0 +1,198 @@ +import asyncio +import json +import logging +import os +import signal +import sys + +import httpx + +from mcp.server import Server +from mcp.server.sse import SseServerTransport +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Mount, Route +from mcp.types import Tool, TextContent + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger("google-mcp") + +app = Server("google-search") +PORT = int(os.environ.get("MCP_PORT", "3001")) +SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080") +RATE_LIMIT_SECONDS = float(os.environ.get("RATE_LIMIT_SECONDS", "5")) + + +class RateLimiter: + def __init__(self, min_interval: float): + self.min_interval = min_interval + self._last_request = None + self._lock = asyncio.Lock() + + async def acquire(self): + async with self._lock: + now = asyncio.get_event_loop().time() + if self._last_request: + elapsed = now - self._last_request + wait = self.min_interval - elapsed + if wait > 0: + logger.info(f"Rate limit: waiting {wait:.1f}s") + await asyncio.sleep(wait) + self._last_request = asyncio.get_event_loop().time() + + +rate_limiter = RateLimiter(RATE_LIMIT_SECONDS) + + +async def do_search(query: str, num_results: int, engine: str): + """Shared search logic.""" + await rate_limiter.acquire() + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + f"{SEARXNG_URL}/search", + params={ + "q": query, + "format": "json", + "engines": engine, + "categories": "general", + "language": "en", + }, + ) + resp.raise_for_status() + data = resp.json() + + results = data.get("results", [])[:num_results] + return [ + { + "title": r.get("title", ""), + "url": r.get("url", ""), + "snippet": r.get("content", "")[:200], + } + for r in results + ] + + +# --- HTTP endpoint --- + +async def search_http(request: Request): + query = request.query_params.get("q", "") + if not query: + return JSONResponse({"error": "'q' parameter required"}, status_code=400) + + num = min(int(request.query_params.get("num", "10")), 20) + try: + results = await do_search(query, num, "google") + return JSONResponse({"query": query, "results": results}) + except Exception as e: + logger.exception("Search failed") + return JSONResponse({"error": str(e)}, status_code=500) + + +# --- MCP tool handlers --- + +@app.list_tools() +async def list_tools() -> list[Tool]: + return [ + Tool( + name="google_search", + description="Search Google and return top results with titles, URLs, and snippets.", + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query."}, + "num_results": { + "type": "integer", + "description": "Maximum number of results (default 10, max 20).", + "default": 10, + }, + }, + "required": ["query"], + }, + ) + ] + + +@app.call_tool() +async def call_tool(name: str, arguments: dict) -> list[TextContent]: + if name != "google_search": + raise ValueError(f"Unknown tool: {name}") + + query = arguments.get("query", "") + if not query: + return [TextContent(type="text", text="Error: 'query' is required.")] + + num_results = min(int(arguments.get("num_results", 10)), 20) + + try: + results = await do_search(query, num_results, "google") + except Exception as e: + return [TextContent(type="text", text=f"Search failed: {e}")] + + if not results: + return [TextContent(type="text", text=f"No results found for: {query}")] + + lines = [f"Search results for: {query}\n"] + for i, r in enumerate(results, 1): + lines.append(f"{i}. {r['title']}") + lines.append(f" URL: {r['url']}") + if r["snippet"]: + lines.append(f" {r['snippet']}") + lines.append("") + + return [TextContent(type="text", text="\n".join(lines))] + + +# --- SSE MCP transport --- + +sse = SseServerTransport("/messages/") + + +async def handle_sse(request): + async with sse.connect_sse( + request.scope, request.receive, request._send + ) as (read_stream, write_stream): + await app.run( + read_stream, + write_stream, + app.create_initialization_options(), + ) + return Response() + + +starlette_app = Starlette( + debug=True, + routes=[ + Route("/search", endpoint=search_http), + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=sse.handle_post_message), + ], +) + + +async def main(): + logger.info(f"Google MCP server ready on port {PORT}") + + import uvicorn + + config = uvicorn.Config(starlette_app, host="0.0.0.0", port=PORT, log_level="info") + server = uvicorn.Server(config) + + loop = asyncio.get_running_loop() + + def handle_signal(): + server.should_exit = True + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, handle_signal) + + await server.serve() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/integration_test.py b/integration_test.py new file mode 100644 index 0000000..e40557b --- /dev/null +++ b/integration_test.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Integration test: connect to MCP servers via SSE and test search.""" +import asyncio +import json +import sys +import aiohttp + + +class SSEClient: + def __init__(self): + self.events = [] + self.lock = asyncio.Lock() + + async def add_event(self, data): + async with self.lock: + self.events.append(data) + + async def wait_for_id(self, expected_id, timeout=30): + start = asyncio.get_event_loop().time() + while asyncio.get_event_loop().time() - start < timeout: + async with self.lock: + for evt in self.events: + if isinstance(evt, dict) and evt.get("id") == expected_id: + return evt + await asyncio.sleep(0.1) + return None + + +async def test_server(name, host, port, tool_name, query): + print(f"\n{'='*60}") + print(f"Testing {name} at http://{host}:{port}") + print(f"{'='*60}") + + base = f"http://{host}:{port}" + collector = SSEClient() + messages_endpoint = None + + async with aiohttp.ClientSession() as session: + async with session.get(f"{base}/sse") as sse_resp: + if sse_resp.status != 200: + print(f"FAIL: SSE status {sse_resp.status}") + return False + + async def read_sse(): + try: + async for line in sse_resp.content: + line_str = line.decode().strip() + if not line_str or not line_str.startswith("data:"): + continue + data_str = line_str[5:].strip() + try: + data = json.loads(data_str) + await collector.add_event(data) + except json.JSONDecodeError: + nonlocal messages_endpoint + messages_endpoint = base.rstrip("/") + data_str + print(f" Endpoint: {messages_endpoint}") + await collector.add_event(data_str) + except Exception as e: + print(f" SSE error: {e}") + + reader = asyncio.create_task(read_sse()) + + try: + for _ in range(50): + if messages_endpoint: + break + await asyncio.sleep(0.1) + + if not messages_endpoint: + print("FAIL: No endpoint") + return False + + # Initialize + async with session.post(messages_endpoint, json={ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0"}}, + }) as resp: + pass + + init = await collector.wait_for_id(1, timeout=10) + if not init: + print("FAIL: No init response") + return False + print(f" Protocol: {init['result']['protocolVersion']}") + + # Initialized notification + async with session.post(messages_endpoint, json={ + "jsonrpc": "2.0", "method": "notifications/initialized", + }) as resp: + pass + + # List tools + async with session.post(messages_endpoint, json={ + "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}, + }) as resp: + pass + + tools = await collector.wait_for_id(2, timeout=10) + if tools: + print(f" Tools: {[t['name'] for t in tools.get('result', {}).get('tools', [])]}") + + # Call search + print(f" Searching: '{query}'") + async with session.post(messages_endpoint, json={ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": tool_name, "arguments": {"query": query, "num_results": 5}}, + }) as resp: + pass + + result = await collector.wait_for_id(3, timeout=30) + if not result: + print("FAIL: No search response") + return False + + content = result.get("result", {}).get("content", []) + if content: + text = content[0].get("text", "") + print(f"\n --- RESULTS ---\n{text}\n --- END ---") + if "Search failed" in text: + print(" PARTIAL: Error in response") + return False + if "No results" in text: + print(" PARTIAL: No results") + return False + return True + + print("FAIL: Empty response") + return False + + finally: + reader.cancel() + try: + await reader + except asyncio.CancelledError: + pass + + +async def main(): + host = "localhost" + results = {} + + # Wait for services to be ready + print("Waiting for services to start...") + await asyncio.sleep(5) + + results["google"] = await test_server("Google", host, 3001, "google_search", "Python programming language") + + await asyncio.sleep(2) + + results["duckduckgo"] = await test_server("DuckDuckGo", host, 3002, "duckduckgo_search", "Python programming language") + + print(f"\n{'='*60}") + print("SUMMARY") + print(f"{'='*60}") + for name, ok in results.items(): + print(f" {name}: {'PASS' if ok else 'FAIL'}") + + sys.exit(0 if all(results.values()) else 1) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..f962e0f --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1,11 @@ +from lib.playwright_manager import PlaywrightManager +from lib.rate_limiter import RateLimiter +from lib.google_search import GoogleSearch +from lib.duckduckgo_search import DuckDuckGoSearch + +__all__ = [ + "PlaywrightManager", + "RateLimiter", + "GoogleSearch", + "DuckDuckGoSearch", +] \ No newline at end of file diff --git a/lib/__pycache__/__init__.cpython-313.pyc b/lib/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0611d3a526869ede2e80800f461fa7aaa9a69480 GIT binary patch literal 431 zcmX|7%}N6?82qw7-D)ki2gPIcWCa^29*ZZTu&bi(1BBQv>DKIS`MQhr=tKAr`bdF2 zdGaRq1x(V`z&Fe{6J|oz?e;$C^Lgsb&j`SmS$4;%X}fb%O>CNtK-l!;2UUYxbN?&W0^h=7XKA2+jQ9Ls$)#lRZMQW zY=G(-S=dUpw4F9n9T|P9Yt4va=b0)MFvg{1>;u+%1vR9zB1`C;W6E=C9w4x&@+sZq zUS^X8)p3-uG*3&$28)$yjbUjb%g$VSe|J3aS0(cT-9)1!$l3lU(atjZvnQo F`3LQKb$9>( literal 0 HcmV?d00001 diff --git a/lib/__pycache__/duckduckgo_search.cpython-313.pyc b/lib/__pycache__/duckduckgo_search.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..08389724914a13fed8d06abcc26506091aff3ab0 GIT binary patch literal 4604 zcmb6dTWlN0ahG?;myQ%o$+RfR)=9EJQI)7vvf@`9$BAjBlB=e?5GWv}?3p~0=N#|o zy`vvW0vHKUIA3vE7;%9Db^GNu{m7?29Xn0}^oO!i(7n2-gPB>`BEiE%vyQH!bFvcER%hq-rX@AyS)i^XhUZ~|?rKO~&@_J>wY*y6qytASMZPNg{ejdOT zw1BA6#3N$Dh#ta`Dg|>Yj4(8Lc z&nx!K`?eEC5Lm@SrrPD^MF3pGR0N|#QvOxJ)CHAV>YPlW@Z!raza&)jf-ERSK{_ibs&q<~ zQ$dTbC|XX@zfmbXh^L4v&}hiadT=B8sGqS7EnHM6YRu z@sh5WRC%nDua2wAsSUdeYfz)5s&Gy4a-YpvqrQ$HH7>50C@|_`X8Wo5D8$MEXuf} z8|uu)SVn3VMPD_n_!XEJR zeJ+#iF{V#wZ`M?J7NY?c{rl1eaV!>JHl4y}3s_R{6H*=BpuUgSs9cy7{$F+ZC7 z0?J>Y{0Yjhp`3&A3n*RvXnw%u>qF8wLK?S0ry+N&Rf}ONyV#!P;B;-!U_Rl}h!K12 z(aFnlvIovOcZ@6IB09qKp(5@F4Ib$${NMV*opJ7wAJLmsgg=Mxex`_%JLVSq=cKbr zNiwMi1<%-W5Q5`C4hdGIpjW_OnX+c8x>S%0%2PE2e$5J=lfbRiG*eM6pQ%)2y=E@9 zD_XT?&TCRdJ~D7-;1!EYQy;pxbC8^qPmML*hmdj*%P*OxlrIC*tA}i+oMD1DBt2lH z2h@!&_H2?#a9odE5EjFL+x|!>>g15HxMwqQ5nMWu!nlxFWD-Esy&cgZA=yc&7qom* z{!C3FGNCTb@|R>YSB1E0c~!jx;niXlt*BdUNjG)Ncl`KtE}J=Nu|-8yE&r?Xg;Tmj z3Kmxd;S6hsZ6;VBe6lL%O`QOHT8dx<7fpz2g$Q>WK#q-iwbk;*)pc zlPmG5M#t2p}TwUy{pgP*GNQ+I)S?A@_ObZ9v`bgi@!O*QybolgOABRaes9lje$TwlB~ zwaTE5jr~BAj{BDXTak`(qHA4G8^5FC!`w^3*_^1HvZ}T`k zp8Q9OpB|90GlJvQ8J!#*4K+i=i^LzvPhf+qy#_76u+^e`XyaDaa-Fee7l$AkUT zJo5>Qr+xlUI3LA%2I#G}igeyhKfaT8&}8B*DC;u-nrR2lQ0MX3C*tjbfyQJR5oJBB z9TMgOwtMuwv3b*vsp@cI zOV5``9^Qtd>(Pz~^RH~Cp?++*wX~oY9TAdSNJwRMNF|VvO8Q;th7(OUoXbgD$?W>z>bd$^yy8XUw)YzK3Af0bYa zKL2w`{D3}i;e83OBATPXt07_&eSBeIntiX6%}Hbs-nr74#U1&4(>T1 zToF<|K3#w>c>eQa?t6>U|S_ZMqpjbN0S^#2raL9p$hfm zdeLOUiots`MPH)M?+&?&^TUIaFh%I###gQL&<#Kyb<$3t$TPr$(2SbU)Y;KKO|eH7 zvHZ_C+wv@#g#qf$jROEy8I1AYQ5gRJiAGn@=)cguf1u&}fk-3JeJ9X;^}tGC??vzZ T;P6G?L+|q#4}FO!v^oC|3idq_ literal 0 HcmV?d00001 diff --git a/lib/__pycache__/google_search.cpython-313.pyc b/lib/__pycache__/google_search.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47f41211bc699c3fdb62da9f6fc960aacfd47a1a GIT binary patch literal 7741 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 literal 0 HcmV?d00001 diff --git a/lib/__pycache__/rate_limiter.cpython-313.pyc b/lib/__pycache__/rate_limiter.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..979b8134de9d65d56a069099b46c744db30befca GIT binary patch literal 2040 zcmbVN&2JM&6rb5IuN?=I#0kWhFa#Rmw00mVxH3>tTn;oSmAs8wiKyF+J$5!-?>e*V zM9B%YQk6?3N{C$bQmL2H8>fn1$v;3Eq*&Xus?-xVXh2+g=$rKi6mjZ!=goU>-n^N4 z@At<0y}c0x?Y-l|;)sOMUtH)kkb}1W84R09LmHk&tTkeW16g1KjKZ||fy5+&=5QhK z*IL-2qi9Y!i(3*49Y&)_6B9_2&I)avTEQv#5f^4s*jGQSd8T6Y za>Xt?tVmr_HEpw6uaXkAj8!sEy%kDr!st?+x}HlshZxI_St8y7b3D&7?Mf!{(E_7J zDeW3%O27><+*P|s>MkWKrng|)#2_vhT9nXbYJ0?ToLVO0$6+tswBZ!XhNZi-=-4H< z*p5VSN6z!x?iYY;qE8N5ftnKy%_6HENrbL2EAm0=68il6V5HY)_P z;;g9Q+--!6E0Eo8B^7#KF$Yl-z*dYw6}{f$0i?-Oa;NIxUI#ip3I{vfi*XdqiXGdl zfU^G=6TlhAZP4IVfM2J=ufi+wU!N67)66f>&p;0!M{l9P24ddIg^Hr+RNGex@>muK zItKY6sDdtv$50t(#WGGQGmIx}ZHNPiw6T>VD~9Po?U6MxbEdr3=aN$Zchaf499a|G zR4UAPTzx4(d${V@j_25Bk@2`Q{zCYQTe!H`z=a-6h#=_!8Nhp=OfoCez!=^xe>QGJ7-J97ukTc{Y!k6%Pb3C&!#rc?We`JL77kWUy6~g&GX-ic&AH$&E>cgBLQ1auUtzn!` zMz<1C&X0vapAuWi;CTu%0)Gz`;GwCP;7x}I#5SrF9*~HxSDjMb;(S!sm+FSqo{8yt z*<>z%w5jdDbV%1rP7xTnY&nLeef2`?n(wj{Oh8>A_e~&>OpJd+2ZFEXcXZ^>@WI`1;@5EE V+jn-tCvPavl|wlDHv$qk`xjlHxxfGb literal 0 HcmV?d00001 diff --git a/lib/duckduckgo_search.py b/lib/duckduckgo_search.py new file mode 100644 index 0000000..e49b611 --- /dev/null +++ b/lib/duckduckgo_search.py @@ -0,0 +1,79 @@ +import asyncio +import logging +from typing import List, Dict, Any, Optional + +from lib.playwright_manager import PlaywrightManager +from lib.rate_limiter import RateLimiter + +logger = logging.getLogger(__name__) + +DDG_URL = "https://duckduckgo.com/html/" + + +class DuckDuckGoSearch: + """ + Search DuckDuckGo using Playwright headless browser. + Falls back to lightweight HTTP mode if available. + """ + + def __init__(self, min_interval: float = 3.0): + self.rate_limiter = RateLimiter(min_interval_seconds=min_interval) + + async def search( + self, query: str, num_results: int = 10 + ) -> List[Dict[str, Any]]: + await self.rate_limiter.acquire() + + page = None + try: + page = await PlaywrightManager.get_page() + logger.info(f"Navigating to DuckDuckGo search: {query}") + + await page.goto(DDG_URL, wait_until="domcontentloaded", timeout=30000) + + await page.fill('input[name="q"]', query) + await page.keyboard.press("Enter") + + await page.wait_for_selector( + "#rweb-results .result", state="attached", timeout=15000 + ) + await asyncio.sleep(1) + + results = await self._parse_page(page, num_results) + logger.info( + f"DuckDuckGo search returned {len(results)} results for: {query}" + ) + return results + + except Exception as e: + logger.error(f"DuckDuckGo search failed for '{query}': {e}") + raise RuntimeError(f"DuckDuckGo search failed: {e}") from e + finally: + if page: + await PlaywrightManager.close_page(page) + + async def _parse_page( + self, page, max_results: int + ) -> List[Dict[str, Any]]: + results = await page.evaluate( + """() => { + const items = document.querySelectorAll('#rweb-results .result'); + const results = []; + for (const item of items) { + const a = item.querySelector('.result__a'); + const h2 = item.querySelector('.result__title'); + const snippetEl = item.querySelector('.result__snippet'); + if (!a || !h2) continue; + + const url = a.href || ''; + const title = h2.textContent?.trim() || ''; + const snippet = snippetEl?.textContent?.trim() || ''; + + if (title) { + results.push({ title, url, snippet }); + } + } + return results; + }""" + ) + return results[:max_results] \ No newline at end of file diff --git a/lib/google_search.py b/lib/google_search.py new file mode 100644 index 0000000..803e821 --- /dev/null +++ b/lib/google_search.py @@ -0,0 +1,155 @@ +import asyncio +import logging +import re +from typing import List, Dict, Any + +from lib.playwright_manager import PlaywrightManager +from lib.rate_limiter import RateLimiter + +logger = logging.getLogger(__name__) + +GOOGLE_URL = "https://www.google.com/search" + + +class GoogleSearch: + """ + Search Google using Playwright headless browser with stealth mode. + Respects rate limits and cleans up page resources after each search. + """ + + def __init__(self, min_interval: float = 5.0): + self.rate_limiter = RateLimiter(min_interval_seconds=min_interval) + + async def search( + self, query: str, num_results: int = 10, language: str = "en" + ) -> List[Dict[str, Any]]: + await self.rate_limiter.acquire() + + page = None + try: + page = await PlaywrightManager.get_page(stealth=True) + + url_params = f"{GOOGLE_URL}?q={query}&num={min(num_results, 20)}&hl={language}" + logger.info(f"Navigating to Google search: {query}") + + await page.goto(url_params, wait_until="domcontentloaded", timeout=30000) + await asyncio.sleep(3) + + html = await page.content() + results = self._parse_results(html, num_results) + logger.info(f"Google search returned {len(results)} results for: {query}") + return results + + except Exception as e: + logger.error(f"Google search failed for '{query}': {e}") + raise RuntimeError(f"Google search failed: {e}") from e + finally: + if page: + await PlaywrightManager.close_page(page) + + def _parse_results( + self, html: str, max_results: int + ) -> List[Dict[str, Any]]: + results = [] + seen_urls = set() + + # Strategy 1: Parse /url?q= links (Google's redirect URLs) + url_pattern = re.findall( + r']*href="(/url\?q=([^&"]+)&[^"]*)"[^>]*>(.*?)', + html, + re.DOTALL, + ) + + for _, raw_url, anchor_html in url_pattern: + if len(results) >= max_results: + break + + url = self._clean_url(raw_url) + if not url or url in seen_urls or "google.com" in url: + continue + seen_urls.add(url) + + title = re.sub(r'<[^>]+>', "", anchor_html).strip() + if not title or len(title) < 3: + continue + + results.append({"title": title, "url": url, "snippet": ""}) + + # Strategy 2: If we got results, try to pair them with nearby snippets + if results: + results = self._add_snippets(html, results) + else: + # Strategy 3: Parse data-href attributes (modern Google) + data_hrefs = re.findall(r'data-href="(.*?)"', html) + for raw_url in data_hrefs: + if len(results) >= max_results: + break + url = self._clean_url(raw_url) + if not url or url in seen_urls or "google.com" in url: + continue + seen_urls.add(url) + results.append({"title": url, "url": url, "snippet": ""}) + + # Strategy 4: Parse from

tags and nearby tags + if not results: + results = self._parse_from_h3(html, max_results) + + return results[:max_results] + + def _clean_url(self, raw_url: str) -> str: + """Decode Google redirect URL.""" + import urllib.parse + + raw_url = urllib.parse.unquote(raw_url) + # Remove tracking parameters + url = re.sub(r"&[a-z_]+=.*$", "", raw_url) + url = re.sub(r"[?#].*$", "", url) + if not url.startswith("http"): + url = "https://" + url.lstrip("//") + return url.strip() + + def _add_snippets( + self, html: str, results: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Try to find snippets near result URLs.""" + snippet_blocks = re.findall( + r']*>([\s\S]*?)', html + ) + for i, result in enumerate(results): + for block in snippet_blocks: + clean = re.sub(r'<[^>]+>', "", block).strip() + if 50 < len(clean) < 300 and result["url"] in html[max(0, html.find(clean) - 500):html.find(clean) + 500]: + result["snippet"] = clean[:200] + break + return results + + def _parse_from_h3( + self, html: str, max_results: int + ) -> List[Dict[str, Any]]: + """Parse results from h3 title tags and nearby links.""" + results = [] + h3_pattern = re.findall( + r']*>([\s\S]*?)

', html, re.DOTALL + ) + + for h3_content in h3_pattern: + if len(results) >= max_results: + break + + # Extract title from h3 + title = re.sub(r'<[^>]+>', "", h3_content).strip() + if not title or len(title) < 3: + continue + + # Find the link inside the h3 + link_match = re.search(r'href="([^"]+)"', h3_content) + url = "" + if link_match: + url = self._clean_url(link_match.group(1)) + + if url and "google.com" not in url: + results.append({"title": title, "url": url, "snippet": ""}) + elif title: + results.append({"title": title, "url": url, "snippet": ""}) + + return results \ No newline at end of file diff --git a/lib/playwright_manager.py b/lib/playwright_manager.py new file mode 100644 index 0000000..b5d168f --- /dev/null +++ b/lib/playwright_manager.py @@ -0,0 +1,114 @@ +import asyncio +import logging +from typing import Optional + +from playwright.async_api import async_playwright, Browser, BrowserContext, Page + +logger = logging.getLogger(__name__) + + +class PlaywrightManager: + """ + Singleton-style manager for a single Playwright browser instance. + Ensures only ONE browser/process runs per container, with proper cleanup. + """ + + _instance: Optional["PlaywrightManager"] = None + _pw_cm = None + _pw = None + _browser: Optional[Browser] = None + _context: Optional[BrowserContext] = None + _initialized = False + _cleanup_lock = asyncio.Lock() + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @classmethod + async def initialize(cls, user_agent: Optional[str] = None, headless: bool = True): + if cls._initialized: + logger.debug("Playwright already initialized, reusing instance") + return cls._instance + + cls._pw_cm = async_playwright() + cls._pw = await cls._pw_cm.__aenter__() + browser_args = { + "headless": headless, + "args": [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--disable-blink-features=AutomationControlled", + ], + } + cls._browser = await cls._pw.chromium.launch(**browser_args) + + ua = user_agent or ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/131.0.0.0" + ) + cls._context = await cls._browser.new_context( + user_agent=ua, + viewport={"width": 1920, "height": 1080}, + locale="en-US", + timezone_id="America/New_York", + extra_http_headers={"Accept-Language": "en-US,en;q=0.9"}, + ) + cls._initialized = True + logger.info("Playwright browser initialized (single instance)") + return cls._instance + + @classmethod + async def get_page(cls, stealth: bool = False) -> Page: + if not cls._initialized: + raise RuntimeError( + "PlaywrightManager not initialized. Call initialize() first." + ) + page = await cls._context.new_page() + if stealth: + try: + from playwright_stealth import Stealth + stealth = Stealth() + await stealth.apply_stealth_async(page) + logger.debug("Stealth mode applied to page") + except (ImportError, Exception) as e: + logger.warning(f"Could not apply stealth: {e}") + return page + + @classmethod + async def close_page(cls, page: Page): + try: + await page.close() + except Exception as e: + logger.debug(f"Error closing page: {e}") + + @classmethod + async def shutdown(cls): + async with cls._cleanup_lock: + if not cls._initialized: + return + logger.info("Shutting down Playwright browser...") + try: + if cls._context: + await cls._context.close() + cls._context = None + if cls._browser: + await cls._browser.close() + cls._browser = None + if cls._pw_cm: + await cls._pw_cm.__aexit__(None, None, None) + cls._pw = None + cls._pw_cm = None + except Exception as e: + logger.error(f"Error during Playwright shutdown: {e}") + finally: + cls._initialized = False + cls._instance = None + + @classmethod + def is_initialized(cls) -> bool: + return cls._initialized \ No newline at end of file diff --git a/lib/rate_limiter.py b/lib/rate_limiter.py new file mode 100644 index 0000000..6a48624 --- /dev/null +++ b/lib/rate_limiter.py @@ -0,0 +1,32 @@ +import asyncio +import logging +import time +from typing import Optional + +logger = logging.getLogger(__name__) + + +class RateLimiter: + """ + Enforces minimum delay between requests to avoid throttling. + Thread-safe for async use within a single event loop. + """ + + def __init__(self, min_interval_seconds: float = 3.0): + self.min_interval = min_interval_seconds + self._last_request_time: Optional[float] = None + self._lock = asyncio.Lock() + + async def acquire(self): + async with self._lock: + now = time.monotonic() + if self._last_request_time is not None: + elapsed = now - self._last_request_time + wait_time = self.min_interval - elapsed + if wait_time > 0: + logger.info( + f"Rate limiter: waiting {wait_time:.1f}s " + f"(min interval: {self.min_interval}s)" + ) + await asyncio.sleep(wait_time) + self._last_request_time = time.monotonic() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..72178dc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +mcp>=1.0.0,<2.0.0 +httpx>=0.27.0 \ No newline at end of file diff --git a/searxng-settings.yml b/searxng-settings.yml new file mode 100644 index 0000000..2753454 --- /dev/null +++ b/searxng-settings.yml @@ -0,0 +1,88 @@ +use_default_settings: True + +general: + instance_name: "SearXNG" + debug: false + mail_to: admin@example.com + contact_url: false + donation_url: false + enable_metrics: true + +search: + safe_search: 0 + autocomplete: "google" + default_lang: "en-US" + formats: + - html + - json + +server: + port: 8080 + bind_address: "0.0.0.0" + secret_key: "mcp-search-secret-key-change-in-production" + limiter: false + image_proxy: false + method: "GET" + +engines: + - name: google + engine: google + sorting: relevance + use_mobile_ui: false + max_pages: 10 + enabled: true + weight: 2 + + - name: duckduckgo + engine: duckduckgo + use_mobile_ui: false + enabled: true + weight: 1 + + - name: brave + engine: brave + enabled: true + weight: 1 + + - name: wikipedia + engine: wikipedia + enabled: true + weight: 1 + + - name: bing + engine: bing + enabled: true + weight: 1 + + - name: startpage + engine: startpage + enabled: true + weight: 1 + + - name: qwant + engine: qwant + enabled: true + weight: 1 + + - name: mozillian_ahrefs + engine: mozillian_ahrefs + enabled: true + + - name: library_genesis + engine: library_genesis + enabled: false + + - name: google news + engine: google_news + enabled: true + + - name: duckduckgo news + engine: duckduckgo_news + enabled: true + +outgoing: + request_timeout: 5.0 + max_retries: 1 + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/131.0.0.0" + max_connection_limit: 128 + max_keepalive_connection: 20 \ No newline at end of file diff --git a/test_client.py b/test_client.py new file mode 100644 index 0000000..6858158 --- /dev/null +++ b/test_client.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Test client that connects to an MCP server over stdio and runs a search. +Usage: + python test_client.py google # tests google-mcp container + python test_client.py duckduckgo # tests duckduckgo-mcp container +""" + +import asyncio +import json +import subprocess +import sys +import time + + +async def test_mcp_server(service_name: str): + container_name = f"{service_name}-mcp" + query = "Python programming language" + print(f"\n{'='*60}") + print(f"Testing {container_name} with query: '{query}'") + print(f"{'='*60}") + + proc = subprocess.Popen( + [ + "docker", "exec", "-i", container_name, + "python", "-c", + f""" +import sys +sys.path.insert(0, '/app') +import asyncio +from mcp.server.stdio import stdio_server +from {service_name.replace('-', '_')}_mcp.server import app + +async def test(): + async with stdio_server() as (read, write): + await asyncio.sleep(0.5) + init_req = json.loads(await read.__anext__()) + # Send initialize response + init_result = {{'jsonrpc': '2.0', 'id': init_req['id'], 'result': {{'protocolVersion': '2024-11-05', 'capabilities': {{}}, 'serverInfo': {{'name': 'test', 'version': '1.0'}}}}}} + await write.send(json.dumps(init_result)) + # Client sends initialized notification + await read.__anext__() + # List tools + list_req = {{'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list', 'params': {{}}}} + await write.send(json.dumps(list_req)) + list_resp = json.loads(await read.__anext__()) + print("TOOLS:", json.dumps(list_resp.get('result', {{}}).get('tools', []), indent=2)) +""", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.PIPE, + ) + + try: + stdout, stderr = proc.communicate(timeout=30) + print("STDOUT:", stdout.decode()) + if stderr: + print("STDERR:", stderr.decode()) + except subprocess.TimeoutExpired: + proc.kill() + print("TIMEOUT: Test timed out after 30s") + return False + + return proc.returncode == 0 + + +async def test_with_curl(service_name: str): + """Test using docker exec to run a Python test directly in the container.""" + container_name = f"{service_name}-mcp" + module = service_name.replace("-", "_") + + test_script = f""" +import sys, asyncio, os +sys.path.insert(0, '/app') +os.environ.setdefault('RATE_LIMIT_SECONDS', '1') + +from lib.playwright_manager import PlaywrightManager +from lib.google_search import GoogleSearch +from lib.duckduckgo_search import DuckDuckGoSearch + +async def test(): + await PlaywrightManager.initialize() + try: + query = "Python programming language" + if "{service_name}" == "google": + engine = GoogleSearch(min_interval=1) + results = await engine.search(query, num_results=5) + else: + engine = DuckDuckGoSearch(min_interval=1) + results = await engine.search(query, num_results=5) + + print(f"Query: {{query}}") + print(f"Results found: {{len(results)}}") + for i, r in enumerate(results, 1): + title = r.get('title', 'N/A') + url = r.get('url', 'N/A') + snippet = r.get('snippet', '')[:100] + print(f" {{i}}. {{title}}") + print(f" URL: {{url}}") + if snippet: + print(f" {{snippet}}") + + if results: + print("\\nSUCCESS: Got {{len(results)}} results") + sys.exit(0) + else: + print("\\nFAILURE: No results returned") + sys.exit(1) + except Exception as e: + print(f"ERROR: {{e}}") + import traceback + traceback.print_exc() + sys.exit(1) + finally: + await PlaywrightManager.shutdown() + +asyncio.run(test()) +""" + + print(f"\n{'='*60}") + print(f"Testing {container_name}") + print(f"{'='*60}") + + try: + result = subprocess.run( + [ + "docker", "exec", "-i", container_name, + "python", "-c", test_script, + ], + capture_output=True, + text=True, + timeout=60, + ) + print(result.stdout) + if result.stderr: + print("STDERR:", result.stderr) + return result.returncode == 0 + + except subprocess.TimeoutExpired: + print("TIMEOUT: Test timed out after 60s") + return False + except FileNotFoundError: + print("ERROR: docker not found") + return False + + +async def main(): + if len(sys.argv) < 2: + print("Usage: python test_client.py [google|duckduckgo|both]") + sys.exit(1) + + target = sys.argv[1] + services = ["google", "duckduckgo"] if target == "both" else [target] + + results = {} + for service in services: + success = await test_with_curl(service) + results[service] = success + + print(f"\n{'='*60}") + print("SUMMARY") + print(f"{'='*60}") + for service, success in results.items(): + status = "PASS" if success else "FAIL" + print(f" {service}: {status}") + + all_passed = all(results.values()) + sys.exit(0 if all_passed else 1) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db12ce65f6570ad3e93010d5db8125841b513379 GIT binary patch literal 149 zcmey&%ge<81ZxzTvOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~h}enx(7s(w~t zQD$PEenC!RWqMJ5X-ietdjpUS>&ryk0@&Ee@O9{FKt1 YRJ$Tppdlbjib0Hz%#4hTMa)1J04;JN-2eap literal 0 HcmV?d00001 diff --git a/tests/__pycache__/integration_test.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/integration_test.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..840288349b0b7fa2fd1a32895bb9adb6110d1dcc GIT binary patch literal 12945 zcmd5iZBSd+mG|mBNe@W~0RjmjKzx`FgZKuUkJ!e>U$IRR9`Z+Uqbf*XB_NR};RHHu zJxRB9zoz7?#+{vpc6WBkv^(ogceZYKX6w(yPMuUj*pi=Vw&`|e$NLN0>5{tb%${?F zB)}xLyW43l=)CjpJ@=e*?>YCrd(XXh($h5r(tGuaXNZZA|HPL>;+K#+-v#7#A`*#+ zR8;Jtl7c0jF!+KHOevlBCnUKTk-ok_usb>i}oL)j&mNHss9I=rh#gL*(nCRN=3wnkmSJ3PC z*@B)xu-!J`_xU^nL0iyo+qdm;Tfie7^+*BRQLoD;irZ~2pPLavflC@VQhO6Q_6#fu zV2>qL$({A^`Z_sGBnsUC&6hY)0c!KuBuOc%MczW5p^{2eO6nn1l^~~qgl)rKk1u#L z4eAaoshi}=0d{8x<61`?WSA8o=ugNby$J&(%LsI0q644~@?dF*yh(eicv@C*+IP!pTnS$D)g;;DPaC3#)Gy3t|R_>klu^SSGW z{QjX~k9}ldv<`bQQ0Gncs}pl-N5^Hs>Gb-%L8nvBO185$k%t}?$OUkW#B$BY`EO|F zjrk|`zOwh#p5y#C)9#?k1IbpxiyD3}!SBu`0HwqRJ?$X9fUi!J?1uh70_{FZU*~$1 zI-mv0<-C@v^eR`^CB#8fP9uUlizMNSyA`Xs4iSO$)Wc6*Olk-#2W!No3PeXLm)N_Z zHwP6qQUscSE0RaJA~HxfaD!CBR#kD579NrSKvua%M?F4wl|s_tEAMs%T>;eHW|Pt} zC(Y${JDCaP>{PcVGxb0dr~_~ldp6s2qU}W6t8Foz;ahvYx+khD4C@M~f^)jEMUEH? zurN;W$3PduAguY621I*X|YkzKW?#p$d0GA?2+=@wJekHX&?9h!NtMZ!37;_&d$J4U9=$ot3gV}L~1+ax+ z1va973?s@7Kk;EkRnL+5sDgS=;K-`tdSr#y{a@cXiRq`CtKc&OYRdW{PcS}woWp+q zsLThwBc5gBPvZ@EeSx6MH{gN6=N$;j9Mq7Nfnkqlv`QHtm0HgWqwvk}xSd|NtVRz0 zF{BB)B%t>k4F+&#v0;i2TJ7^LmC(RsMol#lQ_ZRDpO}7RI&J)M!I|o-4f|q6B_}nDYGN)vG5+%S_55;} z5)l7TAla4^_E+rFTqLV3sw)fW%0ASRjNFeEeO14CK}QV6MJ;1+V*Iu7ySD_AzV7#d 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 Ob(J?QaD=xoVgCyr9X>Mv literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_rate_limiter.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_rate_limiter.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..404f22fbb128539eb03c9f866c9ac7eb7f8b7fa8 GIT binary patch literal 8136 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_search_parsing.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_search_parsing.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d59e56f1f1ce25ca76dd311f22a1d9fc461783b7 GIT binary patch literal 9686 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 0.25 + + asyncio.get_event_loop().run_until_complete(run()) + + def test_consecutive_requests_space_correctly(self): + async def run(): + interval = 0.2 + limiter = RateLimiter(min_interval_seconds=interval) + + times = [] + for _ in range(5): + await limiter.acquire() + times.append(time.monotonic()) + + for i in range(1, len(times)): + gap = times[i] - times[i - 1] + assert gap >= interval * 0.8 + + asyncio.get_event_loop().run_until_complete(run()) + + def test_custom_interval(self): + async def run(): + limiter = RateLimiter(min_interval_seconds=0.1) + await limiter.acquire() + + start = time.monotonic() + await limiter.acquire() + elapsed = time.monotonic() - start + + assert elapsed >= 0.05 + + asyncio.get_event_loop().run_until_complete(run()) + + def test_no_delay_after_long_pause(self): + async def run(): + limiter = RateLimiter(min_interval_seconds=0.3) + await limiter.acquire() + await asyncio.sleep(0.5) + + start = time.monotonic() + await limiter.acquire() + elapsed = time.monotonic() - start + + assert elapsed < 0.1 + + asyncio.get_event_loop().run_until_complete(run()) \ No newline at end of file diff --git a/tests/test_search_parsing.py b/tests/test_search_parsing.py new file mode 100644 index 0000000..ddbdb52 --- /dev/null +++ b/tests/test_search_parsing.py @@ -0,0 +1,73 @@ +import pytest + + +class TestSearchResultFormatting: + """Test SearXNG result formatting.""" + + def test_format_single_result(self): + results = [ + { + "title": "Python.org", + "url": "https://www.python.org/", + "content": "Python is a programming language", + } + ] + lines = [] + for i, r in enumerate(results, 1): + lines.append(f"{i}. {r.get('title', 'N/A')}") + lines.append(f" URL: {r.get('url', 'N/A')}") + snippet = r.get("content", "")[:200] + if snippet: + lines.append(f" {snippet}") + + assert len(lines) == 3 + assert "1. Python.org" in lines[0] + assert "https://www.python.org/" in lines[1] + assert "Python is a programming language" in lines[2] + + def test_format_multiple_results(self): + results = [ + {"title": f"Result {i}", "url": f"https://example.com/{i}", "content": f"Content {i}"} + for i in range(5) + ] + lines = [] + for i, r in enumerate(results, 1): + lines.append(f"{i}. {r.get('title', 'N/A')}") + lines.append(f" URL: {r.get('url', 'N/A')}") + snippet = r.get("content", "")[:200] + if snippet: + lines.append(f" {snippet}") + + assert len(lines) == 15 + + def test_format_missing_fields(self): + results = [{"title": "No URL", "content": "Some content"}] + lines = [] + for i, r in enumerate(results, 1): + lines.append(f"{i}. {r.get('title', 'N/A')}") + lines.append(f" URL: {r.get('url', 'N/A')}") + snippet = r.get("content", "")[:200] + if snippet: + lines.append(f" {snippet}") + + assert "URL: N/A" in lines[1] + + def test_format_empty_results(self): + results = [] + lines = [] + for i, r in enumerate(results, 1): + lines.append(f"{i}. {r.get('title', 'N/A')}") + assert lines == [] + + def test_snippet_truncation(self): + long_content = "x" * 500 + results = [{"title": "Long", "url": "https://example.com", "content": long_content}] + lines = [] + for i, r in enumerate(results, 1): + lines.append(f"{i}. {r.get('title', 'N/A')}") + lines.append(f" URL: {r.get('url', 'N/A')}") + snippet = r.get("content", "")[:200] + if snippet: + lines.append(f" {snippet}") + + assert len(lines[2]) <= 203 \ No newline at end of file