diff --git a/src/bedrock_agentcore/gateway/client.py b/src/bedrock_agentcore/gateway/client.py index 813ef540..156e16f7 100644 --- a/src/bedrock_agentcore/gateway/client.py +++ b/src/bedrock_agentcore/gateway/client.py @@ -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 "___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 + + 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]]: 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..264bc90b --- /dev/null +++ b/tests/unit/gateway/test_gateway_web_search_targets.py @@ -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): + 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): + 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 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..bf07bc01 --- /dev/null +++ b/tests_integ/gateway/test_gateway_web_search_targets.py @@ -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") + 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. + + The web-search connector is enabled per 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"