2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

アウトバウンドで3LOしてSlack MCP Serverに接続するAmazon Bedrock AgentCore GatewayをTerraformで構築する

2
Last updated at Posted at 2026-06-28

本記事で紹介するAWS Lambda Functionのスクリプトはコーディングエージェントを用いて作成しました。
読者の方のお使いのコーディングエージェントに要件を伝えれば大体同じように作ってくれると思うので、本記事の内容は参考程度に考えてください。

はじめに

Amazon Bedrock AgentCore Gatewayは、AIエージェントが安全に通信を行うためのフルマネージドなゲートウェイだ。単一のエンドポイントから、AIエージェントをMCPやA2Aなどの様々なプロトコルで外部サービスと接続することができる。さらに、外部のREST APIやLambda 関数をMCP互換ツールに変換することも可能だ。その際の認可等も担ってくれるため、このゲートウェイに任せておくと、アプリケーションの認可の作り込みを減らすことができる。

自分のブログでも、以前は主にInbound Gateway機能を中心に紹介をした。

今回はOutbound Gatewayの機能の一つである、3LO(Three-Legged OAuth)によりSlack MCP Serverに接続する構成を、Terraformで構築する方法を紹介する。

3LO(Three-Legged OAuth)とは

リソースオーナー(ユーザ)、クライアント(Amazon Bedrock AgentCore Gateway)、リソースサーバ(Slack)の3者が関わるOAuth 2.0の認可フローだ。今回の構成では、ユーザがSlackに対してAmazon Bedrock AgentCore Gatewayの操作を認可することで、AIエージェントがSlackのMCPツールを呼び出せるようになる。

Amazon Bedrock AgentCore Gatewayの構築

IAM

IAMは以下のように定義する。
Amazon Bedrock AgentCore Gatewayは、TokenVaultという管理領域からアクセストークンを出し入れするため、GetWorkloadAccessTokenとGetResourceOauth2TokenのSIDで指定したアクセス権を付与して、トークン管理を可能にしておく。
また、TokenVaultはデフォルトでKMSの暗号化が行われるため、secretsmanager:GetSecretValueでのアクセス権の付与が必要だ。

resource "aws_iam_role" "bedrock_agentcore_gateway" {
  name               = local.iam_role_bac_gateway_name
  assume_role_policy = data.aws_iam_policy_document.bedrock_agentcore_gateway_assume.json
}

data "aws_iam_policy_document" "bedrock_agentcore_gateway_assume" {
  statement {
    effect = "Allow"

    principals {
      type = "Service"
      identifiers = [
        "bedrock-agentcore.amazonaws.com",
      ]
    }

    actions = [
      "sts:AssumeRole",
    ]

    condition {
      test     = "StringEquals"
      variable = "aws:SourceAccount"
      values = [
        data.aws_caller_identity.self.id,
      ]
    }

    condition {
      test     = "ArnLike"
      variable = "AWS:SourceArn"
      values = [
        "arn:aws:bedrock-agentcore:${data.aws_region.current.region}:${data.aws_caller_identity.self.id}:*",
      ]
    }
  }
}

resource "aws_iam_role_policy" "bedrock_agentcore_gateway" {
  name   = local.iam_policy_bac_gateway_name
  role   = aws_iam_role.bedrock_agentcore_gateway.id
  policy = data.aws_iam_policy_document.bedrock_agentcore_gateway_custom.json
}

data "aws_iam_policy_document" "bedrock_agentcore_gateway_custom" {
  statement {
    sid    = "GetWorkloadAccessToken"
    effect = "Allow"

    actions = [
      "bedrock-agentcore:GetWorkloadAccessToken",
      "bedrock-agentcore:GetWorkloadAccessTokenForJWT",
    ]

    resources = [
      "arn:aws:bedrock-agentcore:${data.aws_region.current.region}:${data.aws_caller_identity.self.id}:workload-identity-directory/default",
      "arn:aws:bedrock-agentcore:${data.aws_region.current.region}:${data.aws_caller_identity.self.id}:workload-identity-directory/default/workload-identity/${local.bac_gateway_mcp_name}-*",
    ]
  }

  statement {
    sid    = "GetResourceOauth2Token"
    effect = "Allow"

    actions = [
      "bedrock-agentcore:GetResourceOauth2Token",
    ]

    resources = [
      "arn:aws:bedrock-agentcore:${data.aws_region.current.region}:${data.aws_caller_identity.self.id}:token-vault/default",
      "arn:aws:bedrock-agentcore:${data.aws_region.current.region}:${data.aws_caller_identity.self.id}:token-vault/default/oauth2credentialprovider/${local.bac_gateway_mcp_name}-*",
      "arn:aws:bedrock-agentcore:${data.aws_region.current.region}:${data.aws_caller_identity.self.id}:workload-identity-directory/default",
      "arn:aws:bedrock-agentcore:${data.aws_region.current.region}:${data.aws_caller_identity.self.id}:workload-identity-directory/default/workload-identity/${local.bac_gateway_mcp_name}-*",
    ]
  }

  statement {
    sid    = "GetSecretValue"
    effect = "Allow"

    actions = [
      "secretsmanager:GetSecretValue",
    ]

    resources = [
      "arn:aws:secretsmanager:${data.aws_region.current.region}:${data.aws_caller_identity.self.id}:secret:bedrock-agentcore-identity!default/oauth2/${local.bac_gateway_mcp_name}-*",
    ]
  }
}

