This website uses cookies

Read our Privacy policy and Terms of use for more information.

What we are building

By the end of this you will have a running AgentCore Gateway: a managed endpoint that speaks the Model Context Protocol (MCP), sits in front of an AWS Lambda function, and presents that function's two operations as callable tools to any MCP-aware agent. Then we add a second target backed by a REST API described with an OpenAPI spec, so the same gateway serves tools from two completely different backends through one URL.

The problem this solves is the one nobody warns you about until you hit it. The moment you have more than a couple of agents and more than a handful of tools, you are wiring every agent to every tool by hand. AWS calls this the M by N integration problem, and it is exactly as miserable as it sounds: each new tool means touching every agent, each new agent means re-plumbing every tool, and you are hand-rolling MCP servers, auth, and retries for all of it. Gateway collapses that into M agents pointing at one gateway, and N tools registered behind it as targets.

The non-obvious design choice here is that you write zero MCP server code. You write a normal Lambda function and a JSON schema describing its tools. Gateway does the protocol translation, the auth, the tool discovery, and the hosting. What you ship at the end is an agent that asks "check the status of order 1019" and gets a real answer back from your Lambda, with the entire MCP handshake handled by AWS.

Prerequisites

You need an AWS account with credentials configured locally (run aws sts get-caller-identity to confirm), and IAM permissions to create roles, Lambda functions, and AgentCore resources. If your account is locked down, the actions to ask for are bedrock-agentcore:*, lambda:*, iam:CreateRole, and iam:PassRole, scoped to a sandbox account if you have one.

You need Node.js 18 or newer (the AgentCore CLI is a Node package) and Python 3.10 or newer (the agent script). You need model access to Anthropic's Claude Sonnet 3.7 in the Bedrock console under Model access, in the same region you deploy to. Pick us-east-1 or us-west-2 to avoid region-availability surprises.

You should be comfortable reading Python and a JSON schema, and you should have a terminal where you can run npm install -g. No prior MCP knowledge is required. If you followed episode 7 (Strands) and episode 8 (AgentCore Runtime), you already have the mental model and most of the tooling. If you skipped them, you lose nothing here. This tutorial is self-contained.

Setup

Install the AgentCore CLI globally. This is the @aws/agentcore package, which is now the recommended path for new projects (it replaces the older bedrock-agentcore starter toolkit for greenfield work):

npm install -g @aws/agentcore
agentcore --version

Scaffold a project with a default Strands agent and gateway support:

agentcore create --name MyGatewayAgent --defaults
cd MyGatewayAgent

The --defaults flag gives you a Python Strands agent wired to Bedrock. Drop it and the CLI runs an interactive wizard instead, where you can pick a different framework or model provider.

Smoke test before writing any real code. Confirm the CLI sees your AWS identity and that the project scaffolded:

aws sts get-caller-identity --query Account --output text
ls -la   # you should see agentcore.yaml and an agent file

If get-caller-identity returns your account number and the project files exist, you are ready. If the AWS call fails, fix your credentials before going further. Everything downstream assumes they work.

Step 1: Write the Lambda that backs your tools

Gateway needs something to call when a tool fires. That something is a Lambda function. Create order_tools.py with a handler that serves two operations, an order lookup and a refund:

import json
ORDERS = {
    "1019": {"status": "shipped", "total": 89.90, "carrier": "UPS"},
    "1042": {"status": "processing", "total": 240.00, "carrier": None},
}
def lambda_handler(event, context):
    # Tools arrive as "TargetName___tool_name". Strip the prefix.
    delimiter = "___"
    raw = context.client_context.custom["bedrockAgentCoreToolName"]
    tool = raw[raw.index(delimiter) + len(delimiter):]
    if tool == "get_order_status":
        order = ORDERS.get(event["orderId"])
        if not order:
            return {"error": f"order {event['orderId']} not found"}
        return order
    if tool == "create_refund":
        return {"refundId": "rf_" + event["orderId"], "status": "issued",
                "amount": event["amount"]}
    return {"error": f"unknown tool {tool}"}

The detail that trips everyone up is the tool name. Gateway makes every tool visible to the agent as ${target_name}___${tool_name}, with three underscores as the separator. Inside the Lambda you read the original name from context.client_context.custom["bedrockAgentCoreToolName"] and strip the prefix yourself. Gateway does not do that for you. If you skip the strip, your if tool == "get_order_status" check never matches and every call falls through to the error branch.

The event is just a flat map of the tool's input properties: if the schema declares orderId, then event["orderId"] holds the value the model passed. There is no nested envelope to unwrap. Return any JSON-serializable dict and Gateway hands it back to the agent as the tool result.

Deploy this as a standard Lambda. You can zip and upload with aws lambda create-function, or use the console. Note the function ARN when it is created; you will register it with the gateway next.

Step 2: Describe the tools with a JSON schema

Gateway cannot guess what your Lambda does. You tell it with a tool schema: a JSON array where each entry has a name, a description, and an input schema. The description is not decoration. It is the text the model reads to decide whether and how to call the tool, so write it like you are briefing a junior engineer. Create tools.json:

[
  {
    "name": "get_order_status",
    "description": "Look up the current status, total, and carrier for an order by its numeric order ID.",
    "inputSchema": {
      "type": "object",
      "properties": {
        "orderId": { "type": "string", "description": "the order ID, e.g. 1019" }
      },
      "required": ["orderId"]
    }
  },
  {
    "name": "create_refund",
    "description": "Issue a refund for an order. Use only after confirming the order exists.",
    "inputSchema": {
      "type": "object",
      "properties": {
        "orderId": { "type": "string", "description": "the order ID to refund" },
        "amount": { "type": "number", "description": "refund amount in USD" }
      },
      "required": ["orderId", "amount"]
    }
  }
]

Each inputSchema is a standard object schema: type must be object, properties maps argument names to their own schema definitions (string, number, integer, boolean, array, or nested object), and required lists the mandatory ones. There is an optional outputSchema with the same shape if you want to constrain the return, but it is not required and I am leaving it off here.

The two tool names in this file must exactly match the names your Lambda checks for in Step 1. The names in tools.json are what becomes the suffix after the ___ separator. Mismatch them and the agent will happily call a tool your Lambda has never heard of.

Step 3: Create the gateway and attach the Lambda target

Now wire it together. Add a gateway to the project, then register the Lambda as a target. For a first build, use NONE for inbound auth so you are not fighting OAuth tokens while you are still proving the plumbing works:

# A managed MCP server, no inbound auth yet
agentcore add gateway --name OrdersGateway \
  --authorizer-type NONE \
  --runtimes MyGatewayAgent
# Register the Lambda and its tool schema as a target
agentcore add gateway-target --name OrderOps \
  --type lambda-function-arn \
  --lambda-arn <YOUR_LAMBDA_ARN> \
  --tool-schema-file tools.json \
  --gateway OrdersGateway

A gateway is the MCP endpoint. A target is one backend behind it. The target type lambda-function-arn tells Gateway to invoke your function over the AWS API, and because it is a Lambda, outbound auth is always IAM: Gateway assumes a role you control and calls the function with scoped permissions. There are no API keys to manage for Lambda targets. That is the OpenAPI target's problem, which we get to in Step 5.

The target name matters more than it looks. OrderOps is the prefix half of every tool name, so the agent will see OrderOps___get_order_status and OrderOps___create_refund. Keep it short and meaningful. It shows up in logs, traces, and the model's tool list.

Two flags worth knowing. --authorizer-type NONE is fine for a sandbox but you would never ship it: production gateways use CUSTOM_JWT with a discovery URL from Cognito, Okta, or Auth0, so only authorized callers reach your tools. And semantic search is on by default. Gateway provisions a built-in tool named x_amz_bedrock_agentcore_search that lets the agent find the right tool by natural-language query instead of loading all of them, which is how you avoid tool overload once a gateway holds dozens of tools. Pass --no-semantic-search to turn it off.

Step 4: Deploy and point a Strands agent at it

Deploy the stack. The CLI synthesizes a CDK stack and provisions the gateway, target, IAM roles, and agent:

agentcore deploy

This takes two to three minutes. When it finishes, read back the gateway URL:

agentcore status

You get a URL shaped like https://<gateway-id>.gateway.bedrock-agentcore.<region>.amazonaws.com/mcp. That is a real MCP server. Now connect an agent to it. Install the client libraries and create run_agent.py:

from strands import Agent
from strands.models import BedrockModel
from strands.tools.mcp.mcp_client import MCPClient
from mcp.client.streamable_http import streamablehttp_client
GATEWAY_URL = "<YOUR_GATEWAY_URL>"  # from `agentcore status`
MODEL_ID = "anthropic.claude-3-7-sonnet-20250219-v1:0"
mcp_client = MCPClient(lambda: streamablehttp_client(GATEWAY_URL))
with mcp_client:
    tools = mcp_client.list_tools_sync()
    print("Tools:", [t.tool_name for t in tools])
    agent = Agent(model=BedrockModel(model_id=MODEL_ID, streaming=True),
                  tools=tools)
    print(agent("Check the status of order 1019 and show the tool's exact response."))
pip install strands-agents mcp
python run_agent.py

The whole MCP integration is those four lines around MCPClient. Gateway speaks streamable HTTP, so streamablehttp_client(GATEWAY_URL) is the transport, list_tools_sync() runs the MCP tools/list call and returns the tools as native Strands tools, and Agent(tools=tools) hands them to Claude. Because the gateway uses NONE inbound auth, there is no Authorization header here. Switch to JWT and you would add headers={"Authorization": f"Bearer {token}"} to the transport. Nothing else in the agent changes.

Step 5: Add a REST API as a second target

The point of a gateway is composition: many backends, one MCP endpoint. Your Lambda was one target. Now add a REST API as a second, described by an OpenAPI spec, so the same gateway serves tools from both. OpenAPI targets carry their own outbound auth, usually an API key. Register one with boto3, since the API path gives you the clearest view of what the CLI does under the hood:

