Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions src/fast_agent/mcp/transport_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
103 changes: 103 additions & 0 deletions tests/unit/fast_agent/mcp/test_transport_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading