diff --git a/src/fast_agent/mcp/transport_tracking.py b/src/fast_agent/mcp/transport_tracking.py index c5bc04c83..1e5429090 100644 --- a/src/fast_agent/mcp/transport_tracking.py +++ b/src/fast_agent/mcp/transport_tracking.py @@ -335,6 +335,16 @@ def _handle_post_event(self, event: ChannelEvent, now: datetime) -> None: self._post_last_at = now self._record_response_channel(event) + if classification is ActivityState.ERROR: + detail = self._ping_failure_detail(event.message) + if detail is not None: + self._post_last_error = detail + mode_stats.last_error = detail + elif classification is ActivityState.PING and isinstance( + event.message, JSONRPCResponse + ): + self._post_last_error = None + mode_stats.last_error = None if classification is not ActivityState.PING: self._record_history(event.channel, classification, now) elif event.event_type == "error": @@ -372,7 +382,17 @@ def _handle_get_event(self, event: ChannelEvent, now: datetime) -> None: summary = _summarise_classified_message(classification, event.message) self._get_last_summary = summary self._get_last_at = now - self._get_last_event = "ping" if classification is ActivityState.PING else "message" + if classification is ActivityState.PING: + self._get_last_event = "ping" + if isinstance(event.message, JSONRPCResponse): + self._get_last_error = None + elif classification is ActivityState.ERROR: + self._get_last_event = "error" + detail = self._ping_failure_detail(event.message) + if detail is not None: + self._get_last_error = detail + else: + self._get_last_event = "message" self._get_last_event_at = now self._record_response_channel(event) @@ -547,9 +567,19 @@ def _classify_ping_exchange( return classification if classification is ActivityState.RESPONSE and request_id in self._ping_request_ids: self._ping_request_ids.discard(request_id) + if isinstance(root, JSONRPCError): + return ActivityState.ERROR return ActivityState.PING return classification + @staticmethod + def _ping_failure_detail(message: JSONRPCMessage) -> str | None: + if isinstance(message, JSONRPCError): + code = message.error.code + text = message.error.message or "ping failed" + return f"{text} ({code})" if code is not None else text + return None + def _tally_classification( self, channel_key: str, @@ -849,10 +879,10 @@ def _has_get_snapshot_activity(self) -> bool: ) def _get_state(self) -> str: - if self._get_connected: - return "open" if self._get_last_error is not None: return "disabled" if self._get_last_status_code == 405 else "error" + if self._get_connected: + return "open" if self._get_had_connection: return "off" return "idle" diff --git a/tests/unit/fast_agent/mcp/test_transport_tracking.py b/tests/unit/fast_agent/mcp/test_transport_tracking.py index fd58290b0..372e41df8 100644 --- a/tests/unit/fast_agent/mcp/test_transport_tracking.py +++ b/tests/unit/fast_agent/mcp/test_transport_tracking.py @@ -288,6 +288,109 @@ def test_response_channel_records_error_response() -> None: assert metrics.consume_response_channel(7) == "get" +def test_ping_error_response_is_recorded_as_a_connection_failure() -> None: + metrics = TransportChannelMetrics() + metrics.register_ping_request(1) + + metrics.record_event( + ChannelEvent( + channel="get", + event_type="message", + message=JSONRPCError( + jsonrpc="2.0", + id=1, + error=ErrorData(code=-32603, message="ping timeout"), + ), + ) + ) + + snapshot = metrics.snapshot() + assert snapshot.get is not None + assert snapshot.get.state == "error" + assert snapshot.get.last_error == "ping timeout (-32603)" + assert snapshot.get.last_event == "error" + + +def test_ping_error_response_on_post_channel_is_recorded_as_a_connection_failure() -> None: + metrics = TransportChannelMetrics() + metrics.register_ping_request(1) + + metrics.record_event( + ChannelEvent( + channel="post-json", + event_type="message", + message=JSONRPCError( + jsonrpc="2.0", + id=1, + error=ErrorData(code=-32603, message="ping timeout"), + ), + ) + ) + + snapshot = metrics.snapshot() + assert snapshot.post is not None + assert snapshot.post.state == "error" + assert snapshot.post.last_error == "ping timeout (-32603)" + + +def test_get_ping_error_is_visible_while_the_channel_stays_connected() -> None: + metrics = TransportChannelMetrics() + metrics.record_event(ChannelEvent(channel="get", event_type="connect")) + metrics.register_ping_request(1) + + metrics.record_event( + ChannelEvent( + channel="get", + event_type="message", + message=JSONRPCError( + jsonrpc="2.0", + id=1, + error=ErrorData(code=-32603, message="ping timeout"), + ), + ) + ) + + snapshot = metrics.snapshot() + assert snapshot.get is not None + assert snapshot.get.connected is True + assert snapshot.get.state == "error" + assert snapshot.get.last_error == "ping timeout (-32603)" + + +def test_ping_error_clears_on_the_next_successful_ping() -> None: + metrics = TransportChannelMetrics() + metrics.record_event(ChannelEvent(channel="get", event_type="connect")) + metrics.register_ping_request(1) + metrics.record_event( + ChannelEvent( + channel="get", + event_type="message", + message=JSONRPCError( + jsonrpc="2.0", + id=1, + error=ErrorData(code=-32603, message="ping timeout"), + ), + ) + ) + first_snapshot = metrics.snapshot() + assert first_snapshot.get is not None + assert first_snapshot.get.state == "error" + + metrics.register_ping_request(2) + metrics.record_event( + ChannelEvent( + channel="get", + event_type="message", + message=JSONRPCResponse(jsonrpc="2.0", id=2, result={}), + ) + ) + + snapshot = metrics.snapshot() + assert snapshot.get is not None + assert snapshot.get.state == "open" + assert snapshot.get.last_error is None + + @pytest.mark.parametrize( "method", ["ping", "PING", " notifications/PING ", "mcp.ping"],