import boto3
acp = boto3.client("bedrock-agentcore-control")
# 1. Store the downstream API's key in AgentCore Identity
provider = acp.create_api_key_credential_provider(
    name="WeatherApiKey", apiKey="<YOUR_DOWNSTREAM_API_KEY>")
# 2. Point the target at an OpenAPI spec in S3
target_config = {"mcp": {"openApiSchema": {"s3": {"uri": "s3://my-specs/weather.json"}}}}
# 3. Tell Gateway how to send the key on outbound calls
cred_config = [{
  "credentialProviderType": "API_KEY",
  "credentialProvider": {"apiKeyCredentialProvider": {
    "credentialParameterName": "x-api-key",
    "providerArn": provider["credentialProviderArn"],
    "credentialLocation": "HEADER"}}}]
acp.create_gateway_target(
    gatewayIdentifier="<YOUR_GATEWAY_ID>",
    name="Weather",
    targetConfiguration=target_config,
    credentialProviderConfigurations=cred_config)

Gateway reads the OpenAPI spec and turns each operation into a tool automatically, no schema file and no Lambda. The credential provider stores the downstream key in AgentCore Identity, and credentialLocation plus credentialParameterName tell Gateway whether to inject it as a header or a query parameter and under what name. After this runs, list_tools_sync() in your agent returns the Lambda tools and the weather API tools together. One gateway, two backends, one tool list. That is the composition payoff.

Verify it works

Three checks confirm the build. First, hit the gateway directly with the MCP tools/list call and confirm your tools come back:

curl -X POST <YOUR_GATEWAY_URL> \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

You should see OrderOps___get_order_status and OrderOps___create_refund in the response, plus x_amz_bedrock_agentcore_search if semantic search stayed on.

Second, run the agent. The expected output is a printed tool list followed by Claude calling get_order_status and returning the real Lambda payload:

Tools: ['OrderOps___get_order_status', 'OrderOps___create_refund', 'x_amz_bedrock_agentcore_search']
Agent: Order 1019 is shipped, total $89.90, shipped via UPS.

Third, watch the logs while the agent runs to see the invocation land:

aws logs tail /aws/bedrock-agentcore/gateways/<YOUR_GATEWAY_ID> --follow

If all three line up, the agent talked to your Lambda through a managed MCP server you did not have to build.

When it breaks

If the agent calls a tool and every call returns unknown tool, you skipped the prefix strip in the Lambda. The incoming name is OrderOps___get_order_status, not get_order_status. Read it from context.client_context.custom["bedrockAgentCoreToolName"] and split on ___.

If list_tools_sync() returns an empty list or the agent says it has no tools, the deploy did not finish or DNS has not propagated. Gateway creation needs 30 to 60 seconds after agentcore deploy reports success before the endpoint answers. Wait, then retry the curl check.

If you see AccessDeniedException, the gateway's IAM role cannot invoke your Lambda, or your own credentials lack bedrock-agentcore:*. Check both the gateway execution role's permission to call the function and your local identity's permissions.

If the model errors with "model not enabled", you did not enable Claude Sonnet 3.7 in the Bedrock console for this region, or you deployed to a region where it is not available. Enable it under Model access, or switch MODEL_ID to a model you do have.

If a tool fires but the model never picks the right one, your tool descriptions are too vague. The model routes on the description field. Rewrite "gets order data" as "look up the current status, total, and carrier for an order by its numeric order ID" and watch the routing improve.

What this costs

Gateway pricing is consumption-based and cheap at tutorial scale. You pay per MCP operation: tool invocations run $5 per million calls, semantic search queries run $25 per million, and indexing tools for search costs $0.02 per 100 tools. Running this tutorial is a few dozen calls, which rounds to nothing. The Lambda invocations and Cognito sit inside the free tier for a build this size. The realistic cost of following along is a fraction of a cent, plus whatever the underlying Claude model tokens cost on Bedrock.

Cleanup

Tear it down so nothing lingers:

agentcore remove gateway --name OrdersGateway
# or remove everything the project created
agentcore remove all

Then delete the Lambda function and the API-key credential provider if you created the OpenAPI target, and remove the OpenAPI spec from S3. Gateway itself has no idle charge, but leaving the Lambda and roles around is untidy.

Where to take it next

First, swap NONE for real auth. Recreate the gateway with --authorizer-type CUSTOM_JWT and a Cognito discovery URL, then add headers={"Authorization": f"Bearer {token}"} to the transport in run_agent.py. That is the difference between a demo and something you would put in front of a real backend.

Second, lean on semantic search. Register ten or twenty tools across several targets, then have the agent call x_amz_bedrock_agentcore_search with a natural-language query instead of loading the full list. This is the real answer to tool overload, and it is the reason Gateway indexes tool metadata in the first place.

Third, turn a real internal REST API into tools. Take an OpenAPI spec you already own, trim it to the handful of operations an agent actually needs, and register it as an OpenAPI target. The interesting work is not the wiring, which you have now done. It is deciding which of your existing APIs an agent should be allowed to touch, and writing tool descriptions good enough that the model uses them correctly. That judgment is the part Gateway cannot do for you.

Sources

Keep Reading