Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/examples/a2a_protocol_examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Starts a Bedrock-compatible A2A server with `uvicorn`.
|-----------|------|---------|-------------|
| `executor` | `AgentExecutor` | required | An a2a-sdk `AgentExecutor` that implements the agent logic |
| `agent_card` | `AgentCard` | `None` | Agent metadata. Auto-built from executor if omitted (works best with Strands) |
| `port` | `int \| None` | `None` | Port to serve on. Uses `PORT`, then `9000`, when omitted |
| `port` | `int \| None` | `None` | Port to serve on. Uses `A2A_PORT`, then `9000`, when omitted. The generic `PORT` is ignored — see [Ports](#ports) |
| `host` | `str` | `None` | Host to bind to. Auto-detected: `0.0.0.0` in Docker, `127.0.0.1` otherwise |
| `task_store` | `TaskStore` | `None` | Custom task store; defaults to `InMemoryTaskStore` |
| `context_builder` | `CallContextBuilder` | `None` | Custom context builder; defaults to `BedrockCallContextBuilder` |
Expand Down Expand Up @@ -178,6 +178,29 @@ Headers extracted:

## Behavior Details

### Ports

The AgentCore Runtime service contract fixes the A2A container port at **9000** (HTTP uses 8080, MCP uses 8000). The
runtime proxies invocations to 9000 only, so an A2A server bound elsewhere is unreachable and every invocation fails
with **HTTP 424** (`RuntimeClientError`).

`serve_a2a` therefore defaults to 9000 and **ignores the generic `PORT` environment variable**. `PORT` is a widespread
convention (Heroku, Cloud Run, App Runner) and is commonly already set to another protocol's port — notably in images
shared across an HTTP and an A2A runtime, where `PORT=8080` is correct for HTTP and fatal for A2A.

To override the port for local development, use the protocol-scoped `A2A_PORT`, or pass `port=` explicitly:

```bash
A2A_PORT=9001 python main.py
```

```python
serve_a2a(executor, port=9001)
```

Precedence is `port=` argument, then `A2A_PORT`, then 9000. When the resolved port is not 9000, `serve_a2a` logs a
warning, since that configuration cannot work in a deployed runtime.

### Agent Card Auto-Population

When deployed on Bedrock AgentCore, the `AGENTCORE_RUNTIME_URL` environment variable is set automatically. The agent card's `url` field is updated to match, so you don't need to hardcode the deployed URL.
Expand Down
27 changes: 23 additions & 4 deletions src/bedrock_agentcore/runtime/a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@

logger = logging.getLogger(__name__)

# The AgentCore Runtime A2A service contract fixes the container port at 9000
# (HTTP is 8080, MCP is 8000). It is not negotiable, so it is only overridable
# via an explicit ``port=`` argument or the protocol-scoped env var below.
A2A_CONTRACT_PORT = 9000
A2A_PORT_ENV = "A2A_PORT"


def _check_a2a_sdk() -> None:
"""Raise ImportError with install instructions if a2a-sdk is missing."""
Expand Down Expand Up @@ -275,7 +281,7 @@ def build_a2a_app(
from starlette.routing import Route

runtime_url_override = os.environ.get(AGENTCORE_RUNTIME_URL_ENV) or runtime_url
advertised_url = runtime_url_override or "http://localhost:9000/"
advertised_url = runtime_url_override or f"http://localhost:{A2A_CONTRACT_PORT}/"
is_a2a_v1 = _is_a2a_v1()

if agent_card is None:
Expand Down Expand Up @@ -354,8 +360,11 @@ def serve_a2a(
executor: An ``AgentExecutor`` that implements the agent logic.
agent_card: Optional ``a2a.types.AgentCard`` describing the agent.
If ``None``, one is built automatically by introspecting the executor.
port: Port to serve on. Defaults to the ``PORT`` environment variable,
or 9000 when it is unset.
port: Port to serve on. Defaults to the ``A2A_PORT`` environment
variable, or 9000 when it is unset. ``PORT`` is deliberately not
consulted: the AgentCore A2A contract fixes the port at 9000, and
``PORT`` is widely set to another protocol's port (8080 for HTTP,
8000 for MCP) in images shared across runtimes.
host: Host to bind to; auto-detected if ``None``.
task_store: Optional ``TaskStore``; defaults to ``InMemoryTaskStore``.
context_builder: Optional ``ServerCallContextBuilder``; defaults to
Expand All @@ -367,7 +376,17 @@ def serve_a2a(

import uvicorn

resolved_port = port if port is not None else int(os.environ.get("PORT", "9000"))
resolved_port = port if port is not None else int(os.environ.get(A2A_PORT_ENV, A2A_CONTRACT_PORT))

if resolved_port != A2A_CONTRACT_PORT:
logger.warning(
"A2A server binding port %d, but the AgentCore Runtime A2A service contract "
"requires %d. Deployed invocations will fail with HTTP 424 (RuntimeClientError) "
"because the runtime proxies to %d only.",
resolved_port,
A2A_CONTRACT_PORT,
A2A_CONTRACT_PORT,
)

app = build_a2a_app(
executor,
Expand Down
46 changes: 41 additions & 5 deletions tests/bedrock_agentcore/runtime/test_a2a.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import contextvars
import logging
import uuid
from unittest.mock import patch

Expand Down Expand Up @@ -456,7 +457,7 @@ def test_default_localhost(self, mock_uvicorn_run):
with patch.dict("os.environ", {}, clear=False):
import os

os.environ.pop("PORT", None)
os.environ.pop("A2A_PORT", None)
with patch("os.path.exists", return_value=False):
serve_a2a(_EchoExecutor(), _make_agent_card())
kw = mock_uvicorn_run.call_args[1]
Expand All @@ -465,7 +466,7 @@ def test_default_localhost(self, mock_uvicorn_run):

@patch("uvicorn.run")
def test_port_from_environment(self, mock_uvicorn_run):
with patch.dict("os.environ", {"PORT": "9001"}, clear=True):
with patch.dict("os.environ", {"A2A_PORT": "9001"}, clear=True):
serve_a2a(_EchoExecutor())

app = mock_uvicorn_run.call_args.args[0]
Expand All @@ -475,7 +476,7 @@ def test_port_from_environment(self, mock_uvicorn_run):

@patch("uvicorn.run")
def test_port_from_environment_updates_explicit_card(self, mock_uvicorn_run):
with patch.dict("os.environ", {"PORT": "9002"}, clear=True):
with patch.dict("os.environ", {"A2A_PORT": "9002"}, clear=True):
serve_a2a(_EchoExecutor(), _make_agent_card())

app = mock_uvicorn_run.call_args.args[0]
Expand All @@ -485,7 +486,7 @@ def test_port_from_environment_updates_explicit_card(self, mock_uvicorn_run):

@patch("uvicorn.run")
def test_explicit_port_overrides_environment(self, mock_uvicorn_run):
with patch.dict("os.environ", {"PORT": "9001"}):
with patch.dict("os.environ", {"A2A_PORT": "9001"}):
serve_a2a(_EchoExecutor(), _make_agent_card(), port=8888)
app = mock_uvicorn_run.call_args.args[0]
response = TestClient(app).get("/.well-known/agent-card.json")
Expand All @@ -497,7 +498,7 @@ def test_runtime_url_environment_overrides_port_for_card(self, mock_uvicorn_run)
with patch.dict(
"os.environ",
{
"PORT": "9002",
"A2A_PORT": "9002",
"AGENTCORE_RUNTIME_URL": "https://deployed.example.com/",
},
clear=True,
Expand All @@ -509,6 +510,41 @@ def test_runtime_url_environment_overrides_port_for_card(self, mock_uvicorn_run)
assert mock_uvicorn_run.call_args.kwargs["port"] == 9002
assert _card_response_url(response.json()) == "https://deployed.example.com/"

@patch("uvicorn.run")
def test_generic_port_env_var_is_ignored(self, mock_uvicorn_run):
"""PORT must not affect the A2A port: shared images set it to 8080 for HTTP."""
with patch.dict("os.environ", {"PORT": "8080"}, clear=True):
serve_a2a(_EchoExecutor(), _make_agent_card())

app = mock_uvicorn_run.call_args.args[0]
response = TestClient(app).get("/.well-known/agent-card.json")
assert mock_uvicorn_run.call_args.kwargs["port"] == 9000
assert _card_response_url(response.json()) == "http://localhost:9000/"

@patch("uvicorn.run")
def test_a2a_port_takes_precedence_over_generic_port(self, mock_uvicorn_run):
with patch.dict("os.environ", {"PORT": "8080", "A2A_PORT": "9003"}, clear=True):
serve_a2a(_EchoExecutor(), _make_agent_card())

assert mock_uvicorn_run.call_args.kwargs["port"] == 9003

@patch("uvicorn.run")
def test_warns_when_binding_non_contract_port(self, mock_uvicorn_run, caplog):
with patch.dict("os.environ", {}, clear=True):
with caplog.at_level(logging.WARNING, logger="bedrock_agentcore.runtime.a2a"):
serve_a2a(_EchoExecutor(), _make_agent_card(), port=8888)

assert "requires 9000" in caplog.text
assert "424" in caplog.text

@patch("uvicorn.run")
def test_no_warning_on_contract_port(self, mock_uvicorn_run, caplog):
with patch.dict("os.environ", {}, clear=True):
with caplog.at_level(logging.WARNING, logger="bedrock_agentcore.runtime.a2a"):
serve_a2a(_EchoExecutor(), _make_agent_card())

assert caplog.text == ""

@patch("uvicorn.run")
def test_docker_detection_dockerenv(self, mock_uvicorn_run):
with patch("os.path.exists", return_value=True):
Expand Down
Loading