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"