Skip to content
Open
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
95 changes: 95 additions & 0 deletions src/bedrock_agentcore/gateway/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,101 @@ def create_agentic_retrieve_target(
**target_kwargs,
)

# Web Search target helpers
# -------------------------------------------------------------------------
def create_web_search_target(
self,
gateway_identifier: str,
name: Optional[str] = None,
description: Optional[str] = None,
exclude_domains: Optional[List[str]] = None,
include_domains: Optional[List[str]] = None,
connector_version: Optional[str] = None,
parameter_overrides: Optional[List[Dict[str, Any]]] = None,
wait_config: Optional[WaitConfig] = None,
**kwargs,
) -> Dict[str, Any]:
"""Create a gateway target that exposes Amazon Web Search as an MCP WebSearch tool.

The tool the agent discovers is named "<target name>___WebSearch", because Gateway
prefixes every tool with its target name. The default target name is therefore chosen
so the agent-facing tool reads as "amazon-web-search___WebSearch".

The gateway's service role needs bedrock-agentcore:InvokeWebSearch on the connector,
and whoever calls the resulting tool needs bedrock-agentcore:InvokeGateway on the
gateway ARN. Web search takes no API key of its own.

Args:
gateway_identifier: Gateway ID or ARN.
name: Target name, and the prefix of the agent-facing tool name.
Defaults to "amazon-web-search".
description: Agent-facing description of the WebSearch tool.
exclude_domains: Optional list of domains to drop from results, up to 100.
Enforced server-side and hidden from the calling agent. A result is
dropped if its domain is on this list or on the caller's own exclude
list, so the agent can narrow this but never relax it.
include_domains: Optional list of domains to restrict results to, up to 100.
Requires connector version 1.2.0 or later. A result is returned only if
its domain appears on every include list that is set, so a caller
passing its own include list narrows to the intersection with this one,
and disjoint lists return no results at all. A root domain matches its
subdomains.
connector_version: Optional connector version to pin, e.g. "1.2.0". Defaults
to the connector's current default version.
parameter_overrides: Optional per-parameter visibility/description overrides,
keyed by JSONPath, e.g. {"path": "$.maxResults", "visible": True}.
wait_config: Optional WaitConfig for polling behavior.
**kwargs: Additional arguments forwarded to create_gateway_target
(e.g., credentialProviderConfigurations, roleArn). Overrides built values on conflict.

Returns:
Gateway target details when READY.
"""
# parameterValues is always sent, even when empty. The service drops every
# configuration whose parameterValues is absent before it validates them, so a
# configuration carrying nothing but a name leaves nothing to validate and the
# request is rejected with "Connector configurations must not be empty".
# An empty object is accepted.
tool_config: Dict[str, Any] = {"name": "WebSearch", "parameterValues": {}}
domain_filter: Dict[str, List[str]] = {}
if include_domains:
domain_filter["include"] = include_domains
if exclude_domains:
domain_filter["exclude"] = exclude_domains
if domain_filter:
tool_config["parameterValues"]["domainFilter"] = domain_filter
if description:
tool_config["description"] = description
if parameter_overrides:
tool_config["parameterOverrides"] = parameter_overrides

source: Dict[str, Any] = {"connectorId": "web-search"}
if connector_version:
source["version"] = connector_version

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If include_domains is set and connector_version is not, this sends domainFilter.include against the connector default (1.1.0), which only 1.2.0+ accepts. The service rejects it with a ValidationException. Since the point of the helper is to hide connector details, either default source["version"] to "1.2.0" when include_domains is set, or raise a ValueError here so the caller gets a clear message instead of a server-side error.


target_kwargs = {
"gatewayIdentifier": gateway_identifier,
"name": name or "amazon-web-search",
"targetConfiguration": {
"mcp": {
"connector": {
"source": source,
"enabled": ["WebSearch"],
"configurations": [tool_config],
},
},
},
"credentialProviderConfigurations": [
{"credentialProviderType": "GATEWAY_IAM_ROLE"},
],
}
target_kwargs.update(kwargs)

return self.create_gateway_target_and_wait(
wait_config=wait_config,
**target_kwargs,
)

