MCP-Hosting auf AWS ECS Express Mode (DEPRECATED)

⚠️ DEPRECATED seit 2026-05-11. Dieses Pattern hat zwei harte Limitationen die fuer Production-MCPs nicht akzeptabel sind:

  1. Nur ein primary Container pro Task. Kein Sidecar fuer Cloudflare-Tunnel moeglich. Damit ist das Tunnel-Pattern (Standard ab Mai 2026) blockiert.
  2. AWS-managed ALB akzeptiert nur den *.ecs.<region>.on.aws-Hostname. Custom-Domain mcp-<kunde>.agenticventures.de bringt 404 zurueck, weil der Listener-Rule den Host-Header filtert. Manuelle Custom-Cert + Listener-Rule am managed ALB ist undokumentiert + fragil (ECS-Reconciliation kann Aenderungen ueberschreiben).

Stattdessen verwenden: mcp-hosting-fargate-tunnel — klassisches ECS Fargate (NICHT Express) + Cloudflare-Tunnel-Sidecar. Entscheidung dokumentiert unter hosted-mcp-architektur-2026.

Diese Datei bleibt als historische Referenz, plus damit der “warum nicht ECS Express”-Stolperer einmalig dokumentiert ist.

Setup-Pattern um einen Eigenbau-MCP als hosted Service in av-production (eu-central-1) bereitzustellen. Endzustand: claude.ai Pro Custom Connector → unsere URL → MCP laeuft auf AWS, EU-Daten-Standort, Multi-Tenant-faehig.

Erstanwendung: gsuite-hosted (repo) am 2026-05-09 — Plan und Run unter _index. Phase 4 dieses Projekts extrahiert daraus Templates + einen mcp-aws-deploy-Skill.

Wann nutzen

  • Eigenbau-MCP soll von claude.ai Pro / iPhone aus erreichbar sein (nicht nur lokal in Claude Code)
  • EU-Daten-Standort wird gefordert (DSGVO-strenge Industriekunden)
  • Multi-Tenant-Faehigkeit gewuenscht (ein Service traegt mehrere Kunden, Phase 2)
  • App Runner ist seit April 2026 closed for new customers — frueher die naheliegendste Loesung, jetzt nicht mehr verfuegbar

Was AWS macht vs. was wir bauen

AWS-managed (ECS Express Mode)Wir bauen
Application Load Balancer + Listener-RulesECR-Repository fuer Container-Images
TLS-Cert auf *.ecs.<region>.on.awsSecrets Manager Secret (gefuellt mit OAuth-Files o.ae.)
Target-Groups + Healthchecks3 IAM-Rollen (Exec / Infra / Task)
Auto-Scaling-Policies (CPU-basiert)Container-Image (Dockerfile Multi-Stage, non-root)
CloudWatch Log GroupFastMCP-Server-Code (mit ScalekitProvider + GuardMiddleware)
Security GroupsBoot-Script entrypoint.sh + fetch_secrets.py
5xx-Rollback-Alarme + Canary-DeploysCDK-Stack der das Ganze beschreibt
VPC + Subnets (default)Cloudflare-DNS-Eintrag fuer Custom-Domain

Resourcen im CDK-Stack: 8 (statt 14 mit klassischem Fargate+ALB+ACM-Setup). Pro hosted MCP also rund halb so viel Code zu pflegen.

Architektur

claude.ai Pro / iPhone
    │ OAuth (Scalekit EU) + JWT
    ▼
Cloudflare (TLS-Edge fuer <subdomain>.agenticventures.de, WAF)
    │  Full(strict) → Origin
    ▼
ECS Express Mode
    Endpoint: <service-name>.ecs.eu-central-1.on.aws
    AWS-managed: ALB + TLS + AutoScaling + LogGroup
    │
    ▼
Container (FastMCP-Server) in eu-central-1, av-production
    │
    ▼
Sub-MCP via stdio-Subprocess (`create_proxy(StdioTransport)`)

Boot:
  entrypoint.sh → fetch_secrets.py
                → AWS Secrets Manager
                → /app/<sub-mcp>-config/{...files}

