MCP-Hosting auf AWS ECS Fargate + Cloudflare Tunnel

Setup-Pattern um einen Eigenbau-MCP als hosted Service in av-production (eu-central-1) bereitzustellen. Standard-Pattern seit 2026-05-11 nach hosted-mcp-architektur-2026.

Architektur

claude.ai Pro / iPhone
    │ OAuth 2.1 (Scalekit EU) + JWT
    ▼
Cloudflare Edge (TLS, WAF, Rate, EU-Frankfurt-PoP)
    │ mcp-<kunde>.agenticventures.de
    ▼
Cloudflare Tunnel (kein Public-Ingress am Origin)
    │ outbound von cloudflared-Sidecar
    ▼
AWS ECS Fargate Task (eu-central-1, av-production)
    ├─ Container 1: FastMCP "vf-mono" mit ScalekitProvider
    │   ├─ Stdio-Subprozess: mcp-papierkram → Papierkram-API
    │   ├─ Stdio-Subprozess: mcp-ticketpay  → TicketPAY-API
    │   └─ Stdio-Subprozess: mcp-m365       → MS Graph API
    └─ Container 2: cloudflared (Sidecar)
        └─ Tunnel-Token aus AWS Secrets Manager
        └─ verbindet outbound zu Cloudflare-Edge

Wichtige Eigenschaften:

  • Keine Public IP am ECS-Task. Inbound-Security-Group leer.
  • Outbound-only auf Port 443 zu Cloudflare-Anycast-IPs.
  • Tunnel = einziger Public-Eingang. Kein ALB. Kein ACM-Cert.
  • 2 Container im selben Task. localhost:8080 zwischen ihnen.
  • Multi-Replica moeglich: gleicher Tunnel-Token in N Tasks, Cloudflare-Edge load-balanced automatic.

Was AWS macht vs. was Cloudflare macht

AWSCloudflare
ECS-Cluster + Task-Definition (klassisch, nicht Express)TLS-Edge fuer mcp-<kunde>.agenticventures.de
Fargate Compute (1 vCPU / 2 GB Default)Tunnel-Daemon (cloudflared) registriert outbound
ECR Container-RegistryPublic DNS → CF-IPs, Tunnel proxiert intern
Secrets Manager (Sub-MCP-Tokens + Tunnel-Token)WAF, Bot-Detection, Rate-Limit am Edge
CloudWatch Logs (App + Tunnel)Analytics (Latency, 5xx, Geographic)
IAM (Exec-Role + Task-Role)Optional: Access (Zero Trust), AI Gateway
VPC (default reicht, kein NAT noetig wenn Subnets Public sind)

CDK-Stack-Skelett

Resourcen im Stack (klassisches Fargate, kein Express-Magic):

// 1. ECR Repository
new ecr.Repository(this, 'EcrRepo', { repositoryName: 'mcp-<name>-hosted', ... })
 
// 2. Secrets Manager — zwei Secrets:
//    a) Sub-MCP-Tokens (papierkram, ticketpay, m365)
//    b) Cloudflare-Tunnel-Token
new secretsmanager.Secret(this, 'UpstreamTokens', { secretName: '<name>/upstream-tokens' })
new secretsmanager.Secret(this, 'CloudflaredToken', { secretName: '<name>/cloudflared-token' })
 
// 3. IAM Roles (klassisch):
const execRole = new iam.Role('ExecRole', {
  assumedBy: 'ecs-tasks.amazonaws.com',
  policies: [TaskExecutionRolePolicy],
})
upstreamTokens.grantRead(execRole)
cloudflaredToken.grantRead(execRole)
 
const taskRole = new iam.Role('TaskRole', {
  assumedBy: 'ecs-tasks.amazonaws.com',
})
// task-role kann leer bleiben — secrets sind via exec-role aufgeloest, container macht keine AWS-API-Calls
 
// 4. ECS Cluster (kann av-production-shared sein)
const cluster = ecs.Cluster.fromClusterAttributes(this, 'Cluster', { clusterName: 'default' })
 
// 5. Task Definition mit ZWEI Containern — Right-Sized default
const taskDef = new ecs.FargateTaskDefinition(this, 'TaskDef', {
  cpu: 256,             // 0.25 vCPU (Fargate-Min) — reicht fuer FastMCP-I/O-Workload
  memoryLimitMiB: 512,  // 0.5 GB
  executionRole: execRole,
  taskRole: taskRole,
})
 
