Skip to content

feat(tools): add WebSearchClient for invoking Amazon Web Search - #658

Open
sundargthb wants to merge 2 commits into
mainfrom
feat/web-search-client
Open

feat(tools): add WebSearchClient for invoking Amazon Web Search#658
sundargthb wants to merge 2 commits into
mainfrom
feat/web-search-client

Conversation

@sundargthb

@sundargthb sundargthb commented Sep 3, 2026

Copy link
Copy Markdown
Member

Description

The SDK can already create a web search connector target on a gateway (GatewayClient.create_web_search_target, #656), but there is no way to call the resulting tool from Python. Today anything that is not already an MCP client has to hand-roll the SigV4 signed MCP handshake against the gateway endpoint to use web search.

This adds WebSearchClient:

from bedrock_agentcore.tools import WebSearchClient

client = WebSearchClient(region="us-east-1", gateway_id="my-gateway-abc123")
for result in client.search("what is amazon bedrock agentcore", max_results=5):
    print(result.title, result.url)

Design notes

No new dependency. The transport sits behind a WebSearchBackend seam. GatewayMcpBackend speaks only the slice of MCP streamable HTTP that one tool call needs (initialize, the notifications/initialized ack, optionally tools/list, then tools/call) using urllib3 and botocore.auth.SigV4Auth, both already core dependencies. mcp-proxy-for-aws would do the signing for us, but it is currently dev/test only here and it is async, so it would change the SDK's dependency surface and the calling style for one capability. If reviewers would rather take that dependency as a published extra, the backend is one file to swap and the public search() signature does not move.

Two response framings. A gateway may answer a POST with application/json or with text/event-stream. Both are parsed, since which one comes back for a single tools/call is not something the client can assume.

Tool name resolution. Gateway prefixes every tool with the name of the target it came from, delimited by three underscores, so the tool the agent sees is <targetName>___WebSearch, not WebSearch. Passing target_name derives the name directly and skips a round trip. Otherwise tools/list is walked (following nextCursor) and the WebSearch tool is picked, with a clear error when more than one target exposes one.

Validation before the call. query is required and capped at 200 characters, max_results at 1 to 25, per the documented tool schema. The domain and published-date filters require connector version 1.2.0 or later on the target, which the docstring says.

Citations are preserved. WebSearchResult keeps url, title and published_date alongside text, because the acceptable use terms require source citations and links to be retained and displayed in anything surfaced to an end user. Dropping them in the response type would make compliant use harder than it needs to be.

Endpoint safety. get_gateway_mcp_endpoint validates the gateway identifier as a DNS label before interpolating it into the hostname, matching the region validation already in _utils/endpoints.py, so a crafted identifier cannot redirect the request off AWS. The connection header is excluded from signing, which otherwise produces a signature mismatch server-side.

Region handling. The connector is offered in us-east-1, eu-west-1 and ap-northeast-1. Calling from another region logs a warning rather than raising, so a stale constant in the SDK never blocks a call to a region that was added after the release.

Testing

uv run pytest tests -q passes: 3501 passed, 10 skipped, 4 xpassed. 97 new unit tests (81 for the client, 16 for the gateway identifier validation) cover the SigV4 header set, the request sequence, session reuse, both response framings, tool-name resolution including pagination and ambiguity, the isError and JSON-RPC error paths, ARN parsing, and the gateway identifier validation. ruff check and ruff format --check are clean. Statement coverage of the new module is 100%.

Not verified against a live service. The web search connector is enabled per account and this repo's integration test account is not entitled to it, so the invoke path has been exercised only against mocked HTTP responses. The request shapes come from the public tool schema and response format documentation, and the MCP sequence from the streamable HTTP transport spec, but nothing here has completed a real search. tests_integ/tools/test_web_search_client.py is included and will exercise the real path once run against an entitled account with WEB_SEARCH_GATEWAY_ID set; it skips rather than fails without one. Reviewers with an entitled account running that file would be the useful confirmation.

Confirmed with the service team

The API surface this is built on has since been confirmed by the Web Search Tool team, and matches what is implemented here: SigV4 (AWS_IAM) with no search-specific API key or credential; availability in us-east-1, eu-west-1 and ap-northeast-1; access scoped through IAM policies on the gateway ARN rather than any key; and both domainFilter (include and exclude, 100 domains per list) and publishedDateFilter (from/to, ISO-8601 UTC, inclusive) at target and request level from connector version 1.2.0.

Two clarifications from that exchange are now in the docstrings: the caller's credentials need bedrock-agentcore:InvokeGateway on the gateway ARN while the gateway service role needs bedrock-agentcore:InvokeWebSearch on the connector, and request-level include lists intersect with the target-level include list rather than replacing it, so disjoint lists return no results with no error raised.

Checklist

  • Unit tests added
  • Integration tests added (skip without an entitled account and a gateway)
  • Lint and format pass
  • No new dependencies

The SDK can create a web search connector target on a gateway, but there is no
way to call the resulting tool from Python. Anything that is not already an MCP
client has to hand-roll the SigV4 signed MCP handshake to use it.

WebSearchClient closes that gap:

    client = WebSearchClient(region="us-east-1", gateway_id="my-gateway-abc123")
    for result in client.search("what is agentcore", max_results=5):
        print(result.title, result.url)

Details:

- Transport sits behind a WebSearchBackend seam. GatewayMcpBackend speaks the
  slice of MCP streamable HTTP that one tool call needs (initialize, the
  initialized notification, optionally tools/list, then tools/call) using
  urllib3 and botocore.auth.SigV4Auth, so no new dependency is added. If the
  direct web search API arrives later, it is a second backend behind the same
  search() signature.
- Both response framings are handled, since a gateway may answer a POST with
  application/json or with text/event-stream.
- Tool name resolution accounts for Gateway prefixing every tool with its
  target name: target_name derives "<target>___WebSearch" directly, otherwise
  tools/list is walked (following nextCursor) and the WebSearch tool is picked,
  erroring when the choice is ambiguous.
- Inputs are validated against the documented limits before the call: query is
  required and capped at 200 characters, max_results at 1 to 25. Domain and
  published-date filters need connector version 1.2.0 or later on the target.
- Results are returned as WebSearchResult with url, title and published_date
  retained, because citations have to be displayable in anything shown to an
  end user.
- get_gateway_mcp_endpoint validates the gateway identifier as a DNS label
  before interpolating it into the hostname, matching the existing region
  validation, so a crafted identifier cannot redirect the request off AWS.
- A region outside the connector's availability warns instead of failing, so a
  stale constant never blocks a call to a newly added region.
@sundargthb
sundargthb requested a review from a team September 3, 2026 19:32
… needs

Web search takes no API key of its own: the caller's credentials need
bedrock-agentcore:InvokeGateway on the gateway ARN and the gateway service role
needs bedrock-agentcore:InvokeWebSearch on the connector. AccessDenied is almost
always the first of those, so say so where a caller will look.

Also spell out how request filters compose with the target's own domain rules.
A result is dropped if its domain is on either exclude list, and returned only
if it is on every include list that is set, so passing include_domains against a
target that already has an include list narrows to the intersection and disjoint
lists return nothing. That empty result is silent rather than an error, which is
worth knowing before debugging it.
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

✅ No Breaking Changes Detected

No public API breaking changes found in this PR.

@github-actions github-actions Bot added size/xl PR size: XL and removed size/xl PR size: XL labels Sep 3, 2026
@sundargthb
sundargthb deployed to auto-approve September 3, 2026 20:21 — with GitHub Actions Active
@sundargthb
sundargthb deployed to auto-approve September 3, 2026 20:21 — with GitHub Actions Active
@sundargthb
sundargthb deployed to auto-approve September 3, 2026 20:21 — with GitHub Actions Active
@sundargthb
sundargthb deployed to auto-approve September 3, 2026 20:21 — with GitHub Actions Active
@sundargthb
sundargthb deployed to auto-approve September 3, 2026 20:21 — with GitHub Actions Active
@sundargthb
sundargthb deployed to auto-approve September 3, 2026 20:21 — with GitHub Actions Active
@sundargthb
sundargthb deployed to auto-approve September 3, 2026 20:21 — with GitHub Actions Active
@sundargthb
sundargthb deployed to auto-approve September 3, 2026 20:21 — with GitHub Actions Active
@github-actions github-actions Bot added size/xl PR size: XL and removed size/xl PR size: XL labels Sep 3, 2026
@agentcore-devx-automation agentcore-devx-automation Bot added the claude-security-reviewing Claude Code /security-review in progress label Sep 3, 2026
@agentcore-devx-automation

Copy link
Copy Markdown
Contributor

Claude Security Review: no high-confidence findings. (run)

@tejaskash tejaskash left a comment

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.

Reviewed the transport layer against the MCP streamable HTTP spec. Tests pass locally. Inline comments below, ordered roughly by severity. The first three matter once this runs against a real gateway.

message = json.loads(chunk)
except json.JSONDecodeError:
continue
if isinstance(message, dict) and "jsonrpc" in message:

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 returns the first JSON-RPC message on the stream, not the response. If the gateway sends a notification (e.g. notifications/progress) before the tools/call result, the notification is returned and search() fails with "contained no text content".

_post knows the outgoing id, so matching on it fixes this. At minimum, require a result or error key.

if self._initialized:
extra["MCP-Protocol-Version"] = self._protocol_version

response = self._http.request(

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.

Connection refused, DNS failure, TLS errors and read timeouts escape here as urllib3 exceptions (MaxRetryError, ReadTimeoutError, ProtocolError). The search() docstring promises WebSearchError, so callers who follow it will miss the most common failure for a 30s HTTP call.

Suggest wrapping urllib3.exceptions.HTTPError into WebSearchError with from exc.

if session_id:
self._mcp_session_id = session_id

if response.status >= 400:

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 the gateway expires the session and returns 404 (the spec's re-initialize signal), this raises but leaves _initialized=True and _mcp_session_id set. Every later search() on this instance fails the same way. Only close() recovers, and that is not documented.

On 404 with a session id in flight, reset _initialized and _mcp_session_id before raising so the next call re-initializes.

if isinstance(negotiated, str) and negotiated:
self._protocol_version = negotiated

self._initialized = True

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.

Set after the notification POST on the next line succeeds. If that POST raises, the flag stays True and the next search() skips the handshake.

text = data.decode("utf-8", "replace")

if "text/event-stream" in content_type.lower():
for line in text.splitlines():

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.

Per the SSE spec, consecutive data: lines in one event are joined with \n. Parsing each line alone means a message split across two lines is dropped and reported as "Gateway did not answer". Unlikely in practice but the parser claims to handle this framing.

"""
import boto3

self._session = boto3_session or boto3.Session()

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 backend is supplied this session is only used to read region_name, but it still does the full credential provider chain setup. Harmless, just wasted work.

Args:
query: What to search for. 200 characters or fewer.
max_results: How many results to return, 1 to 25. Service default is 10.
include_domains: Restrict results to these domains. Up to 100. A root

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.

"Up to 100" is documented for both domain lists but not enforced in _build_arguments, while query length and max_results are. Not wrong, just inconsistent.

self.close()


def _parse_gateway_arn(arn: str) -> tuple:

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.

Tuple[str, str] to match browser_client.py.

create_browser_config,
)
from .web_search_client import (
WebSearchBackend,

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.

GatewayMcpBackend is not exported though WebSearchBackend is. Anyone building the concrete backend directly (e.g. to share one across clients) has to import from the module path.

assert headers["Accept"] == "application/json, text/event-stream"
assert headers["Content-Length"] == str(len(backend._http.request.call_args_list[2].kwargs["body"]))

def test_connection_header_is_never_signed(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 tests botocore, not this PR. connection is in botocore's SIGNED_HEADERS_BLACKLIST and no connection header is ever built here, so the test cannot fail. Suggest removing it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xl PR size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants