はじめに

Claude などの AI エージェントが、社内で使っている SaaS を直接操作できるようになってきました。その接続の仕組みとして広まっているのが MCP(Model Context Protocol) です。

便利な一方で、セキュリティ担当としては気になる点があります。

  • 誰が、AI を通じて何を操作したのか、あとから追えるか
  • AI に削除系のツールまで使わせてよいか
  • 退職者や権限変更に、きちんと追従できるか

そこで、AI と SaaS のあいだに ゲートウェイ(関所) を1枚挟み、認証・記録・ツールの制限を1か所で行う構成を試しました。本稿では、その構成と、実際に試して分かったことを紹介します。題材には、プロジェクト管理ツールの Backlog を使いました。

Zuplo とは

今回ゲートウェイに使った Zuplo は、API の前段に置く、マネージドの API ゲートウェイです。認証、レート制限、ログ・分析といった「API の入口でやりたいこと」を、設定とコードで組み立てられます。REST や GraphQL の API 向けに使われてきましたが、現在は MCP ゲートウェイの機能も持っています。

MCP ゲートウェイは、AI クライアント(Claude、Cursor、VS Code など)と、接続先の MCP サーバーのあいだに入ります。リクエストごとに、次のことを行います。

  • 認証: ゲートウェイ自身が OAuth 2.1 の認可サーバーとして動き、社内の IdP(今回は Google)で利用者を認証します。
  • 上流への認証情報の受け渡し: 接続先のサービスには、OAuth クライアントとして接続します。認証情報の渡し方は、利用者ごとの OAuth、共有の OAuth、共有の API キーから選べます。今回は、利用者ごとの OAuth を使いました。
  • ツールの絞り込み: 接続先が公開しているツールのうち、見せるものだけを選んで公開できます(本稿の「次に試すこと」で検証予定)。
  • 記録と分析: リクエストごとに、利用者・ツール・成否を構造化したイベントとして残し、分析画面で見られます。

設定は、ルートを定義する routes.oas.json と、ポリシー(認証や変換などの処理)の定義が中心です。ポリシーは、標準のものに加えて、TypeScript で自作できます。Git で管理でき、リポジトリに push するとデプロイされます。

今回は、Backlog の提供元(Nulab)が公開している MCP サーバーを自分で AWS に建てて、その前段に Zuplo を置く形にしました。詳しくは、公式ドキュメントの MCP Gateway overview を参照してください。

作ったもの

Claude(カスタムコネクタ)
   │ ① 社内の Google アカウントでログイン
   ▼
API ゲートウェイ(Zuplo)… 認証・ログ・ツールの制限
   │ ② 利用者本人の Backlog 認証情報を付けて転送
   ▼
ALB + AWS WAF
   ▼
ECS Fargate 上の Backlog MCP サーバー
   ▼
Backlog

ポイントは3つです。

  1. 利用者本人の権限で動く。 共有アカウントを使わず、社内の Google アカウントでログインし、その人自身の Backlog 権限で MCP を使います。見えないはずのプロジェクトは AI からも見えません。
  2. 入口でログを取る。 ゲートウェイを通るので、利用者・ツール・成否が1か所に記録されます。
  3. 二重に守る。 ゲートウェイでの認証に加えて、AWS 側も ALB と WAF で守ります。

なぜ ECS Fargate を選んだか

バックエンドの置き場所は、AWS Lambda、Google Cloud Run、ECS Fargate を比較しました。決め手は、この MCP サーバーの性質です。

  • 既製のコンテナイメージをそのまま動かせる。 公開されているイメージを使うので、コードを書き換えずに済みます。常駐する HTTP サーバーなので、コンテナ実行基盤が素直に合います。Lambda は、Web アダプターなどを挟む工夫が要ります。
  • OAuth の状態をメモリに持つ。 状態を持つサーバーなので、インスタンスを1つに固定して動かしたい。同時実行で増減する Lambda とは相性が悪く、Fargate なら台数を1に固定できます。
  • 接続が長めになりうる。 MCP の通信は、1回の接続が長くなることがあります。ALB のアイドルタイムアウトを延ばして対応できます。

