Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **MCP `accounts_get_follower_stats` returned only the account name, dropping the follower count and daily series.** The shared `_format_response` helper pattern-matches on the response shape, and `FollowerStatsResponse` has an `accounts` attribute, so it fell into the generic account-list branch that prints only `- {platform}: {username}` and silently discarded `currentFollowers`, `growth`, and the daily `stats` series. Hit in practice by a developer pulling LinkedIn org follower stats (data was present server-side: latest count plus a week of daily snapshots), who saw only the account name come back through the tool. `_format_response` now checks for a `stats` attribute (unique to `FollowerStatsResponse` among all response models) BEFORE the generic `accounts` branch and returns the full `model_dump_json(by_alias=True, exclude_none=True)`, so the count, growth, and series reach the LLM losslessly. Fixed in both the emitted `generated_tools.py` and the `generate_mcp_tools.py` template so a future regen keeps it. Two regression tests added in `tests/test_integration.py`. (The related model gap, `FollowerStatsResponse` missing `stats`/`granularity`, was already corrected on `develop` by an earlier OpenAPI regen, so no model change was needed here.)

## [1.4.49]

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ dev = [
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.0.0",
"respx>=0.21.0",
# Needed so tests can import late.mcp.generated_tools (formatter regression test).
"mcp>=1.8.0",
"ruff>=0.8.0",
"mypy>=1.13.0",
"datamodel-code-generator>=0.26.0",
Expand Down
6 changes: 6 additions & 0 deletions scripts/generate_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,12 @@ def main() -> int:
" status = _enum_str(getattr(p, 'status', 'unknown'))",
' lines.append(f"- [{status}] {content}...")',
' return "\\n".join(lines)',
" if hasattr(response, 'accounts') and hasattr(response, 'stats'):",
" # Follower-stats: lossless structured output for the LLM. Must precede the",
" # generic 'accounts' branch (which would print only 'platform: username' and",
" # drop currentFollowers/growth and the daily series). 'stats' attr is unique",
" # to FollowerStatsResponse, so no other tool's response matches.",
" return response.model_dump_json(by_alias=True, exclude_none=True)",
" if hasattr(response, 'accounts') and response.accounts:",
" accs = response.accounts",
' lines = [f"Found {len(accs)} account(s):"]',
Expand Down
6 changes: 6 additions & 0 deletions src/late/mcp/generated_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ def _format_response(response: Any) -> str:
status = _enum_str(getattr(p, "status", "unknown"))
lines.append(f"- [{status}] {content}...")
return "\n".join(lines)
if hasattr(response, "accounts") and hasattr(response, "stats"):
# Follower-stats: lossless structured output for the LLM. Must precede the
# generic 'accounts' branch (which would print only 'platform: username' and
# drop currentFollowers/growth and the daily series). 'stats' attr is unique
# to FollowerStatsResponse, so no other tool's response matches.
return response.model_dump_json(by_alias=True, exclude_none=True)
if hasattr(response, "accounts") and response.accounts:
accs = response.accounts
lines = [f"Found {len(accs)} account(s):"]
Expand Down
4 changes: 2 additions & 2 deletions src/late/resources/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def get_follower_stats(
granularity: Aggregation level ('daily' | 'weekly' | 'monthly')

Returns:
FollowerStatsResponse with 'accounts', 'dateRange', 'aggregation'
FollowerStatsResponse with 'accounts', 'stats', 'dateRange', 'granularity'
"""
if isinstance(account_ids, list):
account_ids = ",".join(account_ids)
Expand Down Expand Up @@ -131,7 +131,7 @@ async def aget_follower_stats(
granularity: Aggregation level ('daily' | 'weekly' | 'monthly')

Returns:
FollowerStatsResponse with 'accounts', 'dateRange', 'aggregation'
FollowerStatsResponse with 'accounts', 'stats', 'dateRange', 'granularity'
"""
if isinstance(account_ids, list):
account_ids = ",".join(account_ids)
Expand Down
87 changes: 87 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,93 @@ def test_get_follower_stats_forwards_all_params(self, client: Late) -> None:
assert "toDate=2026-01-31" in url_str
assert "granularity=weekly" in url_str

@respx.mock
def test_get_follower_stats_parses_stats_and_granularity(
self, client: Late
) -> None:
"""FollowerStatsResponse must surface stats map and granularity from the
live wire shape. Pre-fix this failed because the model lacked both fields
(Pydantic extra='ignore' silently dropped them)."""
wire = {
"accounts": [
{
"_id": "acc_111",
"platform": "linkedin",
"username": "acme",
"profileId": "prof_1",
"isActive": True,
"currentFollowers": 5000,
"growth": 100,
}
],
"stats": {
"acc_111": [
{"date": "2026-01-01", "followers": 4900},
{"date": "2026-01-02", "followers": 5000},
]
},
"dateRange": {
"from": "2026-01-01T00:00:00.000Z",
"to": "2026-01-31T23:59:59.999Z",
},
"granularity": "daily",
}
respx.get("https://api.test.com/v1/accounts/follower-stats").mock(
return_value=httpx.Response(200, json=wire)
)

result = client.accounts.get_follower_stats(account_ids="acc_111")

assert result.stats is not None
assert result.stats != {}
assert result.granularity == "daily"
# Follower count on the account object must survive model_validate
assert result.accounts is not None
assert len(result.accounts) == 1
assert result.accounts[0].currentFollowers == 5000

def test_format_follower_stats_response(self) -> None:
"""_format_response must return lossless JSON for FollowerStatsResponse,
not the lossy 'Found N account(s): - platform: username' one-liner.
Pre-fix this returned the generic accounts branch output."""
import json

from late.mcp.generated_tools import _format_response
from late.models._generated.models import FollowerStatsResponse

wire = {
"accounts": [
{
"_id": "acc_111",
"platform": "linkedin",
"username": "acme",
"profileId": "prof_1",
"isActive": True,
"currentFollowers": 5000,
"growth": 100,
}
],
"stats": {
"acc_111": [
{"date": "2026-01-01", "followers": 4900},
{"date": "2026-01-02", "followers": 5000},
]
},
"granularity": "daily",
}
response = FollowerStatsResponse.model_validate(wire)
output = _format_response(response)

# Must be valid JSON (model_dump_json path)
parsed = json.loads(output)
assert parsed.get("granularity") == "daily"
assert "stats" in parsed
# Must include the follower count from the daily series
first_series = list(parsed["stats"].values())[0]
assert any(point["followers"] == 5000 for point in first_series)
# Must NOT be the lossy one-liner
assert not output.startswith("Found ")


# =============================================================================
# Media Resource Tests
Expand Down
4 changes: 3 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading