From 7bd55a52b8085c5dfcefd145a63ff13c0ed973c4 Mon Sep 17 00:00:00 2001 From: Carlos Chinchilla Corbacho <188046461+cchinchilla-dev@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:53:00 +0100 Subject: [PATCH 1/5] fix(grpc): normalize extension metadata header key to lowercase --- src/a2a/client/transports/grpc.py | 4 +- .../server/request_handlers/grpc_handler.py | 4 +- tests/client/transports/test_grpc_client.py | 41 +++++++++++++------ .../request_handlers/test_grpc_handler.py | 20 ++++----- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/src/a2a/client/transports/grpc.py b/src/a2a/client/transports/grpc.py index 6a8b16f92..50118b7e7 100644 --- a/src/a2a/client/transports/grpc.py +++ b/src/a2a/client/transports/grpc.py @@ -65,9 +65,9 @@ def _get_grpc_metadata( ) -> list[tuple[str, str]] | None: """Creates gRPC metadata for extensions.""" if extensions is not None: - return [(HTTP_EXTENSION_HEADER, ','.join(extensions))] + return [(HTTP_EXTENSION_HEADER.lower(), ','.join(extensions))] if self.extensions is not None: - return [(HTTP_EXTENSION_HEADER, ','.join(self.extensions))] + return [(HTTP_EXTENSION_HEADER.lower(), ','.join(self.extensions))] return None @classmethod diff --git a/src/a2a/server/request_handlers/grpc_handler.py b/src/a2a/server/request_handlers/grpc_handler.py index 105b99471..0752e6106 100644 --- a/src/a2a/server/request_handlers/grpc_handler.py +++ b/src/a2a/server/request_handlers/grpc_handler.py @@ -56,7 +56,7 @@ def _get_metadata_value( md = context.invocation_metadata raw_values: list[str | bytes] = [] if isinstance(md, Metadata): - raw_values = md.get_all(key) + raw_values = md.get_all(key.lower()) elif isinstance(md, Sequence): lower_key = key.lower() raw_values = [e for (k, e) in md if k.lower() == lower_key] @@ -417,7 +417,7 @@ def _set_extension_metadata( if server_context.activated_extensions: context.set_trailing_metadata( [ - (HTTP_EXTENSION_HEADER, e) + (HTTP_EXTENSION_HEADER.lower(), e) for e in sorted(server_context.activated_extensions) ] ) diff --git a/tests/client/transports/test_grpc_client.py b/tests/client/transports/test_grpc_client.py index 111e44ba6..4f81b1957 100644 --- a/tests/client/transports/test_grpc_client.py +++ b/tests/client/transports/test_grpc_client.py @@ -202,7 +202,7 @@ async def test_send_message_task_response( _, kwargs = mock_grpc_stub.SendMessage.call_args assert kwargs['metadata'] == [ ( - HTTP_EXTENSION_HEADER, + HTTP_EXTENSION_HEADER.lower(), 'https://example.com/test-ext/v3', ) ] @@ -228,7 +228,7 @@ async def test_send_message_message_response( _, kwargs = mock_grpc_stub.SendMessage.call_args assert kwargs['metadata'] == [ ( - HTTP_EXTENSION_HEADER, + HTTP_EXTENSION_HEADER.lower(), 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', ) ] @@ -283,7 +283,7 @@ async def test_send_message_streaming( # noqa: PLR0913 _, kwargs = mock_grpc_stub.SendStreamingMessage.call_args assert kwargs['metadata'] == [ ( - HTTP_EXTENSION_HEADER, + HTTP_EXTENSION_HEADER.lower(), 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', ) ] @@ -313,7 +313,7 @@ async def test_get_task( ), metadata=[ ( - HTTP_EXTENSION_HEADER, + HTTP_EXTENSION_HEADER.lower(), 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', ) ], @@ -338,7 +338,7 @@ async def test_get_task_with_history( ), metadata=[ ( - HTTP_EXTENSION_HEADER, + HTTP_EXTENSION_HEADER.lower(), 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', ) ], @@ -363,7 +363,7 @@ async def test_cancel_task( mock_grpc_stub.CancelTask.assert_awaited_once_with( a2a_pb2.CancelTaskRequest(name=f'tasks/{sample_task.id}'), - metadata=[(HTTP_EXTENSION_HEADER, 'https://example.com/test-ext/v3')], + metadata=[(HTTP_EXTENSION_HEADER.lower(), 'https://example.com/test-ext/v3')], ) assert response.status.state == TaskState.canceled @@ -395,7 +395,7 @@ async def test_set_task_callback_with_valid_task( ), metadata=[ ( - HTTP_EXTENSION_HEADER, + HTTP_EXTENSION_HEADER.lower(), 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', ) ], @@ -458,7 +458,7 @@ async def test_get_task_callback_with_valid_task( ), metadata=[ ( - HTTP_EXTENSION_HEADER, + HTTP_EXTENSION_HEADER.lower(), 'https://example.com/test-ext/v1,https://example.com/test-ext/v2', ) ], @@ -506,27 +506,27 @@ async def test_get_task_callback_with_invalid_task( ( ['ext1'], None, - [(HTTP_EXTENSION_HEADER, 'ext1')], + [(HTTP_EXTENSION_HEADER.lower(), 'ext1')], ), # Case 2: Initial, No input ( None, ['ext2'], - [(HTTP_EXTENSION_HEADER, 'ext2')], + [(HTTP_EXTENSION_HEADER.lower(), 'ext2')], ), # Case 3: No initial, Input ( ['ext1'], ['ext2'], - [(HTTP_EXTENSION_HEADER, 'ext2')], + [(HTTP_EXTENSION_HEADER.lower(), 'ext2')], ), # Case 4: Initial, Input (override) ( ['ext1'], ['ext2', 'ext3'], - [(HTTP_EXTENSION_HEADER, 'ext2,ext3')], + [(HTTP_EXTENSION_HEADER.lower(), 'ext2,ext3')], ), # Case 5: Initial, Multiple inputs (override) ( ['ext1', 'ext2'], ['ext3'], - [(HTTP_EXTENSION_HEADER, 'ext3')], + [(HTTP_EXTENSION_HEADER.lower(), 'ext3')], ), # Case 6: Multiple initial, Single input (override) ], ) @@ -540,3 +540,18 @@ def test_get_grpc_metadata( grpc_transport.extensions = initial_extensions metadata = grpc_transport._get_grpc_metadata(input_extensions) assert metadata == expected_metadata + +def test_get_grpc_metadata_uses_lowercase_header_key( + grpc_transport: GrpcTransport, +) -> None: + """Test gRPC metadata header key is always lowercase.""" + # Regression: gRPC rejects non-lowercase metadata keys + metadata = grpc_transport._get_grpc_metadata(['ext1']) + assert metadata is not None + key, _ = metadata[0] + assert key == key.lower() + + metadata = grpc_transport._get_grpc_metadata() + assert metadata is not None + key, _ = metadata[0] + assert key == key.lower() diff --git a/tests/server/request_handlers/test_grpc_handler.py b/tests/server/request_handlers/test_grpc_handler.py index 647d9e86f..e9d8cb264 100644 --- a/tests/server/request_handlers/test_grpc_handler.py +++ b/tests/server/request_handlers/test_grpc_handler.py @@ -350,8 +350,8 @@ async def test_send_message_with_extensions( mock_grpc_context: AsyncMock, ) -> None: mock_grpc_context.invocation_metadata = grpc.aio.Metadata( - (HTTP_EXTENSION_HEADER, 'foo'), - (HTTP_EXTENSION_HEADER, 'bar'), + (HTTP_EXTENSION_HEADER.lower(), 'foo'), + (HTTP_EXTENSION_HEADER.lower(), 'bar'), ) def side_effect(request, context: ServerCallContext): @@ -379,8 +379,8 @@ def side_effect(request, context: ServerCallContext): mock_grpc_context.set_trailing_metadata.call_args.args[0] ) assert set(called_metadata) == { - (HTTP_EXTENSION_HEADER, 'foo'), - (HTTP_EXTENSION_HEADER, 'baz'), + (HTTP_EXTENSION_HEADER.lower(), 'foo'), + (HTTP_EXTENSION_HEADER.lower(), 'baz'), } async def test_send_message_with_comma_separated_extensions( @@ -390,8 +390,8 @@ async def test_send_message_with_comma_separated_extensions( mock_grpc_context: AsyncMock, ) -> None: mock_grpc_context.invocation_metadata = grpc.aio.Metadata( - (HTTP_EXTENSION_HEADER, 'foo ,, bar,'), - (HTTP_EXTENSION_HEADER, 'baz , bar'), + (HTTP_EXTENSION_HEADER.lower(), 'foo ,, bar,'), + (HTTP_EXTENSION_HEADER.lower(), 'baz , bar'), ) mock_request_handler.on_message_send.return_value = types.Message( message_id='1', @@ -415,8 +415,8 @@ async def test_send_streaming_message_with_extensions( mock_grpc_context: AsyncMock, ) -> None: mock_grpc_context.invocation_metadata = grpc.aio.Metadata( - (HTTP_EXTENSION_HEADER, 'foo'), - (HTTP_EXTENSION_HEADER, 'bar'), + (HTTP_EXTENSION_HEADER.lower(), 'foo'), + (HTTP_EXTENSION_HEADER.lower(), 'bar'), ) async def side_effect(request, context: ServerCallContext): @@ -450,6 +450,6 @@ async def side_effect(request, context: ServerCallContext): mock_grpc_context.set_trailing_metadata.call_args.args[0] ) assert set(called_metadata) == { - (HTTP_EXTENSION_HEADER, 'foo'), - (HTTP_EXTENSION_HEADER, 'baz'), + (HTTP_EXTENSION_HEADER.lower(), 'foo'), + (HTTP_EXTENSION_HEADER.lower(), 'baz'), } From 4768db1f200c1401fbe14f21a29746cd87326f4b Mon Sep 17 00:00:00 2001 From: Carlos Chinchilla Corbacho <188046461+cchinchilla-dev@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:56:10 +0100 Subject: [PATCH 2/5] fix(grpc): normalize extension metadata header key to lowercase (linter) --- tests/client/transports/test_grpc_client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/client/transports/test_grpc_client.py b/tests/client/transports/test_grpc_client.py index 4f81b1957..c4884081c 100644 --- a/tests/client/transports/test_grpc_client.py +++ b/tests/client/transports/test_grpc_client.py @@ -363,7 +363,9 @@ async def test_cancel_task( mock_grpc_stub.CancelTask.assert_awaited_once_with( a2a_pb2.CancelTaskRequest(name=f'tasks/{sample_task.id}'), - metadata=[(HTTP_EXTENSION_HEADER.lower(), 'https://example.com/test-ext/v3')], + metadata=[ + (HTTP_EXTENSION_HEADER.lower(), 'https://example.com/test-ext/v3') + ], ) assert response.status.state == TaskState.canceled @@ -541,6 +543,7 @@ def test_get_grpc_metadata( metadata = grpc_transport._get_grpc_metadata(input_extensions) assert metadata == expected_metadata + def test_get_grpc_metadata_uses_lowercase_header_key( grpc_transport: GrpcTransport, ) -> None: From b84fecbebf092555e071482a3304e7d30df46eb1 Mon Sep 17 00:00:00 2001 From: Carlos Chinchilla Corbacho <188046461+cchinchilla-dev@users.noreply.github.com> Date: Sun, 8 Feb 2026 19:08:20 +0100 Subject: [PATCH 3/5] Update tests/client/transports/test_grpc_client.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tests/client/transports/test_grpc_client.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/client/transports/test_grpc_client.py b/tests/client/transports/test_grpc_client.py index c4884081c..0d5021dcc 100644 --- a/tests/client/transports/test_grpc_client.py +++ b/tests/client/transports/test_grpc_client.py @@ -544,17 +544,20 @@ def test_get_grpc_metadata( assert metadata == expected_metadata +@pytest.mark.parametrize( + 'test_extensions', + [ + (['ext1']), # Test with explicit extensions + (None), # Test with transport's default extensions + ], +) def test_get_grpc_metadata_uses_lowercase_header_key( grpc_transport: GrpcTransport, + test_extensions: list[str] | None, ) -> None: """Test gRPC metadata header key is always lowercase.""" # Regression: gRPC rejects non-lowercase metadata keys - metadata = grpc_transport._get_grpc_metadata(['ext1']) - assert metadata is not None - key, _ = metadata[0] - assert key == key.lower() - - metadata = grpc_transport._get_grpc_metadata() + metadata = grpc_transport._get_grpc_metadata(test_extensions) assert metadata is not None key, _ = metadata[0] assert key == key.lower() From 090340501881596c8597455f623589fb8abe8ec1 Mon Sep 17 00:00:00 2001 From: Carlos Chinchilla Corbacho <188046461+cchinchilla-dev@users.noreply.github.com> Date: Sun, 8 Feb 2026 19:12:55 +0100 Subject: [PATCH 4/5] refactor: apply review suggestions for clarity and DRY --- src/a2a/client/transports/grpc.py | 7 +++---- src/a2a/server/request_handlers/grpc_handler.py | 4 ++-- tests/client/transports/test_grpc_client.py | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/a2a/client/transports/grpc.py b/src/a2a/client/transports/grpc.py index 50118b7e7..fa3d017a8 100644 --- a/src/a2a/client/transports/grpc.py +++ b/src/a2a/client/transports/grpc.py @@ -64,10 +64,9 @@ def _get_grpc_metadata( extensions: list[str] | None = None, ) -> list[tuple[str, str]] | None: """Creates gRPC metadata for extensions.""" - if extensions is not None: - return [(HTTP_EXTENSION_HEADER.lower(), ','.join(extensions))] - if self.extensions is not None: - return [(HTTP_EXTENSION_HEADER.lower(), ','.join(self.extensions))] + ext_to_use = extensions if extensions is not None else self.extensions + if ext_to_use is not None: + return [(HTTP_EXTENSION_HEADER.lower(), ','.join(ext_to_use))] return None @classmethod diff --git a/src/a2a/server/request_handlers/grpc_handler.py b/src/a2a/server/request_handlers/grpc_handler.py index 0752e6106..d4a6bbd4c 100644 --- a/src/a2a/server/request_handlers/grpc_handler.py +++ b/src/a2a/server/request_handlers/grpc_handler.py @@ -55,10 +55,10 @@ def _get_metadata_value( ) -> list[str]: md = context.invocation_metadata raw_values: list[str | bytes] = [] + lower_key = key.lower() if isinstance(md, Metadata): - raw_values = md.get_all(key.lower()) + raw_values = md.get_all(lower_key) elif isinstance(md, Sequence): - lower_key = key.lower() raw_values = [e for (k, e) in md if k.lower() == lower_key] return [e if isinstance(e, str) else e.decode('utf-8') for e in raw_values] diff --git a/tests/client/transports/test_grpc_client.py b/tests/client/transports/test_grpc_client.py index 0d5021dcc..3d11ae200 100644 --- a/tests/client/transports/test_grpc_client.py +++ b/tests/client/transports/test_grpc_client.py @@ -548,7 +548,7 @@ def test_get_grpc_metadata( 'test_extensions', [ (['ext1']), # Test with explicit extensions - (None), # Test with transport's default extensions + (None), # Test with transport's default extensions ], ) def test_get_grpc_metadata_uses_lowercase_header_key( From 00a0b07be89b86af4a2b488c1fe5f1980d18811d Mon Sep 17 00:00:00 2001 From: Carlos Chinchilla Corbacho <188046461+cchinchilla-dev@users.noreply.github.com> Date: Sun, 8 Feb 2026 20:12:10 +0100 Subject: [PATCH 5/5] Update tests/client/transports/test_grpc_client.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tests/client/transports/test_grpc_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/client/transports/test_grpc_client.py b/tests/client/transports/test_grpc_client.py index 3d11ae200..3be7b3f09 100644 --- a/tests/client/transports/test_grpc_client.py +++ b/tests/client/transports/test_grpc_client.py @@ -558,6 +558,6 @@ def test_get_grpc_metadata_uses_lowercase_header_key( """Test gRPC metadata header key is always lowercase.""" # Regression: gRPC rejects non-lowercase metadata keys metadata = grpc_transport._get_grpc_metadata(test_extensions) - assert metadata is not None - key, _ = metadata[0] - assert key == key.lower() + if metadata: + key, _ = metadata[0] + assert key == key.lower()