Cloud Run も有力な候補でしたが、最終的に Fargate にしました。理由は、再認証が起きる頻度です。このサーバーは状態をメモリに持つので、止まるたびに全員が Backlog への再認証を求められます。Fargate は台数を1つに固定して常駐させられ、デプロイしない限り止まりません。Cloud Run のようにインスタンスがプラットフォーム側で入れ替わる基盤では、再認証が起きやすくなります。

トレードオフもあります。Fargate は常時起動なので、使われない時間も費用がかかります。また、デプロイやタスクの入れ替えで止まれば、Fargate でも全員が再同意になります。

本番利用では、GKEも選択肢になると考えています。

構築の流れ

1. バックエンド(AWS)に MCP サーバーを建てる

Backlog の MCP サーバーを、ALB + ECS Fargate + WAF の構成で CloudFormation からデプロイしました。事前に必要なものは次のとおりです。

準備するもの 内容
AWS アカウント CloudFormation で IAM ロールを作れる権限と、ACM 証明書・DNS が必要
Backlog アプリケーション Backlog の開発者向けページで登録し、クライアント ID とシークレットを発行する。リダイレクト URI は https:///callback
ACM 証明書 MCP サーバーのドメインを含むもの

クライアントシークレットは、Secrets Manager に保存します。テンプレートのパラメータには直接書かず、コマンド履歴やリポジトリに残さないようにしました。

aws cloudformation deploy \
  --stack-name <スタック名> \
  --template-file <テンプレート>.yaml \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides \
    DomainName=<MCPサーバーのFQDN> \
    CertificateArn=<ACM証明書のARN> \
    HostedZoneId=<ホストゾーンID> \
    BacklogOAuthClientId=<クライアントID> \
    BacklogOAuthClientSecret=<クライアントシークレット>

使用した CloudFormation テンプレート(backlog-mcp-ecs.yaml)は、本稿の末尾に全文を掲載しています。含まれるリソースは次のとおりです。

  • ALB(HTTPS 終端。HTTP は HTTPS へリダイレクト)
  • AWS WAF(マネージドルール + レートベースルール + MCP 向けの例外設定)
  • ECS Fargate のクラスター・サービス・タスク定義(Backlog MCP サーバー)
  • Secrets Manager(Backlog のクライアントシークレット)
  • CloudWatch Logs(アプリと WAF のログ)
  • VPC・パブリックサブネット・セキュリティグループ(受信は ALB のみに制限)

BacklogDomain の既定値は例示用のプレースホルダーです。自分の Backlog スペースのドメインを指定してください。

デプロイ後に出力される MCP エンドポイントを、次のゲートウェイ側の設定に反映します。

2. ゲートウェイ(Zuplo)を設定する

ゲートウェイには Zuplo を使いました。ルートの転送先に先ほどの MCP エンドポイントを設定し、ログインには Google の OAuth を使います。

  • Google 側で OAuth クライアントを作成し、クライアント ID とシークレットをゲートウェイの環境変数として登録します。
  • Claude のカスタムコネクタには、https://<ゲートウェイのドメイン>/mcp/backlog を登録します。
  • Claude から接続すると、Google ログイン → Backlog の認可画面、の順に進み、完了すると Backlog のツールが使えるようになります。

接続前の確認は、ブラウザで次の URL を開くだけで済みます。

パス 期待する応答
/mcp/backlog 401(未認証なので正常)
/.well-known/oauth-protected-resource/... 認可サーバーの情報を含む JSON
/.well-known/oauth-authorization-server/... 認可・トークンのエンドポイントを含む JSON

ハマったところ

実際に構築して詰まった点を、あとから同じ構成を試す方のために残しておきます。

① ゲートウェイの URL が2種類ある。 専用環境のプランでは、デプロイ後に表示される URL のうち、使えるのは専用ドメイン側だけでした。もう一方の URL では、接続時に 500 エラーになります。

② Google ログインで redirect_uri_mismatch。 ゲートウェイのドメインを変えたとき、Google 側に新しいリダイレクト先を登録していないと出ます。エラー画面に表示される redirect_uri を、一字一句そのまま OAuth クライアントの「承認済みのリダイレクト URI」に追加して解決しました。反映に数分かかることがあります。

