DEV Community

Cover image for Agentic AWS - Day 1: Amazon Bedrock AgentCore Gateway
Suhas Mallesh
Suhas Mallesh

Posted on

Agentic AWS - Day 1: Amazon Bedrock AgentCore Gateway

Series: Agentic AWS | Post: 1 of 6 | Cloud: AWS


The Problem with DIY MCP Tool Servers

Every production AI agent needs tools - APIs, Lambda functions, internal services. Before AgentCore Gateway, connecting those tools to an agent meant writing your own MCP server, managing OAuth flows, handling protocol translation, building throttling, and wiring up observability. That is weeks of undifferentiated work before a single line of agent logic.

AgentCore Gateway eliminates that entirely. It is a fully managed MCP server that converts Lambda functions and OpenAPI specs into agent-ready tools - with built-in auth, routing, and semantic tool discovery - in zero code.

This post provisions an AgentCore Gateway via Terraform, registers a Lambda function as a tool target, and connects a Bedrock-powered agent to it using the MCP streamable HTTP transport.


Architecture

Bedrock Agent (Python + MCP client)
        |
        | streamable HTTP (MCP protocol)
        v
AgentCore Gateway  <-- IAM inbound auth
        |
        | IAM role assumption
        v
Lambda Target: order-status-tool
        |
        v
DynamoDB (mock order table)
Enter fullscreen mode Exit fullscreen mode

The gateway handles inbound authentication (IAM or OAuth), routes MCP requests to the correct target, translates between MCP and the Lambda invocation protocol, and returns tool results back to the agent.


Terraform Infrastructure

Provider and Variables

# versions.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.80"
    }
  }
  required_version = ">= 1.6"
}

provider "aws" {
  region = var.aws_region
}
Enter fullscreen mode Exit fullscreen mode
# variables.tf
variable "aws_region" {
  description = "AWS region for deployment"
  type        = string
}

variable "environment" {
  description = "Deployment environment (dev or prod)"
  type        = string
}

variable "project_name" {
  description = "Project prefix for resource naming"
  type        = string
  default     = "agentic-aws"
}

variable "semantic_search_enabled" {
  description = "Enable semantic tool search index on the gateway"
  type        = bool
  default     = false
}

variable "lambda_memory_mb" {
  description = "Lambda memory allocation in MB"
  type        = number
  default     = 256
}
Enter fullscreen mode Exit fullscreen mode
# dev.tfvars
aws_region             = "us-east-1"
environment            = "dev"
semantic_search_enabled = false
lambda_memory_mb       = 256
Enter fullscreen mode Exit fullscreen mode
# prod.tfvars
aws_region             = "us-east-1"
environment            = "prod"
semantic_search_enabled = true
lambda_memory_mb       = 512
Enter fullscreen mode Exit fullscreen mode

Lambda Tool - Order Status

# lambda.tf

# IAM role for the Lambda function
resource "aws_iam_role" "order_tool_lambda" {
  name = "${var.project_name}-order-tool-lambda-${var.environment}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.order_tool_lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-read"
  role = aws_iam_role.order_tool_lambda.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["dynamodb:GetItem", "dynamodb:Query"]
      Resource = aws_dynamodb_table.orders.arn
    }]
  })
}

# Lambda function (zip from local source)
data "archive_file" "order_tool" {
  type        = "zip"
  source_dir  = "${path.module}/lambda/order_tool"
  output_path = "${path.module}/.build/order_tool.zip"
}

resource "aws_lambda_function" "order_tool" {
  function_name    = "${var.project_name}-order-tool-${var.environment}"
  role             = aws_iam_role.order_tool_lambda.arn
  filename         = data.archive_file.order_tool.output_path
  source_code_hash = data.archive_file.order_tool.output_base64sha256
  runtime          = "python3.12"
  handler          = "handler.lambda_handler"
  memory_size      = var.lambda_memory_mb
  timeout          = 30

  environment {
    variables = {
      ORDERS_TABLE = aws_dynamodb_table.orders.name
      ENVIRONMENT  = var.environment
    }
  }
}

# Mock orders table
resource "aws_dynamodb_table" "orders" {
  name         = "${var.project_name}-orders-${var.environment}"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }
}
Enter fullscreen mode Exit fullscreen mode

AgentCore Gateway and Target

# gateway.tf

# IAM role that AgentCore Gateway assumes to invoke Lambda
resource "aws_iam_role" "gateway_execution" {
  name = "${var.project_name}-gateway-exec-${var.environment}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "bedrock.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "gateway_invoke_lambda" {
  name = "invoke-order-tool"
  role = aws_iam_role.gateway_execution.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "lambda:InvokeFunction"
      Resource = aws_lambda_function.order_tool.arn
    }]
  })
}