# Name-based lookup
# -------------------------------------------------------------------------
def get_gateway_by_name(self, name: str, **kwargs) -> Optional[Dict[str, Any]]:
Expand Down
164 changes: 164 additions & 0 deletions tests/unit/gateway/test_gateway_web_search_targets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Tests for GatewayClient Web Search target helper methods."""

from unittest.mock import MagicMock, Mock

from bedrock_agentcore.gateway.client import GatewayClient


class TestCreateWebSearchTarget:
"""Tests for create_web_search_target."""

def _make_client(self):
mock_session = MagicMock()
mock_session.region_name = "us-west-2"
client = GatewayClient(boto3_session=mock_session)
client.create_gateway_target_and_wait = Mock(return_value={"status": "READY", "targetId": "t-789"})
return client

def test_minimal(self):
client = self._make_client()

result = client.create_web_search_target(gateway_identifier="gw-123")

assert result["status"] == "READY"
client.create_gateway_target_and_wait.assert_called_once_with(
wait_config=None,
gatewayIdentifier="gw-123",
name="amazon-web-search",
targetConfiguration={
"mcp": {
"connector": {
"source": {"connectorId": "web-search"},
"enabled": ["WebSearch"],
"configurations": [{"name": "WebSearch", "parameterValues": {}}],
},
},
},
credentialProviderConfigurations=[
{"credentialProviderType": "GATEWAY_IAM_ROLE"},
],
)

def test_with_all_options(self):
client = self._make_client()

result = client.create_web_search_target(
gateway_identifier="gw-123",
name="custom-search",
description="Search the public web",
exclude_domains=["example.com", "spam.example"],
include_domains=["allowed.example"],
connector_version="1.2.0",
parameter_overrides=[{"path": "$.maxResults", "visible": True}],
)

assert result["status"] == "READY"
call_kwargs = client.create_gateway_target_and_wait.call_args[1]
assert call_kwargs["name"] == "custom-search"
connector = call_kwargs["targetConfiguration"]["mcp"]["connector"]
assert connector["source"] == {"connectorId": "web-search", "version": "1.2.0"}
assert connector["enabled"] == ["WebSearch"]
config = connector["configurations"][0]
assert config["name"] == "WebSearch"
assert config["description"] == "Search the public web"
assert config["parameterValues"] == {
"domainFilter": {
"include": ["allowed.example"],
"exclude": ["example.com", "spam.example"],
}
}
assert config["parameterOverrides"] == [{"path": "$.maxResults", "visible": True}]

def test_include_domains_only(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This asserts a request the service will reject (see comment on client.py). Once the version default or validation is added, this test needs to change with it.

client = self._make_client()

client.create_web_search_target(
gateway_identifier="gw-123",
include_domains=["docs.aws.amazon.com"],
)

call_kwargs = client.create_gateway_target_and_wait.call_args[1]
config = call_kwargs["targetConfiguration"]["mcp"]["connector"]["configurations"][0]
assert config["parameterValues"] == {"domainFilter": {"include": ["docs.aws.amazon.com"]}}

def test_connector_version_omitted_by_default(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_minimal already asserts the full request with exact equality, so this test, test_no_domain_filter_when_no_exclude_domains, test_parameter_values_always_present, and test_default_credential_provider add no new coverage. Suggest dropping them. test_empty_exclude_domains_is_omitted is the only one in this group with a distinct input and should stay.

client = self._make_client()

client.create_web_search_target(gateway_identifier="gw-123")

call_kwargs = client.create_gateway_target_and_wait.call_args[1]
source = call_kwargs["targetConfiguration"]["mcp"]["connector"]["source"]
assert source == {"connectorId": "web-search"}

def test_no_domain_filter_when_no_exclude_domains(self):
client = self._make_client()

client.create_web_search_target(gateway_identifier="gw-123")

call_kwargs = client.create_gateway_target_and_wait.call_args[1]
config = call_kwargs["targetConfiguration"]["mcp"]["connector"]["configurations"][0]
assert config["parameterValues"] == {}

def test_empty_exclude_domains_is_omitted(self):
client = self._make_client()

client.create_web_search_target(gateway_identifier="gw-123", exclude_domains=[])

call_kwargs = client.create_gateway_target_and_wait.call_args[1]
config = call_kwargs["targetConfiguration"]["mcp"]["connector"]["configurations"][0]
assert config["parameterValues"] == {}

def test_parameter_values_always_present(self):
"""The service drops configurations without parameterValues, then rejects the
request as empty, so the key is sent even when there is nothing to configure."""
client = self._make_client()

client.create_web_search_target(gateway_identifier="gw-123", description="Search the web")

call_kwargs = client.create_gateway_target_and_wait.call_args[1]
config = call_kwargs["targetConfiguration"]["mcp"]["connector"]["configurations"][0]
assert "parameterValues" in config

def test_kwargs_override_target_configuration(self):
client = self._make_client()

custom_target_config = {"mcp": {"lambda": {"lambdaArn": "arn:..."}}}
client.create_web_search_target(
gateway_identifier="gw-123",
targetConfiguration=custom_target_config,
)

call_kwargs = client.create_gateway_target_and_wait.call_args[1]
assert call_kwargs["targetConfiguration"] == custom_target_config

def test_kwargs_override_credential_provider(self):
client = self._make_client()

custom_creds = [{"credentialProviderType": "CUSTOM"}]
client.create_web_search_target(
gateway_identifier="gw-123",
credentialProviderConfigurations=custom_creds,
)

call_kwargs = client.create_gateway_target_and_wait.call_args[1]
assert call_kwargs["credentialProviderConfigurations"] == custom_creds

def test_default_credential_provider(self):
client = self._make_client()

client.create_web_search_target(gateway_identifier="gw-123")

call_kwargs = client.create_gateway_target_and_wait.call_args[1]
assert call_kwargs["credentialProviderConfigurations"] == [
{"credentialProviderType": "GATEWAY_IAM_ROLE"},
]

def test_wait_config_passed_through(self):
from bedrock_agentcore._utils.config import WaitConfig

client = self._make_client()
wc = WaitConfig(max_wait=60, poll_interval=5)

client.create_web_search_target(gateway_identifier="gw-123", wait_config=wc)

assert client.create_gateway_target_and_wait.call_args[1]["wait_config"] == wc
106 changes: 106 additions & 0 deletions tests_integ/gateway/test_gateway_web_search_targets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Integration tests for GatewayClient Web Search target helper methods.

Requires environment variables:
BEDROCK_TEST_REGION: AWS region (default: us-east-1). The web-search connector is
only offered in us-east-1, eu-west-1 and ap-northeast-1.
GATEWAY_ROLE_ARN: IAM role ARN with AgentCore gateway trust policy
"""

