From 645d8820f13107f50d384d328adf25e793352594 Mon Sep 17 00:00:00 2001 From: Jarian Cottingham Date: Thu, 20 Aug 2026 23:27:27 +0000 Subject: [PATCH] fix: repair middleware wiring, input validation, and resource lifecycle - Wrap RateLimitMiddleware in Middleware() - bare class broke all requests (500) - Clamp/validate num param - ?num=abc no longer returns 500 - Query length cap (500), result counts clamped to 1..20 - Wire lifespan into Starlette so shared httpx client closes on shutdown - Purge stale per-IP rate limit entries to bound memory growth - URL-encode queries in the Playwright scraper path - Lazy lib imports so the package works without optional playwright - Add pyproject.toml (activates ruff/pytest/bandit in CI), README, LICENSE - Remove committed __pycache__, add .gitignore/.dockerignore - Fix SearXNG healthcheck path (/health -> /healthz) and compose docs --- .dockerignore | 11 ++ .gitignore | 7 + LICENSE | 21 +++ README.md | 134 ++++++++++++++++++ USAGE.md | 8 +- conftest.py | 2 + docker-compose.yml | 2 +- duckduckgo-mcp/server.py | 54 +++++-- google-mcp/server.py | 54 +++++-- integration_test.py | 28 ++-- lib/__init__.py | 34 +++-- lib/__pycache__/__init__.cpython-313.pyc | Bin 431 -> 0 bytes .../duckduckgo_search.cpython-313.pyc | Bin 4604 -> 0 bytes lib/__pycache__/google_search.cpython-313.pyc | Bin 7741 -> 0 bytes .../playwright_manager.cpython-313.pyc | Bin 6616 -> 0 bytes lib/__pycache__/rate_limiter.cpython-313.pyc | Bin 2040 -> 0 bytes lib/duckduckgo_search.py | 4 +- lib/google_search.py | 20 ++- lib/playwright_manager.py | 6 +- lib/rate_limiter.py | 2 +- pyproject.toml | 37 +++++ test_client.py | 13 +- tests/__pycache__/__init__.cpython-313.pyc | Bin 149 -> 0 bytes ...egration_test.cpython-313-pytest-9.0.3.pyc | Bin 12945 -> 0 bytes ...t_integration.cpython-313-pytest-9.0.3.pyc | Bin 12945 -> 0 bytes ..._rate_limiter.cpython-313-pytest-9.0.3.pyc | Bin 8136 -> 0 bytes ...earch_parsing.cpython-313-pytest-9.0.3.pyc | Bin 9686 -> 0 bytes tests/test_rate_limiter.py | 45 ++---- tests/test_search_parsing.py | 3 +- 29 files changed, 391 insertions(+), 94 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 conftest.py delete mode 100644 lib/__pycache__/__init__.cpython-313.pyc delete mode 100644 lib/__pycache__/duckduckgo_search.cpython-313.pyc delete mode 100644 lib/__pycache__/google_search.cpython-313.pyc delete mode 100644 lib/__pycache__/playwright_manager.cpython-313.pyc delete mode 100644 lib/__pycache__/rate_limiter.cpython-313.pyc create mode 100644 pyproject.toml delete mode 100644 tests/__pycache__/__init__.cpython-313.pyc delete mode 100644 tests/__pycache__/integration_test.cpython-313-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_integration.cpython-313-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_rate_limiter.cpython-313-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_search_parsing.cpython-313-pytest-9.0.3.pyc diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5829868 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +__pycache__ +*.py[cod] +.pytest_cache +.env +tests +test_client.py +integration_test.py +docs +*.md +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ade4eb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.env +.venv/ +venv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eba8c1d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jarian Cottingham + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e018f2e --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# MCP Search Servers + +Model Context Protocol (MCP) servers that expose **Google** and **DuckDuckGo** search +as tools for LLM clients (Claude Desktop, Cursor, VS Code, ...), backed by a +self-hosted [SearXNG](https://github.com/searxng/searxng) metasearch instance. + +SearXNG aggregates results from Google, DuckDuckGo, Brave, Wikipedia, and more with +its own request handling, so a single SearXNG container serves both MCP servers — +no browser fingerprinting, no API keys, fully self-hosted. + +## Services + +| Service | Port | MCP tool | Description | +|-------------------|------|--------------------|--------------------------------------| +| `google-mcp` | 3001 | `google_search` | Google results via SearXNG | +| `duckduckgo-mcp` | 3002 | `duckduckgo_search`| DuckDuckGo results via SearXNG | +| `searxng` | — | — | Internal metasearch engine (not published) | + +Each server exposes: + +- `/sse` + `/messages/` — MCP SSE transport +- `/search?q=...&num=N` — plain HTTP JSON endpoint +- `/health` — liveness probe + +## Quick Start + +```bash +cp .env.example .env # fill in SEARXNG_SECRET +docker compose up -d --build +``` + +Generate a SearXNG secret: + +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` + +Verify: + +```bash +curl http://localhost:3001/health +curl "http://localhost:3001/search?q=python&num=3" +``` + +Then point your LLM client at `http://localhost:3001/sse` (Google) and/or +`http://localhost:3002/sse` (DuckDuckGo). Client configuration examples for +Claude Desktop, Cursor/Windsurf, and VS Code are in [USAGE.md](USAGE.md). + +## Tool Contract + +```json +{ + "name": "google_search", + "arguments": { "query": "Python programming language", "num_results": 5 } +} +``` + +Returns numbered results with title, URL, and snippet (max 20 results, +queries capped at 500 characters). + +## Configuration + +| Variable | Default | Description | +|----------------------|----------------------|------------------------------------------| +| `SEARXNG_SECRET` | — (required) | SearXNG session/CSRF secret | +| `RATE_LIMIT_SECONDS` | 5 / 3 | 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 | + +## Security Model + +- **Internal network** — SearXNG is on a `internal: true` Docker network; only the + two MCP containers can reach it. It is not published to the host. +- **Per-IP rate limiting** — HTTP endpoints allow 30 requests/minute per IP + (`/health` exempt); excess requests get `429`. +- **Request pacing** — each server also enforces a minimum interval between + upstream SearXNG queries to avoid throttling. +- **No auth on `/search` and `/sse`** — the servers assume they are bound to a + trusted network. Expose them publicly only behind an auth proxy. +- **Input validation** — query length capped, result counts clamped to 1..20, + malformed parameters return `400` instead of crashing. + +## Optional: Playwright Scraping Fallback + +`lib/google_search.py` and `lib/duckduckgo_search.py` contain a legacy +Playwright-based scraping path (single shared browser, stealth user agent, +HTML parsing with multiple fallback strategies). It is **not** used by the +production servers — Google aggressively blocks headless browsers and SearXNG +is the working path. If you want to run the scraper directly: + +```bash +pip install playwright +playwright install chromium +``` + +## Development + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" + +ruff check . +pytest tests/ -v + +# End-to-end test (requires running containers) +python3 integration_test.py +``` + +## Project Structure + +``` +├── docker-compose.yml # All 3 services +├── searxng-settings.yml # SearXNG engine config +├── google-mcp/ +│ ├── Dockerfile +│ └── server.py # Google MCP server (SearXNG-backed) +├── duckduckgo-mcp/ +│ ├── Dockerfile +│ └── server.py # DuckDuckGo MCP server (SearXNG-backed) +├── lib/ +│ ├── playwright_manager.py # Single-instance browser manager (optional path) +│ ├── rate_limiter.py # Async minimum-interval rate limiter +│ ├── google_search.py # Playwright Google scraper (optional path) +│ └── duckduckgo_search.py # Playwright DuckDuckGo scraper (optional path) +├── tests/ +│ ├── test_rate_limiter.py +│ └── test_search_parsing.py +├── integration_test.py # End-to-end MCP SSE test +└── test_client.py # Manual stdio/SSE smoke client +``` + +## License + +[MIT](LICENSE) diff --git a/USAGE.md b/USAGE.md index e02b826..061a076 100644 --- a/USAGE.md +++ b/USAGE.md @@ -5,7 +5,7 @@ MCP (Model Context Protocol) servers that expose Google and DuckDuckGo search as ## Quick Start ```bash -cd google-mcp +# From the repository root docker compose up -d --build ``` @@ -169,9 +169,11 @@ python3 integration_test.py ## Troubleshooting -**No results returned** — SearXNG may be blocked on your network. Check which engines are working: +**No results returned** — SearXNG may be blocked on your network. Check which engines are working +(SearXNG is on an internal Docker network, so query it from inside its container): ```bash -curl "http://localhost:8080/search?q=test&format=json" | python3 -m json.tool +docker compose exec searxng python -c \ + "import urllib.request; print(urllib.request.urlopen('http://localhost:8080/search?q=test&format=json').read()[:500])" ``` **Services not starting** — Check for port conflicts: diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..5569969 --- /dev/null +++ b/conftest.py @@ -0,0 +1,2 @@ +# Ensures the repo root is on sys.path so `lib` and the server packages +# are importable in tests without installation. diff --git a/docker-compose.yml b/docker-compose.yml index f371a0f..362fdc8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: networks: - mcp-internal healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')"] interval: 30s timeout: 10s retries: 3 diff --git a/duckduckgo-mcp/server.py b/duckduckgo-mcp/server.py index d6a8be7..3628873 100644 --- a/duckduckgo-mcp/server.py +++ b/duckduckgo-mcp/server.py @@ -1,5 +1,4 @@ import asyncio -import json import logging import os import signal @@ -9,15 +8,15 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone import httpx - from mcp.server import Server from mcp.server.sse import SseServerTransport +from mcp.types import TextContent, Tool from starlette.applications import Starlette +from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware 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, @@ -31,6 +30,19 @@ 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")) +DEFAULT_RESULTS = 10 +MAX_RESULTS = 20 +MAX_QUERY_LEN = 500 + + +def parse_num_results(value) -> int: + """Parse a result-count value, clamped to 1..MAX_RESULTS.""" + try: + num = int(value) + except (TypeError, ValueError): + return DEFAULT_RESULTS + return max(1, min(num, MAX_RESULTS)) + # --- HTTPX singleton (#8) --- @@ -105,10 +117,17 @@ class RateLimitMiddleware(BaseHTTPMiddleware): client_ip = request.client.host if request.client else "unknown" now = datetime.now(timezone.utc).timestamp() + cutoff = now - self.window_seconds async with self._lock: + if len(self._requests) > 1000: + for ip in [ + ip + for ip, ts in self._requests.items() + if not ts or ts[-1] <= cutoff + ]: + del self._requests[ip] timestamps = self._requests[client_ip] - cutoff = now - self.window_seconds self._requests[client_ip] = [t for t in timestamps if t > cutoff] if len(self._requests[client_ip]) >= self.max_requests: return JSONResponse( @@ -155,15 +174,20 @@ async def do_search(query: str, num_results: int, engine: str): # --- HTTP endpoint --- async def search_http(request: Request): - query = request.query_params.get("q", "") + query = request.query_params.get("q", "").strip() if not query: return JSONResponse({"error": "'q' parameter required"}, status_code=400) + if len(query) > MAX_QUERY_LEN: + return JSONResponse( + {"error": f"'q' must be {MAX_QUERY_LEN} characters or fewer"}, + status_code=400, + ) - num = min(int(request.query_params.get("num", "10")), 20) + num = parse_num_results(request.query_params.get("num")) try: results = await do_search(query, num, "duckduckgo") return JSONResponse({"query": query, "results": results}) - except Exception as e: + except Exception: logger.exception("Search failed") return JSONResponse({"error": "Search service unavailable"}, status_code=503) @@ -204,14 +228,21 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: raise ValueError(f"Unknown tool: {name}") query = arguments.get("query", "") - if not query: + if not isinstance(query, str) or not query: return [TextContent(type="text", text="Error: 'query' is required.")] + if len(query) > MAX_QUERY_LEN: + return [ + TextContent( + type="text", + text=f"Error: query must be {MAX_QUERY_LEN} characters or fewer.", + ) + ] - num_results = min(int(arguments.get("num_results", 10)), 20) + num_results = parse_num_results(arguments.get("num_results")) try: results = await do_search(query, num_results, "duckduckgo") - except Exception as e: + except Exception: logger.exception("Search failed") return [TextContent(type="text", text="Search failed: service unavailable")] @@ -248,13 +279,14 @@ async def handle_sse(request): starlette_app = Starlette( debug=False, + lifespan=lifespan, routes=[ Route("/health", endpoint=health_check), Route("/search", endpoint=search_http), Route("/sse", endpoint=handle_sse), Mount("/messages/", app=sse.handle_post_message), ], - middleware=[RateLimitMiddleware], + middleware=[Middleware(RateLimitMiddleware)], ) diff --git a/google-mcp/server.py b/google-mcp/server.py index d031876..e39e33e 100644 --- a/google-mcp/server.py +++ b/google-mcp/server.py @@ -1,5 +1,4 @@ import asyncio -import json import logging import os import signal @@ -9,15 +8,15 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone import httpx - from mcp.server import Server from mcp.server.sse import SseServerTransport +from mcp.types import TextContent, Tool from starlette.applications import Starlette +from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware 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, @@ -31,6 +30,19 @@ 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")) +DEFAULT_RESULTS = 10 +MAX_RESULTS = 20 +MAX_QUERY_LEN = 500 + + +def parse_num_results(value) -> int: + """Parse a result-count value, clamped to 1..MAX_RESULTS.""" + try: + num = int(value) + except (TypeError, ValueError): + return DEFAULT_RESULTS + return max(1, min(num, MAX_RESULTS)) + # --- HTTPX singleton (#8) --- @@ -105,10 +117,17 @@ class RateLimitMiddleware(BaseHTTPMiddleware): client_ip = request.client.host if request.client else "unknown" now = datetime.now(timezone.utc).timestamp() + cutoff = now - self.window_seconds async with self._lock: + if len(self._requests) > 1000: + for ip in [ + ip + for ip, ts in self._requests.items() + if not ts or ts[-1] <= cutoff + ]: + del self._requests[ip] timestamps = self._requests[client_ip] - cutoff = now - self.window_seconds self._requests[client_ip] = [t for t in timestamps if t > cutoff] if len(self._requests[client_ip]) >= self.max_requests: return JSONResponse( @@ -155,15 +174,20 @@ async def do_search(query: str, num_results: int, engine: str): # --- HTTP endpoint --- async def search_http(request: Request): - query = request.query_params.get("q", "") + query = request.query_params.get("q", "").strip() if not query: return JSONResponse({"error": "'q' parameter required"}, status_code=400) + if len(query) > MAX_QUERY_LEN: + return JSONResponse( + {"error": f"'q' must be {MAX_QUERY_LEN} characters or fewer"}, + status_code=400, + ) - num = min(int(request.query_params.get("num", "10")), 20) + num = parse_num_results(request.query_params.get("num")) try: results = await do_search(query, num, "google") return JSONResponse({"query": query, "results": results}) - except Exception as e: + except Exception: logger.exception("Search failed") return JSONResponse({"error": "Search service unavailable"}, status_code=503) @@ -204,14 +228,21 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: raise ValueError(f"Unknown tool: {name}") query = arguments.get("query", "") - if not query: + if not isinstance(query, str) or not query: return [TextContent(type="text", text="Error: 'query' is required.")] + if len(query) > MAX_QUERY_LEN: + return [ + TextContent( + type="text", + text=f"Error: query must be {MAX_QUERY_LEN} characters or fewer.", + ) + ] - num_results = min(int(arguments.get("num_results", 10)), 20) + num_results = parse_num_results(arguments.get("num_results")) try: results = await do_search(query, num_results, "google") - except Exception as e: + except Exception: logger.exception("Search failed") return [TextContent(type="text", text="Search failed: service unavailable")] @@ -248,13 +279,14 @@ async def handle_sse(request): starlette_app = Starlette( debug=False, + lifespan=lifespan, routes=[ Route("/health", endpoint=health_check), Route("/search", endpoint=search_http), Route("/sse", endpoint=handle_sse), Mount("/messages/", app=sse.handle_post_message), ], - middleware=[RateLimitMiddleware], + middleware=[Middleware(RateLimitMiddleware)], ) diff --git a/integration_test.py b/integration_test.py index e40557b..f5ca78b 100644 --- a/integration_test.py +++ b/integration_test.py @@ -3,6 +3,7 @@ import asyncio import json import sys + import aiohttp @@ -76,7 +77,7 @@ async def test_server(name, host, port, tool_name, query): "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) @@ -88,25 +89,31 @@ async def test_server(name, host, port, tool_name, query): # 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', [])]}") + tool_names = [ + t["name"] for t in tools.get("result", {}).get("tools", []) + ] + print(f" Tools: {tool_names}") # 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: + "params": { + "name": tool_name, + "arguments": {"query": query, "num_results": 5}, + }, + }): pass result = await collector.wait_for_id(3, timeout=30) @@ -145,11 +152,14 @@ async def main(): print("Waiting for services to start...") await asyncio.sleep(5) - results["google"] = await test_server("Google", host, 3001, "google_search", "Python programming language") + query = "Python programming language" + results["google"] = await test_server("Google", host, 3001, "google_search", query) await asyncio.sleep(2) - results["duckduckgo"] = await test_server("DuckDuckGo", host, 3002, "duckduckgo_search", "Python programming language") + results["duckduckgo"] = await test_server( + "DuckDuckGo", host, 3002, "duckduckgo_search", query + ) print(f"\n{'='*60}") print("SUMMARY") @@ -161,4 +171,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/lib/__init__.py b/lib/__init__.py index f962e0f..d4d3986 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -1,11 +1,25 @@ -from lib.playwright_manager import PlaywrightManager -from lib.rate_limiter import RateLimiter -from lib.google_search import GoogleSearch -from lib.duckduckgo_search import DuckDuckGoSearch +"""Shared search utilities. -__all__ = [ - "PlaywrightManager", - "RateLimiter", - "GoogleSearch", - "DuckDuckGoSearch", -] \ No newline at end of file +Playwright-based scrapers are imported lazily so the package (and its +rate limiter) works without the optional ``playwright`` dependency. +""" + +from lib.rate_limiter import RateLimiter + +__all__ = ["PlaywrightManager", "RateLimiter", "GoogleSearch", "DuckDuckGoSearch"] + + +def __getattr__(name): + if name == "PlaywrightManager": + from lib.playwright_manager import PlaywrightManager + + return PlaywrightManager + if name == "GoogleSearch": + from lib.google_search import GoogleSearch + + return GoogleSearch + if name == "DuckDuckGoSearch": + from lib.duckduckgo_search import DuckDuckGoSearch + + return DuckDuckGoSearch + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/lib/__pycache__/__init__.cpython-313.pyc b/lib/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 0611d3a526869ede2e80800f461fa7aaa9a69480..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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>( diff --git a/lib/__pycache__/duckduckgo_search.cpython-313.pyc b/lib/__pycache__/duckduckgo_search.cpython-313.pyc deleted file mode 100644 index 08389724914a13fed8d06abcc26506091aff3ab0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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_ diff --git a/lib/__pycache__/google_search.cpython-313.pyc b/lib/__pycache__/google_search.cpython-313.pyc deleted file mode 100644 index 47f41211bc699c3fdb62da9f6fc960aacfd47a1a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7741 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 diff --git a/lib/__pycache__/rate_limiter.cpython-313.pyc b/lib/__pycache__/rate_limiter.cpython-313.pyc deleted file mode 100644 index 979b8134de9d65d56a069099b46c744db30befca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/lib/duckduckgo_search.py b/lib/duckduckgo_search.py index e49b611..83d7eb9 100644 --- a/lib/duckduckgo_search.py +++ b/lib/duckduckgo_search.py @@ -1,6 +1,6 @@ import asyncio import logging -from typing import List, Dict, Any, Optional +from typing import Any, Dict, List from lib.playwright_manager import PlaywrightManager from lib.rate_limiter import RateLimiter @@ -76,4 +76,4 @@ class DuckDuckGoSearch: return results; }""" ) - return results[:max_results] \ No newline at end of file + return results[:max_results] diff --git a/lib/google_search.py b/lib/google_search.py index 803e821..82c9354 100644 --- a/lib/google_search.py +++ b/lib/google_search.py @@ -1,7 +1,8 @@ import asyncio import logging import re -from typing import List, Dict, Any +import urllib.parse +from typing import Any, Dict, List from lib.playwright_manager import PlaywrightManager from lib.rate_limiter import RateLimiter @@ -29,7 +30,10 @@ class GoogleSearch: try: page = await PlaywrightManager.get_page(stealth=True) - url_params = f"{GOOGLE_URL}?q={query}&num={min(num_results, 20)}&hl={language}" + url_params = ( + f"{GOOGLE_URL}?q={urllib.parse.quote(query)}" + f"&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) @@ -98,8 +102,6 @@ class GoogleSearch: 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) @@ -118,7 +120,13 @@ class GoogleSearch: 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]: + if not 50 < len(clean) < 300: + continue + pos = html.find(clean) + if pos == -1: + continue + window = html[max(0, pos - 500): pos + 500] + if result["url"] in window: result["snippet"] = clean[:200] break return results @@ -152,4 +160,4 @@ class GoogleSearch: elif title: results.append({"title": title, "url": url, "snippet": ""}) - return results \ No newline at end of file + return results diff --git a/lib/playwright_manager.py b/lib/playwright_manager.py index b5d168f..16b4024 100644 --- a/lib/playwright_manager.py +++ b/lib/playwright_manager.py @@ -2,7 +2,7 @@ import asyncio import logging from typing import Optional -from playwright.async_api import async_playwright, Browser, BrowserContext, Page +from playwright.async_api import Browser, BrowserContext, Page, async_playwright logger = logging.getLogger(__name__) @@ -75,7 +75,7 @@ class PlaywrightManager: stealth = Stealth() await stealth.apply_stealth_async(page) logger.debug("Stealth mode applied to page") - except (ImportError, Exception) as e: + except Exception as e: logger.warning(f"Could not apply stealth: {e}") return page @@ -111,4 +111,4 @@ class PlaywrightManager: @classmethod def is_initialized(cls) -> bool: - return cls._initialized \ No newline at end of file + return cls._initialized diff --git a/lib/rate_limiter.py b/lib/rate_limiter.py index 6a48624..fa9a80f 100644 --- a/lib/rate_limiter.py +++ b/lib/rate_limiter.py @@ -29,4 +29,4 @@ class RateLimiter: f"(min interval: {self.min_interval}s)" ) await asyncio.sleep(wait_time) - self._last_request_time = time.monotonic() \ No newline at end of file + self._last_request_time = time.monotonic() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eacbd01 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcp-search-servers" +version = "1.1.0" +description = "MCP servers exposing Google and DuckDuckGo search via SearXNG" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +dependencies = [ + "mcp>=1.0.0,<2.0.0", + "httpx>=0.27.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "ruff>=0.4", +] +playwright = [ + "playwright>=1.44", +] + +[tool.setuptools] +packages = ["lib"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/test_client.py b/test_client.py index 6858158..84da46c 100644 --- a/test_client.py +++ b/test_client.py @@ -7,10 +7,8 @@ Usage: """ import asyncio -import json import subprocess import sys -import time async def test_mcp_server(service_name: str): @@ -36,7 +34,13 @@ async def test(): 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'}}}}}} + 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__() @@ -68,7 +72,6 @@ async def test(): 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 @@ -170,4 +173,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index db12ce65f6570ad3e93010d5db8125841b513379..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 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 deleted file mode 100644 index 840288349b0b7fa2fd1a32895bb9adb6110d1dcc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 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 deleted file mode 100644 index 404f22fbb128539eb03c9f866c9ac7eb7f8b7fa8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8136 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 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 deleted file mode 100644 index d59e56f1f1ce25ca76dd311f22a1d9fc461783b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9686 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 0.25 - - asyncio.get_event_loop().run_until_complete(run()) + assert asyncio.run(run()) >= 0.25 def test_consecutive_requests_space_correctly(self): + interval = 0.2 + async def run(): - interval = 0.2 limiter = RateLimiter(min_interval_seconds=interval) times = [] for _ in range(5): await limiter.acquire() times.append(time.monotonic()) + return times - 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()) + times = asyncio.run(run()) + for i in range(1, len(times)): + gap = times[i] - times[i - 1] + assert gap >= interval * 0.8 def test_custom_interval(self): async def run(): @@ -63,11 +52,9 @@ class TestRateLimiter: start = time.monotonic() await limiter.acquire() - elapsed = time.monotonic() - start + return time.monotonic() - start - assert elapsed >= 0.05 - - asyncio.get_event_loop().run_until_complete(run()) + assert asyncio.run(run()) >= 0.05 def test_no_delay_after_long_pause(self): async def run(): @@ -77,8 +64,6 @@ class TestRateLimiter: start = time.monotonic() await limiter.acquire() - elapsed = time.monotonic() - start + return time.monotonic() - start - assert elapsed < 0.1 - - asyncio.get_event_loop().run_until_complete(run()) \ No newline at end of file + assert asyncio.run(run()) < 0.1 diff --git a/tests/test_search_parsing.py b/tests/test_search_parsing.py index ddbdb52..f2ff01f 100644 --- a/tests/test_search_parsing.py +++ b/tests/test_search_parsing.py @@ -1,4 +1,3 @@ -import pytest class TestSearchResultFormatting: @@ -70,4 +69,4 @@ class TestSearchResultFormatting: if snippet: lines.append(f" {snippet}") - assert len(lines[2]) <= 203 \ No newline at end of file + assert len(lines[2]) <= 203