③ WAF が正常な MCP の通信を止める。 AWS WAF の標準的なルールには、MCP の通信と相性が悪いものがありました。

  • リクエスト本文が大きい JSON-RPC 通信が、本文サイズの制限に引っかかる
  • ゲートウェイからの転送には User-Agent が付かず、ヘッダー必須のルールに引っかかる

どちらも、ルールを無効にせず、Block から Count(記録のみ)に変更して対処しました。通信は通しつつ、ログで動きを見続けられます。

④ ヘルスチェックが 403 を返す。 ALB のヘルスチェックはホスト名がタスクの IP になり、MCP サーバー側の許可ホストと一致せず 403 になります。200 と 403 の両方を正常として扱うことで解決しました。

⑤ OAuth の状態がメモリにある。 この MCP サーバーは OAuth の状態をメモリに持つため、タスクは常に1つで動かしています。再起動すると全員の同意が消え、再同意が必要になります。本番利用では、ここが最大の運用上の論点です。

⑥ 古いチャットは古いコネクタのまま。 コネクタを差し替えたあと、開いたままのチャットは開始時の接続先を使い続けます。動作確認は新しいチャットで行ってください。

何が見えるようになったか

ゲートウェイの Observability(可観測性)のうち、MCP 専用の分析画面で、次のことが見えました。以下の画面は、PoC で数日使った時点のもので、直近24時間に約660件の MCP イベントが記録されています。

  • 誰が使ったか(Google アカウント単位)
  • どのツールを呼んだか(回数・エラー率・遅延)
  • MCP の操作の種類(ツール一覧の取得 / ツールの実行)
  • 失敗の原因の切り分け(認証エラー / ゲートウェイ / バックエンド)

MCP の利用状況

MCP 専用の画面の上部には、イベント数、成功率、エラー数が並びます。時間帯ごとの推移も、イベントの種類(トークンの検証、上流の認証情報の解決、ツールの実行など)で色分けして見えます。

図1 MCP の分析画面(概要)

どのツールを、誰が使ったか

ツールごとの呼び出し回数、エラー率、p95 の遅延が一覧できます。あわせて、利用者(Consumers)ごとのイベント数も見えます。利用者は、ログインに使った Google アカウント単位で集計されます。

図2 ツール別の呼び出しと、利用者別の集計(利用者 ID は伏せています)

ただし、現状は利用者が数字の ID で表示されます。監査では、メールアドレスで見えるほうが扱いやすいので、後述のとおり改善を検討しています。

何が起きて、どこで失敗したか

MCP の操作の種類や、失敗がどこで起きたかも分かります。この例では、失敗はすべてクライアント側で、理由は missing_token(トークンなしで届いたアクセスを、ゲートウェイが拒否した記録)です。ゲートウェイやバックエンドの障害と、クライアント側の問題を分けて見られます。

図3 操作の種類と、失敗の発生箇所

Zuplo には、このほかにリクエスト全体(Requests)や利用者(Consumers)などの汎用の分析画面もあります。ただし、MCP の用途では、上の MCP 専用の画面だけで「誰が・どのツールを・成功したか」が追えるため、本稿ではこの画面を中心に紹介しました。

「AI 経由で課題が消された」といったことが起きたとき、誰が・いつ・どのツールで、を追える土台になります。

次に試すこと

今回は「つながって、見える」ところまででした。次の検証を考えています。

  1. 公開するツールを絞る。 現状は約40のツールが見えており、削除系や、プロジェクトを変更するツールも含まれます。まずは参照系だけを許可リストにして、削除を頼むと「ツールが存在しない」として止まることを確かめます。
  2. 利用者ごとの監査。 複数人で使い、Backlog 側の更新者が本人になっていることを突き合わせます。
  3. 退職者・権限変更への追従。 アカウントを止めたら即座に使えなくなることを確認し、将来は IdP をグループ単位の制御に切り替えます。
  4. MCP の窓口を1つにまとめる。 別の SaaS の MCP も同じゲートウェイに載せ、認証・監査・制限を統一できるかを確かめます。
  5. 攻撃を想定した確認。 トークンなし、別ルートのトークン流用、隠したツールの直接呼び出しが拒否されることを確かめます。

本番化に向けた課題

課題 方向性
MCP サーバー再起動で全員が再同意 トークンを永続化できる構成を待つ、または運用でカバー
ゲートウェイを経由しない経路が残る プライベート接続や、オリジン検証ヘッダーの運用を検証
プランの制約 SSO・ロール管理・監査ログなど、本番に必要な機能の要否を確認
コンテナイメージのバージョン latest をやめ、バージョンを固定
利用者の表示 内部 ID ではなくメールアドレスで表示し、監査で使いやすくする

まとめ

AI エージェントに社内 SaaS を使わせるとき、「つなぐ」ことの次に必要になるのは、誰が何をしたかを追えることと、使えるツールを絞れることです。ゲートウェイを1枚挟むことで、どちらも1か所で扱えるようになります。

今回は PoC の段階ですが、設計の考え方は他の SaaS の MCP にもそのまま使えます。AI の社内活用を進めるうえでのガバナンスの土台として、引き続き検証していきます。

弊社では、本製品を含む Akamai 製品の取り扱いが可能です。
お気軽にお問い合わせください。

アイレット、Akamai とパートナー契約を締結


http://cloudpack.jp/lp/akamai/

付録: CloudFormation テンプレート(backlog-mcp-ecs.yaml)

AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  backlog-mcp-server (nulab) on ECS Fargate behind ALB + AWS WAF.
  OAuth mode / Streamable HTTP. Intended as the upstream of a Zuplo MCP Gateway.
  NOTE: backlog-mcp-server keeps OAuth state in memory, so the service runs exactly 1 task.

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: { default: "Domain / TLS" }
        Parameters: [DomainName, CertificateArn, HostedZoneId]
      - Label: { default: "Backlog" }
        Parameters: [BacklogDomain, BacklogOAuthClientId, BacklogOAuthClientSecret, EnableToolsets]
      - Label: { default: "Container" }
        Parameters: [ContainerImage, TaskCpu, TaskMemory]
      - Label: { default: "Network" }
        Parameters: [VpcCidr]
      - Label: { default: "Protection of /mcp (optional)" }
        Parameters: [McpOriginHeaderValue, WafRateLimitPer5Min]

