Most agent demos stop at "it answers in a notebook". A customer asked me for the other half: how do you host an agent, give it tools, keep it safe, ship changes through a pipeline, and undo a bad release? I built a small reference to answer that on Amazon Bedrock AgentCore, using Strands Agents. This post walks through the design and the problems I hit.
What it does
- A Strands agent served over A2A on AgentCore Runtime
- A separate MCP tool server, also on AgentCore Runtime, called with SigV4
- Amazon Bedrock Guardrails, plus a tool-policy layer and tool-side validation
- ADOT tracing, CloudWatch alarms and a dashboard
- A CodePipeline with ARM64 CodeBuild, an eval gate, a manual approval and a bake step
- Rollback by moving an endpoint back to the previous runtime version
Code: https://github.com/Neloh/agentcore-enterprise-poc
Pinned versions: strands-agents 1.63.0, bedrock-agentcore 1.25.2, mcp 2.3.0, a2a-sdk 0.3.26, aws-opentelemetry-distro 0.22.0.
caller --A2A--> AgentCore Runtime: agent (staging | live) |-- Strands Agent + BedrockModel + Guardrail |-- ToolPolicyHook (allowlist, call budget, refund limit) `-- MCP client --SigV4--> AgentCore Runtime: MCP tools (staging | live) Hosting: two runtimes, two protocols
AgentCore Runtime has a server protocol per runtime, and each has a fixed contract.
| Port | Path | Notes | |
|---|---|---|---|
| HTTP | 8080 | /invocations, /ping | BedrockAgentCoreApp |
| A2A | 9000 | / (JSON-RPC), agent card, /ping | JSON-RPC 2.0 |
| MCP | 8000 | /mcp | stateless streamable HTTP |
The SDK builds the A2A app for you, so the agent is mostly wiring:
from bedrock_agentcore.runtime.a2a import build_a2a_app from strands.multiagent.a2a.executor import StrandsA2AExecutor executor = StrandsA2AExecutor(agent_factory=make_agent_factory(settings, registry)) app = build_a2a_app(executor, AGENT_CARD, runtime_url=settings.public_url, ping_handler=ping) I used agent_factory rather than a single shared Agent, so each A2A context gets its own agent and conversation state.
The MCP server must be stateless:
app = mcp.streamable_http_app(streamable_http_path="/mcp", stateless_http=True, json_response=True) One thing that cost me time: mcp 2.x renamed FastMCP to MCPServer (from mcp.server.mcpserver import MCPServer). Most samples online still use the old import and fail with a ModuleNotFoundError.
Guardrails in three layers
- Bedrock Guardrails on the model: content filters, prompt-attack detection, a denied topic (investment advice), and PII handling.
BedrockModel(model_id=..., guardrail_id=..., guardrail_version=..., guardrail_redact_output=True, guardrail_redact_output_message="Sorry, I can't help with that request.") - Tool policy as a Strands hook.
BeforeToolCallEvent.cancel_toolblocks a call and returns the reason to the model. Mine enforces an allowlist, a per-request call budget, and "refunds above 200 need a human". - Tool-side validation in the MCP server. The tool never trusts the agent.
I tested the deployed guardrail directly with apply-guardrail: a prompt-injection string and an investment question were blocked, a normal order question passed.
For production scale, Gateway plus Policy in AgentCore (Cedar) is the managed way to do layer 2. I called the MCP runtime directly to keep the moving parts visible.
Agent to MCP: SigV4
The MCP runtime uses the default IAM inbound authorizer, so every request has to be signed for the bedrock-agentcore service. Strands accepts an httpx.Auth, so a small signer is enough:
class SigV4HttpxAuth(httpx.Auth): requires_request_body = True def auth_flow(self, request): creds = self._session.get_credentials().get_frozen_credentials() aws = AWSRequest(method=request.method, url=str(request.url), data=request.content, headers={k: v for k, v in request.headers.items() if k.lower() in ("content-type", "accept")}) SigV4Auth(creds, "bedrock-agentcore", self._region).add_auth(aws) request.headers.update(dict(aws.headers.items())) yield request The agent's execution role can only invoke the MCP runtime ARN.
Observability
Both images start with opentelemetry-instrument. Strands emits GenAI spans for the agent loop, model calls and tool calls. Three things matter:
- CloudWatch Transaction Search must be enabled (once per account and Region) or the GenAI Observability dashboard stays empty.
- AgentCore publishes service metrics in
AWS/Bedrock-AgentCore(Invocations,SystemErrors,Latency,Throttles). - For guardrail and policy events I emit EMF lines to stdout, which CloudWatch Logs turns into custom metrics:
{"_aws": {"CloudWatchMetrics": [{"Namespace": "AgentCorePoc", "Dimensions": [["Service", "Tool"]], "Metrics": [{"Name": "ToolCallsDenied", "Unit": "Count"}]}]}, "Service": "poc_agent", "Tool": "calculate_refund", "ToolCallsDenied": 1} The alarm dimensions I used are Resource=<runtime ARN>, Operation=InvokeAgentRuntime and Name=<runtime>::<endpoint>. The monitoring stack deployed, but I did not confirm the alarms receive data. Check them in your own account.
CI/CD
CodePipeline V2 with native ARM64 CodeBuild (AgentCore requires linux/arm64 images):
- Build and test: unit tests, cfn-lint, image build, an offline integration test of the exact images (using a stub model), push to ECR with immutable tags.
- Staging: deploy new runtime versions, point the
stagingendpoints at them, deploy alarms and the dashboard, then run the eval gate against the real model and guardrail. - Approval: manual.
- Production: promote
live, run a smoke eval, bake on a composite alarm, roll back on failure.
The eval gate is a list of golden prompts with expected or forbidden text, covering tool use, policy denial and prompt-attack blocking. It is deterministic and cheap. In my staging run it passed 6/6 on Claude Haiku 4.5 with the guardrail attached. I'd add AgentCore Evaluations for quality scoring as a second step.
The offline stub model deserves a mention. It speaks Strands' streaming event protocol, so the real event loop, hooks, MCP calls and A2A server all run in CI without any model access.
Rollback
Every UpdateAgentRuntime creates an immutable version. Named endpoints point at a version. Production callers use qualifier=live, and never DEFAULT, which always tracks the newest version.
ctl.update_agent_runtime_endpoint(agentRuntimeId=rid, endpointName="live", agentRuntimeVersion=previous) No rebuild, no redeploy. Promotion stores the previous version in SSM, so deploy/agentcore.py rollback --name poc_agent --endpoint live works at any time. The bake step watches a composite alarm (agent 5xx, agent p90 latency, MCP 5xx, model call errors) and rolls both endpoints back if it fires. The MCP side must stay backward compatible, because the previous agent version keeps calling MCP::live.
What is verified and what is not: the promote, rollback and bake logic is covered by unit tests against a fake control plane. I ran the pipeline through Build and Staging in a real account, and the agent and MCP runtimes were serving the staging endpoints. I had not yet approved the production stage when I wrote this, so the live promotion and a live rollback are not demonstrated here.
Gotchas
- ARM64 only. An x86 image fails with
exec format error. - MMDSv2. AgentCore has required
requireMMDSV2: trueon runtimes since 30 June 2026.CreateAgentRuntimedoes not accept it, onlyUpdateAgentRuntime, so my deploy script always calls update after create. -
403 Forbiddenfrompublic.ecr.awsduringdocker build: an expired cached login.docker logout public.ecr.awsfixes it. - A2A is fixed to port 9000. The SDK warns if you bind another port, and invocations fail with a 424.
- CloudFormation rejects YAML anchors unless you run
package. I expanded them inline. - CodeConnections authorizations can be revoked on the GitHub side. The connection goes to
ERROR, and the pipeline fails at Source until you re-authorize it in the browser. Also make sure the GitHub app installation includes the repo, or you get "No Branch [main] found". - Regions. Check the AgentCore Regions page. Not every feature, such as Memory, is in every Region.
Cost and cleanup
Nothing here runs when idle: runtimes scale to zero. Standing costs are ECR storage, CloudWatch alarms and a dashboard, and the artifact bucket. The runtimes are created by the pipeline rather than CloudFormation, so delete them explicitly (delete-agent-runtime) before deleting the stacks.
Takeaways
- Treat the endpoint, not the image, as the release unit. That is what makes rollback instant.
- Layer your guardrails. Content filtering alone does not stop a bad tool call.
- Make the pipeline testable offline, then gate on the real model in staging.
- Turn on Transaction Search first. Without it, you debug blind.
The code is a sample for reference, not an official AWS solution.