Amazon Bedrock AgentCore Gateway

Amazon Bedrock AgentCore Gateway本体は以下のように定義する。
今回の構成では、Auth0での認可を行う前提で作っているため、authorizer_configurationでAuth0のカスタムJWT認可を設定している。
また、Slack MCP Serverに接続してMCPのToolsとして使わせるため、protocol_typeにはMCPを指定する。

3LOでの認可は、protocol_configuration.mcp.supported_versionsに"2025-11-25"が含まれている必要があるため、忘れずに含めておく。

resource "aws_bedrockagentcore_gateway" "mcp" {
  name        = local.bac_gateway_mcp_name
  role_arn    = aws_iam_role.bedrock_agentcore_gateway.arn

  authorizer_type = "CUSTOM_JWT"
  authorizer_configuration {
    custom_jwt_authorizer {
      discovery_url = "https://${data.auth0_tenant.amazon_bedrock_agentcore_runtime_auth.domain}/.well-known/openid-configuration"
      allowed_audience = [
        auth0_resource_server.amazon_bedrock_agentcore_runtime_auth.identifier,
      ]
    }
  }

  protocol_type = "MCP"
  protocol_configuration {
    mcp {
      instructions = "MCP Gateway aggregating multiple targets"
      supported_versions = [
        "2025-03-26",
        "2025-06-18",
        "2025-11-25",
      ]
    }
  }
}

OAuth2 Credential Provider

Amazon Bedrock AgentCore GatewayがアウトバウンドでSlackのOAuth2の認可を行う際に使用するCredential Providerを作成する。
クレデンシャル情報は、AWS Secrets Managerの情報を利用しよう。シークレット情報がTerraformのstateファイルに残らないよう、ephemeralリソースを使う。

data "aws_secretsmanager_secret" "credentials" {
  name = "<あらかじめ用意したシークレット名>"
}

ephemeral "aws_secretsmanager_secret_version" "credentials" {
  secret_id = data.aws_secretsmanager_secret.credentials.arn
}

なお、今回のシークレット情報は以下のJSONで作成している。

{ 
  "client_id": "...",
  "client_secret": "..."
}

認証エンドポイント等の情報はdiscovery_urlを使いたいところだが、最新の/.well-known/oauth-authorization-serverを使おうとすると、エンドポイントがSlack MCP Serverに対応していなかった(Bot Userを求めてきてしまう)ため、使用できない。
authorization_server_metadataを以下のように設定するとうまく認可が通る。

locals {
  credentials = jsondecode(ephemeral.aws_secretsmanager_secret_version.credentials.secret_string)
}

resource "aws_bedrockagentcore_oauth2_credential_provider" "slack" {
  name = local.oauth2_credential_provider_slack_name

  credential_provider_vendor = "CustomOauth2"

  oauth2_provider_config {
    custom_oauth2_provider_config {
      client_id_wo                  = local.credentials.client_id
      client_secret_wo              = local.credentials.client_secret
      client_credentials_wo_version = ephemeral.aws_secretsmanager_secret_version.id

      oauth_discovery {
        authorization_server_metadata {
          issuer                 = "https://slack.com"
          authorization_endpoint = "https://slack.com/oauth/v2_user/authorize"
          token_endpoint         = "https://slack.com/api/oauth.v2.access"
          response_types         = ["code"]
        }
      }
    }
  }
}

Amazon Bedrock AgentCore Gateway Target

続いて、Gateway Targetを定義する。Gateway Targetはその名の通り、Amazon Bedrock AgentCore Gatewayの接続先情報を管理するためのリソースだ。

Gateway Targetでは、接続先エンドポイントとOAuth認可フローを設定する。grant_type = "AUTHORIZATION_CODE"を指定することで3LOの認可フローが有効になる。default_return_urlはOAuth認可後のリダイレクト先で、このエンドポイントで認可フローを完了させる処理を行う。

endpointは直接https://mcp.slack.com/mcpに向けるのではなく、後述のLambda Function URLに向けるのがポイントだ。

resource "aws_bedrockagentcore_gateway_target" "slack" {
  name               = local.bedrockagentcore_gateway_target_name
  gateway_identifier = aws_bedrockagentcore_gateway.mcp.gateway_id
  description        = "Slack MCP Server target"

  target_configuration {
    mcp {
      mcp_server {
        endpoint = aws_lambda_function_url.slack_mcp_proxy.function_url
      }
    }
  }

  credential_provider_configuration {
    oauth {
      provider_arn       = aws_bedrockagentcore_oauth2_credential_provider.slack.credential_provider_arn
      grant_type         = "AUTHORIZATION_CODE"
      scopes             = ["channels:history", "channels:read", "chat:write", "reactions:read"]
      default_return_url = "<OAuth認可後のリダイレクト先URL>"
    }
  }
}

Amazon Bedrock AgentCore GatewayとSlack MCPの仕様差分を吸収する

問題点

Amazon Bedrock AgentCore GatewayはGateway Targetを登録(Sync)する際に、対象のMCPサーバに対して以下のメソッドを呼び出す。

  • tools/list
  • resources/list
  • resources/templates/list

一方で、Slack MCP Server(mcp.slack.com)はresources/list、resources/templates/listの実装がされてなく、呼び出すと-32601(Method not found)のエラーが返ってくる。Amazon Bedrock AgentCore Gatewayはこれをsync失敗として扱うため、Target StatusがREADYにならない。初回認可が完了せず、その後のSlack MCP Serverの呼び出しもすべて正常に動作しない(Tools無しの動作になる)。

Amazon Bedrock AgentCore GatewayにはInterceptorという仕組みもあるが、sync時のoutbound callはInterceptorが発火しないため、この方法では回避できなかった。

なお、Slack MCP Serverが悪いかどうかまでは確認できていない。Amazon Bedrock AgentCore Gatewayのチェックが厳密すぎる可能性もあるが、MCP Serverの初回認可時のログの調査が必要でそこまでは行っていない状況だ。

回避策

そこで、以下のような構成でこの問題を回避する。

  • Gateway TargetのendpointをSlackに直接向けるのではなく、AWS Lambda Function URLsに向ける
  • AWS Lambda FunctionはMCPプロキシとして動作し、resources/list等のSlack MCP Server未実装のメソッドが来た場合、空のリストにフォールバックして正常応答する
  • それ以外のリクエストはhttps://mcp.slack.com/mcpにそのまま転送する

まずLambda本体とFunction URLのTerraform定義から見ていこう。

Function URLのauthorization_typeはNONEにしている。これは、Amazon Bedrock AgentCore GatewayがターゲットエンドポイントへのリクエストにIAM署名を付けないためだ。セキュリティ上の懸念に聞こえるかもしれないが、転送先はLambdaの環境変数で固定されており、かつSlack側でBearerトークンの検証が行われるため、実質的なリスクは限定的だと考える。

data "archive_file" "slack_mcp_proxy" {
  type        = "zip"
  source_file = "${path.module}/script/lambda/slack_mcp_proxy/index.py"
  output_path = "${path.module}/terraform_tmp/script/lambda/slack_mcp_proxy.zip"
}

resource "aws_lambda_function" "slack_mcp_proxy" {
  function_name    = local.slack_mcp_proxy_name
  role             = aws_iam_role.lambda_slack_mcp_proxy.arn
  handler          = "index.lambda_handler"
  runtime          = "python3.12"
  filename         = data.archive_file.slack_mcp_proxy.output_path
  source_code_hash = data.archive_file.slack_mcp_proxy.output_base64sha256
  timeout          = 30

  environment {
    variables = {
      SLACK_MCP_URL = "https://mcp.slack.com/mcp"
    }
  }
}

resource "aws_cloudwatch_log_group" "lambda_slack_mcp_proxy" {
  name = local.cloudwatch_log_group_slack_mcp_proxy_name
}

resource "aws_lambda_function_url" "slack_mcp_proxy" {
  function_name      = aws_lambda_function.slack_mcp_proxy.function_name
  authorization_type = "NONE"
}

resource "aws_iam_role" "lambda_slack_mcp_proxy" {
  name               = local.iam_role_slack_mcp_proxy_name
  assume_role_policy = data.aws_iam_policy_document.lambda_slack_mcp_proxy_trust.json
}

