From cabcba3c152071cf752c80f0f0d5400e37425913 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 Adds a GatewayClient helper that creates a gateway target exposing Amazon Web Search as an MCP WebSearch tool, so callers do not have to hand-assemble the connector target configuration. - Defaults the target name to "amazon-web-search", because Gateway prefixes every tool with its target name and the agent therefore discovers "amazon-web-search___WebSearch". - Supports target-level include and exclude domain lists, connector version pinning, an agent-facing description, and per-parameter overrides. - Always sends parameterValues, even when empty. The service drops any configuration whose parameterValues is absent before validating, so a configuration carrying nothing but a name leaves nothing to validate and CreateGatewayTarget fails with "Connector configurations must not be empty". - Documents that domain include lists intersect rather than replace, so a caller can narrow the target's list but never relax it, and disjoint lists return no results without raising. - Documents the two IAM actions involved: the caller needs InvokeGateway on the gateway ARN, the gateway service role needs InvokeWebSearch on the connector. Web search takes no API key of its own. Integration tests skip only when the account is not entitled to the web-search connector and re-raise every other error. --- src/bedrock_agentcore/gateway/client.py | 95 ++++++++++ .../test_gateway_web_search_targets.py | 164 ++++++++++++++++++ .../test_gateway_web_search_targets.py | 106 +++++++++++ 3 files changed, 365 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..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"