Custom-Domain laeuft via Cloudflare-Proxy (orange-cloud) auf den AWS-Endpoint. Cloudflare macht TLS-Termination fuer <subdomain>.agenticventures.de, AWS-managed Cert sichert die Origin-Verbindung. ECS Express selbst kennt unsere Domain nicht und braucht keinen ACM-Cert in unserem Account.

Drei-Rollen-IAM-Pattern

ECS Express verlangt zwei AWS-Rollen, plus eine dritte fuer die App selbst:

RolleTrust PrincipalManaged Policy / InlineWofuer
Exec-Roleecs-tasks.amazonaws.comAmazonECSTaskExecutionRolePolicyECS pullt das Image aus ECR und schreibt CloudWatch-Logs
Infra-Roleecs.amazonaws.comAmazonECSInfrastructureRoleforExpressGatewayServicesECS provisioniert ALB / Target-Groups / Auto-Scaling im Auftrag
Task-Roleecs-tasks.amazonaws.cominline: secretsmanager:GetSecretValue auf genau das SecretWas die App tun darf — least-privilege auf einen Secret-ARN

Trust-Principal-Verwechslung ist die Hauptfehlerquelle: ecs.amazonaws.com fuer die Infra-Rolle, NICHT ecs-tasks.amazonaws.com. Beim Deploy gibt das sonst CFN-Errors a la “infrastructure role cannot be assumed by ECS”.

Secrets-Boot-Pattern (statt Image-Backing)

Wenn der Sub-MCP File-basierte Konfiguration braucht (gsuite liest .gauth.json, .accounts.json, .oauth2.<email>.json aus einem CWD): NICHT die Files ins Image kopieren — Secrets im Image-Layer sind kompromittiert in jedem ECR-Pull.

Stattdessen:

  1. Secrets Manager Secret mit JSON-Object {filename: content}:
    {
      ".gauth.json": { "installed": { "client_id": "...", "client_secret": "..." } },
      ".accounts.json": { "accounts": [...] },
      ".oauth2.hello@marvinkuehlmann.com.json": { "access_token": "...", "refresh_token": "..." }
    }
  2. scripts/fetch_secrets.py im Repo — boto3-Client zieht Secret, parst JSON, schreibt jeden Key als File mit chmod 0600 in $CONFIG_DIR. Skipt silently wenn <SERVICE>_SECRET_ARN leer ist (Local-Dev).
  3. scripts/entrypoint.sh als Container-Entrypoint — ruft fetch_secrets.py auf, dann exec python -m <package>.main (PID 1 fuer saubere SIGTERM-Behandlung von ECS).
  4. Sub-MCP via CLI-Args konfigurieren — z.B. mcp-gsuite --gauth-file=<dir>/.gauth.json --accounts-file=<dir>/.accounts.json --credentials-dir=<dir>. Vermeidet WORKDIR-Tricks und ist im stdio-Subprocess robust.
  5. Task-Role IAM-Permission auf genau das Secret-ARN (oauthSecret.grantRead(taskRole) in CDK).

Volume-Mount fuer den Config-Dir braucht es nicht — wir schreiben in den Container-Filesystem-Layer beim Boot, der bei einem Restart auch wieder leer ist und neu aus Secrets Manager gezogen wird. Das ist sauber, weil OAuth-Token-Rotation dann automatisch via Secret-Update + Container-Restart wirkt.

Container-Setup (Dockerfile-Konvention)

  • Multi-Stage: python:3.13-slim AS builder (uv install) → python:3.13-slim AS runtime (nur venv kopiert)
  • Non-root user: uid 10000, group 10000
  • Build-Context = mono-repo-Wurzel (~/source/mcps/), nicht das Service-Subdir — sonst kann uv path = "../mcp-<sub>" nicht aufloesen. Build-Befehl: cd ~/source/mcps && docker build -f mcp-<service>-hosted/Dockerfile -t <name> .
  • .dockerignore im Service-Subdir mit .venv, .pytest_cache, __pycache__, infra/node_modules, .env* — sonst kommen IaC-Tools und venvs in den Layer
  • Healthcheck via curl auf /health — minimaler Endpoint, auth-frei. Curl muss im Runtime-Image installiert sein (apt-get install curl).
  • Entrypoint ist entrypoint.sh, nicht direkt das Python-Module — sonst kein Secret-Bootstrap

CDK-Stack-Skelett

Pflicht-Resourcen im Stack:

// 1. ECR repo (image scan + 10-image lifecycle, RemovalPolicy: RETAIN)
new ecr.Repository(...)
 
// 2. Secrets Manager — leer initial; Wert via aws secretsmanager put-secret-value
new secretsmanager.Secret(...)
 
// 3. Drei IAM-Rollen
new iam.Role('ExecRole',  { assumedBy: 'ecs-tasks.amazonaws.com', policies: [TaskExecutionRolePolicy] })
new iam.Role('InfraRole', { assumedBy: 'ecs.amazonaws.com',       policies: [InfrastructureRoleforExpressGatewayServices] })
new iam.Role('TaskRole',  { assumedBy: 'ecs-tasks.amazonaws.com' })
secret.grantRead(taskRole)
 
// 4. ECS Express Service (L1, kein L2 verfuegbar Stand 2026-05)
new ecs.CfnExpressGatewayService(this, 'GatewayService', {
  serviceName: '...',
  cpu: '1', memory: '2',          // Strings; vCPU / GB
  executionRoleArn: execRole.roleArn,
  infrastructureRoleArn: infraRole.roleArn,
  taskRoleArn: taskRole.roleArn,
  healthCheckPath: '/health',
  primaryContainer: {
    image: `${repo.repositoryUri}:latest`,
    containerPort: 8080,           // Default 80, wir wollen 8080
    environment: [{ name, value }, ...],
  },
  scalingTarget: { minTaskCount: 1, maxTaskCount: 1 },  // Phase 1: pin
})

Account / Region setzen wir explizit in bin/app.tsaccount: '425924867359' (av-production), region: 'eu-central-1'. Default-VPC reicht (ECS Express bringt eigene SGs mit).

CloudFormation-Resource-Type: AWS::ECS::ExpressGatewayService (CDK L1 CfnExpressGatewayService). Stand Mai 2026 gibt es kein L2, GitHub-Issue aws/aws-cdk#36234 trackt das.

Wichtig fuer Outputs: das attrEndpoint-Attribut liefert die DNS-URL fuer den Cloudflare-CNAME (<service>.ecs.<region>.on.aws). Form https://....

Deploy-Reihenfolge (Schritt-fuer-Schritt)

# 1. SSO-Login
aws sso login --profile av-prod
 
# 2. CDK-Bootstrap (einmalig pro Account+Region, dann nicht mehr noetig)
cd <repo>/infra
npx cdk bootstrap aws://425924867359/eu-central-1 --profile av-prod
 
# 3. Initial-Deploy (Secret leer, ENV-Vars Placeholder, Service kommt nicht in ACTIVE)
npx cdk deploy --profile av-prod
# Output: EcrUri, OauthSecretArn, ServiceEndpoint
 
# 4. Container-Image bauen (linux/amd64 wegen Fargate-Underbau!)
cd ~/source/mcps
docker buildx build --platform linux/amd64 \
  -f mcp-<service>-hosted/Dockerfile \
  -t <ecr-uri>:latest --push .
 
# 5. Secrets-Manager-Wert setzen (JSON-Format pro Sub-MCP definiert)
aws secretsmanager put-secret-value --profile av-prod \
  --secret-id <oauth-secret-arn> \
  --secret-string file://./<secret>.json
# WICHTIG: Datei NICHT committen, danach `shred -u` oder rm
 
# 6. Force-Deploy damit Service neu startet und Secret zieht
aws ecs update-service --profile av-prod --cluster default \
  --service <service-name> --force-new-deployment
 
# 7. Cloudflare-CNAME setzen
# DNS: <subdomain>.agenticventures.de CNAME <service>.ecs.eu-central-1.on.aws (orange-clouded)
 
# 8. Scalekit-Resource anlegen mit Identifier https://<subdomain>.agenticventures.de/mcp
# 9. Scalekit-ENV-Vars im Stack updaten + cdk deploy
# 10. claude.ai Pro Custom Connector mit https://<subdomain>.agenticventures.de/mcp