data "aws_iam_policy_document" "lambda_slack_mcp_proxy_trust" {
  statement {
    effect = "Allow"

    principals {
      type = "Service"
      identifiers = [
        "lambda.amazonaws.com",
      ]
    }

    actions = [
      "sts:AssumeRole",
    ]
  }
}

resource "aws_iam_role_policy" "lambda_slack_mcp_proxy" {
  name   = local.iam_policy_slack_mcp_proxy_name
  role   = aws_iam_role.lambda_slack_mcp_proxy.id
  policy = data.aws_iam_policy_document.lambda_slack_mcp_proxy_custom.json
}

data "aws_iam_policy_document" "lambda_slack_mcp_proxy_custom" {
  statement {
    effect = "Allow"

    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]

    resources = [
      aws_cloudwatch_log_group.lambda_slack_mcp_proxy.arn,
      "${aws_cloudwatch_log_group.lambda_slack_mcp_proxy.arn}:log-stream:*",
    ]
  }
}

続いて、Lambda関数本体のPythonコードを参考までに掲載しておく(冒頭に記載した通り、ここはコーディングエージェントにて生成した部分)。
受信したリクエストのJSON-RPCメソッドを判定し、resources/listなどの未実装メソッドなら空の正常応答を即座に返す。それ以外はAuthorizationヘッダをはじめとする必要なヘッダをそのままSlack MCP Serverへ転送する。Authorizationヘッダを転送することで、Amazon Bedrock AgentCore Gatewayがアウトバウンドで付与したSlackのユーザトークン(xoxp-...で開始するトークン)がそのままSlack側の認証に使われる仕組みだ。

script/lambda/slack_mcp_proxy/index.py
import base64
import json
import logging
import os
import urllib.error
import urllib.request

logger = logging.getLogger()
logger.setLevel(logging.INFO)

SLACK_MCP_URL = os.environ.get("SLACK_MCP_URL", "https://mcp.slack.com/mcp")

_EMPTY_RESULTS = {
    "resources/list": {"resources": []},
    "resources/templates/list": {"resourceTemplates": []},
}

_FORWARD_HEADER_ALLOWLIST = {
    "authorization",
    "content-type",
    "accept",
    "mcp-session-id",
    "mcp-protocol-version",
    "user-agent",
}


def lambda_handler(event, context):
    method_http = event.get("requestContext", {}).get("http", {}).get("method", "")
    body_raw = event.get("body", "") or ""
    if event.get("isBase64Encoded"):
        body_raw = base64.b64decode(body_raw).decode("utf-8")

    body = None
    if body_raw:
        try:
            body = json.loads(body_raw)
        except json.JSONDecodeError:
            body = None

    json_rpc_method = body.get("method") if isinstance(body, dict) else None

    logger.info(
        "incoming http_method=%s json_rpc_method=%s body_len=%d",
        method_http,
        json_rpc_method,
        len(body_raw),
    )

    if json_rpc_method in _EMPTY_RESULTS:
        request_id = body.get("id", 0) if isinstance(body, dict) else 0
        synthetic = {
            "jsonrpc": "2.0",
            "id": request_id,
            "result": _EMPTY_RESULTS[json_rpc_method],
        }
        logger.info(
            "short_circuit method=%s id=%s",
            json_rpc_method,
            request_id,
        )
        return _response_json(200, synthetic)

    return _forward_to_slack(event, body_raw, method_http)


def _forward_to_slack(event, body_raw, method_http):
    raw_headers = event.get("headers") or {}
    forward_headers = {
        # AWS Lambda Function URLs は header 名を lower-case で渡してくる. Slack は大文字小文字について
        # 比較的寛容なので lower-case のまま転送する.
        k: v
        for k, v in raw_headers.items()
        if isinstance(k, str) and k.lower() in _FORWARD_HEADER_ALLOWLIST
    }
    # Host header は AWS Lambda Function URLs のドメインが入っているので除外する.
    forward_headers.pop("host", None)

    data = body_raw.encode("utf-8") if body_raw else None
    req = urllib.request.Request(
        SLACK_MCP_URL,
        data=data,
        headers=forward_headers,
        method=method_http or "POST",
    )

    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            status = resp.status
            resp_body = resp.read().decode("utf-8", errors="replace")
            resp_content_type = resp.headers.get("Content-Type", "application/json")
    except urllib.error.HTTPError as e:
        status = e.code
        resp_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
        resp_content_type = e.headers.get("Content-Type", "application/json") if e.headers else "application/json"
    except Exception as e:
        logger.exception("upstream forward failed")
        return _response_json(502, {"error": "upstream forward failed", "detail": str(e)})

    logger.info(
        "forwarded status=%s resp_len=%d",
        status,
        len(resp_body),
    )

    return {
        "statusCode": status,
        "headers": {"Content-Type": resp_content_type},
        "body": resp_body,
    }