import os
import time

import pytest
from botocore.exceptions import ClientError

from bedrock_agentcore.gateway.client import GatewayClient


@pytest.mark.integration
class TestGatewayWebSearchTarget:
"""Integration tests for create_web_search_target."""

@classmethod
def setup_class(cls):
cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-east-1")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other gateway integ test defaults to us-west-2, and the CI workflow sets AWS_REGION=us-west-2 without setting BEDROCK_TEST_REGION. This file defaults to us-east-1, so the gateway gets created in a different region than the rest of the suite. If the account is not entitled in us-east-1, all three tests skip on every CI run. Worth confirming which region CI should actually use here.

cls.gateway_role_arn = os.environ.get("GATEWAY_ROLE_ARN")
if not cls.gateway_role_arn:
pytest.fail("GATEWAY_ROLE_ARN must be set")

cls.gateway_client = GatewayClient(region_name=cls.region)
cls.test_prefix = f"sdk-integ-ws-tgt-{int(time.time())}"
cls.gateway_id = None
cls.target_ids = []

gw = cls.gateway_client.create_gateway_and_wait(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the connector is not available, all three tests skip but setup_class still creates a real gateway and teardown_class deletes it on every run. Consider checking availability here, before create_gateway_and_wait, and skipping the whole class instead.

name=f"{cls.test_prefix}-gw",
roleArn=cls.gateway_role_arn,
authorizerType="NONE",
protocolType="MCP",
)
cls.gateway_id = gw["gatewayId"]

@classmethod
def teardown_class(cls):
for target_id in cls.target_ids:
try:
cls.gateway_client.delete_gateway_target_and_wait(
gatewayIdentifier=cls.gateway_id,
targetId=target_id,
)
except Exception as e:
print(f"Failed to delete target {target_id}: {e}")

if cls.gateway_id:
try:
cls.gateway_client.delete_gateway_and_wait(gatewayIdentifier=cls.gateway_id)
except Exception as e:
print(f"Failed to delete gateway {cls.gateway_id}: {e}")

def _create_target(self, **kwargs):
"""Create a web search target, skipping the test if the account is not entitled.

The web-search connector is enabled per account. When it is not, CreateGatewayTarget

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says the connector is enabled per account, but the module docstring says it is only offered in three regions. If the skip in CI is really a region mismatch, a reader will look at the wrong thing. Worth reconciling the two explanations.

rejects the request with "Connector integration web-search is not available for this
account." Any other error still fails the test.
"""
try:
return self.gateway_client.create_web_search_target(gateway_identifier=self.gateway_id, **kwargs)
except ClientError as e:
error = e.response.get("Error", {})
if error.get("Code") == "ValidationException" and "not available for this account" in error.get(
"Message", ""
):
pytest.skip(f"web-search connector not enabled for this account: {error.get('Message')}")
raise

@pytest.mark.order(1)
def test_create_web_search_target_minimal(self):
target = self._create_target()
self.__class__.target_ids.append(target["targetId"])
assert target["status"] == "READY"
assert target["name"] == "amazon-web-search"

@pytest.mark.order(2)
def test_create_web_search_target_with_options(self):
target = self._create_target(
name=f"{self.test_prefix}-custom",
description="Search the public web",
exclude_domains=["example.com"],
include_domains=["docs.aws.amazon.com"],
connector_version="1.2.0",
parameter_overrides=[{"path": "$.maxResults", "visible": True, "description": "How many results"}],
)
self.__class__.target_ids.append(target["targetId"])
assert target["status"] == "READY"
assert target["name"] == f"{self.test_prefix}-custom"

@pytest.mark.order(3)
def test_create_web_search_target_with_credential_config(self):
target = self._create_target(
name=f"{self.test_prefix}-cred",
credentialProviderConfigurations=[
{"credentialProviderType": "GATEWAY_IAM_ROLE"},
],
)
self.__class__.target_ids.append(target["targetId"])
assert target["status"] == "READY"
Loading