// Container 1: vf-mono
taskDef.addContainer('vf-mono', {
  image: ecs.ContainerImage.fromEcrRepository(repo, 'latest'),
  essential: true,
  portMappings: [{ containerPort: 8080 }],
  environment: {
    SCALEKIT_ENV_URL: 'https://...',
    SCALEKIT_RESOURCE_ID: '...',
    PUBLIC_BASE_URL: 'https://mcp-<kunde>.agenticventures.de',
    AWS_REGION: this.region,
    LOG_LEVEL: 'INFO',
  },
  secrets: {
    PAPIERKRAM_TOKEN: ecs.Secret.fromSecretsManager(upstreamTokens, 'PAPIERKRAM_TOKEN'),
    PAPIERKRAM_SUBDOMAIN: ecs.Secret.fromSecretsManager(upstreamTokens, 'PAPIERKRAM_SUBDOMAIN'),
    TICKETPAY_API_KEY: ecs.Secret.fromSecretsManager(upstreamTokens, 'TICKETPAY_API_KEY'),
    M365_TENANT_ID: ecs.Secret.fromSecretsManager(upstreamTokens, 'M365_TENANT_ID'),
    M365_CLIENT_ID: ecs.Secret.fromSecretsManager(upstreamTokens, 'M365_CLIENT_ID'),
    M365_CLIENT_SECRET: ecs.Secret.fromSecretsManager(upstreamTokens, 'M365_CLIENT_SECRET'),
  },
  logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'vf-mono' }),
  healthCheck: {
    command: ['CMD-SHELL', 'curl -f http://localhost:8080/health || exit 1'],
    interval: cdk.Duration.seconds(30),
    timeout: cdk.Duration.seconds(5),
  },
})
 
// Container 2: cloudflared-Sidecar
taskDef.addContainer('cloudflared', {
  image: ecs.ContainerImage.fromRegistry('cloudflare/cloudflared:latest'),
  essential: true,
  command: ['tunnel', '--no-autoupdate', 'run'],
  secrets: {
    TUNNEL_TOKEN: ecs.Secret.fromSecretsManager(cloudflaredToken),
  },
  logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'cloudflared' }),
})
 
// 6. ECS Service — kein Load-Balancer
new ecs.FargateService(this, 'Service', {
  cluster,
  taskDefinition: taskDef,
  desiredCount: 1,
  assignPublicIp: false,  // private subnet — only outbound via NAT or via IGW with security-group restrict
  // Wenn default-VPC mit public subnets: assignPublicIp: true geht auch, security-group nur outbound 443
})

CFN-Resourcen total: ~14 (gegen ~8 bei ECS Express, aber dafuer haben wir Sidecar + keinen ALB-Posten).

Pre-Run-Setup fuer Tunnel-Anlage (Cloudflare-API)

Vor cdk deploy muss der Tunnel angelegt sein, damit der Token im AWS-Secret liegt:

export CF_API_TOKEN="..."  # mit Account:Cloudflare Tunnel:Edit + Zone:DNS:Edit
export CF_ACCOUNT_ID="..."
export CF_ZONE_ID="..."
 
# 1. Tunnel anlegen
TUNNEL_RESPONSE=$(curl -sS -X POST \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"mcp-<name>-hosted","config_src":"cloudflare"}' \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel")
TUNNEL_ID=$(echo "$TUNNEL_RESPONSE" | jq -r '.result.id')
TUNNEL_TOKEN=$(echo "$TUNNEL_RESPONSE" | jq -r '.result.token')
 
# 2. Ingress-Config setzen
curl -sS -X PUT \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"config\":{\"ingress\":[
    {\"hostname\":\"mcp-<name>.agenticventures.de\",\"service\":\"http://localhost:8080\"},
    {\"service\":\"http_status:404\"}
  ]}}" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/configurations"
 
# 3. Token in AWS Secret pushen (CDK referenziert das Secret dann)
aws secretsmanager create-secret \
  --name "mcp-<name>-hosted/cloudflared-token" \
  --secret-string "$TUNNEL_TOKEN" \
  --profile av-production --region eu-central-1

Dann cdk deploy → der Stack bindet das Secret in den cloudflared-Sidecar ein.

Nach Stack-Active + Tunnel-Connected:

# 4. DNS-Record fuer mcp-<name> in Cloudflare anlegen
curl -sS -X POST \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"type\": \"CNAME\",
    \"name\": \"mcp-<name>\",
    \"content\": \"$TUNNEL_ID.cfargotunnel.com\",
    \"proxied\": true
  }" \
  "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records"

Cost-Modell

Pro hosted MCP, eu-central-1, Right-Sized 0.25 vCPU / 0.5 GB (Default seit 2026-05-12):

