Merge pull request 'fix: batch fix all issues' (#10) from fix/all into main

Reviewed-on: https://git.example.com/jarianc/google-mcp/pulls/10
This commit is contained in:
Jarian Cottingham 2026-07-05 17:07:39 -05:00
commit 762b2d83db
5 changed files with 252 additions and 49 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
# SearXNG secret key (generate: python -c "import secrets; print(secrets.token_hex(32))")
SEARXNG_SECRET=

View File

@ -2,16 +2,25 @@ services:
searxng: searxng:
image: searxng/searxng:latest image: searxng/searxng:latest
container_name: searxng container_name: searxng
ports: expose:
- "8080:8080" - "8080"
volumes: volumes:
- ./searxng-settings.yml:/etc/searxng/settings.yml:ro - ./searxng-settings.yml:/etc/searxng/settings.yml:ro
environment: environment:
- SEARXNG_BASE_URL=http://localhost:8080/ - SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
- UWSGI_WORKERS=4 - UWSGI_WORKERS=4
- UWSGI_THREADS=4 - UWSGI_THREADS=4
restart: unless-stopped restart: unless-stopped
stop_grace_period: 5s stop_grace_period: 5s
networks:
- mcp-internal
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
google-search: google-search:
build: build:
@ -26,9 +35,19 @@ services:
- SEARXNG_URL=http://searxng:8080 - SEARXNG_URL=http://searxng:8080
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
depends_on: depends_on:
- searxng searxng:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
stop_grace_period: 5s stop_grace_period: 5s
networks:
- mcp-internal
- mcp-external
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3001/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
duckduckgo-search: duckduckgo-search:
build: build:
@ -43,6 +62,22 @@ services:
- SEARXNG_URL=http://searxng:8080 - SEARXNG_URL=http://searxng:8080
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
depends_on: depends_on:
- searxng searxng:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
stop_grace_period: 5s stop_grace_period: 5s
networks:
- mcp-internal
- mcp-external
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3002/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
mcp-internal:
internal: true
mcp-external:
driver: bridge

View File

@ -4,12 +4,16 @@ import logging
import os import os
import signal import signal
import sys import sys
from collections import defaultdict
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import httpx import httpx
from mcp.server import Server from mcp.server import Server
from mcp.server.sse import SseServerTransport from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
@ -17,7 +21,7 @@ from mcp.types import Tool, TextContent
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
stream=sys.stderr, stream=sys.stderr,
) )
logger = logging.getLogger("duckduckgo-mcp") logger = logging.getLogger("duckduckgo-mcp")
@ -28,6 +32,40 @@ SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080")
RATE_LIMIT_SECONDS = float(os.environ.get("RATE_LIMIT_SECONDS", "3")) RATE_LIMIT_SECONDS = float(os.environ.get("RATE_LIMIT_SECONDS", "3"))
# --- HTTPX singleton (#8) ---
_httpx_client: httpx.AsyncClient | None = None
async def get_httpx_client() -> httpx.AsyncClient:
global _httpx_client
if _httpx_client is None:
_httpx_client = httpx.AsyncClient(
timeout=httpx.Timeout(15.0),
limits=httpx.Limits(max_connections=32, max_keepalive_connections=16),
)
return _httpx_client
async def close_httpx_client():
global _httpx_client
if _httpx_client is not None:
await _httpx_client.aclose()
_httpx_client = None
@asynccontextmanager
async def lifespan(app):
logger.info("HTTPX client initialized")
try:
yield
finally:
await close_httpx_client()
logger.info("HTTPX client closed")
# --- Rate limiter ---
class RateLimiter: class RateLimiter:
def __init__(self, min_interval: float): def __init__(self, min_interval: float):
self.min_interval = min_interval self.min_interval = min_interval
@ -36,36 +74,72 @@ class RateLimiter:
async def acquire(self): async def acquire(self):
async with self._lock: async with self._lock:
now = asyncio.get_event_loop().time() now = asyncio.get_running_loop().time()
if self._last_request: if self._last_request:
elapsed = now - self._last_request elapsed = now - self._last_request
wait = self.min_interval - elapsed wait = self.min_interval - elapsed
if wait > 0: if wait > 0:
logger.info(f"Rate limit: waiting {wait:.1f}s") logger.info(f"Rate limit: waiting {wait:.1f}s")
await asyncio.sleep(wait) await asyncio.sleep(wait)
self._last_request = asyncio.get_event_loop().time() self._last_request = asyncio.get_running_loop().time()
rate_limiter = RateLimiter(RATE_LIMIT_SECONDS) rate_limiter = RateLimiter(RATE_LIMIT_SECONDS)
# --- Per-IP rate limiting middleware (#3) ---
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Per-IP rate limiting for HTTP endpoints."""
def __init__(self, app, max_requests: int = 30, window_seconds: int = 60):
super().__init__(app)
self.max_requests = max_requests
self.window_seconds = window_seconds
self._requests: dict[str, list[float]] = defaultdict(list)
self._lock = asyncio.Lock()
async def dispatch(self, request: Request, call_next):
if request.url.path == "/health":
return await call_next(request)
client_ip = request.client.host if request.client else "unknown"
now = datetime.now(timezone.utc).timestamp()
async with self._lock:
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(
{"error": "Rate limit exceeded. Try again later."},
status_code=429,
)
self._requests[client_ip].append(now)
response = await call_next(request)
return response
# --- Search logic ---
async def do_search(query: str, num_results: int, engine: str): async def do_search(query: str, num_results: int, engine: str):
"""Shared search logic.""" """Shared search logic."""
await rate_limiter.acquire() await rate_limiter.acquire()
async with httpx.AsyncClient(timeout=15) as client: client = await get_httpx_client()
resp = await client.get( resp = await client.get(
f"{SEARXNG_URL}/search", f"{SEARXNG_URL}/search",
params={ params={
"q": query, "q": query,
"format": "json", "format": "json",
"engines": engine, "engines": engine,
"categories": "general", "categories": "general",
"language": "en", "language": "en",
}, },
) )
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
results = data.get("results", [])[:num_results] results = data.get("results", [])[:num_results]
return [ return [
@ -91,7 +165,13 @@ async def search_http(request: Request):
return JSONResponse({"query": query, "results": results}) return JSONResponse({"query": query, "results": results})
except Exception as e: except Exception as e:
logger.exception("Search failed") logger.exception("Search failed")
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": "Search service unavailable"}, status_code=503)
# --- Health check (#6) ---
async def health_check(request: Request):
return JSONResponse({"status": "ok", "service": "duckduckgo-mcp"})
# --- MCP tool handlers --- # --- MCP tool handlers ---
@ -132,7 +212,8 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
try: try:
results = await do_search(query, num_results, "duckduckgo") results = await do_search(query, num_results, "duckduckgo")
except Exception as e: except Exception as e:
return [TextContent(type="text", text=f"Search failed: {e}")] logger.exception("Search failed")
return [TextContent(type="text", text="Search failed: service unavailable")]
if not results: if not results:
return [TextContent(type="text", text=f"No results found for: {query}")] return [TextContent(type="text", text=f"No results found for: {query}")]
@ -166,12 +247,14 @@ async def handle_sse(request):
starlette_app = Starlette( starlette_app = Starlette(
debug=True, debug=False,
routes=[ routes=[
Route("/health", endpoint=health_check),
Route("/search", endpoint=search_http), Route("/search", endpoint=search_http),
Route("/sse", endpoint=handle_sse), Route("/sse", endpoint=handle_sse),
Mount("/messages/", app=sse.handle_post_message), Mount("/messages/", app=sse.handle_post_message),
], ],
middleware=[RateLimitMiddleware],
) )

View File

@ -4,12 +4,16 @@ import logging
import os import os
import signal import signal
import sys import sys
from collections import defaultdict
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import httpx import httpx
from mcp.server import Server from mcp.server import Server
from mcp.server.sse import SseServerTransport from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
@ -17,7 +21,7 @@ from mcp.types import Tool, TextContent
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
stream=sys.stderr, stream=sys.stderr,
) )
logger = logging.getLogger("google-mcp") logger = logging.getLogger("google-mcp")
@ -28,6 +32,40 @@ SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080")
RATE_LIMIT_SECONDS = float(os.environ.get("RATE_LIMIT_SECONDS", "5")) RATE_LIMIT_SECONDS = float(os.environ.get("RATE_LIMIT_SECONDS", "5"))
# --- HTTPX singleton (#8) ---
_httpx_client: httpx.AsyncClient | None = None
async def get_httpx_client() -> httpx.AsyncClient:
global _httpx_client
if _httpx_client is None:
_httpx_client = httpx.AsyncClient(
timeout=httpx.Timeout(15.0),
limits=httpx.Limits(max_connections=32, max_keepalive_connections=16),
)
return _httpx_client
async def close_httpx_client():
global _httpx_client
if _httpx_client is not None:
await _httpx_client.aclose()
_httpx_client = None
@asynccontextmanager
async def lifespan(app):
logger.info("HTTPX client initialized")
try:
yield
finally:
await close_httpx_client()
logger.info("HTTPX client closed")
# --- Rate limiter ---
class RateLimiter: class RateLimiter:
def __init__(self, min_interval: float): def __init__(self, min_interval: float):
self.min_interval = min_interval self.min_interval = min_interval
@ -36,36 +74,72 @@ class RateLimiter:
async def acquire(self): async def acquire(self):
async with self._lock: async with self._lock:
now = asyncio.get_event_loop().time() now = asyncio.get_running_loop().time()
if self._last_request: if self._last_request:
elapsed = now - self._last_request elapsed = now - self._last_request
wait = self.min_interval - elapsed wait = self.min_interval - elapsed
if wait > 0: if wait > 0:
logger.info(f"Rate limit: waiting {wait:.1f}s") logger.info(f"Rate limit: waiting {wait:.1f}s")
await asyncio.sleep(wait) await asyncio.sleep(wait)
self._last_request = asyncio.get_event_loop().time() self._last_request = asyncio.get_running_loop().time()
rate_limiter = RateLimiter(RATE_LIMIT_SECONDS) rate_limiter = RateLimiter(RATE_LIMIT_SECONDS)
# --- Per-IP rate limiting middleware (#3) ---
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Per-IP rate limiting for HTTP endpoints."""
def __init__(self, app, max_requests: int = 30, window_seconds: int = 60):
super().__init__(app)
self.max_requests = max_requests
self.window_seconds = window_seconds
self._requests: dict[str, list[float]] = defaultdict(list)
self._lock = asyncio.Lock()
async def dispatch(self, request: Request, call_next):
if request.url.path == "/health":
return await call_next(request)
client_ip = request.client.host if request.client else "unknown"
now = datetime.now(timezone.utc).timestamp()
async with self._lock:
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(
{"error": "Rate limit exceeded. Try again later."},
status_code=429,
)
self._requests[client_ip].append(now)
response = await call_next(request)
return response
# --- Search logic ---
async def do_search(query: str, num_results: int, engine: str): async def do_search(query: str, num_results: int, engine: str):
"""Shared search logic.""" """Shared search logic."""
await rate_limiter.acquire() await rate_limiter.acquire()
async with httpx.AsyncClient(timeout=15) as client: client = await get_httpx_client()
resp = await client.get( resp = await client.get(
f"{SEARXNG_URL}/search", f"{SEARXNG_URL}/search",
params={ params={
"q": query, "q": query,
"format": "json", "format": "json",
"engines": engine, "engines": engine,
"categories": "general", "categories": "general",
"language": "en", "language": "en",
}, },
) )
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
results = data.get("results", [])[:num_results] results = data.get("results", [])[:num_results]
return [ return [
@ -91,7 +165,13 @@ async def search_http(request: Request):
return JSONResponse({"query": query, "results": results}) return JSONResponse({"query": query, "results": results})
except Exception as e: except Exception as e:
logger.exception("Search failed") logger.exception("Search failed")
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": "Search service unavailable"}, status_code=503)
# --- Health check (#6) ---
async def health_check(request: Request):
return JSONResponse({"status": "ok", "service": "google-mcp"})
# --- MCP tool handlers --- # --- MCP tool handlers ---
@ -132,7 +212,8 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
try: try:
results = await do_search(query, num_results, "google") results = await do_search(query, num_results, "google")
except Exception as e: except Exception as e:
return [TextContent(type="text", text=f"Search failed: {e}")] logger.exception("Search failed")
return [TextContent(type="text", text="Search failed: service unavailable")]
if not results: if not results:
return [TextContent(type="text", text=f"No results found for: {query}")] return [TextContent(type="text", text=f"No results found for: {query}")]
@ -166,12 +247,14 @@ async def handle_sse(request):
starlette_app = Starlette( starlette_app = Starlette(
debug=True, debug=False,
routes=[ routes=[
Route("/health", endpoint=health_check),
Route("/search", endpoint=search_http), Route("/search", endpoint=search_http),
Route("/sse", endpoint=handle_sse), Route("/sse", endpoint=handle_sse),
Mount("/messages/", app=sse.handle_post_message), Mount("/messages/", app=sse.handle_post_message),
], ],
middleware=[RateLimitMiddleware],
) )

View File

@ -19,9 +19,9 @@ search:
server: server:
port: 8080 port: 8080
bind_address: "0.0.0.0" bind_address: "0.0.0.0"
secret_key: "mcp-search-secret-key-change-in-production" secret_key: "${SEARXNG_SECRET}"
limiter: false limiter: true
image_proxy: false image_proxy: true
method: "GET" method: "GET"
engines: engines: