Merge pull request #665 from cbcoutinho/feat/claude-funnel-config

feat: add Tailscale Funnel config for Claude AI connector testing
This commit is contained in:
Chris Coutinho
2026-03-29 21:00:16 +02:00
committed by GitHub
6 changed files with 176 additions and 1 deletions
+88
View File
@@ -0,0 +1,88 @@
# Claude AI IP Filter for Tailscale Funnel
#
# Routes MCP transport to Claude AI IPs only, while allowing
# OAuth/auth endpoints from any IP (needed for user login flow).
#
# Pattern: homelab-argocd/atlantis/templates/nginx-webhook-config.yaml
worker_processes auto;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
sendfile on;
keepalive_timeout 65;
# Map Claude AI outbound IPs (must be at http level)
geo $remote_addr $is_claude_ai {
default 0;
# Anthropic Claude AI Outbound IP Range
# Source: https://docs.claude.com/en/api/ip-addresses
# Last updated: 2026-03-29
# IPv4 range
160.79.104.0/21 1; # Claude AI
}
server {
listen 8080;
server_name _;
# Trust Tailscale proxy for real IP extraction
real_ip_header X-Forwarded-For;
set_real_ip_from 100.64.0.0/10; # Tailscale CGNAT range
set_real_ip_from 10.0.0.0/8; # Docker internal networks
set_real_ip_from 172.16.0.0/12; # Docker bridge networks
real_ip_recursive on;
# OAuth/auth endpoints + favicon - allow ALL IPs (user browser needs access for login flow)
location ~ ^/(oauth|\.well-known|app|favicon\.ico)(/|$) {
proxy_pass http://mcp-login-flow:8004;
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 https;
}
# MCP transport endpoint - Claude AI IPs ONLY
# login-flow uses streamable-http transport (no /sse needed)
location /mcp {
if ($is_claude_ai = 0) {
return 403 '{"error": "Access denied - IP not in Claude AI range", "source_ip": "$remote_addr"}\n';
}
proxy_pass http://mcp-login-flow:8004;
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 https;
# Streamable HTTP support (long-lived connections)
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_http_version 1.1;
proxy_set_header Connection '';
}
# Default - deny everything else
location / {
return 404 '{"error": "Not found"}\n';
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"${TS_CERT_DOMAIN}:443": {
"Handlers": {
"/": {
"Proxy": "http://nginx-claude-filter:8080"
}
}
}
},
"AllowFunnel": {
"${TS_CERT_DOMAIN}:443": true
}
}
+34
View File
@@ -306,6 +306,7 @@ services:
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8004
#- NEXTCLOUD_MCP_SERVER_URL=https://nextcloud-mcp-dev.tail148d5.ts.net
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Login Flow v2 (ADR-022)
@@ -327,6 +328,38 @@ services:
profiles:
- login-flow
# Tailscale Funnel for Claude AI connector testing
# Usage: docker compose --profile login-flow --profile claude-funnel up --build -d
# Requires: TS_AUTHKEY in .env file (see env.sample.claude-funnel)
tailscale-mcp:
image: tailscale/tailscale:latest
hostname: nextcloud-mcp-dev
restart: always
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_STATE_DIR=/var/lib/tailscale
- TS_SERVE_CONFIG=/config/serve-config.json
- TS_HOSTNAME=nextcloud-mcp-dev
- TS_EXTRA_ARGS=--advertise-tags=tag:container
volumes:
- tailscale-state:/var/lib/tailscale
- ./claude-funnel/serve-config.json:/config/serve-config.json:ro
cap_add:
- NET_ADMIN
- SYS_MODULE
profiles:
- claude-funnel
nginx-claude-filter:
image: docker.io/library/nginx:alpine
restart: always
depends_on:
- mcp-login-flow
volumes:
- ./claude-funnel/nginx.conf:/etc/nginx/nginx.conf:ro
profiles:
- claude-funnel
qdrant:
image: docker.io/qdrant/qdrant:v1.17.1@sha256:94728574965d17c6485dd361aa3c0818b325b9016dac5ea6afec7b4b2700865f
restart: always
@@ -357,3 +390,4 @@ volumes:
qdrant-data:
mcp-data:
multi-user-basic-data:
tailscale-state:
+22
View File
@@ -0,0 +1,22 @@
# Claude AI Connector - Tailscale Funnel Configuration
#
# This profile exposes the MCP server (login-flow mode) via Tailscale Funnel
# for testing Claude AI connector integration.
#
# Usage:
# 1. Copy this file to .env and fill in TS_AUTHKEY
# 2. Run: docker compose --profile login-flow --profile claude-funnel up --build -d
# 3. Configure Claude.ai connector with: https://nextcloud-mcp-dev.<tailnet>.ts.net/mcp
#
# Prerequisites:
# - Generate auth key at https://login.tailscale.com/admin/settings/keys
# with tag:container and reusable=yes
# - Ensure Tailscale ACL allows funnel for tag:container:
# "nodeAttrs": [{ "target": ["tag:container"], "attr": ["funnel"] }]
#
# Security:
# - /mcp endpoint restricted to Claude AI IPs (160.79.104.0/21)
# - /oauth/* and /.well-known/* open to all IPs (needed for user login flow)
# - All other paths return 404
TS_AUTHKEY=tskey-auth-REPLACE_ME
+13 -1
View File
@@ -24,7 +24,7 @@ from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse, RedirectResponse
from starlette.responses import FileResponse, JSONResponse, RedirectResponse
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from starlette.types import ASGIApp, Receive, Send
@@ -2354,6 +2354,18 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
routes.append(Mount("/app", app=browser_app))
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
# Favicon for connector directory discovery (Google favicon service)
favicon_path = os.path.join(
os.path.dirname(__file__), "auth", "static", "favicon.png"
)
if os.path.isfile(favicon_path):
routes.append(
Route(
"/favicon.ico",
lambda request: FileResponse(favicon_path, media_type="image/png"),
)
)
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
routes.append(Mount("/", app=mcp_app))
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB