はじめに
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つです。
- 利用者本人の権限で動く。 共有アカウントを使わず、社内の Google アカウントでログインし、その人自身の Backlog 権限で MCP を使います。見えないはずのプロジェクトは AI からも見えません。
- 入口でログを取る。 ゲートウェイを通るので、利用者・ツール・成否が1か所に記録されます。
- 二重に守る。 ゲートウェイでの認証に加えて、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:// |
| 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 経由で課題が消された」といったことが起きたとき、誰が・いつ・どのツールで、を追える土台になります。
次に試すこと
今回は「つながって、見える」ところまででした。次の検証を考えています。
- 公開するツールを絞る。 現状は約40のツールが見えており、削除系や、プロジェクトを変更するツールも含まれます。まずは参照系だけを許可リストにして、削除を頼むと「ツールが存在しない」として止まることを確かめます。
- 利用者ごとの監査。 複数人で使い、Backlog 側の更新者が本人になっていることを突き合わせます。
- 退職者・権限変更への追従。 アカウントを止めたら即座に使えなくなることを確認し、将来は IdP をグループ単位の制御に切り替えます。
- MCP の窓口を1つにまとめる。 別の SaaS の MCP も同じゲートウェイに載せ、認証・監査・制限を統一できるかを確かめます。
- 攻撃を想定した確認。 トークンなし、別ルートのトークン流用、隠したツールの直接呼び出しが拒否されることを確かめます。
本番化に向けた課題
| 課題 | 方向性 |
|---|---|
| MCP サーバー再起動で全員が再同意 | トークンを永続化できる構成を待つ、または運用でカバー |
| ゲートウェイを経由しない経路が残る | プライベート接続や、オリジン検証ヘッダーの運用を検証 |
| プランの制約 | SSO・ロール管理・監査ログなど、本番に必要な機能の要否を確認 |
| コンテナイメージのバージョン | latest をやめ、バージョンを固定 |
| 利用者の表示 | 内部 ID ではなくメールアドレスで表示し、監査で使いやすくする |
まとめ
AI エージェントに社内 SaaS を使わせるとき、「つなぐ」ことの次に必要になるのは、誰が何をしたかを追えることと、使えるツールを絞れることです。ゲートウェイを1枚挟むことで、どちらも1か所で扱えるようになります。
今回は PoC の段階ですが、設計の考え方は他の SaaS の MCP にもそのまま使えます。AI の社内活用を進めるうえでのガバナンスの土台として、引き続き検証していきます。
弊社では、本製品を含む 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