Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docker/Dockerfile.nginx-simple
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Single image with WebCat + nginx auth proxy
# Based on existing WebCat Dockerfile
FROM python:3.11-slim

WORKDIR /app

# Install nginx and supervisor
RUN apt-get update && \
apt-get install -y nginx supervisor && \
rm -rf /var/lib/apt/lists/*

# Copy project metadata and structure
COPY pyproject.toml README.md LICENSE /app/
COPY docker/ /app/docker/
COPY examples/ /app/examples/

# Install package with dependencies
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir /app

# Create log directory
RUN mkdir -p /var/log/webcat && chmod 755 /var/log/webcat

# Copy nginx and supervisor configs
COPY docker/nginx-single-image.conf /etc/nginx/nginx.conf
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/entrypoint-nginx.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Expose port (nginx listens on 8000, proxies to webcat on 4000)
EXPOSE 8000

# Environment variables
ENV PYTHONUNBUFFERED=1
ENV PORT=4000
ENV LOG_LEVEL=INFO
ENV LOG_DIR=/var/log/webcat
ENV SERPER_API_KEY=""
ENV WEBCAT_API_KEY=""

# Use entrypoint to configure auth before starting
ENTRYPOINT ["/entrypoint.sh"]
39 changes: 39 additions & 0 deletions docker/docker-compose-nginx.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Docker Compose setup with nginx reverse proxy for authentication
#
# USAGE:
# 1. Set SERPER_API_KEY and WEBCAT_API_KEY in your environment or .env file
# 2. Run: docker-compose -f docker-compose-nginx.yml up -d
# 3. Access WebCat at: https://localhost:4000/mcp
# 4. Include header: Authorization: Bearer <WEBCAT_API_KEY>
#
# NOTE: WebCat runs on internal network without auth
# Nginx validates bearer token before proxying to WebCat

version: '3.8'

services:
webcat:
image: tmfrisinger/webcat:latest
environment:
- SERPER_API_KEY=${SERPER_API_KEY}
# DON'T set WEBCAT_API_KEY - nginx handles auth
networks:
- internal
# Not exposed externally - only nginx can reach it

nginx:
image: nginx:alpine
ports:
- "4000:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
environment:
- WEBCAT_API_KEY=${WEBCAT_API_KEY} # For config templating
networks:
- internal
depends_on:
- webcat

networks:
internal:
driver: bridge
16 changes: 16 additions & 0 deletions docker/entrypoint-nginx.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/sh
# Replace __WEBCAT_API_KEY__ placeholder with actual value from env

if [ -n "$WEBCAT_API_KEY" ]; then
echo "✅ Auth enabled with WEBCAT_API_KEY"
# Replace map placeholder and add auth check
sed -i "s|__WEBCAT_API_KEY__|$WEBCAT_API_KEY|g" /etc/nginx/nginx.conf
sed -i '/# AUTH_CHECK_PLACEHOLDER/c\ if ($auth_valid = 0) { return 401 '"'"'{"error": "Unauthorized"}'"'"'; }' /etc/nginx/nginx.conf
else
echo "✅ No WEBCAT_API_KEY set - auth disabled"
# Remove auth check line entirely
sed -i '/# AUTH_CHECK_PLACEHOLDER/d' /etc/nginx/nginx.conf
fi

# Start supervisor
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
8 changes: 6 additions & 2 deletions docker/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ async def search_tool() -> dict:
f"SERPER API key: {'Set' if SERPER_API_KEY else 'Not set (using DuckDuckGo fallback)'}"
)

# Create FastMCP instance (no authentication required)

# Create FastMCP instance
mcp_server = FastMCP("WebCat Search")

# Register tools with MCP server
Expand All @@ -78,9 +79,12 @@ async def search_tool() -> dict:

if __name__ == "__main__":
port = int(os.environ.get("PORT", 8000))
logging.info(f"Starting FastMCP server on port {port}")
logging.info(
f"Starting FastMCP server on port {port} (no auth - use nginx proxy for auth)"
)

# Run the server with modern HTTP transport (Streamable HTTP with JSON-RPC 2.0)
# NOTE: Authentication should be handled by reverse proxy (nginx) not in-app
mcp_server.run(
transport="http",
host="0.0.0.0",
Expand Down
56 changes: 56 additions & 0 deletions docker/nginx-auth.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Nginx config for WebCat with Bearer token auth
#
# SETUP:
# 1. Replace YOUR_SECRET_TOKEN_HERE with your actual API key
# 2. Replace your-domain.com with your domain
# 3. Update SSL certificate paths
# 4. Place in /etc/nginx/sites-available/webcat
# 5. Symlink to sites-enabled: ln -s /etc/nginx/sites-available/webcat /etc/nginx/sites-enabled/
# 6. Test: nginx -t
# 7. Reload: systemctl reload nginx
#
# ALTERNATIVE for Docker Compose:
# Use docker-compose-nginx.yml instead of deploying nginx separately

upstream webcat_backend {
server localhost:4000; # WebCat server without auth
}

server {
listen 443 ssl http2;
server_name your-domain.com;

# SSL certificates
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;

# Bearer token validation
location /mcp {
# Check Authorization header
set $auth_valid 0;

if ($http_authorization = "Bearer YOUR_SECRET_TOKEN_HERE") {
set $auth_valid 1;
}

if ($auth_valid = 0) {
return 401 '{"error": "Unauthorized: Invalid or missing bearer token"}';
}

# Forward to WebCat
proxy_pass http://webcat_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# SSE support
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
45 changes: 45 additions & 0 deletions docker/nginx-single-image.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
events {
worker_connections 1024;
}

http {
# WebCat backend (running locally in same container)
upstream webcat_backend {
server 127.0.0.1:4000;
}

# Map to dynamically check bearer token from env var
map $http_authorization $auth_valid {
default 0;
# This will be replaced by entrypoint script with actual WEBCAT_API_KEY
"~^Bearer\s+__WEBCAT_API_KEY__$" 1;
}

server {
listen 8000;
server_name _;

# MCP endpoint with optional auth
location /mcp {
# AUTH_CHECK_PLACEHOLDER - replaced by entrypoint script

# Proxy to WebCat
proxy_pass http://webcat_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# SSE/Streamable HTTP support
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}

# Health check (no auth)
location /health {
proxy_pass http://webcat_backend/health;
}
}
}
23 changes: 23 additions & 0 deletions docker/supervisord.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[supervisord]
nodaemon=true
user=root

[program:webcat]
command=python3.11 mcp_server.py
directory=/app/docker
environment=PORT=4000,PYTHONPATH=/app/docker,SERPER_API_KEY="%(ENV_SERPER_API_KEY)s"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
105 changes: 105 additions & 0 deletions docker/test_mcp_no_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Copyright (c) 2024 Travis Frisinger
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

"""Test MCP server without auth - verify max_results parameter works."""

import json

import requests

base_url = "http://localhost:8000/mcp" # nginx proxy

# Create session with required headers
session = requests.Session()
session.headers.update({"Accept": "application/json, text/event-stream"})

# Step 1: Initialize
print("1. Initializing MCP session...")
init_payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
}

init_resp = session.post(base_url, json=init_payload)
print(f"Initialize status: {init_resp.status_code}")

if init_resp.status_code != 200:
print(f"Initialize failed: {init_resp.text}")
exit(1)

# Get session ID
session_id = init_resp.headers.get("mcp-session-id")
print(f"Session ID: {session_id}")

if not session_id:
print("No session ID in response!")
exit(1)

# Step 2: Send initialized notification
print("\n2. Sending initialized notification...")
initialized_payload = {"jsonrpc": "2.0", "method": "notifications/initialized"}

notif_resp = session.post(
base_url, json=initialized_payload, headers={"mcp-session-id": session_id}
)
print(f"Initialized notification status: {notif_resp.status_code}")

# Step 3: Call search tool with max_results=2
print("\n3. Calling search tool with max_results=2...")
search_payload = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "search",
"arguments": {"query": "Model Context Protocol", "max_results": 2},
},
}

search_resp = session.post(
base_url, json=search_payload, headers={"mcp-session-id": session_id}, timeout=30
)

print(f"Search status: {search_resp.status_code}")
print("Content-Type:", search_resp.headers.get("content-type"))
print("\nResponse (first 2000 chars):")
print(search_resp.text[:2000])

# Parse SSE response
if search_resp.status_code == 200:
# Extract JSON from SSE format
lines = search_resp.text.strip().split("\n")
for line in lines:
if line.startswith("data: "):
data_json = line[6:] # Remove "data: " prefix
result = json.loads(data_json)

if "result" in result and "content" in result["result"]:
content = result["result"]["content"]
if isinstance(content, list) and len(content) > 0:
text_content = content[0].get("text", "")
# Parse the JSON string inside text
search_result = json.loads(text_content)

result_count = len(search_result.get("results", []))
print(f"\n✅ Found {result_count} results (expected 2)")
print(f"Search source: {search_result.get('search_source')}")

for i, res in enumerate(search_result.get("results", []), 1):
print(f"\n{i}. {res.get('title')}")
print(f" URL: {res.get('url')}")
print(f" Snippet: {res.get('snippet')[:100]}...")

if result_count == 2:
print("\n✅ max_results parameter works correctly!")
else:
print(f"\n⚠️ Expected 2 results but got {result_count}")
break
Loading
Loading