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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion src/late/mcp/chatgpt_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
35 changes: 34 additions & 1 deletion tests/test_chatgpt_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,27 @@
"needsReconnection": True,
"enabled": True,
},
{
"_id": "acc_th",
"platform": "threads",
"username": "demo_th",
"isActive": True,
"enabled": True,
},
{
"_id": "acc_ads",
"platform": "metaads",
"username": "ads",
"isActive": True,
"enabled": True,
},
{
"_id": "acc_reddit_ads",
"platform": "redditads",
"username": "reddit_ads",
"isActive": True,
"enabled": True,
},
{
"_id": "acc_off",
"platform": "linkedin",
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down
Loading