Cost-Modell

Gemessen / kalkuliert fuer eu-central-1, Stand 2026-05.

Fix-Kosten pro hosted MCP (Phase 1, 1 Task pinned):

PostenDefault (1 vCPU / 2 GB)Min (0.25 vCPU / 0.5 GB)
Fargate Compute (730h/Monat)~36 USD~9 USD
ALB (Basis + LCU)~25 USD~25 USD
Secrets Manager (1 Secret)0.40 USD0.40 USD
ECR Storage (~3 GB max)0.30 USD0.30 USD
CloudWatch Logs~2 USD~2 USD
Data Transfer Out~1 USD~1 USD
AWS-managed TLS-Cert0 USD0 USD
Total~65 USD ≈ 60 €~38 USD ≈ 35 €

Default-Sizing (1/2) ist haeufig ueberdimensioniert fuer FastMCP-Workloads — die sind mostly async, JWT-Validation + stdio-IPC ist nicht CPU-bound. Wenn Express Mode 0.25/0.5 akzeptiert (ist Fargate-Min, sollte gehen), faellt der Fargate-Posten auf ein Viertel.

ALB-Sharing als Cost-Saver: ECS Express teilt einen ALB ueber bis zu 25 Express-Services in derselben Region+VPC (Doku). Heisst: der zweite hosted MCP in av-production (z.B. papierkram-hosted neben gsuite-hosted) zahlt keinen extra ALB-Posten. Das macht den Stack nach dem ersten MCP DEUTLICH guenstiger.

Skalierung mit Last:

LastZusatz-TasksZusatz-Cost
1 User, occasional Tools00
5-10 paying Customers0-10-9 USD
20-30 Customers1-29-36 USD
50+ Customers2-436-144 USD

Bottleneck ist meistens nicht unser Container, sondern Upstream-API-Quotas (Gmail: 250 Quota-Units/Sekunde/User; Papierkram: 60 Requests/Minute pro Tenant). FastMCP haelt 50-100 parallele Tool-Calls pro Task locker durch.

Pricing-Sanity-Check (Phase 3):

SetupCost/MonatRevenue (30€/Kunde)Marge
Phase 1, Marvin allein~50 €
5 Kunden~60 €150 €90 €
20 Kunden~85 €600 €515 €
50 Kunden~140 €1500 €1360 €

Caveat: Pricing gilt PRO hosted MCP-Service. Mehrere Kunden auf einem Service teilen die Fix-Kosten — Multi-Tenant-Architektur (Phase 2: DynamoDB + ABAC + KMS-Customer-Key) verduennt die ALB+Fargate-Fixkosten ueber alle Tenants. Single-Tenant-Hosting (eigener Stack pro Kunde) skaliert nicht — nur sinnvoll bei Compliance-Zwang.

