feat(vended-tools): port a2a_client tool to TypeScript (#4575)

This commit is contained in:
liramon2
2026-09-29 16:34:55 -04:00
committed by GitHub
parent 303a2bed7a
commit ff114a9bd1
13 changed files with 670 additions and 85 deletions
@@ -76,3 +76,9 @@ import { Agent } from '@strands-agents/sdk'
import { makeWebFetch } from '@strands-agents/sdk/vended-tools/web-fetch'
import { BedrockModel } from '@strands-agents/sdk/models/bedrock'
// --8<-- [end:web_fetch_custom_import]
// --8<-- [start:a2a_client_import]
import { Agent } from '@strands-agents/sdk'
import { ClientFactory, DefaultAgentCardResolver, JsonRpcTransportFactory, RestTransportFactory, createAuthenticatingFetchWithRetry } from '@a2a-js/sdk/client'
import { makeA2AClient } from '@strands-agents/sdk/vended-tools/a2a-client'
// --8<-- [end:a2a_client_import]
@@ -13,6 +13,7 @@ sourceLinks:
- path: strands-ts/src/vended-tools/sleep/sleep.ts
- path: strands-ts/src/vended-tools/web-fetch/web-fetch.ts
- path: strands-ts/src/experimental/vended-tools/stop/stop.ts
- path: strands-ts/src/vended-tools/a2a-client/a2a-client.ts
- path: strands-py/src/strands/vended_tools/handoff_to_user/handoff_to_user.py
- path: strands-py/src/strands/vended_tools/http_request/http_request.py
- path: strands-py/src/strands/vended_tools/notebook/notebook.py
@@ -51,7 +52,7 @@ Each tool is imported from its own subpath under `@strands-agents/sdk/vended-too
| [Handoff to User](#handoff-to-user) | Pause the agent loop and surface a message to the user | Python, TypeScript (Node.js, browsers) |
| [Stop](#stop-experimental) | Gracefully end the agent loop when the task is complete | Python, TypeScript (Node.js, browsers) |
| [Web Fetch](#web-fetch) | Fetch a URL and return cleaned markdown for a model to read | Python, TypeScript (Node.js) |
| [A2A Client](#a2a-client) | Discover and send messages to remote A2A-protocol agents | Python |
| [A2A Client](#a2a-client) | Discover and send messages to remote A2A-protocol agents | Python, TypeScript (Node.js, browsers) |
### File editor
@@ -648,17 +649,48 @@ Two operations are available:
- **`send_message`** — sends a text message to a remote A2A agent and returns the response.
The tool is stateless: a fresh `A2AAgent` is created on every call using the
`ClientConfig` configured for that endpoint.
Use ``make_a2a_client`` to control which endpoints the model may contact, with
per-endpoint configuration. Use `make_a2a_client` / `makeA2AClient` to control which endpoints the model may contact, with
per-endpoint authentication, and to tune size limits.
Requires `pip install 'strands-agents[a2a]'`.
_Supported in: Python._
_Supported in: Python, TypeScript (Node.js, browsers)._
:::note[Install required]
<Tabs>
<Tab label="Python">
`a2a_client` requires the optional `a2a` extra:
```bash
pip install 'strands-agents[a2a]'
```
</Tab>
<Tab label="TypeScript">
Requires the optional `@a2a-js/sdk` peer dependency:
```bash
npm install @a2a-js/sdk
```
</Tab>
</Tabs>
:::
:::caution[Endpoint security]
`allowed_endpoints` is required and checked before any network connection is made, but it only gates the agent-card fetch. `send_message` is delivered to the `url` in that card, which may point to a different host, and HTTP redirects are not checked either. For full egress control, enforce it at the network layer.
`allowed_endpoints` / `allowedEndpoints` is required and checked before any network connection is made, but it only gates the agent-card fetch. `send_message` is delivered to the `url` in that card, which may point to a different host, and HTTP redirects are not checked either. For full egress control, enforce it at the network layer.
:::
**Example:**
<Tabs>
<Tab label="TypeScript">
```typescript
--8<-- "user-guide/sdk/tools/vended-tools-imports.ts:a2a_client_import"
--8<-- "user-guide/sdk/tools/vended-tools.ts:a2a_client_example"
```
</Tab>
<Tab label="Python">
```python
import httpx
from a2a.client import ClientConfig
@@ -666,21 +698,25 @@ from strands import Agent
from strands.vended_tools import make_a2a_client
a2a_client = make_a2a_client(
allowed_endpoints={
"https://agent.example.com": None,
"https://researcher.example.com": ClientConfig(
allowed_endpoints=[
"https://agent.example.com",
("https://researcher.example.com", ClientConfig(
httpx_client=httpx.AsyncClient(
headers={"Authorization": "Bearer your-token"},
timeout=60.0,
),
),
},
max_bytes=1 * 1024 * 1024,
)),
],
)
agent = Agent(tools=[a2a_client])
agent("What has the research agent found recently?")
```
</Tab>
</Tabs>
Full API reference: [TypeScript](https://github.com/strands-agents/harness-sdk/blob/main/strands-ts/src/vended-tools/a2a-client/README.md)
---
## Using multiple tools together
@@ -15,6 +15,8 @@ import { sleep, makeSleep } from '@strands-agents/sdk/vended-tools/sleep'
import { stop } from '@strands-agents/sdk/experimental/vended-tools/stop'
import { webFetch, makeWebFetch } from '@strands-agents/sdk/vended-tools/web-fetch'
import { BedrockModel } from '@strands-agents/sdk/models/bedrock'
import { makeA2AClient } from '@strands-agents/sdk/vended-tools/a2a-client'
import { ClientFactory, DefaultAgentCardResolver, JsonRpcTransportFactory, RestTransportFactory, createAuthenticatingFetchWithRetry } from '@a2a-js/sdk/client'
// Agent with vended tools example
async function agentWithVendedToolsExample() {
@@ -231,3 +233,31 @@ async function webFetchCustomExample() {
// --8<-- [end:web_fetch_custom_example]
void agent
}
// A2A client example
async function a2aClientExample() {
// --8<-- [start:a2a_client_example]
const authFetch = createAuthenticatingFetchWithRetry(fetch, {
headers: async () => ({ Authorization: 'Bearer your-token' }),
shouldRetryWithHeaders: async () => undefined,
})
const a2aClient = makeA2AClient({
allowedEndpoints: [
// No auth needed
'https://agent.example.com',
// Custom ClientFactory for authenticated requests
['https://researcher.example.com', new ClientFactory({
transports: [
new JsonRpcTransportFactory({ fetchImpl: authFetch }),
new RestTransportFactory({ fetchImpl: authFetch }),
],
cardResolver: new DefaultAgentCardResolver({ fetchImpl: authFetch }),
})],
],
})
const agent = new Agent({ tools: [a2aClient] })
await agent.invoke('What has the research agent found recently?')
// --8<-- [end:a2a_client_example]
}
@@ -17,8 +17,8 @@ requires the optional ``web-fetch`` extra (``pip install 'strands-agents[web-fet
and is imported lazily, so accessing it without that extra raises :class:`ImportError`.
The :func:`make_a2a_client` factory creates a tool that discovers and sends messages to remote A2A-protocol
agents. Supply the required ``allowed_endpoints`` dict plus optional
authentication via a :class:`~a2a.client.ClientConfig`, or custom size limits.
agents. Supply the required ``allowed_endpoints`` list — each entry is either a bare URL string or a
``(url, ClientConfig)`` tuple.
It requires the optional ``a2a`` extra (``pip install 'strands-agents[a2a]'``)
and is imported lazily, so accessing it without that extra raises :class:`ImportError`.
@@ -14,14 +14,14 @@ Example Usage:
from strands.vended_tools import make_a2a_client
tool = make_a2a_client(
allowed_endpoints={
"https://agent.example.com": None,
"https://secure-agent.example.com": ClientConfig(
allowed_endpoints=[
"https://agent.example.com",
("https://secure-agent.example.com", ClientConfig(
httpx_client=httpx.AsyncClient(
headers={"Authorization": "Bearer your-token"},
),
),
}
)),
]
)
agent = Agent(tools=[tool])
```
@@ -1,7 +1,7 @@
"""A2A client tool for communicating with remote A2A-protocol agents.
Provides :func:`make_a2a_client`, a factory that requires an explicit mapping of
permitted endpoints to their :class:`~a2a.client.ClientConfig`, plus optional size limits.
Provides :func:`make_a2a_client`, a factory that requires an explicit list of
permitted endpoints (with optional :class:`~a2a.client.ClientConfig`).
The tool is a stateless shim over :class:`~strands.agent.a2a_agent.A2AAgent`.
A fresh ``A2AAgent`` is constructed on every call so the tool carries no session
@@ -12,7 +12,6 @@ state between invocations. Each endpoint may carry its own
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, Literal
try:
@@ -26,8 +25,6 @@ from ...tools.decorator import tool
if TYPE_CHECKING:
from ...tools.decorator import DecoratedFunctionTool
_DEFAULT_MAX_BYTES = 5 * 1024 * 1024
_A2AClientOutput = dict[str, Any]
DEFAULT_A2A_CLIENT_DESCRIPTION = (
@@ -42,12 +39,15 @@ class A2AClientError(RuntimeError):
"""Raised when an A2A operation fails."""
AllowedEndpoint = str | tuple[str, ClientConfig]
"""A permitted endpoint: a bare URL string, or a ``(url, ClientConfig)`` tuple."""
def make_a2a_client(
*,
name: str = "a2a_client",
description: str | None = None,
allowed_endpoints: dict[str, ClientConfig | None],
max_bytes: int = _DEFAULT_MAX_BYTES,
allowed_endpoints: list[AllowedEndpoint],
) -> DecoratedFunctionTool:
"""Create an A2A client tool.
@@ -56,24 +56,29 @@ def make_a2a_client(
description: Tool description shown to the model. When ``None``,
generated from ``DEFAULT_A2A_CLIENT_DESCRIPTION`` plus the
permitted endpoints list.
allowed_endpoints: Mapping of permitted base URLs to their
:class:`~a2a.client.ClientConfig`. Use ``None`` as the value for
endpoints that need no custom configuration. Any endpoint not in this
mapping is rejected before a network connection is made.
max_bytes: Maximum size in bytes of the result dict returned to the model.
Does not cap the network transfer or binary parts.
Results larger than this cap are rejected with an error.
allowed_endpoints: Permitted base URLs. Each entry is either a bare URL
string (no custom config) or a ``(url, ClientConfig)`` tuple for
per-endpoint authentication. Any endpoint not in this list is
rejected before a network connection is made.
Returns:
A decorated tool that communicates with A2A agents.
"""
if not allowed_endpoints:
raise ValueError("allowed_endpoints must contain at least one endpoint")
if max_bytes <= 0:
raise ValueError(f"max_bytes must be positive, got {max_bytes}")
for entry in allowed_endpoints:
if isinstance(entry, str):
continue
if not isinstance(entry, tuple) or len(entry) != 2 or not isinstance(entry[0], str):
raise TypeError(
f"Each allowed endpoint must be a string URL or (str, ClientConfig) tuple, got {type(entry).__name__}"
)
endpoints_map = _normalize_endpoints(allowed_endpoints)
if description is None:
endpoints_list = ", ".join(sorted(allowed_endpoints))
endpoints_list = ", ".join(sorted(endpoints_map))
description = f"{DEFAULT_A2A_CLIENT_DESCRIPTION} Permitted endpoints: {endpoints_list}."
@tool(name=name, description=description)
@@ -100,42 +105,50 @@ def make_a2a_client(
underlying A2A call fails.
"""
# Check if the endpoint is allowed via exact-match.
if endpoint not in allowed_endpoints:
if endpoint not in endpoints_map:
raise A2AClientError(
f"Endpoint '{endpoint}' is not in the allowed endpoints list. "
f"Permitted endpoints: {sorted(allowed_endpoints)}"
f"Permitted endpoints: {sorted(endpoints_map)}"
)
agent = A2AAgent(endpoint, client_config=allowed_endpoints[endpoint])
agent = A2AAgent(endpoint, client_config=endpoints_map[endpoint])
if operation == "discover":
return await _handle_discover(agent, max_bytes)
return await _handle_discover(agent)
if operation == "send_message":
if not message:
raise A2AClientError("'message' is required for send_message operation")
return await _handle_send_message(agent, message, max_bytes)
return await _handle_send_message(agent, message)
raise A2AClientError(f"Unknown operation: {operation!r}")
return a2a_client_tool
async def _handle_discover(agent: A2AAgent, max_bytes: int) -> _A2AClientOutput:
def _normalize_endpoints(entries: list[AllowedEndpoint]) -> dict[str, ClientConfig | None]:
"""Convert the user-facing list into an internal ``{url: config}`` mapping."""
result: dict[str, ClientConfig | None] = {}
for entry in entries:
if isinstance(entry, str):
result[entry] = None
else:
url, config = entry
result[url] = config
return result
async def _handle_discover(agent: A2AAgent) -> _A2AClientOutput:
"""Fetch the agent card via *agent* and return it as a dict."""
try:
agent_card = await agent.get_agent_card()
except Exception as error:
raise A2AClientError(f"Failed to discover agent card at {agent.endpoint!r}: {error}") from error
result: dict[str, Any] = agent_card.model_dump(mode="json", exclude_none=True)
size = len(json.dumps(result).encode())
if size > max_bytes:
raise A2AClientError(f"Agent card response exceeds max_bytes limit ({size} > {max_bytes})")
return result
return agent_card.model_dump(mode="json", exclude_none=True)
async def _handle_send_message(agent: A2AAgent, message_text: str, max_bytes: int) -> _A2AClientOutput:
async def _handle_send_message(agent: A2AAgent, message_text: str) -> _A2AClientOutput:
"""Send *message_text* via *agent* and return the response as a dict."""
try:
agent_result = await agent.invoke_async(message_text)
@@ -150,7 +163,4 @@ async def _handle_send_message(agent: A2AAgent, message_text: str, max_bytes: in
)
result: dict[str, Any] = {"message": agent_result.message}
size = len(json.dumps(result).encode())
if size > max_bytes:
raise A2AClientError(f"Response exceeds max_bytes limit ({size} > {max_bytes})")
return result
@@ -25,7 +25,7 @@ _FAKE_CARD = {
_FAKE_MESSAGE = {"role": "assistant", "content": [{"text": "Hello from agent"}]}
_ENDPOINT = "https://agent.example.com"
_ENDPOINTS: dict[str, ClientConfig | None] = {_ENDPOINT: None}
_ENDPOINTS: list[str] = [_ENDPOINT]
class _FakeAgentCard:
@@ -58,7 +58,7 @@ def fake_agent(monkeypatch):
class TestAllowlist:
@pytest.mark.asyncio
async def test_rejects_endpoint_not_in_allowlist(self):
tool = make_a2a_client(allowed_endpoints={"https://a.example.com": None, "https://b.example.com": None})
tool = make_a2a_client(allowed_endpoints=["https://a.example.com", "https://b.example.com"])
with pytest.raises(A2AClientError, match="not in the allowed endpoints list") as exc_info:
await tool(operation="discover", endpoint="https://evil.example.com")
assert "https://a.example.com" in str(exc_info.value)
@@ -85,19 +85,6 @@ class TestDiscover:
await tool(operation="discover", endpoint=_ENDPOINT)
assert exc_info.value.__cause__ is original
@pytest.mark.asyncio
async def test_rejects_oversized_agent_card(self, monkeypatch):
class _BigCardAgent(_FakeA2AAgent):
async def get_agent_card(self) -> _FakeAgentCard:
card = _FakeAgentCard()
card.model_dump = lambda **_: {"data": "x" * 1000}
return card
monkeypatch.setattr(a2a_client_module, "A2AAgent", _BigCardAgent)
tool = make_a2a_client(allowed_endpoints=_ENDPOINTS, max_bytes=100)
with pytest.raises(A2AClientError, match="exceeds max_bytes limit"):
await tool(operation="discover", endpoint=_ENDPOINT)
class TestSendMessage:
@pytest.mark.asyncio
@@ -155,35 +142,26 @@ class TestSendMessage:
await tool(operation="send_message", endpoint=_ENDPOINT, message="Hello")
assert exc_info.value.__cause__ is original
@pytest.mark.asyncio
async def test_rejects_oversized_response(self, monkeypatch):
class _BigResponseAgent(_FakeA2AAgent):
async def invoke_async(self, prompt: str) -> _FakeAgentResult:
result = _FakeAgentResult()
result.message = {"role": "assistant", "content": [{"text": "x" * 1000}]}
return result
monkeypatch.setattr(a2a_client_module, "A2AAgent", _BigResponseAgent)
tool = make_a2a_client(allowed_endpoints=_ENDPOINTS, max_bytes=100)
with pytest.raises(A2AClientError, match="exceeds max_bytes limit"):
await tool(operation="send_message", endpoint=_ENDPOINT, message="Hello")
class TestFactory:
def test_empty_allowed_endpoints_raises(self):
with pytest.raises(ValueError, match="allowed_endpoints must contain at least one endpoint"):
make_a2a_client(allowed_endpoints={})
make_a2a_client(allowed_endpoints=[])
def test_non_positive_max_bytes_raises(self):
with pytest.raises(ValueError, match="max_bytes must be positive"):
make_a2a_client(allowed_endpoints=_ENDPOINTS, max_bytes=0)
def test_non_string_endpoint_raises(self):
with pytest.raises(TypeError, match="Each allowed endpoint must be a string URL"):
make_a2a_client(allowed_endpoints=[123])
def test_non_string_tuple_endpoint_raises(self):
with pytest.raises(TypeError, match="Each allowed endpoint must be a string URL"):
make_a2a_client(allowed_endpoints=[(123, ClientConfig())])
def test_custom_name(self):
tool = make_a2a_client(name="my_agent", allowed_endpoints=_ENDPOINTS)
assert tool.tool_name == "my_agent"
def test_description_includes_endpoints(self):
tool = make_a2a_client(allowed_endpoints={"https://a.example.com": None, "https://b.example.com": None})
tool = make_a2a_client(allowed_endpoints=["https://a.example.com", "https://b.example.com"])
desc = tool.tool_spec["description"]
assert "https://a.example.com" in desc
assert "https://b.example.com" in desc
@@ -206,7 +184,7 @@ class TestFactory:
monkeypatch.setattr(a2a_client_module, "A2AAgent", _CapturingAgent)
config = ClientConfig()
tool = make_a2a_client(allowed_endpoints={_ENDPOINT: config})
tool = make_a2a_client(allowed_endpoints=[(_ENDPOINT, config)])
await tool(operation="discover", endpoint=_ENDPOINT)
assert seen_config[0] is config
+4
View File
@@ -84,6 +84,10 @@
"types": "./dist/src/vended-tools/sleep/index.d.ts",
"default": "./dist/src/vended-tools/sleep/index.js"
},
"./vended-tools/a2a-client": {
"types": "./dist/src/vended-tools/a2a-client/index.d.ts",
"default": "./dist/src/vended-tools/a2a-client/index.js"
},
"./experimental/vended-tools/stop": {
"types": "./dist/src/experimental/vended-tools/stop/index.d.ts",
"default": "./dist/src/experimental/vended-tools/stop/index.js"
+10
View File
@@ -163,6 +163,16 @@ export class A2AAgent implements InvokableAgent {
return result
}
/**
* Returns the remote agent's card, connecting lazily on first call.
*
* @returns The remote agent's AgentCard
*/
async getAgentCard(): Promise<AgentCard> {
await this._getClient()
return this._agentCard!
}
/**
* Returns the cached A2A SDK client, creating one lazily on first use.
* Also fetches and caches the agent card for name/description.
@@ -0,0 +1,78 @@
# A2A Client Tool
Communicates with remote A2A (Agent-to-Agent) protocol agents. Exposes `discover` (fetch agent card) and `send_message` (send text, receive response) operations behind an endpoint allowlist.
## ⚠️ Security Warning
**This tool makes network requests to remote A2A agent endpoints.**
- Only allow endpoints you trust — the allowlist is enforced before any network connection is made
- Requests execute with the network access of the host process
- For production deployments, consider running in a sandboxed environment (containers, VMs, etc.)
- Never expose this tool to untrusted users or untrusted prompt input without additional security measures
## Usage
```typescript
import { Agent } from '@strands-agents/sdk'
import {
ClientFactory,
DefaultAgentCardResolver,
JsonRpcTransportFactory,
RestTransportFactory,
createAuthenticatingFetchWithRetry,
} from '@a2a-js/sdk/client'
import { makeA2AClient } from '@strands-agents/sdk/vended-tools/a2a-client'
const authFetch = createAuthenticatingFetchWithRetry(fetch, {
headers: async () => ({ Authorization: 'Bearer your-token' }),
shouldRetryWithHeaders: async () => undefined,
})
const a2aClient = makeA2AClient({
allowedEndpoints: [
// No auth needed
'https://agent.example.com',
// Custom ClientFactory for authenticated requests
[
'https://secure-agent.example.com',
new ClientFactory({
transports: [
new JsonRpcTransportFactory({ fetchImpl: authFetch }),
new RestTransportFactory({ fetchImpl: authFetch }),
],
cardResolver: new DefaultAgentCardResolver({ fetchImpl: authFetch }),
}),
],
],
})
const agent = new Agent({
model,
tools: [a2aClient],
systemPrompt: 'You can talk to remote agents. Discover them first, then send messages.',
})
const result = await agent.invoke('Ask the agent at https://agent.example.com to summarize the news')
```
## Input schema
| Field | Type | Required | Description |
| ----------- | ------------------------------ | ------------------ | --------------------------------------------------- |
| `operation` | `'discover' \| 'send_message'` | Yes | Action to perform |
| `endpoint` | `string` | Yes | Base URL of the target agent (must be in allowlist) |
| `message` | `string \| null` | For `send_message` | Text to send |
## How it works
- **Discover** connects to the endpoint and returns the agent card as a plain JSON object.
- **Send message** calls the A2A agent and returns the serialized response message.
Both operations enforce the endpoint allowlist before making any network connection.
## Limitations
- **Text only.** Binary parts, images, and other modalities are not supported.
- **Stateless.** Each call creates a fresh connection with no session continuity.
- **No streaming.** The tool waits for the full response before returning.
@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { makeA2AClient } from '../a2a-client.js'
import type { AgentCard } from '@a2a-js/sdk'
import type { ClientFactory as ClientFactoryType } from '@a2a-js/sdk/client'
const mockGetAgentCard = vi.fn()
const mockSendMessageStream = vi.fn()
vi.mock('@a2a-js/sdk/client', () => ({
ClientFactory: class MockClientFactory {
async createFromUrl(): Promise<{
sendMessageStream: typeof mockSendMessageStream
getAgentCard: typeof mockGetAgentCard
}> {
return {
sendMessageStream: mockSendMessageStream,
getAgentCard: mockGetAgentCard,
}
}
},
}))
const FAKE_CARD: AgentCard = {
name: 'Test Agent',
description: 'A test agent',
version: '1.0.0',
protocolVersion: '0.2.0',
url: 'https://agent.example.com',
defaultInputModes: ['text/plain'],
defaultOutputModes: ['text/plain'],
skills: [],
capabilities: {},
}
const ENDPOINT = 'https://agent.example.com'
const ENDPOINTS: string[] = [ENDPOINT]
async function* mockStream(...events: unknown[]): AsyncGenerator<unknown, void, undefined> {
for (const event of events) {
yield event
}
}
function setupSendMessageResponse(text: string): void {
mockSendMessageStream.mockReturnValue(
mockStream({
kind: 'task',
id: 'task-1',
contextId: 'ctx-1',
status: { state: 'completed' },
artifacts: [
{
artifactId: 'art-1',
parts: [{ kind: 'text', text }],
},
],
})
)
}
describe('a2a-client tool', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAgentCard.mockResolvedValue(FAKE_CARD)
setupSendMessageResponse('Hello from agent')
})
describe('factory', () => {
it('throws when allowedEndpoints is empty', () => {
expect(() => makeA2AClient({ allowedEndpoints: [] })).toThrow(
'allowedEndpoints must contain at least one endpoint'
)
})
it('uses custom name', () => {
const t = makeA2AClient({ name: 'my_agent', allowedEndpoints: ENDPOINTS })
expect(t.name).toBe('my_agent')
})
it('default description includes endpoints', () => {
const t = makeA2AClient({
allowedEndpoints: ['https://a.example.com', 'https://b.example.com'],
})
expect(t.description).toContain('https://a.example.com')
expect(t.description).toContain('https://b.example.com')
})
it('custom description overrides the default', () => {
const t = makeA2AClient({
description: 'My custom description',
allowedEndpoints: ENDPOINTS,
})
expect(t.description).toBe('My custom description')
})
})
describe('allowlist', () => {
it('rejects an endpoint not in the allowlist', async () => {
const t = makeA2AClient({
allowedEndpoints: ['https://a.example.com', 'https://b.example.com'],
})
await expect(t.invoke({ operation: 'discover', endpoint: 'https://evil.example.com' })).rejects.toThrow(
'not in the allowed endpoints list'
)
})
it('includes permitted endpoints in the rejection message', async () => {
const t = makeA2AClient({
allowedEndpoints: ['https://a.example.com'],
})
await expect(t.invoke({ operation: 'discover', endpoint: 'https://evil.example.com' })).rejects.toThrow(
'https://a.example.com'
)
})
})
describe('discover', () => {
it('returns the agent card as a plain object', async () => {
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
const result = await t.invoke({ operation: 'discover', endpoint: ENDPOINT })
expect(result).toEqual(FAKE_CARD)
})
it('wraps discovery errors with cause', async () => {
const original = new Error('connection refused')
mockGetAgentCard.mockRejectedValue(original)
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
await expect(t.invoke({ operation: 'discover', endpoint: ENDPOINT })).rejects.toSatisfy((err: Error) => {
expect(err).toBeInstanceOf(Error)
expect(err.message).toContain('Failed to discover agent card')
expect(err.cause).toBe(original)
return true
})
})
})
describe('send_message', () => {
it('returns the response message', async () => {
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
const result = (await t.invoke({
operation: 'send_message',
endpoint: ENDPOINT,
message: 'Hello',
})) as Record<string, unknown>
expect(result).toHaveProperty('message')
const msg = result['message'] as Record<string, unknown>
expect(msg).toHaveProperty('role', 'assistant')
})
it("requires 'message' parameter", async () => {
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT })).rejects.toThrow("'message' is required")
})
it("rejects empty string as 'message'", async () => {
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT, message: '' })).rejects.toThrow(
"'message' is required"
)
})
it("rejects null as 'message'", async () => {
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT, message: null })).rejects.toThrow(
"'message' is required"
)
})
it('wraps send errors with cause', async () => {
const original = new Error('timeout')
mockSendMessageStream.mockImplementation(() => {
throw original
})
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT, message: 'Hello' })).rejects.toSatisfy(
(err: Error) => {
expect(err).toBeInstanceOf(Error)
expect(err.message).toContain('Failed to send message')
expect(err.cause).toBe(original)
return true
}
)
})
it.each(['failed', 'rejected', 'canceled', 'input-required', 'auth-required'])(
'throws when remote task state is %s',
async (state) => {
mockSendMessageStream.mockReturnValue(
mockStream({
kind: 'task',
id: 'task-1',
contextId: 'ctx-1',
status: {
state,
message: {
kind: 'message',
messageId: 'msg-1',
role: 'agent',
parts: [{ kind: 'text', text: 'something went wrong' }],
},
},
})
)
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT, message: 'Hello' })).rejects.toThrow(
`did not complete: task state is '${state}'. something went wrong`
)
}
)
it('throws for a failed task with no status message', async () => {
mockSendMessageStream.mockReturnValue(
mockStream({
kind: 'task',
id: 'task-1',
contextId: 'ctx-1',
status: { state: 'failed' },
})
)
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT, message: 'Hello' })).rejects.toThrow(
"did not complete: task state is 'failed'"
)
})
})
describe('per-endpoint config', () => {
it('uses the provided ClientFactory for the endpoint', async () => {
const customGetAgentCard = vi.fn().mockResolvedValue(FAKE_CARD)
const customFactory = {
createFromUrl: vi.fn().mockResolvedValue({
getAgentCard: customGetAgentCard,
sendMessageStream: mockSendMessageStream,
}),
}
const t = makeA2AClient({ allowedEndpoints: [[ENDPOINT, customFactory as unknown as ClientFactoryType]] })
await t.invoke({ operation: 'discover', endpoint: ENDPOINT })
expect(customFactory.createFromUrl).toHaveBeenCalledWith(ENDPOINT, undefined)
expect(customGetAgentCard).toHaveBeenCalled()
})
})
})
@@ -0,0 +1,179 @@
import type { ClientFactory as ClientFactoryType } from '@a2a-js/sdk/client'
import type { AgentCard, TextPart } from '@a2a-js/sdk'
import { tool } from '../../tools/tool-factory.js'
import type { JSONValue } from '../../types/json.js'
import type { MessageData } from '../../types/messages.js'
import { A2AAgent } from '../../a2a/a2a-agent.js'
import { A2AStreamUpdateEvent } from '../../a2a/events.js'
import { z } from 'zod'
/**
* Zod schema for A2A client input validation.
*/
const a2aClientInputSchema = z.object({
operation: z
.enum(['discover', 'send_message'])
.describe("Action to perform — 'discover' to fetch the agent card, or 'send_message' to send a message"),
endpoint: z.string().describe('Base URL of the target A2A agent. Must be one of the permitted endpoints.'),
message: z
.string()
.nullable()
.optional()
.describe("Text to send to the agent. Required when operation is 'send_message'; ignored otherwise."),
})
/**
* A single endpoint entry: either a bare URL string, or a
* `[url, ClientFactory]` tuple for endpoints that need custom configuration.
*/
type EndpointEntry = string | [string, ClientFactoryType]
/**
* Default description shown to the model for the A2A client tool.
*
* @example
* ```typescript
* import { ClientFactory, DefaultAgentCardResolver, JsonRpcTransportFactory, RestTransportFactory, createAuthenticatingFetchWithRetry } from '@a2a-js/sdk/client'
*
* const authFetch = createAuthenticatingFetchWithRetry(fetch, {
* headers: async () => ({ Authorization: 'Bearer your-token' }),
* shouldRetryWithHeaders: async () => undefined,
* })
*
* const a2aClient = makeA2AClient({
* allowedEndpoints: [
* 'https://agent.example.com',
* ['https://secure-agent.example.com', new ClientFactory({
* transports: [
* new JsonRpcTransportFactory({ fetchImpl: authFetch }),
* new RestTransportFactory({ fetchImpl: authFetch }),
* ],
* cardResolver: new DefaultAgentCardResolver({ fetchImpl: authFetch }),
* })],
* ],
* })
* const agent = new Agent({ model, tools: [a2aClient] })
* ```
*/
export const DEFAULT_A2A_CLIENT_DESCRIPTION =
'Interacts with remote A2A (Agent-to-Agent) protocol agents. ' +
"Use operation='discover' to fetch an agent card from an endpoint. " +
"Use operation='send_message' to send a message and receive a response. " +
'Only the listed endpoints are permitted.'
export interface MakeA2AClientOptions {
name?: string
description?: string
allowedEndpoints: EndpointEntry[]
}
/**
* Create an A2A client tool that communicates with remote A2A-protocol agents.
* Each endpoint may carry its own ClientFactory for per-endpoint authentication.
* A fresh A2AAgent is constructed on every call (stateless).
*/
export function makeA2AClient(options: MakeA2AClientOptions): ReturnType<typeof tool> {
if (options.allowedEndpoints.length === 0) {
throw new Error('allowedEndpoints must contain at least one endpoint')
}
const endpointsMap = normalizeEndpoints(options.allowedEndpoints)
const endpoints = Object.keys(endpointsMap)
const description =
options.description ?? `${DEFAULT_A2A_CLIENT_DESCRIPTION} Permitted endpoints: ${[...endpoints].sort().join(', ')}.`
return tool({
name: options.name ?? 'a2a_client',
description,
inputSchema: a2aClientInputSchema,
callback: async (input) => {
const { operation, endpoint, message } = input
if (!Object.hasOwn(endpointsMap, endpoint)) {
throw new Error(
`Endpoint '${endpoint}' is not in the allowed endpoints list. ` +
`Permitted endpoints: ${[...endpoints].sort().join(', ')}`
)
}
const clientFactory = endpointsMap[endpoint]
const agent = new A2AAgent({
url: endpoint,
...(clientFactory !== undefined ? { clientFactory } : {}),
})
if (operation === 'discover') {
return handleDiscover(agent) as unknown as Promise<JSONValue>
}
if (operation === 'send_message') {
if (!message) {
throw new Error("'message' is required for send_message operation")
}
return handleSendMessage(agent, message) as unknown as Promise<JSONValue>
}
throw new Error(`Unknown operation: '${String(operation)}'`)
},
})
}
function normalizeEndpoints(entries: EndpointEntry[]): Record<string, ClientFactoryType | undefined> {
const result: Record<string, ClientFactoryType | undefined> = {}
for (const entry of entries) {
if (typeof entry === 'string') {
result[entry] = undefined
} else if (Array.isArray(entry) && entry.length === 2 && typeof entry[0] === 'string') {
result[entry[0]] = entry[1]
} else {
throw new Error(`Each endpoint entry must be a string or [string, ClientFactory] tuple`)
}
}
return result
}
async function handleDiscover(agent: A2AAgent): Promise<AgentCard> {
let agentCard: AgentCard
try {
agentCard = await agent.getAgentCard()
} catch (error) {
throw new Error(`Failed to discover agent card at '${agent.id}': ${String(error)}`, { cause: error })
}
return agentCard
}
async function handleSendMessage(agent: A2AAgent, message: string): Promise<{ message: MessageData }> {
let taskState: string | undefined
let statusText = ''
let resultMessage: MessageData
try {
const gen = agent.stream(message)
let next = await gen.next()
while (!next.done) {
const event = next.value instanceof A2AStreamUpdateEvent ? next.value.event : undefined
if (event?.kind === 'task' || event?.kind === 'status-update') {
taskState = event.status.state
statusText = (event.status.message?.parts ?? [])
.filter((p): p is TextPart => p.kind === 'text')
.map((p) => p.text)
.join(' ')
}
next = await gen.next()
}
const { role, content } = next.value.lastMessage.toJSON()
resultMessage = { role, content }
} catch (error) {
throw new Error(`Failed to send message to '${agent.id}': ${String(error)}`, { cause: error })
}
if (taskState !== undefined && taskState !== 'completed') {
throw new Error(
`Remote agent at '${agent.id}' did not complete: task state is '${taskState}'. ${statusText}`.trimEnd()
)
}
return { message: resultMessage }
}
@@ -0,0 +1,6 @@
/**
* A2A client tool for communicating with remote A2A-protocol agents.
*/
export { makeA2AClient, DEFAULT_A2A_CLIENT_DESCRIPTION } from './a2a-client.js'
export type { MakeA2AClientOptions } from './a2a-client.js'