PostenRight-Sized (Default)Wenn Workload > 70% saturiert: 1 vCPU / 2 GB
Fargate Compute (730h/Monat)~9 USD~36 USD
ECR Storage (~3 GB)0.30 USD0.30 USD
Secrets Manager (2 Secrets)0.80 USD0.80 USD
CloudWatch Logs (app + cloudflared)~3 USD~3 USD
Data Transfer Out (Cloudflare-Egress via NAT/IGW)~1 USD~1 USD
Cloudflare Tunnel0 USD (Free-Tier)0 USD
Cloudflare DNS/Edge0 USD (Free-Tier)0 USD
Total~14 USD ≈ 13 €~41 USD ≈ 38 €

Vergleich zu altem ECS-Express-Pattern (~65 €/Monat): ~50 €/Monat eingespart pro MCP. (~25 € vom ALB-Wegfall, ~25 € vom Right-Sizing.)

Right-Sizing-Regel: FastMCP-Workload ist mostly async I/O (JWT-Validation gegen JWKS-Cache, stdio-IPC zu Sub-Prozessen, HTTPS-Calls zu Upstream-APIs). Beobachtete CPU-Auslastung bei 1 vCPU war 5-15%, also Faktor 4-6 Headroom. Fargate-Min reicht locker fuer Single-Tenant-MCPs mit bis zu ~50 parallelen Tool-Calls. Bei CloudWatch-Beobachtung von >70% CPU oder Memory: erst auf 512/1024 hochziehen, dann 512/2048, dann 1024/2048.

Bei mehreren MCPs in av-production: Cost ist linear pro MCP — kein ALB-Sharing-Effekt mehr (gab’s nur bei ECS Express). Aber: ECR-Repos und Secrets Manager skalieren billig.

Skalierungs-Patterns

  • Multi-Task pro MCP: desiredCount hochziehen, Cloudflare-Edge load-balanced automatic ueber alle Connector-Replicas. Kein Service-Discovery-Bullshit. Pro Task 4 outbound Connections, also bei 3 Tasks = 12 Connections im Tunnel.
  • Auto-Scaling: Application Auto Scaling auf CPU-Threshold haengen. Multi-Replica-Tunnel ist nativ unterstuetzt.
  • Multi-Tenant (Phase 2 vom Pipeline-Projekt): ein Service pro Tenant, Multi-Tenant-Pattern (DynamoDB + ABAC) gleich wie bei klassischem Fargate.

Lessons Learned

  • ECS Express Mode war fuer Custom-Domain ein No-Go — der managed ALB erlaubt nur den AWS-vergebenen Hostname, Custom-Cert + Listener-Rule manuell anflanschen ist nicht dokumentiert + fragil. Sidecar-Container sind hard requirement fuer Tunnel-Pattern → klassisches Fargate.
  • Cloudflared-Container ist stateless. Token im Secret, Container kann jederzeit neu gestartet werden, Tunnel-Connection rebuilds automatic.
  • cloudflared tunnel run (ohne Tunnel-ID-Arg) liest die Tunnel-ID + Config aus dem Token — du musst die nicht separat injizieren. Sauberer als die alte cloudflared --config /etc/cloudflared/config.yml-Welt.
  • config_src: cloudflare beim Tunnel-Anlegen → Config liegt zentral in CF, du brauchst keine config.yml im Container. Ingress-Rules sind via API setzbar, also reproducible.
  • Health-Check muss am App-Container haengen (curl localhost:8080/health). Cloudflared selbst hat keinen /health-Endpoint, aber essential: true reicht — ECS killt den ganzen Task wenn cloudflared abkackt.
  • Logs zusammenhalten: zwei separate CloudWatch-Log-Groups oder gleiche Group mit verschiedenen Stream-Prefixes. Beim Debugging will man beide neben einander sehen (aws logs tail --follow --filter-pattern '?vf-mono ?cloudflared').

Stolperer-Cheatsheet

