diff --git a/CHANGELOG.md b/CHANGELOG.md index fd12a22..10ce038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 866285b..935ec02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/scripts/generate_mcp_tools.py b/scripts/generate_mcp_tools.py index 921fc96..894a755 100644 --- a/scripts/generate_mcp_tools.py +++ b/scripts/generate_mcp_tools.py @@ -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):"]', diff --git a/src/late/mcp/generated_tools.py b/src/late/mcp/generated_tools.py index aa724d5..b431a61 100644 --- a/src/late/mcp/generated_tools.py +++ b/src/late/mcp/generated_tools.py @@ -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):"] diff --git a/src/late/resources/accounts.py b/src/late/resources/accounts.py index e9e51f4..eec0d66 100644 --- a/src/late/resources/accounts.py +++ b/src/late/resources/accounts.py @@ -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) @@ -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) diff --git a/tests/test_integration.py b/tests/test_integration.py index da4d2ac..f785d07 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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 diff --git a/uv.lock b/uv.lock index 6427e50..c6c084f 100644 --- a/uv.lock +++ b/uv.lock @@ -1905,7 +1905,7 @@ wheels = [ [[package]] name = "zernio-sdk" -version = "1.4.171" +version = "1.4.172" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1926,6 +1926,7 @@ anthropic = [ ] dev = [ { name = "datamodel-code-generator" }, + { name = "mcp" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1947,6 +1948,7 @@ requires-dist = [ { name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = ">=0.26.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "mcp", marker = "extra == 'all'", specifier = ">=1.8.0" }, + { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.8.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.8.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" },