diff --git a/CHANGELOG.md b/CHANGELOG.md index f5b687b5..3470f62c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **The ChatGPT MCP server hid Threads accounts.** `_is_posting_account` dropped every platform ending in "ads" to keep ad connections out, and "threads" ends in "ads", so `list_social_accounts` omitted Threads accounts and `validate_post`, `schedule_post` and `publish_post_now` rejected their ids with "Unknown account id". Ad platforms are now an explicit list. - **DELETE operations with a request body sent no body.** The generator emitted `_delete(path, params=...)` for every DELETE and dropped the body kwargs, so calls like `remove_campaign_assets(...)` reached the API empty and failed with `invalid_json_body`. DELETE methods now build the payload and send it as JSON (`_delete` / `_adelete` take `data`). - **MCP tool parameters that take a list of objects (or an object) did not say which keys go inside.** `updates`, `campaigns` and similar were typed `list[dict[str, Any]]` with no keys, so agents had to guess. The parameter description now lists each key with its type, whether it is required and its description, one level of nested object keys included. - **A POST that timed out client-side could be silently replayed, creating a duplicate live post.** `_request_with_retry` (and its async twin) retried every transient error identically, including `httpx.TimeoutException` on a POST. A timeout only means the client gave up waiting - the server may have finished the request anyway - so replaying it can create a second copy of whatever the first attempt already did. Hit in practice on a `publishNow` create: the server-side publish took 222s against `BaseClient.DEFAULT_TIMEOUT`'s 30s, httpx aborted while the server kept working, and the retry loop fired a second identical POST. The SDK sent no request id, so the server couldn't recognize the replay, and its content-hash dedup answered the replay with a 409 while the original request was still live - so the customer saw a failure for a post that had actually published, retried by hand with a one-character caption change to dodge the dedup, and ended up with two live posts. Two independent fixes: (1) every request now carries an `x-request-id` header, minted once per call and reused across retry attempts, so the server can recognize a replay when one does happen; (2) a POST that times out is no longer retried at all - it raises immediately with a message naming the duplicate-post risk, because the server's content-hash dedup can still race ahead of its idempotency check even with a matching request id. `publishNow` creates also get a much longer timeout (`publish_timeout`, default 300s, configurable on `Zernio(...)`) than the SDK default (`timeout`, default 30s), since a publish-now create runs the whole cross-platform publish synchronously inside the request. PUT, PATCH, and DELETE are unaffected - they're idempotent by contract and stay retryable on timeout. Known gaps, left alone here and tracked for follow-up: 5xx responses are never retried, and `PUT /v1/posts/{id}` with `publishNow` has the same synchronous-publish timeout exposure as create. diff --git a/src/late/mcp/chatgpt_server.py b/src/late/mcp/chatgpt_server.py index a9f77e2d..70c5274d 100644 --- a/src/late/mcp/chatgpt_server.py +++ b/src/late/mcp/chatgpt_server.py @@ -264,11 +264,27 @@ def _meta(*scopes: str, **extra: Any) -> dict[str, Any]: # --------------------------------------------------------------------------- +# An explicit list, not a suffix check: "threads" ends with "ads". +_AD_PLATFORMS = frozenset( + { + "metaads", + "tiktokads", + "googleads", + "linkedinads", + "pinterestads", + "xads", + "openaiads", + "redditads", + "whopads", + } +) + + def _is_posting_account(account: dict[str, Any]) -> bool: platform = str(account.get("platform") or "") return ( bool(platform) - and not platform.endswith("ads") + and platform not in _AD_PLATFORMS and account.get("enabled") is not False ) diff --git a/tests/test_chatgpt_server.py b/tests/test_chatgpt_server.py index f4a67492..6973cd2c 100644 --- a/tests/test_chatgpt_server.py +++ b/tests/test_chatgpt_server.py @@ -44,6 +44,13 @@ "needsReconnection": True, "enabled": True, }, + { + "_id": "acc_th", + "platform": "threads", + "username": "demo_th", + "isActive": True, + "enabled": True, + }, { "_id": "acc_ads", "platform": "metaads", @@ -51,6 +58,13 @@ "isActive": True, "enabled": True, }, + { + "_id": "acc_reddit_ads", + "platform": "redditads", + "username": "reddit_ads", + "isActive": True, + "enabled": True, + }, { "_id": "acc_off", "platform": "linkedin", @@ -136,7 +150,7 @@ async def test_list_social_accounts_hides_ads_and_disabled_and_strips_internal_f ) result = await _call("list_social_accounts") accounts = result.structured_content["accounts"] - assert [a["id"] for a in accounts] == ["acc_tw", "acc_ig"] + assert [a["id"] for a in accounts] == ["acc_tw", "acc_ig", "acc_th"] assert accounts[1]["status"] == "needs_reconnection" dumped = json.dumps(result.structured_content) assert ( @@ -225,6 +239,25 @@ async def test_schedule_post_sends_utc_time_and_explicit_targets(): assert "publishNow" not in body +@respx.mock +async def test_schedule_post_accepts_a_threads_account(): + respx.get(f"{API}/v1/accounts").mock( + return_value=httpx.Response(200, json=ACCOUNTS) + ) + create = respx.post(f"{API}/v1/posts").mock( + return_value=httpx.Response(201, json={"post": POST}) + ) + result = await _call( + "schedule_post", + content="Hello", + account_ids=["acc_th"], + scheduled_for="2030-01-01T09:00:00Z", + ) + assert not result.is_error + body = json.loads(create.calls[0].request.content) + assert body["platforms"] == [{"platform": "threads", "accountId": "acc_th"}] + + @respx.mock async def test_publish_now_is_explicit_and_never_implied_by_schedule_post(): respx.get(f"{API}/v1/accounts").mock(