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:
- Nur ein primary Container pro Task. Kein Sidecar fuer Cloudflare-Tunnel moeglich. Damit ist das Tunnel-Pattern (Standard ab Mai 2026) blockiert.
- AWS-managed ALB akzeptiert nur den
*.ecs.<region>.on.aws-Hostname. Custom-Domainmcp-<kunde>.agenticventures.debringt 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-Rules | ECR-Repository fuer Container-Images |
TLS-Cert auf *.ecs.<region>.on.aws | Secrets Manager Secret (gefuellt mit OAuth-Files o.ae.) |
| Target-Groups + Healthchecks | 3 IAM-Rollen (Exec / Infra / Task) |
| Auto-Scaling-Policies (CPU-basiert) | Container-Image (Dockerfile Multi-Stage, non-root) |
| CloudWatch Log Group | FastMCP-Server-Code (mit ScalekitProvider + GuardMiddleware) |
| Security Groups | Boot-Script entrypoint.sh + fetch_secrets.py |
| 5xx-Rollback-Alarme + Canary-Deploys | CDK-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:
| Rolle | Trust Principal | Managed Policy / Inline | Wofuer |
|---|---|---|---|
| Exec-Role | ecs-tasks.amazonaws.com | AmazonECSTaskExecutionRolePolicy | ECS pullt das Image aus ECR und schreibt CloudWatch-Logs |
| Infra-Role | ecs.amazonaws.com | AmazonECSInfrastructureRoleforExpressGatewayServices | ECS provisioniert ALB / Target-Groups / Auto-Scaling im Auftrag |
| Task-Role | ecs-tasks.amazonaws.com | inline: secretsmanager:GetSecretValue auf genau das Secret | Was 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:
- 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": "..." } } scripts/fetch_secrets.pyim Repo — boto3-Client zieht Secret, parst JSON, schreibt jeden Key als File mitchmod 0600in$CONFIG_DIR. Skipt silently wenn<SERVICE>_SECRET_ARNleer ist (Local-Dev).scripts/entrypoint.shals Container-Entrypoint — ruftfetch_secrets.pyauf, dannexec python -m <package>.main(PID 1 fuer saubere SIGTERM-Behandlung von ECS).- 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. - 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 uvpath = "../mcp-<sub>"nicht aufloesen. Build-Befehl:cd ~/source/mcps && docker build -f mcp-<service>-hosted/Dockerfile -t <name> . .dockerignoreim 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.ts — account: '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/mcpCost-Modell
Gemessen / kalkuliert fuer eu-central-1, Stand 2026-05.
Fix-Kosten pro hosted MCP (Phase 1, 1 Task pinned):
| Posten | Default (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 USD | 0.40 USD |
| ECR Storage (~3 GB max) | 0.30 USD | 0.30 USD |
| CloudWatch Logs | ~2 USD | ~2 USD |
| Data Transfer Out | ~1 USD | ~1 USD |
| AWS-managed TLS-Cert | 0 USD | 0 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:
| Last | Zusatz-Tasks | Zusatz-Cost |
|---|---|---|
| 1 User, occasional Tools | 0 | 0 |
| 5-10 paying Customers | 0-1 | 0-9 USD |
| 20-30 Customers | 1-2 | 9-36 USD |
| 50+ Customers | 2-4 | 36-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):
| Setup | Cost/Monat | Revenue (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.fromLookupblockiertcdk synthohne 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 -Rvon Vorlage-Repo kopiert die.envmit echten Production-Secrets der Vorlage rein. SOFORT loeschen vor irgendeinem Commit..env*in.gitignoreschuetzt nur vor dem erstengit add, nicht vor dem erstencp.- Python-Versions-Constraints zwischen Sub-MCP und Wrapper muessen kompatibel sein — gsuite verlangt
>=3.13, Wrapper auf>=3.12failed beiuv 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/amd64beim docker build erzwingen wenn lokal Mac mit Apple Silicon — Fargate-Underbau ist x86_64. Buildx mit--platform linux/amd64rettet.- Trust-Principal-Verwechslung Exec vs Infra:
ecs-tasks.amazonaws.comfuer Tasks,ecs.amazonaws.comfuer 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 mitInvalid 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.RETAINueberleben 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 mitResource already exists. Workaround: ECR viaRepository.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_COMPLETEist nicht re-deployable ohneaws cloudformation delete-stackdavor. CDK macht das nicht automatisch. Pattern:aws cloudformation describe-stacks ... --query 'Stacks[0].StackStatus'→delete-stackwenn 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 zucdk deploy— sobald Secret existiert (describe-secret-success),put-secret-valuemit 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), istprimaryContainer.secrets[]mitvalueFrom: <arn>:KEY::deutlich schlanker als dasentrypoint.sh + fetch_secrets.py-File-Bootstrap-Pattern (gsuite). ECS Express resolved Secrets im Exec-Role-Context — alsosecret.grantRead(execRole), nichttaskRole. 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-Accountav-<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:
~/source/mcps/_templates/single-aws-hosted/— Skeleton mit Platzhaltern fuer Sub-MCP-Name, Container-Image, Sub-MCP-CLI-Command, ENV-Schema- Skill
mcp-aws-deploy— nimmt lokalen MCP + Spec, erzeugt Repo + CDK-Stack + Dockerfile aus dem Template - Skill
mcp-cloud-bereitstellungwird 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
| Symptom | Ursache | Fix |
|---|---|---|
cdk synth Error: “Could not assume role 425924867359 lookup-role” | Vpc.fromLookup braucht SSO-Login | aws sso login --profile av-prod ODER VPC-Lookup raus (ECS Express braucht keine) |
uv sync Error: Python>=3.13 required | Sub-MCP verlangt 3.13, Wrapper auf 3.12 | requires-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://sein, mit/mcp` Suffix |
Related
- _index — Pipeline-Projekt (Phasen + aktueller Stand)
- _index — AWS-Org + Account-Inventar
- accounts — CLI-Profile (
av-prodSSO) - _index — MCP-Inventar
- mcp-vf-hosted — Railway-Variante des Patterns (Vorgaenger, weiter aktiv fuer Vibe Factory)
- aws-multi-account-strategie — warum av-production
- AWS Doku: ECS Express Mode
- CFN-Reference: AWS::ECS::ExpressGatewayService