Lessons Learned (gsuite-hosted, 2026-05-09)

  • App Runner ist closed for new customers, ECS Express ist der Nachfolger fuer “ich will einen Container ohne 14 CFN-Resourcen schreiben”. Plan-File-Stack-Entscheidungen explizit dokumentieren, sonst baut man aus Reflex den klassischen Fargate-Stack.
  • Vpc.fromLookup blockiert cdk synth ohne SSO-Login, weil CDK assume-role auf den Target-Account macht. ECS Express braucht keine VPC im Stack — Default-VPC wird automatisch genutzt. Spart auch beim Synth den Lookup.
  • cp -R von Vorlage-Repo kopiert die .env mit echten Production-Secrets der Vorlage rein. SOFORT loeschen vor irgendeinem Commit. .env* in .gitignore schuetzt nur vor dem ersten git add, nicht vor dem ersten cp.
  • Python-Versions-Constraints zwischen Sub-MCP und Wrapper muessen kompatibel sein — gsuite verlangt >=3.13, Wrapper auf >=3.12 failed bei uv sync. Wrapper auf den hoeheren Min hochziehen.
  • Default ContainerPort ist 80, wir wollen 8080. Explizit setzen sonst gibt’s “Container is not listening on port 80” Healthcheck-Fails.
  • linux/amd64 beim docker build erzwingen wenn lokal Mac mit Apple Silicon — Fargate-Underbau ist x86_64. Buildx mit --platform linux/amd64 rettet.
  • Trust-Principal-Verwechslung Exec vs Infra: ecs-tasks.amazonaws.com fuer Tasks, ecs.amazonaws.com fuer Infra-Rolle. CFN-Error ist sprechend, aber nervig wenn man’s beim ersten Deploy uebersieht.
  • CDK L1 statt L2 fuer ExpressGatewayService — der schoene Type-Wrapper kommt vermutlich erst in spaeteren Versionen. L1-API ist okay, aber Properties als Strings nicht Enums.
  • Cpu/Memory Format ist Fargate-Style in MiB-Strings, nicht vCPU/GB-Shorthand (vf-hosted, 2026-05-10). cpu: '1', memory: '2' synthetisiert ohne Murren, scheitert aber im Deploy mit Invalid CPU/Memory combination. Korrekt: cpu: '1024', memory: '2048'. Valid Combos: '256' + 512/1024/2048, '512' + 1024-4096, '1024' + 2048-8192, '2048' + 4096-16384, '4096' + 8192-30720.
  • IAM-Role-Descriptions duerfen kein non-ASCII enthalten (vf-hosted, 2026-05-10). Em-Dash (), Smart-Quotes, Pfeile usw. werfen `Member must satisfy regular expression pattern: [

-~¡-ÿ]. Tab/LF/CR/printable-ASCII/Latin-1-Supplement OK, U+2014 (Em-Dash) faellt durchs Raster. Greppen vor Deploy: grep -nP ’[^\x00-\x7F]’ lib/-stack.ts`.

  • ECR-Repos mit RemovalPolicy.RETAIN ueberleben failed CREATE-Stacks (vf-hosted, 2026-05-10). Wenn der erste Deploy aus anderen Gruenden rollbacked und das ECR-Repo retained wurde, scheitert der naechste Deploy mit Resource already exists. Workaround: ECR via Repository.fromRepositoryName(this, 'EcrRepo', '<name>') als externe Resource referenzieren — Repo bleibt out-of-stack, gepushte Images bleiben erhalten. Alternative: aws ecr delete-repository --force + Stack-Re-Deploy (zerstoert Images).
  • Stack in ROLLBACK_COMPLETE ist nicht re-deployable ohne aws cloudformation delete-stack davor. CDK macht das nicht automatisch. Pattern: aws cloudformation describe-stacks ... --query 'Stacks[0].StackStatus'delete-stack wenn ROLLBACK_COMPLETE → wait → cdk deploy.
  • Secret-Wert pushen BEFORE Container-Boot, sonst CrashLoopBackoff. ECS resolved secrets:-Mapping (<arn>:KEY::) beim Container-Start. Wenn das Secret leer ist ({}), failed der Resolution → Container startet nie. Workaround: Polling-Loop parallel zu cdk deploy — sobald Secret existiert (describe-secret-success), put-secret-value mit Token-JSON. Trifft das Secret den Service-Start, gibts keinen extra Force-Deploy noetig.
  • secrets:-Permission liegt auf Exec-Role, nicht Task-Role (vf-hosted, 2026-05-10). Wenn der Sub-MCP nur ENV-String-Tokens braucht (nicht Files), ist primaryContainer.secrets[] mit valueFrom: <arn>:KEY:: deutlich schlanker als das entrypoint.sh + fetch_secrets.py-File-Bootstrap-Pattern (gsuite). ECS Express resolved Secrets im Exec-Role-Context — also secret.grantRead(execRole), nicht taskRole. Task-Role kann komplett leer bleiben.

Wann NICHT dieses Pattern

  • Lokaler Single-User-MCP (laeuft nur in Claude Code auf Marvins Mac) — bleibt stdio, kein Hosting noetig
  • Sehr seltene Calls (1 pro Stunde) — ECS Express hat min 1 Task always-on, das ist bei extremer Idle-Last verschwenderisch. Lambda + API Gateway als Alternative fuer extrem-event-driven Use-Cases
  • Kunde will eigenes AWS-Konto haben — dann nicht in av-production, sondern Sub-Account av-<kunde> mit gleichem Pattern (Identity Center weist Marvin als Admin zu)
  • Nicht-HTTP-Workloads — ECS Express ist explizit fuer Web/HTTP. Background-Jobs gehen besser ueber EventBridge + Lambda