def _response_json(status_code, body_obj):
    return {
        "statusCode": status_code,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps(body_obj),
    }

ここまで実施したら、TerraformのApplyを実施できる状態だ。

初回認可を行う

terraform applyでリソースが作成されると、Gateway TargetのStatusはSYNCHRONIZE_PENDING_AUTH(認可待ち)の状態になる。AIエージェントがSlack MCP ServerのToolsを呼び出せるようになるには、ユーザ(リソースオーナー)がSlackに対してAmazon Bedrock AgentCore Gatewayへの操作を認可する必要がある。これが3LOの肝となる部分だ。

手順は3ステップある。

ステップ1: コールバックURLをSlack MCP Serverに設定する

この手順を行わないと、初手でエラーになってGateway Targetの作り直しになるので注意が必要だ。

ちょっとしたTIPS。
Gateway Targetは認可に失敗した場合、10分間のタイムアウトが過ぎるまで、SYNCHRONIZE_PENDING_AUTHからステータスが遷移せず、その状態では変更することも削除することもできない。このタイムアウトを変更する手段もなさそうだったので、失敗すると勿体ない待ち時間が発生する。
ログを見ていると、10分間経過するまで30秒ごとにトークン取得ができたかリトライをしているようだ。なので、Gateway Targetに紐づくOAuth2 Credential Providerを消すと、トークン取得がエラーになってリトライ周期が来た瞬間にステータスがFAILEDに遷移して、変更が行えるようになる。
当然、既に利用中のOAuth2 Credential Providerを削除したら大変なことになってしまうのでできないが、初回構築中でミスった場合には時短に使える手段と考えていただきたい。

以下のCLIを実行して、コールバックURLを取得する。

$ aws bedrock-agentcore-control get-oauth2-credential-provider \
  --name <Amazon Bedrock AgentCore Gateway Target名> \
  --region ap-northeast-1 \
  --query 'callbackUrl' \
  --output text

このコールバックURLを、Slack Appsの管理画面のOAuth & PermissionsのRedirect URLsに設定する。

image.png

ステップ2: 認可URLの取得

続いて、以下のCLIを実行して、Amazon Bedrock AgentCore GatewayがSlack OAuthのauthorization endpointに向けて生成した認可URLと、後続手順で使うuserIdを取得する。

$ aws bedrock-agentcore-control get-gateway-target \
  --gateway-identifier <Amazon Bedrock AgentCore GatewayのID> --target-id <Amazon Bedrock AgentCore Gateway TargetのID> \
  --region ap-northeast-1 \
  --query 'authorizationData.oauth2.{url:authorizationUrl,userId:userId}'

出力されたauthorizationUrlをブラウザで開くと、SlackのOAuth認可画面が表示される。許可するとGateway Targetを定義する際に設定したdefault_return_urlにリダイレクトされ、そのURLのクエリパラメータにsession_uriが付与される。

ステップ3: 認可の完了

リダイレクトされたURLからsession_uriの値を取得し、以下のCLIを実行して認可を完了させる。

aws bedrock-agentcore complete-resource-token-auth \
  --session-uri "<1つ前の手順で出力されたauthorizationUrlのリダイレクト時に設定されるsessionUri>" \
  --user-identifier "{\"userId\": \"<1つ前の手順で出力されたuserId>\"}" \
  --region ap-northeast-1

これを実行すると、Amazon Bedrock AgentCore IdentityがSlackのアクセストークンを取得・保管し、Gateway TargetのStatusがREADYに変わる。以降、AIエージェントがAmazon Bedrock AgentCore Gateway経由でSlack MCPのツールを呼び出せるようになる。

いざ、動かす!

あとは、AIエージェントのアプリケーションでMCP Serverの初期化を行えばOKだ。
Slack MCP Serverを呼び出す際のURLは、https://mcp.slack.com/mcpではなく、Amazon Bedrock AgentCore Gatewayのエンドポイント(aws_bedrockagentcore_gateway.mcp.gateway_url)を設定しよう。

これで、Amazon Bedrock AgentCore Gatewayの一般的な3LOフローに加えて、イレギュラーな実装のSlack MCP ServerにもAIエージェントアプリから接続できるようになった!

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?