Parameters:
  DomainName:
    Type: String
    Description: FQDN for the MCP server (e.g. backlog-mcp.example.com). Used for MCP_SERVER_BASE_URL and the Backlog Redirect URI (https://<DomainName>/callback).
    AllowedPattern: "^[a-z0-9.-]+$"
  CertificateArn:
    Type: String
    Description: ACM certificate ARN (same region as this stack) covering DomainName.
  HostedZoneId:
    Type: String
    Default: ""
    Description: (Optional) Route 53 hosted zone ID. If set, an alias A record for DomainName is created.
  BacklogDomain:
    Type: String
    Default: <your-space>.backlog.com
    Description: Backlog space domain.
  BacklogOAuthClientId:
    Type: String
    Description: client_id from the Backlog Developer application page.
  BacklogOAuthClientSecret:
    Type: String
    NoEcho: true
    Description: client_secret from the Backlog Developer application page (stored in Secrets Manager).
  EnableToolsets:
    Type: String
    Default: "project,issue,wiki"
    Description: "Comma-separated toolsets: space,project,issue,wiki,git,notifications,document (or all)."
  ContainerImage:
    Type: String
    Default: ghcr.io/nulab/backlog-mcp-server:latest
    Description: Container image. Pin a specific version tag (or mirror to ECR) for production.
  TaskCpu:
    Type: String
    Default: "512"
    AllowedValues: ["256", "512", "1024"]
  TaskMemory:
    Type: String
    Default: "1024"
    AllowedValues: ["512", "1024", "2048"]
  VpcCidr:
    Type: String
    Default: 10.80.0.0/16
  McpOriginHeaderValue:
    Type: String
    NoEcho: true
    Default: ""
    Description: >-
      (Optional) Shared secret. If set, WAF blocks requests to /mcp unless header
      "x-origin-verify" equals this value (configure Zuplo to add the header).
      OAuth endpoints (/authorize, /callback, /token, /register, /.well-known/*) stay public.
  WafRateLimitPer5Min:
    Type: Number
    Default: 1000
    MinValue: 100
    Description: Max requests per source IP per 5 minutes.

Conditions:
  CreateDnsRecord: !Not [!Equals [!Ref HostedZoneId, ""]]
  RestrictMcpByHeader: !Not [!Equals [!Ref McpOriginHeaderValue, ""]]

Resources:
  # ---------------------------------------------------------------- Network
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCidr
      EnableDnsHostnames: true
      EnableDnsSupport: true
      Tags: [{ Key: Name, Value: !Sub "${AWS::StackName}-vpc" }]

  InternetGateway:
    Type: AWS::EC2::InternetGateway
  VpcGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref Vpc
      InternetGatewayId: !Ref InternetGateway

  PublicSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: !Select [0, !Cidr [!Ref VpcCidr, 4, 8]]
      MapPublicIpOnLaunch: false
      Tags: [{ Key: Name, Value: !Sub "${AWS::StackName}-public-a" }]
  PublicSubnetC:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: !Select [1, !Cidr [!Ref VpcCidr, 4, 8]]
      MapPublicIpOnLaunch: false
      Tags: [{ Key: Name, Value: !Sub "${AWS::StackName}-public-c" }]

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
  PublicDefaultRoute:
    Type: AWS::EC2::Route
    DependsOn: VpcGatewayAttachment
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
  PublicSubnetARouteAssoc:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetA
      RouteTableId: !Ref PublicRouteTable
  PublicSubnetCRouteAssoc:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetC
      RouteTableId: !Ref PublicRouteTable

  AlbSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ALB - HTTPS from internet
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0, Description: HTTPS }
        - { IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0, Description: HTTP redirect }

  TaskSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Fargate task - only from ALB
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3333
          ToPort: 3333
          SourceSecurityGroupId: !Ref AlbSecurityGroup
          Description: From ALB only

  # ---------------------------------------------------------------- Secrets / Logs
  OAuthClientSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub "${AWS::StackName}/backlog-oauth-client-secret"
      Description: Backlog OAuth client_secret for backlog-mcp-server
      SecretString: !Ref BacklogOAuthClientSecret

  AppLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/ecs/${AWS::StackName}/backlog-mcp"
      RetentionInDays: 90

  # ---------------------------------------------------------------- IAM
  TaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ecs-tasks.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
      Policies:
        - PolicyName: read-oauth-secret
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: secretsmanager:GetSecretValue
                Resource: !Ref OAuthClientSecret

  TaskRole:
    Type: AWS::IAM::Role
    Properties:
      Description: No AWS permissions required by backlog-mcp-server
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ecs-tasks.amazonaws.com }
            Action: sts:AssumeRole

  # ---------------------------------------------------------------- ECS
  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub "${AWS::StackName}-cluster"
      ClusterSettings:
        - { Name: containerInsights, Value: enabled }

  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub "${AWS::StackName}-backlog-mcp"
      RequiresCompatibilities: [FARGATE]
      NetworkMode: awsvpc
      Cpu: !Ref TaskCpu
      Memory: !Ref TaskMemory
      ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
      TaskRoleArn: !GetAtt TaskRole.Arn
      RuntimePlatform:
        OperatingSystemFamily: LINUX
        CpuArchitecture: X86_64
      ContainerDefinitions:
        - Name: backlog-mcp
          Image: !Ref ContainerImage
          Essential: true
          PortMappings:
            - { ContainerPort: 3333, Protocol: tcp }
          Environment:
            - { Name: MCP_TRANSPORT, Value: http }
            - { Name: MCP_HTTP_HOST, Value: 0.0.0.0 }
            - { Name: MCP_HTTP_PORT, Value: "3333" }
            - { Name: MCP_HTTP_PATH, Value: /mcp }
            - { Name: MCP_HTTP_ALLOWED_HOSTS, Value: !Ref DomainName }
            - { Name: MCP_HTTP_JSON_RESPONSE, Value: "true" }
            - { Name: MCP_SERVER_BASE_URL, Value: !Sub "https://${DomainName}" }
            - { Name: BACKLOG_DOMAIN, Value: !Ref BacklogDomain }
            - { Name: BACKLOG_OAUTH_CLIENT_ID, Value: !Ref BacklogOAuthClientId }
            - { Name: ENABLE_TOOLSETS, Value: !Ref EnableToolsets }
          Secrets:
            - { Name: BACKLOG_OAUTH_CLIENT_SECRET, ValueFrom: !Ref OAuthClientSecret }
          LinuxParameters:
            InitProcessEnabled: true
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref AppLogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: app

  Service:
    Type: AWS::ECS::Service
    DependsOn: HttpsListener
    Properties:
      ServiceName: backlog-mcp
      Cluster: !Ref Cluster
      TaskDefinition: !Ref TaskDefinition
      LaunchType: FARGATE
      PlatformVersion: LATEST
      # Exactly one task: OAuth registrations/tokens live in process memory.
      DesiredCount: 1
      DeploymentConfiguration:
        MaximumPercent: 100
        MinimumHealthyPercent: 0
        DeploymentCircuitBreaker: { Enable: true, Rollback: true }
      HealthCheckGracePeriodSeconds: 60
      EnableExecuteCommand: false
      NetworkConfiguration:
        AwsvpcConfiguration:
          # Public subnets + public IP to pull from ghcr.io without a NAT Gateway.
          # Inbound is still limited to the ALB by TaskSecurityGroup.
          AssignPublicIp: ENABLED
          Subnets: [!Ref PublicSubnetA, !Ref PublicSubnetC]
          SecurityGroups: [!Ref TaskSecurityGroup]
      LoadBalancers:
        - ContainerName: backlog-mcp
          ContainerPort: 3333
          TargetGroupArn: !Ref TargetGroup

  # ---------------------------------------------------------------- ALB
  LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: application
      Scheme: internet-facing
      Subnets: [!Ref PublicSubnetA, !Ref PublicSubnetC]
      SecurityGroups: [!Ref AlbSecurityGroup]
      LoadBalancerAttributes:
        - { Key: idle_timeout.timeout_seconds, Value: "300" }
        - { Key: routing.http.drop_invalid_header_fields.enabled, Value: "true" }
        - { Key: routing.http.desync_mitigation_mode, Value: strictest }

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      VpcId: !Ref Vpc
      TargetType: ip
      Protocol: HTTP
      Port: 3333
      HealthCheckProtocol: HTTP
      # Served by backlog-mcp-server when OAuth mode is enabled.
      HealthCheckPath: /.well-known/oauth-authorization-server
      # ALB health checks send Host: <task-ip>:3333, which MCP_HTTP_ALLOWED_HOSTS
      # (DNS-rebinding protection) rejects with 403. Accept 403 as "process is up"
      # instead of weakening the Host allow-list.
      Matcher: { HttpCode: "200,403" }
      HealthCheckIntervalSeconds: 30
      HealthyThresholdCount: 2
      UnhealthyThresholdCount: 3
      TargetGroupAttributes:
        - { Key: deregistration_delay.timeout_seconds, Value: "10" }

  HttpListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: redirect
          RedirectConfig: { Protocol: HTTPS, Port: "443", StatusCode: HTTP_301 }

  HttpsListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Port: 443
      Protocol: HTTPS
      SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
      Certificates: [{ CertificateArn: !Ref CertificateArn }]
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup

  DnsRecord:
    Type: AWS::Route53::RecordSet
    Condition: CreateDnsRecord
    Properties:
      HostedZoneId: !Ref HostedZoneId
      Name: !Ref DomainName
      Type: A
      AliasTarget:
        DNSName: !GetAtt LoadBalancer.DNSName
        HostedZoneId: !GetAtt LoadBalancer.CanonicalHostedZoneID

  # ---------------------------------------------------------------- WAF
  WebAcl:
    Type: AWS::WAFv2::WebACL
    Properties:
      Name: !Sub "${AWS::StackName}-waf"
      Scope: REGIONAL
      DefaultAction: { Allow: {} }
      VisibilityConfig:
        SampledRequestsEnabled: true
        CloudWatchMetricsEnabled: true
        MetricName: !Sub "${AWS::StackName}-waf"
      Rules:
        - !If
          - RestrictMcpByHeader
          - Name: require-origin-header-on-mcp
            Priority: 0
            Action: { Block: {} }
            Statement:
              AndStatement:
                Statements:
                  - ByteMatchStatement:
                      FieldToMatch: { UriPath: {} }
                      PositionalConstraint: STARTS_WITH
                      SearchString: /mcp
                      TextTransformations: [{ Priority: 0, Type: NONE }]
                  - NotStatement:
                      Statement:
                        ByteMatchStatement:
                          FieldToMatch: { SingleHeader: { Name: x-origin-verify } }
                          PositionalConstraint: EXACTLY
                          SearchString: !Ref McpOriginHeaderValue
                          TextTransformations: [{ Priority: 0, Type: NONE }]
            VisibilityConfig:
              SampledRequestsEnabled: true
              CloudWatchMetricsEnabled: true
              MetricName: require-origin-header-on-mcp
          - !Ref AWS::NoValue
        - Name: rate-limit-per-ip
          Priority: 1
          Action: { Block: {} }
          Statement:
            RateBasedStatement:
              Limit: !Ref WafRateLimitPer5Min
              AggregateKeyType: IP
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: rate-limit-per-ip
        - Name: AWSManagedRulesAmazonIpReputationList
          Priority: 2
          OverrideAction: { None: {} }
          Statement:
            ManagedRuleGroupStatement:
              VendorName: AWS
              Name: AWSManagedRulesAmazonIpReputationList
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: ip-reputation
        - Name: AWSManagedRulesCommonRuleSet
          Priority: 3
          OverrideAction: { None: {} }
          Statement:
            ManagedRuleGroupStatement:
              VendorName: AWS
              Name: AWSManagedRulesCommonRuleSet
              # MCP JSON-RPC bodies can exceed 8KB; avoid false positives.
              RuleActionOverrides:
                - Name: SizeRestrictions_BODY
                  ActionToUse: { Count: {} }
                # Zuplo MCP Gateway's upstream OAuth/MCP calls carry no User-Agent.
                - Name: NoUserAgent_HEADER
                  ActionToUse: { Count: {} }
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: common-rule-set
        - Name: AWSManagedRulesKnownBadInputsRuleSet
          Priority: 4
          OverrideAction: { None: {} }
          Statement:
            ManagedRuleGroupStatement:
              VendorName: AWS
              Name: AWSManagedRulesKnownBadInputsRuleSet
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: known-bad-inputs

  WebAclAssociation:
    Type: AWS::WAFv2::WebACLAssociation
    Properties:
      ResourceArn: !Ref LoadBalancer
      WebACLArn: !GetAtt WebAcl.Arn

  WafLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      # Name must start with aws-waf-logs-
      LogGroupName: !Sub "aws-waf-logs-${AWS::StackName}"
      RetentionInDays: 90

  WafLogging:
    Type: AWS::WAFv2::LoggingConfiguration
    Properties:
      ResourceArn: !GetAtt WebAcl.Arn
      LogDestinationConfigs:
        - !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:aws-waf-logs-${AWS::StackName}"
      RedactedFields:
        - SingleHeader: { Name: authorization }
        - SingleHeader: { Name: x-origin-verify }
    DependsOn: WafLogGroup

Outputs:
  McpEndpoint:
    Description: Set this as rewritePattern in Zuplo routes.oas.json
    Value: !Sub "https://${DomainName}/mcp"
  ProtectedResourceMetadataUrl:
    Description: Set this as protectedResourceMetadataUrl in the Zuplo token-exchange policy
    Value: !Sub "https://${DomainName}/.well-known/oauth-protected-resource/mcp"
  BacklogRedirectUri:
    Description: Register this as Redirect URI in the Backlog Developer application
    Value: !Sub "https://${DomainName}/callback"
  AlbDnsName:
    Description: Point DomainName (CNAME/alias) here if HostedZoneId was not provided
    Value: !GetAtt LoadBalancer.DNSName
  LogGroup:
    Value: !Ref AppLogGroup