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)
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
}
# 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
}
# dev.tfvars
aws_region = "us-east-1"
environment = "dev"
semantic_search_enabled = false
lambda_memory_mb = 256
# prod.tfvars
aws_region = "us-east-1"
environment = "prod"
semantic_search_enabled = true
lambda_memory_mb = 512
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"
}
}
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
}
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,
}
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))
Run it:
export GATEWAY_ENDPOINT=$(terraform output -raw gateway_endpoint)
python agent.py "Where is my order ORD-1234?"
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.
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_configurationto 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_targetresources 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)