# AgentCore Gateway
resource "aws_bedrock_agent_core_gateway" "main" {
  name        = "${var.project_name}-gateway-${var.environment}"
  description = "Agentic AWS - order management tool gateway"

  # Inbound auth: IAM - agents must sign requests with SigV4
  authorizer_configuration = {
    type = "AWS_IAM"
  }

  # Enable semantic tool search in prod for large tool sets
  search_type = var.semantic_search_enabled ? "SEMANTIC" : "LEXICAL"
}

# Lambda target - registers the order tool with the gateway
resource "aws_bedrock_agent_core_gateway_target" "order_tool" {
  gateway_id  = aws_bedrock_agent_core_gateway.main.id
  name        = "order-status-target"
  description = "Retrieves order status and shipment details"

  target_configuration = {
    lambda = {
      lambda_arn       = aws_lambda_function.order_tool.arn
      execution_role   = aws_iam_role.gateway_execution.arn
    }
  }
}

# IAM policy allowing the Bedrock agent caller to invoke the gateway
resource "aws_iam_policy" "invoke_gateway" {
  name        = "${var.project_name}-invoke-gateway-${var.environment}"
  description = "Allows agent runtime to call AgentCore Gateway"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "bedrock:InvokeAgentCoreGateway"
      Resource = aws_bedrock_agent_core_gateway.main.arn
    }]
  })
}

# Outputs consumed by the Python agent
output "gateway_endpoint" {
  description = "MCP streamable HTTP endpoint for the gateway"
  value       = aws_bedrock_agent_core_gateway.main.endpoint_url
}

output "gateway_arn" {
  description = "Gateway ARN for IAM policy references"
  value       = aws_bedrock_agent_core_gateway.main.arn
}
Enter fullscreen mode Exit fullscreen mode

Lambda Tool Implementation

# lambda/order_tool/handler.py
import json
import os
import boto3
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["ORDERS_TABLE"])


def get_order_status(order_id: str) -> dict:
    """Retrieve order status from DynamoDB."""
    response = table.get_item(Key={"order_id": order_id})
    item = response.get("Item")

    if not item:
        return {"error": f"Order {order_id} not found"}

    return {
        "order_id": item["order_id"],
        "status": item.get("status", "unknown"),
        "carrier": item.get("carrier"),
        "tracking_number": item.get("tracking_number"),
        "estimated_delivery": item.get("estimated_delivery"),
    }


def lambda_handler(event: dict, context) -> dict:
    """
    AgentCore Gateway invokes Lambda with a standard tool call structure.
    The 'tool_name' field identifies which tool to execute.
    'tool_input' contains the parameters.
    """
    tool_name = event.get("tool_name", "")
    tool_input = event.get("tool_input", {})

    if tool_name == "get_order_status":
        order_id = tool_input.get("order_id")
        if not order_id:
            return {"error": "order_id is required"}
        result = get_order_status(order_id)
    else:
        result = {"error": f"Unknown tool: {tool_name}"}

    return {
        "tool_name": tool_name,
        "tool_result": result,
    }
Enter fullscreen mode Exit fullscreen mode

The Lambda receives a normalized tool call envelope from AgentCore Gateway regardless of the upstream protocol. Your function does not need to understand MCP.


Python Agent - Connecting via MCP

# agent.py
import asyncio
import boto3
import os
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.credentials import Credentials
import httpx

GATEWAY_ENDPOINT = os.environ["GATEWAY_ENDPOINT"]  # from Terraform output
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")
MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0"

bedrock = boto3.client("bedrock-runtime", region_name=AWS_REGION)
session_creds = boto3.session.Session().get_credentials().resolve()


def signed_headers(url: str, method: str = "POST") -> dict:
    """Generate SigV4 signed headers for AgentCore Gateway inbound IAM auth."""
    request = AWSRequest(method=method, url=url)
    SigV4Auth(session_creds, "bedrock", AWS_REGION).add_auth(request)
    return dict(request.headers)


async def run_agent(user_query: str):
    """
    Connect to AgentCore Gateway, discover available tools,
    and run a Bedrock-powered agent loop.
    """
    headers = signed_headers(GATEWAY_ENDPOINT)

    async with streamablehttp_client(GATEWAY_ENDPOINT, headers=headers) as (read, write, _):
        async with ClientSession(read, write) as mcp_session:
            await mcp_session.initialize()

            # Discover tools registered on the gateway
            tools_response = await mcp_session.list_tools()
            tools = [
                {
                    "name": t.name,
                    "description": t.description,
                    "input_schema": t.inputSchema,
                }
                for t in tools_response.tools
            ]

            print(f"Discovered {len(tools)} tools: {[t['name'] for t in tools]}")

            messages = [{"role": "user", "content": user_query}]

            # Agentic loop
            while True:
                response = bedrock.invoke_model(
                    modelId=MODEL_ID,
                    contentType="application/json",
                    body=json.dumps({
                        "anthropic_version": "bedrock-2023-05-31",
                        "max_tokens": 1024,
                        "tools": tools,
                        "messages": messages,
                    }),
                )

                result = json.loads(response["body"].read())
                stop_reason = result.get("stop_reason")
                content = result.get("content", [])

                # Append assistant turn
                messages.append({"role": "assistant", "content": content})

                if stop_reason == "end_turn":
                    # Extract final text response
                    for block in content:
                        if block.get("type") == "text":
                            print(f"\nAgent: {block['text']}")
                    break

                if stop_reason == "tool_use":
                    tool_results = []
                    for block in content:
                        if block.get("type") != "tool_use":
                            continue

                        tool_name = block["name"]
                        tool_input = block["input"]
                        tool_use_id = block["id"]

                        print(f"  -> Calling tool: {tool_name}({tool_input})")

                        # Invoke tool via AgentCore Gateway MCP
                        mcp_result = await mcp_session.call_tool(tool_name, tool_input)
                        tool_output = mcp_result.content[0].text if mcp_result.content else ""

                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": tool_use_id,
                            "content": tool_output,
                        })

                    messages.append({"role": "user", "content": tool_results})
                else:
                    break


if __name__ == "__main__":
    import sys
    query = sys.argv[1] if len(sys.argv) > 1 else "What is the status of order ORD-1234?"
    asyncio.run(run_agent(query))
Enter fullscreen mode Exit fullscreen mode

Run it:

export GATEWAY_ENDPOINT=$(terraform output -raw gateway_endpoint)
python agent.py "Where is my order ORD-1234?"
Enter fullscreen mode Exit fullscreen mode
Discovered 1 tools: ['get_order_status']
  -> Calling tool: get_order_status({'order_id': 'ORD-1234'})

Agent: Your order ORD-1234 is currently in transit with FedEx.
       Tracking number: 794601234567. Estimated delivery: April 17, 2026.
Enter fullscreen mode Exit fullscreen mode

How AgentCore Gateway Handles the Heavy Lifting

When the Python agent calls mcp_session.call_tool("get_order_status", ...), the following happens inside AgentCore Gateway automatically:

Inbound auth - The gateway validates the SigV4 signature against IAM. No valid credentials, no access. In production you can switch this to OAuth (Cognito, Okta, Auth0) with no change to your Lambda code.

Protocol translation - The MCP tool call is converted to a normalized Lambda invocation envelope. Your Lambda never sees raw MCP.

Routing - The gateway resolves which target owns get_order_status and invokes it. As you add more targets, the gateway routes by tool name automatically.

Outbound auth - The gateway assumes the gateway_execution IAM role you configured and invokes Lambda with it. Your Lambda never handles credentials.

Semantic search (prod) - With semantic_search_enabled = true, the gateway builds a vector index of all tool descriptions. The agent can call search_tools("find shipping info") to dynamically discover the right tool rather than enumerating all of them - essential when you have dozens of tools.


Decision Framework

Scenario Target Type Notes
Custom business logic, no existing API Lambda Full control, any language
Existing REST API with OpenAPI spec OpenAPI target Zero code, spec-driven
Existing MCP server (third-party) MCP server target GA Oct 2025 - connect existing servers
Inbound auth: service-to-service IAM (SigV4) Simplest, no token management
Inbound auth: user-delegated flows OAuth (Cognito/Okta) 3LO for user context
Small tool set (< 20 tools) Lexical search Faster, cheaper
Large tool set (> 20 tools) Semantic search Better accuracy, slight cost

Production Additions

A few things to layer in before real traffic:

  • VPC integration - AgentCore Gateway supports VPC and PrivateLink (GA Oct 2025). Add vpc_configuration to the gateway resource to keep traffic off the public internet.
  • CloudWatch observability - AgentCore Observability emits token usage, latency, and error rates to CloudWatch automatically. No configuration needed.
  • Policy guardrails - AgentCore Policy can intercept tool calls before they execute and evaluate them against Cedar rules. Useful for "never issue refunds over $500 without human approval" type controls. Post 4 in this series covers Identity; Policy integrates directly with the gateway.
  • Multiple targets - Add more aws_bedrock_agent_core_gateway_target resources to the same gateway. Each target can have its own auth config and tool set. One gateway endpoint, many services.

What's Next

Post 2 covers AgentCore Runtime - the serverless execution environment where you deploy the agent itself. Runtime adds 8-hour execution windows, session isolation, A2A protocol support, and built-in observability for the agent process, not just the tool calls.

The gateway you built here connects directly to an AgentCore Runtime-hosted agent with no changes to the gateway configuration.


Key Takeaways

  • AgentCore Gateway converts Lambda functions and OpenAPI specs into MCP-compliant tools with zero code changes to your functions
  • Inbound IAM auth (SigV4) is the right starting point; OAuth is a drop-in upgrade for user-delegated scenarios
  • Semantic tool search pays off at scale - enable it in prod when you have more than 20 tools
  • The gateway handles auth, routing, and protocol translation; your Lambda handles only business logic
  • One gateway manages multiple targets - you get a single MCP endpoint for your entire tool estate

Series: Agentic AWS | Next: Post 2 - AgentCore Runtime

Top comments (0)