From f466439aa825252a16d830058ecaf6a62a453887 Mon Sep 17 00:00:00 2001 From: Sundar Raghavan Date: Thu, 3 Sep 2026 15:39:34 -0700 Subject: [PATCH] feat(gateway): add create_web_search_target() helper Creating a web search target by hand means nesting a connector configuration four levels deep and knowing that parameterValues has to be present even when it is empty, because the service drops a configuration without it and then rejects the request as empty. This wraps that in one call, the way the knowledge base helpers already do. The default target name is amazon-web-search, since Gateway prefixes every tool with the name of the target it came from, so the agent discovers the tool as amazon-web-search___WebSearch. An include list needs connector version 1.2.0, and the connector's default is older, so passing include_domains pins 1.2.0 rather than sending a request the service will reject. Pinning an older version alongside an include list is a ValueError, and a version this SDK cannot read is passed through for the service to judge. Either domain list is rejected locally above the documented maximum of 100. The integration test skips for the region before it creates anything, since web search is only offered in us-east-1, eu-west-1 and ap-northeast-1 and creating a gateway to discover that wastes a real resource. Entitlement is separate and can only be found out by asking, so that stays a skip on the error the service returns. --- src/bedrock_agentcore/gateway/client.py | 158 ++++++++++++++ .../test_gateway_web_search_targets.py | 202 ++++++++++++++++++ .../test_gateway_web_search_targets.py | 122 +++++++++++ 3 files changed, 482 insertions(+) create mode 100644 tests/unit/gateway/test_gateway_web_search_targets.py create mode 100644 tests_integ/gateway/test_gateway_web_search_targets.py diff --git a/src/bedrock_agentcore/gateway/client.py b/src/bedrock_agentcore/gateway/client.py index 813ef540..29b3430a 100644 --- a/src/bedrock_agentcore/gateway/client.py +++ b/src/bedrock_agentcore/gateway/client.py @@ -16,6 +16,18 @@ _GATEWAY_FAILED_STATUSES = {"FAILED", "UPDATE_UNSUCCESSFUL"} _TARGET_FAILED_STATUSES = {"FAILED", "UPDATE_UNSUCCESSFUL", "SYNCHRONIZE_UNSUCCESSFUL"} +#: Default name for a web search target. Gateway prefixes every tool with the name of +#: the target it came from, so this is what makes the tool read as +#: "amazon-web-search___WebSearch" to the agent. +DEFAULT_WEB_SEARCH_TARGET_NAME = "amazon-web-search" + +#: First connector version that accepts a target-level include list. The connector's +#: default is older, so an include list has to pin this. +_INCLUDE_DOMAINS_MIN_CONNECTOR_VERSION = "1.2.0" + +#: Documented maximum length of either domain list on a web search target. +_MAX_DOMAIN_FILTER_ENTRIES = 100 + class GatewayClient: """Client for Bedrock AgentCore Gateway operations. @@ -381,6 +393,113 @@ 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 "___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. + Needs connector version 1.2.0 or later, which this method pins for you + when you pass one and do not pin a version yourself. 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, except that an include list + pins 1.2.0 as described above. + 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. + + Raises: + ValueError: If either domain list is longer than 100, or if include_domains + is combined with a pinned connector version that predates it. + """ + # 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"] = _checked_domains("include_domains", include_domains) + if exclude_domains: + domain_filter["exclude"] = _checked_domains("exclude_domains", 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 include_domains: + # A target-level include list only exists from 1.2.0 on, and the connector + # default is older, so sending one unpinned is rejected server-side. Pin the + # first version that accepts it rather than build a request that cannot + # validate. + connector_version = _connector_version_for_include_domains(connector_version) + if connector_version: + source["version"] = connector_version + + target_kwargs = { + "gatewayIdentifier": gateway_identifier, + "name": name or DEFAULT_WEB_SEARCH_TARGET_NAME, + "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]]: @@ -439,3 +558,42 @@ def get_gateway_target_by_name(self, gateway_identifier: str, name: str, **kwarg if not response.get("nextToken"): return None params["nextToken"] = response["nextToken"] + + +def _checked_domains(argument: str, domains: List[str]) -> List[str]: + """Return the domain list, rejecting one longer than the documented maximum.""" + values = list(domains) + if len(values) > _MAX_DOMAIN_FILTER_ENTRIES: + raise ValueError(f"{argument} accepts at most {_MAX_DOMAIN_FILTER_ENTRIES} domains, got {len(values)}") + return values + + +def _connector_version_for_include_domains(connector_version: Optional[str]) -> str: + """Return the connector version to pin when a target-level include list is set. + + Raises: + ValueError: If the caller pinned a version that predates the include list. + """ + if connector_version is None: + return _INCLUDE_DOMAINS_MIN_CONNECTOR_VERSION + if _version_tuple(connector_version) < _version_tuple(_INCLUDE_DOMAINS_MIN_CONNECTOR_VERSION): + raise ValueError( + f"include_domains requires connector version {_INCLUDE_DOMAINS_MIN_CONNECTOR_VERSION} or later, " + f"got {connector_version}" + ) + return connector_version + + +def _version_tuple(version: str) -> tuple: + """Read a dotted version into comparable integers, ignoring anything unparseable. + + Missing components count as zero, so "1.2" is not read as older than "1.2.0". An + unrecognized version sorts high, so a version this SDK does not understand is passed + through to the service to accept or reject rather than rejected locally. + """ + parts = [0, 0, 0] + for index, part in enumerate(version.split(".")): + if not part.isdigit() or index >= len(parts): + return (float("inf"),) + parts[index] = int(part) + return tuple(parts) diff --git a/tests/unit/gateway/test_gateway_web_search_targets.py b/tests/unit/gateway/test_gateway_web_search_targets.py new file mode 100644 index 00000000..04bbe183 --- /dev/null +++ b/tests/unit/gateway/test_gateway_web_search_targets.py @@ -0,0 +1,202 @@ +"""Tests for GatewayClient Web Search target helper methods.""" + +from unittest.mock import MagicMock, Mock + +import pytest + +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): + """parameterValues is sent even when empty. + + The service drops every configuration whose parameterValues is absent and then + rejects the request as empty, so the key always goes on the wire. + """ + 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_pins_the_version_that_supports_it(self): + """An include list is rejected server-side on the connector's default version.""" + client = self._make_client() + + client.create_web_search_target( + gateway_identifier="gw-123", + include_domains=["docs.aws.amazon.com"], + ) + + connector = client.create_gateway_target_and_wait.call_args[1]["targetConfiguration"]["mcp"]["connector"] + assert connector["source"] == {"connectorId": "web-search", "version": "1.2.0"} + assert connector["configurations"][0]["parameterValues"] == { + "domainFilter": {"include": ["docs.aws.amazon.com"]} + } + + def test_a_newer_pinned_version_is_kept(self): + client = self._make_client() + + client.create_web_search_target( + gateway_identifier="gw-123", + include_domains=["docs.aws.amazon.com"], + connector_version="1.3.0", + ) + + source = client.create_gateway_target_and_wait.call_args[1]["targetConfiguration"]["mcp"]["connector"]["source"] + assert source["version"] == "1.3.0" + + def test_include_domains_with_an_older_pinned_version_raises(self): + client = self._make_client() + + with pytest.raises(ValueError, match="requires connector version 1.2.0 or later, got 1.1.0"): + client.create_web_search_target( + gateway_identifier="gw-123", + include_domains=["docs.aws.amazon.com"], + connector_version="1.1.0", + ) + + client.create_gateway_target_and_wait.assert_not_called() + + def test_a_two_component_version_is_not_read_as_older(self): + client = self._make_client() + + client.create_web_search_target( + gateway_identifier="gw-123", + include_domains=["docs.aws.amazon.com"], + connector_version="1.2", + ) + + source = client.create_gateway_target_and_wait.call_args[1]["targetConfiguration"]["mcp"]["connector"]["source"] + assert source["version"] == "1.2" + + def test_an_unrecognized_pinned_version_is_passed_through(self): + """A version this SDK cannot read is the service's to accept or reject.""" + client = self._make_client() + + client.create_web_search_target( + gateway_identifier="gw-123", + include_domains=["docs.aws.amazon.com"], + connector_version="LATEST", + ) + + source = client.create_gateway_target_and_wait.call_args[1]["targetConfiguration"]["mcp"]["connector"]["source"] + assert source["version"] == "LATEST" + + def test_exclude_domains_alone_pins_no_version(self): + client = self._make_client() + + client.create_web_search_target(gateway_identifier="gw-123", exclude_domains=["spam.example"]) + + source = client.create_gateway_target_and_wait.call_args[1]["targetConfiguration"]["mcp"]["connector"]["source"] + assert source == {"connectorId": "web-search"} + + @pytest.mark.parametrize("argument", ["include_domains", "exclude_domains"]) + def test_domain_list_maximum(self, argument): + client = self._make_client() + domains = [f"d{index}.example" for index in range(101)] + + with pytest.raises(ValueError, match="at most 100 domains, got 101"): + client.create_web_search_target(gateway_identifier="gw-123", **{argument: domains}) + + client.create_gateway_target_and_wait.assert_not_called() + + 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_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_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 diff --git a/tests_integ/gateway/test_gateway_web_search_targets.py b/tests_integ/gateway/test_gateway_web_search_targets.py new file mode 100644 index 00000000..689bb574 --- /dev/null +++ b/tests_integ/gateway/test_gateway_web_search_targets.py @@ -0,0 +1,122 @@ +"""Integration tests for GatewayClient Web Search target helper methods. + +These tests skip for two separate reasons, and both are checked before anything is +created. The connector is only offered in three regions, so the class skips outright +in any other one. And it is enabled per account, so a target creation in a supported +region can still come back saying it is not available, which skips as well. + +Requires environment variables: + BEDROCK_TEST_REGION: AWS region. Defaults to us-east-1 rather than the us-west-2 + the other gateway tests use, because web search is not offered in us-west-2 + and CI sets AWS_REGION to it, so taking the ambient region would skip every + run. + 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 + +#: Regions where the web-search connector is offered. +WEB_SEARCH_REGIONS = ("us-east-1", "eu-west-1", "ap-northeast-1") + + +@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") + # Checked before the gateway is created, so an unsupported region does not + # create and delete a real gateway just to find out the target cannot be made. + if cls.region not in WEB_SEARCH_REGIONS: + pytest.skip(f"web search is not offered in {cls.region}, only in {', '.join(WEB_SEARCH_REGIONS)}") + 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( + 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. + + This is the account half of the two reasons in the module docstring, and it can + only be found out by asking: there is no API that reports whether a connector is + available to an account. When it is not, CreateGatewayTarget 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"