Templates fuer naechsten MCP

Phase 4 des Pipeline-Projekts extrahiert das in:

  1. ~/source/mcps/_templates/single-aws-hosted/ — Skeleton mit Platzhaltern fuer Sub-MCP-Name, Container-Image, Sub-MCP-CLI-Command, ENV-Schema
  2. Skill mcp-aws-deploy — nimmt lokalen MCP + Spec, erzeugt Repo + CDK-Stack + Dockerfile aus dem Template
  3. Skill mcp-cloud-bereitstellung wird um AWS-Variante erweitert (heute: Railway-only)

Bis das fertig ist (Phase 4): Pattern manuell aus diesem File und dem gsuite-hosted-Repo abgreifen.

Stolperer-Cheatsheet

SymptomUrsacheFix
cdk synth Error: “Could not assume role 425924867359 lookup-role”Vpc.fromLookup braucht SSO-Loginaws sso login --profile av-prod ODER VPC-Lookup raus (ECS Express braucht keine)
uv sync Error: Python>=3.13 requiredSub-MCP verlangt 3.13, Wrapper auf 3.12requires-python = ">=3.13" im Wrapper-pyproject
CREATE_FAILED IAM::Role: `Member must satisfy regular expression pattern: [

-~¡-ÿ] | Em-Dash / Smart-Quote / Pfeil in IAM-Role-description| Nur ASCII + Latin-1 in Descriptions.grep -nP ’[^\x00-\x7F]’ lib/-stack.tsvor Deploy | |CREATE_FAILEDExpressGatewayService:Invalid CPU/Memory combination|cpu: ‘1’, memory: ‘2’synth-akzeptiert aber API-rejected | Fargate-Style:cpu: ‘1024’, memory: ‘2048’. Combos siehe Lessons-Learned | | Zweiter cdk deployfailed early validation:Resource already exists(ECR) | RETAIN-Policy hat ECR-Repo nach Rollback behalten |Repository.fromRepositoryName(this, ‘EcrRepo’, '')ODERaws ecr delete-repository —force| | Re-deploy nach Rollback failed: Stack istROLLBACK_COMPLETEund nicht-deployable | CFN erlaubt kein Re-Create auf endgueltigen Failed-States |aws cloudformation delete-stack+ warten bis DELETE_COMPLETE, danncdk deploy| | ECS Service stuck inPROVISIONING, nie ACTIVE| Container-Image fehlt in ECR |docker buildx build … —push .mit—platform linux/amd64| | Container CrashLoopBackoff direkt nach Start, ECS-Events zeigen Secret-Resolution-Failure | Secret-Wert leer oder JSON-Key fehlt im Secret |put-secret-valueBEVOR oder waehrendcdk deploydurch ist (Polling-Loop). Bei:KEY::-Mapping muss der Key wirklich in der Secret-JSON sein | | Container CrashLoopBackoff, Logs zeigen "No such file: /app/...config/.gauth.json" | Secret leer oder Task-Role hat keinen secretsmanager:GetSecretValue|aws secretsmanager put-secret-valueausfuehren UND Task-Role-Policy pruefen | | Healthcheck fail "Container is not listening on port 80" | Default-Port 80 nicht ueberschrieben |containerPort: 8080inprimaryContainer| |cdk deployhaengt 30 min an "GatewayService CREATE_IN_PROGRESS" | Normal — Image-Pull + Healthcheck-Stabilisierung | Logs in CloudWatch checken; bei wirklichem Hang: ECS-Service-Events in Console | | Cloudflare zeigt 521 / Origin unreachable | CNAME falsch oder ECS-Service nicht ACTIVE |aws ecs describe-express-gateway-service —service-arn …checken; Endpoint pingen direkt ohne Cloudflare | | 401 von Scalekit obwohl JWT-validiert wirkt |scalekit_resource_idmatcht nicht den Identifier im Scalekit-Dashboard | Identifier muss EXAKThttps://.agenticventures.de/mcpsein, mit/mcp` Suffix |