Skip to content

Commit 396363b

Browse files
akolotovclaude
andcommitted
Reject non-finite x-credits-remaining values in CreditSink
A non-finite `x-credits-remaining` header value (e.g. `-Infinity`) parsed by `_capture_credits_remaining` reached `CreditSink` and caused two defects: - `-inf` later hit `int(remaining)` in the low-credits display branch of `build_tool_response`, raising `OverflowError` and turning a successful tool call into a hard error. - A `nan`/`-inf` recorded first poisoned the running minimum: `value < self.remaining` is `False` for every later observation, so a genuine low balance was dropped and the advisory note silently suppressed for the whole invocation. Guard at the single chokepoint `CreditSink.record`: non-finite values are now ignored, enforcing the invariant "remaining is None or a finite float". This fixes both the crash and the suppression without touching `build_tool_response`. Add unit tests (non-finite ignored, minimum not poisoned) and an end-to-end regression test that a non-finite captured header neither crashes nor emits a note. Bump version to 0.16.0.dev18. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f9cc3af commit 396363b

5 files changed

Lines changed: 70 additions & 3 deletions

File tree

blockscout_mcp_server/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# SPDX-License-Identifier: LicenseRef-Blockscout
22
"""Blockscout MCP Server package."""
33

4-
__version__ = "0.16.0.dev17"
4+
__version__ = "0.16.0.dev18"

blockscout_mcp_server/pro_api_key_context.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import functools
5050
import inspect
5151
import logging
52+
import math
5253
from collections.abc import Awaitable, Callable
5354
from contextvars import ContextVar
5455
from dataclasses import dataclass
@@ -121,6 +122,9 @@ class CreditSink:
121122
122123
The minimum is retained (rather than the latest) as the conservative choice:
123124
it warns earlier and is order-independent across concurrent requests.
125+
126+
Invariant: ``remaining`` is either ``None`` or a *finite* float. See
127+
:meth:`record` for why non-finite values are rejected at the door.
124128
"""
125129

126130
def __init__(self) -> None:
@@ -130,7 +134,21 @@ def record(self, value: float) -> None:
130134
"""Update the stored minimum with *value*.
131135
132136
First observation sets the value; subsequent observations only lower it.
137+
138+
Non-finite values (``nan``, ``+inf``, ``-inf``) are silently ignored so
139+
the invariant "``remaining`` is ``None`` or a *finite* float" always
140+
holds. This is the single chokepoint every value enters through, so the
141+
guard belongs here:
142+
- ``float("-Infinity")`` would otherwise crash a downstream
143+
``int(remaining)`` display conversion with ``OverflowError``.
144+
- A ``nan`` (or ``-inf``) recorded first would *poison* the minimum: the
145+
``value < self.remaining`` comparison is ``False`` for every later
146+
real observation (``x < nan`` is always ``False``; nothing is ``<
147+
-inf``), so a genuine low balance would be dropped and the advisory
148+
note silently suppressed for the whole invocation.
133149
"""
150+
if not math.isfinite(value):
151+
return
134152
if self.remaining is None or value < self.remaining:
135153
self.remaining = value
136154

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "blockscout-mcp-server"
3-
version = "0.16.0.dev17"
3+
version = "0.16.0.dev18"
44
description = "MCP server for Blockscout"
55
requires-python = ">=3.11"
66
dependencies = [

server.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
33
"name": "com.blockscout/mcp-server",
44
"description": "MCP server for Blockscout",
5-
"version": "0.16.0.dev17",
5+
"version": "0.16.0.dev18",
66
"websiteUrl": "https://blockscout.com",
77
"repository": {
88
"url": "https://github.com/blockscout/mcp-server",

tests/tools/test_credit_tracking.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,33 @@ def test_credit_sink_negative_beats_positive():
142142
assert sink.remaining == -5.0
143143

144144

145+
@pytest.mark.parametrize("non_finite", [float("nan"), float("inf"), float("-inf")])
146+
def test_credit_sink_ignores_non_finite_values(non_finite):
147+
"""nan / ±inf are rejected at the door: the sink stays at its prior state.
148+
149+
float("-Infinity") would otherwise crash a downstream int() display
150+
conversion, and a nan/-inf recorded first would poison the running minimum.
151+
"""
152+
sink = CreditSink()
153+
sink.record(non_finite)
154+
assert sink.remaining is None
155+
156+
157+
@pytest.mark.parametrize("non_finite", [float("nan"), float("-inf")])
158+
def test_credit_sink_non_finite_does_not_poison_minimum(non_finite):
159+
"""Regression: a non-finite observation first must not block a later real
160+
low value from being recorded.
161+
162+
Without the finite guard, `value < self.remaining` is always False once
163+
`remaining` is nan/-inf, so the genuine 2000 would be dropped and the
164+
low-credits advisory silently suppressed for the whole invocation.
165+
"""
166+
sink = CreditSink()
167+
sink.record(non_finite)
168+
sink.record(2000.0)
169+
assert sink.remaining == 2000.0
170+
171+
145172
# ---------------------------------------------------------------------------
146173
# _capture_credits_remaining via make_blockscout_request (GET path)
147174
# ---------------------------------------------------------------------------
@@ -773,6 +800,28 @@ def test_build_tool_response_note_present_for_negative_balance():
773800
assert "-50" in response.notes[0]
774801

775802

803+
@pytest.mark.parametrize("header_value", ["-Infinity", "-inf", "nan", "Infinity"])
804+
def test_build_tool_response_no_crash_on_non_finite_captured_header(header_value):
805+
"""End-to-end regression: a non-finite ``x-credits-remaining`` header must
806+
not crash ``build_tool_response`` and must not emit an advisory note.
807+
808+
``float("-Infinity")`` previously reached ``int(remaining)`` in the display
809+
branch and raised ``OverflowError``. With the finite guard in
810+
``CreditSink.record`` the value never enters the sink, so ``remaining``
811+
stays ``None`` and no note is produced.
812+
"""
813+
from blockscout_mcp_server.tools.common import _capture_credits_remaining, build_tool_response
814+
815+
sink = CreditSink()
816+
with _set_sink(sink):
817+
_capture_credits_remaining(_MockResponse(headers={"x-credits-remaining": header_value}))
818+
with patch.object(config, "pro_api_low_credits_threshold", 5000):
819+
response = build_tool_response(data={"ok": True})
820+
821+
assert sink.remaining is None
822+
assert response.notes is None
823+
824+
776825
def test_build_tool_response_note_absent_when_threshold_disabled():
777826
"""No advisory note when threshold is 0 (feature disabled), even with a low balance."""
778827
from blockscout_mcp_server.tools.common import build_tool_response

0 commit comments

Comments
 (0)