An agent that can reach a real system usually carries that system’s key, and the
key outlives every rotation policy you wrote. This article builds the opposite:
an operations assistant that reaches an order service, a ticketing function and
a CRM through one AgentCore Gateway, proves a caller with a JWT, and holds
nothing but the ARN of a secret. Every number was measured in one account, in
us-east-1.
The use case#
Northwind Gear’s support lead starts the week with one question: which orders slipped, which need a ticket, and who owns the customer. Three systems answer it, and none of them should trust the agent directly. The order service demands a bearer token, the ticketing function writes rows, and the CRM is a small MCP server. The assistant has to orchestrate all three in one turn.
What AgentCore does here#
Three of the nine capabilities carry this use case.
- Gateway: one MCP endpoint in front of the three backends. It turns an OpenAPI descriptor, a Lambda tool schema and a remote MCP server into one tool list, and it does every outbound authentication itself.
- Identity: a Cognito machine client mints the token the gateway demands, and a second OAuth credential is what the gateway presents to the order service.
- Runtime, lightly: hosts the assistant, one microVM per session, which is why the token cache lives for the process lifetime.
The other six stay off. No Memory, no Policy engine, no Evaluations.
Requirements#
- Node.js 20 or newer, the AgentCore CLI 0.31.1, AWS CLI 2.x
- Python 3.10 to 3.14 on the host; the runtime target is
PYTHON_3_13 - An AWS account in
us-east-1with Bedrock model access - Model
deepseek.v3.2, passed to the code throughMODEL_ID - IAM permissions for Cognito, Lambda, DynamoDB, Secrets Manager, IAM roles, and AgentCore resources; the CLI writes the runtime and gateway roles
The code#
The whole use case is this folder:
https://github.com/pinxiong/bedrock-agentcore-in-action/tree/master/usecases/03-gateway-identity
03-gateway-identity/
├── app/aia_uc03/main.py three tools, one JSON-RPC client, one token cache
├── app/aia_uc03/model/ model client and response cleanup
├── infra/orders_api.py order service, refuses any caller without a bearer
├── infra/ticketing.py Lambda behind the ticketing target
├── infra/crm_mcp.py small MCP server over a function URL
├── agentcore/agentcore.json runtime, gateway, credential, three targets
├── agentcore/aws-targets.json account and region
└── agentcore/gateway/ OpenAPI descriptor and Lambda tool schemaFive files matter. main.py is the only one the runtime executes, and it holds
the token cache, the mcp_call helper and the three @tool wrappers.
orders_api.py returns 401 until it sees a bearer it can verify, and echoes
which token it saw, which is how the outbound hop becomes observable.
ticketing.py writes a ticket row and returns its id. crm_mcp.py answers
initialize, tools/list and tools/call over JSON-RPC. The two files under
agentcore/gateway/ are the descriptors the gateway turns into tools.
How it works#
The gateway is a declaration, not code. Its whole behaviour sits in the
agentCoreGateways array of agentcore.json: one protocol, one authorizer,
three targets.
{
"name": "ops-gateway",
"protocolType": "MCP",
"authorizerType": "CUSTOM_JWT",
"authorizerConfiguration": {
"customJwtAuthorizer": {
"discoveryUrl": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_CZAVbCDhv/.well-known/openid-configuration",
"allowedClients": ["66n9f4uo3eqc18jdeujq3tltd2"]
}
},
"targets": [
{ "name": "orders", "targetType": "openApiSchema",
"outboundAuth": { "type": "OAUTH", "credentialName": "cognito-orders-m2m" },
"schemaSource": { "inline": { "path": "agentcore/gateway/orders-openapi.json" } } },
{ "name": "ticketing", "targetType": "lambdaFunctionArn",
"lambdaFunctionArn": { "lambdaArn": "arn:aws:lambda:us-east-1:123456789012:function:aia-uc03-ticketing",
"toolSchemaFile": "agentcore/gateway/create-ticket-tool.json" } },
{ "name": "crm", "targetType": "mcpServer",
"endpoint": "https://pakjn2tjya3tmiocnqpujerppe0pidtw.lambda-url.us-east-1.on.aws/mcp" }
]
}Three shapes, one tool list: an OpenAPI document, a Lambda with a tool schema,
and a remote MCP server become orders___listOrders, ticketing___createTicket
and crm___getCustomer. The triple underscore prefix is the target name the
gateway prepends, so the model sees where a tool lives without being told. Only
the first target carries outboundAuth, because only the order service demands
a credential of its caller.
On the agent side there is no system key at all. The runtime mints the gateway’s token from a Cognito machine client, and caches it for the life of the process:
def bearer() -> str:
creds = client_credentials()
basic = base64.b64encode(
f"{creds['client_id']}:{creds['client_secret']}".encode()
).decode()
body = f"grant_type=client_credentials&scope={SCOPE}".encode()
req = urllib.request.Request(
TOKEN_ENDPOINT,
data=body,
headers={
"content-type": "application/x-www-form-urlencoded",
"authorization": f"Basic {basic}",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=15) as r:
payload = json.load(r)
_cache["token"] = payload["access_token"]
_cache["expires"] = time.time() + float(payload.get("expires_in", 3600))
return payload["access_token"]The secret behind client_credentials() never reaches the source. The runtime
reads it from Secrets Manager through CLIENT_SECRET_ARN, an environment
variable in the contract:
def client_credentials() -> dict:
client = boto3.client("secretsmanager", region_name=REGION)
raw = client.get_secret_value(SecretId=SECRET_ARN)["SecretString"]
return json.loads(raw)One IAM detail is easy to miss. The runtime role that the CLI creates knows
nothing about Secrets Manager, so get_secret_value fails on the first invoke,
and the model reports the access denied as a vague tool error. The fix is one
inline policy, attached once with aws iam put-role-policy against the runtime
role and scoped to the single secret:
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:aia-uc03-gateway-client-*" }
]
}Least privilege survives here too: the role can read one secret, not the vault.
Deploy it#
The three backends come up first, because the gateway needs their addresses. Then the declarations, in this order:
agentcore add gateway --name ops-gateway --description "Northwind operations gateway" \
--protocol-type MCP --authorizer-type CUSTOM_JWT \
--discovery-url "$DISCOVERY" --allowed-clients 66n9f4uo3eqc18jdeujq3tltd2
agentcore add credential --name cognito-orders-m2m --type oauth \
--discovery-url "$DISCOVERY" --client-id "$CID" --client-secret "$CSECRET" \
--scopes "https://aia-uc03/ops/orders.read"
agentcore add gateway-target --name orders --gateway ops-gateway \
--type open-api-schema --schema agentcore/gateway/orders-openapi.json \
--outbound-auth oauth --credential-name cognito-orders-m2m
agentcore add gateway-target --name ticketing --gateway ops-gateway \
--type lambda-function-arn \
--lambda-arn arn:aws:lambda:us-east-1:123456789012:function:aia-uc03-ticketing \
--tool-schema-file agentcore/gateway/create-ticket-tool.json
agentcore add gateway-target --name crm --gateway ops-gateway \
--type mcp-server \
--endpoint https://pakjn2tjya3tmiocnqpujerppe0pidtw.lambda-url.us-east-1.on.aws/mcp
Five confirmations, no AWS resource touched yet: the CLI is editing
agentcore.json. Three Lambda functions, two DynamoDB tables and one user pool
are the whole lab, and every resource carries the tags
Project=agentcore-in-action, UseCase=03, AutoDelete=true. The credential
command also prints the warning every reader should see once, about secrets on
the command line.
One command then deploys the runtime, the gateway and both credentials:
agentcore deploy -y --target default
The first deploy took 3 minutes 4 seconds. Changing one environment variable afterwards, because the gateway URL is only knowable once the gateway exists, took 1 minute 32 seconds on the second deploy. The CLI reuses the CDK project, so the second run is a stack update, not a rebuild.
What it produced#
The shape of the finished lab, three backends and one gateway in front of them:

A call without a token is rejected before any routing happens:

Four tool names come back, one per target plus the built-in search tool, and the
prefixes map one to one onto the three target types. Calling the order tool
through the gateway returns three delayed orders, and the payload ends with
"auth": "bearer" plus the first characters of the token the gateway minted.
The order service saw a credential that exists nowhere in the repository:

One invoke then runs the whole workflow. The assistant lists the delayed orders,
opens a ticket for the only one above 100 units, reads the CRM record and quotes
li.wei as the account manager to escalate to:

The independence check closes the loop. One unfiltered tag query lists every
resource in the account, and every row says 03:

Troubleshooting#
- A Lambda function URL with
AuthType NONEneeds two permissions, not one. With onlylambda:InvokeFunctionUrlevery request returns 403 before reaching the handler. Addlambda:InvokeFunctionas a second statement. - DynamoDB hands back
Decimal, andjson.dumpsrefuses it, so the function returned 502 on exactly the requests that read the table. Convert before serialising. - The gateway prefixes every tool with its target name. The first agent build
called
listOrdersand failed; the real name isorders___listOrders. - Cognito returns
invalid_grantforclient_credentialsuntil the app client hasAllowedOAuthFlowsUserPoolClientset to true.
Takeaways#
- The credential boundary is the gateway, not the agent. Agent source that contains a system key cannot be rotated, audited or shared.
- Three target shapes, one tool list. OpenAPI, Lambda and MCP look identical to the model, so the backend choice stays a backend decision.
- The order service echoing which token it saw is the cheapest possible proof that outbound authentication works. Build the echo into your first backend.
- Environment variables are part of the contract, and a value the deploy itself produces needs a second deploy. Budget for it.
agentcore remove all -yemptiesagentcore.jsonandaws-targets.json; restore both from git before deploying again.