SymptomUrsacheFix
Tunnel zeigt inactive im Cloudflare-Dashboard auch nach Task-StartToken nicht korrekt im Container — pruefen ob Secret-Value das Token ist (nicht file://... als String)aws secretsmanager get-secret-value checken
Cloudflared startet aber unauthenticated in LogsToken gehoert zu anderem Tunnel oder Token revokedTunnel-Token aus Cloudflare-Dashboard neu generieren + Secret updaten
mcp-<kunde>.agenticventures.de gibt 522 (Connection Timed Out)DNS-Record fehlt oder zeigt nicht auf <tunnel-id>.cfargotunnel.comDNS-Record in CF pruefen, sollte proxied=true sein
mcp-<kunde>.agenticventures.de gibt 530 (Origin DNS Error)Tunnel-ID stimmt nicht oder Tunnel nicht connecteddig cname mcp-<kunde>... + Cloudflare-Dashboard Tunnel-Status
App-Container CrashLoopBackoffSecret-Werte falsch oder fehlend (z.B. SCALEKIT_RESOURCE_ID)CloudWatch app-Logs lesen, pydantic-Settings-Errors sind sprechend
ECS Service stuck in PROVISIONINGImage fehlt in ECR oder Task-Role hat keinen secretsmanager:GetSecretValueaws ecr describe-images + IAM-Policy pruefen
Container kommt aktiv aber /health HTTPS-extern 5xxcloudflared verbindet nicht zu localhost:8080 (Race-Condition?)Health-Check-Interval/Retries hochziehen, app-Container braucht ~5-10s zum Boot

Tunnel-Token-Rotation

# Quartalsweise oder bei Verdacht:
# 1. Neuen Token via Cloudflare-API generieren
NEW_TOKEN=$(curl -sS -H "Authorization: Bearer $CF_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/token" \
  | jq -r '.result')
 
# 2. AWS Secret updaten
aws secretsmanager put-secret-value \
  --secret-id "mcp-<name>-hosted/cloudflared-token" \
  --secret-string "$NEW_TOKEN" \
  --profile av-production --region eu-central-1
 
# 3. ECS Force-Deploy
aws ecs update-service --cluster default --service mcp-<name>-hosted \
  --force-new-deployment \
  --profile av-production --region eu-central-1

Cloudflare-Doku: alte Tokens bleiben aktiv bis Container restart — kein Service-Disruption.

Wann NICHT dieses Pattern

  • Local Single-User-MCP in Claude Code → stdio bleibt, kein Hosting noetig
  • Sehr seltene Calls (1 pro Stunde) → ECS-Task always-on ist Verschwendung, Lambda + API Gateway koennte besser sein
  • Kunde will eigenes AWS-Konto → dann nicht in av-production sondern in av-<kunde>-Subaccount, gleiches Pattern aber dedicated Compute
  • Volle Mandanten-Trennung mit BSI-Pruefung → eigener Stack pro Kunde inkl. eigenem Cloudflare-Zone wenn noetig

Quirks + Gotchas

FastMCP DNS-Rebinding-Schutz blockt Tunnel-Host (421 Invalid Host header)

Symptom: Production-MCP-Endpoint antwortet auf POST /mcp mit HTTP 421 Invalid Host header. Health-Endpoint GET /health liefert 200. Lokal auf 127.0.0.1:8771 funktioniert alles.

Ursache: mcp.server.fastmcp.FastMCP haengt per Default eine TransportSecuritySettings-Middleware vor /mcp mit enable_dns_rebinding_protection=True und einer Allowlist nur fuer 127.0.0.1:*, localhost:*, [::1]:*. Cloudflared leitet den Public-Host-Header (mcp-<name>.agenticventures.de) ungeaendert durch, der Server lehnt ihn ab.

Fix: Env-Var MCP_ALLOWED_HOSTS=<public-domain> setzen und im main() des Servers vor mcp.run(...):

extra_hosts_raw = os.environ.get("MCP_ALLOWED_HOSTS", "")
extra_hosts = [h.strip() for h in extra_hosts_raw.split(",") if h.strip()]
if extra_hosts:
    sec = mcp.settings.transport_security
    sec.allowed_hosts = list(sec.allowed_hosts) + extra_hosts
    sec.allowed_origins = list(sec.allowed_origins) + [f"https://{h}" for h in extra_hosts]

Im CDK-Stack die Env-Var am Container setzen:

environment: {
  // ...
  MCP_ALLOWED_HOSTS: 'mcp-<name>.agenticventures.de',
},

Pflicht fuer jeden Fargate-Tunnel-MCP der mit FastMCP gebaut wird. Erstmals 2026-05-15 bei mcp-whatsapp aufgeschlagen, weil das der erste Tunnel-MCP ohne ScalekitProvider war (Scalekit fing das Problem implizit ab). Bei reinen API-Key-MCPs ohne Auth-Layer kommt der Request direkt am FastMCP-Streamable-HTTP-Handler an und 421et.

Smoke nach Deploy:

curl -sS -X POST https://mcp-<name>.agenticventures.de/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0.1"}}}'

Erwartet: HTTP 200 + serverInfo-Antwort als SSE-Event. Bei 421 → Allowlist fehlt.

Re-Evaluate Q3-Q4 2026: Cloudflare Containers

Siehe hosted-mcp-architektur-2026. Wenn 5+ Kunden auf diesem Pattern laufen und Cloudflare Containers GA-mature ist:

  1. Test-Migration mit mcp-gsuite-hosted (Marvin-only)
  2. Vergleich Cost + Latenz + Operations ueber 30 Tage
  3. Wenn deutlich besser: Migration aller Kunden-MCPs

Bis dahin: dieses Pattern ist das einzige Standard-Pattern fuer hosted MCPs.