diff --git a/packages/agentenv-protocol/src/agentenv_protocol/client.py b/packages/agentenv-protocol/src/agentenv_protocol/client.py index a1e352879..64dad1546 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/client.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/client.py @@ -80,7 +80,7 @@ def granted_headers(url: str, grants: dict[str | None, dict[str, str]]) -> dict[ async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: bool, headers: dict[str, str] | None) -> dict: request = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} - url = f"{base_url}{RPC_PATH}" + url = f"{base_url.rstrip('/')}{RPC_PATH}" async with httpx.AsyncClient(verify=verify) as client: response = await client.post(url, json=request, timeout=timeout, headers=request_headers(url, headers)) response.raise_for_status() @@ -117,7 +117,7 @@ async def get_data( async def get_card(base_url: str, timeout: int = 10, verify: bool = True) -> dict: async with httpx.AsyncClient(verify=verify) as client: - response = await client.get(f"{base_url}{WELL_KNOWN_PATH}", timeout=timeout) + response = await client.get(f"{base_url.rstrip('/')}{WELL_KNOWN_PATH}", timeout=timeout) response.raise_for_status() return response.json() @@ -341,7 +341,10 @@ async def invoke_extension(base_url: str, card: dict, uri: str, params: dict | N raise ValueError(f"extension {uri} does not advertise method {method!r} (advertises: {offered})") if not op["endpoint"]: raise ValueError(f"extension {uri} has no endpoint in params") - url = f"{base_url}{op['endpoint']}" + endpoint = op["endpoint"] + if not endpoint.startswith("/"): + endpoint = f"/{endpoint}" + url = f"{base_url.rstrip('/')}{endpoint}" sent = request_headers(url, headers) async with httpx.AsyncClient(verify=verify) as client: if op["method"] == "GET": diff --git a/packages/agentenv-protocol/tests/test_client.py b/packages/agentenv-protocol/tests/test_client.py index bfc65e9a7..0b804a174 100644 --- a/packages/agentenv-protocol/tests/test_client.py +++ b/packages/agentenv-protocol/tests/test_client.py @@ -76,6 +76,20 @@ async def test_get_card_gets_well_known(): assert client.get.call_args.args[0] == f"{_BASE}/.well-known/agent-env.json" +@pytest.mark.asyncio +async def test_rpc_and_card_strip_trailing_slashes_from_base_url(): + base_with_slash = f"{_BASE}/" + client = _client(_resp({"jsonrpc": "2.0", "id": 1, "result": {}})) + with _patch(client): + await protocol_v1.reset_data(base_with_slash) + assert client.post.call_args.args[0] == f"{_BASE}/agentenv" + + client = _client(_resp({"name": "items"})) + with _patch(client): + await protocol_v1.get_card(base_with_slash) + assert client.get.call_args.args[0] == f"{_BASE}/.well-known/agent-env.json" + + @pytest.mark.asyncio async def test_supports_v1_true_on_card(): with _patch(_client(_resp({"name": "items"}))): @@ -270,6 +284,36 @@ async def test_invoke_extension_posts_to_advertised_endpoint(): assert kwargs["json"] == {"tool_name": "list_items", "error_rate": 0.75} +@pytest.mark.asyncio +async def test_invoke_extension_strips_trailing_slashes_from_base_url(): + base_with_slash = f"{_BASE}/" + client = _client(_resp({"tool_name": "list_items", "error_rate": 0.75})) + with _patch(client): + await protocol_v1.invoke_extension( + base_with_slash, _CARD_SET_ERRORS, "urn:agentenv:set-errors/v1", + {"tool_name": "list_items", "error_rate": 0.75}, + ) + args, _ = client.request.call_args + assert args[1] == f"{_BASE}/agentenv/ext/set_errors" + + +@pytest.mark.asyncio +async def test_invoke_extension_ensures_leading_slash_on_relative_endpoint(): + card = {"capabilities": {"extensions": [{ + "uri": "urn:agentenv:set-errors/v1", + "params": {"endpoint": "agentenv/ext/set_errors", + "methods": {"set_errors": {"method": "POST", "request": {}}}}, + }]}} + client = _client(_resp({"tool_name": "list_items", "error_rate": 0.75})) + with _patch(client): + await protocol_v1.invoke_extension( + _BASE, card, "urn:agentenv:set-errors/v1", + {"tool_name": "list_items", "error_rate": 0.75}, + ) + args, _ = client.request.call_args + assert args[1] == f"{_BASE}/agentenv/ext/set_errors" + + @pytest.mark.asyncio async def test_invoke_extension_get_uses_query_params(): card = {"capabilities": {"extensions": [{ @@ -396,3 +440,10 @@ async def test_invoke_extension_unknown_method_raises_without_calling(): async def test_invoke_extension_named_method_on_a_methodless_extension_raises(): with pytest.raises(ValueError, match=re.escape("does not advertise method 'set_time' (advertises: none)")): await protocol_v1.invoke_extension(_BASE, _CARD_WITH_EXT, "urn:agentenv:clock/v1", method="set_time") + + +@pytest.mark.asyncio +async def test_invoke_extension_missing_endpoint_raises_value_error(): + card = {"capabilities": {"extensions": [{"uri": "urn:agentenv:test/v1", "params": {}}]}} + with pytest.raises(ValueError, match="has no endpoint in params"): + await protocol_v1.invoke_extension(_BASE, card, "urn:agentenv:test/v1")