fix(mcp): read publish errors from platforms[] instead of a metadata key the API never sets - #38
Merged
Conversation
…inks Post reading through MCP had two defects reported by an integrator in the same ticket. The failed-post views read the error from post.metadata["error"], a key the API never populates, so posts_get and posts_list_failed fell back to the literal "Unknown error" while posts_list rendered no error at all. The real text lives per platform in PlatformTarget.errorMessage, so _platform_errors now collects it from the failed legs and all three views render it. Legs that later published can still carry a stale errorMessage, so the leg's own status selects it rather than the presence of a message; otherwise a platform that published fine would be reported as an error. posts_get and posts_list also raised a validation error on TikTok posts that published fine, because the API emits platformPostUrl: "" when TikTok confirms a publish without returning an id a permalink can be built from, and the spec declared the field as a URI. PlatformTarget.platformPostUrl now matches the permissive declaration already used elsewhere in the spec. The API emitting "" rather than null is tracked separately; the client has to accept what is already in the responses either way.
Account-disconnect cleanup cancels a post's legs and writes the reason onto
them ('Account "X" was disconnected'), so a failed post whose only leg was
cancelled rendered "Unknown error" while the document held the answer. That is
the same complaint the ticket opened with, one status short of fixed.
The leg status enum has six values, and they partition into in-flight
(pending, processing, uploading), success (published), and terminal
non-success (failed, cancelled). The error views want that third set, so the
guard now selects it rather than failed alone.
In-flight legs stay excluded on purpose: every reset path clears their
errorMessage, so anything left on one is stale. The "Unknown error" fallback
stays too, since failed posts whose only leg is pending or stale-published
still reach it.
Drafts and scheduled posts can hold a cancelled leg, because retry never
resets one, so unfiltered posts_list now renders those as well. Deliberate,
and pinned by a test.
Refs Crisp session_6e4c63b7.
"Leg" is not a word this codebase uses anywhere else, so the comments explaining why _platform_errors selects what it selects were the hardest part of the change to read. A post holds one entry in platforms[] per target platform, and that is what the comments now call it. Comments, docstrings and test names only. No behaviour change.
…s-and-empty-permalink # Conflicts: # src/late/models/_generated/models.py
The platformPostUrl declaration is being fixed at the source, in the API repo's public/openapi.yaml, and reaches this repo through the regular "chore: regenerate from OpenAPI spec" pull. Carrying a hand-made copy of it here would collide with that regeneration for no gain. This PR therefore keeps only what is genuinely SDK-side: the MCP post views reading the error from platforms[] instead of the metadata key the API never populates. The empty-permalink coverage goes with the declaration it depends on. Those tests assert that PlatformTarget accepts platformPostUrl: "", which stays false until the regenerated model lands, so they follow in their own PR once the spec pull has gone through.
…s-and-empty-permalink
…llows it The spec fix landed in the API repo and reached this repo through the regular regeneration, so PlatformTarget.platformPostUrl is str | None and the payload the API actually sends for some TikTok publishes parses again. These tests pin that: posts_get and posts_list must return a post whose permalink is an empty string, and the model must keep rejecting nothing while still accepting a real URL. A spec regression that reintroduces format: uri fails here rather than in a customer's MCP client.
The test asserted that a real permalink still parses, using a TikTok URL that AnyUrl leaves untouched, so it passed with and without the fix and guarded nothing. A bare-origin URL is the case that separates them: AnyUrl renders "https://example.com" back as "https://example.com/", so before the fix the client handed consumers a permalink the platform never sent. The test now pins that the value comes back byte for byte, and fails against the previous model.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An integrator could not tell why their posts had failed: every failed post read
Error: Unknown error. The MCP post views were reading the error frompost.metadata["error"], a key the API never populates. The real text lives inplatforms[], the array where each target platform of a post keeps its ownerrorMessage.posts_listwas worse: it rendered no error line at all, not even the fallback.Changes
src/late/mcp/server.py— new_platform_errors(post)helper.posts_get,posts_listandposts_list_failedall build their error lines from it, asError (platform): message.tests/test_mcp_post_error_surfacing.py— new, against anhttpx.MockTransportfake API, using real production payload shapes.No spec or generated-model changes here. The
platformPostUrldeclaration was fixed at the source in zernio-dev/Schedule-Posts-API#2799 and arrived through the regularchore: regenerate from OpenAPI specpull, which this branch has merged.Design notes
Selection is by platform status, not by whether a message is present. A platform that published can still carry an
errorMessageleft over from an earlier attempt. Post691a85709ed078ff2f35bd16is the live case: its instagram entry ispublishedand still holds"Publishing failed due to timeout or max retries reached". Selecting on the message would report a platform that published fine as an error.Two statuses count:
failedandcancelled. The status enum has six values that split into in-progress (pending,processing,uploading), success (published), and "done and did not publish" (failed,cancelled). The error views want the third group.cancelledmatters because account-disconnect cleanup writes the only actionable reason onto that entry (Account "X" was disconnected), so a failed post targeting one platform that got cancelled used to readUnknown errorwhile the document held the answer.In-progress entries are excluded because every reset path clears
errorMessage, so anything still on one is stale.Known behaviour change: drafts and scheduled posts can hold a cancelled platform, since retry never resets one, so unfiltered
posts_listnow renders error lines on them. Deliberate, and pinned by a test.The
Unknown errorfallback inposts_list_failedstays. It is still reachable for failed posts whose only platform ispendingwith no message, or stale-published.The second defect in the same ticket
posts_getraised1 validation error for PostGetResponseon TikTok posts that published fine, because the API sendsplatformPostUrl: ""when TikTok confirms a publish without giving back an id a permalink can be built from, and the spec declared the field as a URI. The fix is the spec change linked above. The tests here guard the regenerated model against a regression that reintroducesformat: uri, so it would fail in CI rather than in a customer's MCP client.Testing
ruff check src testscleanmypy src --ignore-missing-importsclean, 116 files6a8392cdc6abe639aadbcc21, empty permalink6a7e23169598cfb3119bb578, cancelled platform6a74056fa22ac1afb451b7da, draft with a cancelled platform69047ddcbb4db2cc1794478d, stale-published691a85709ed078ff2f35bd16Reviewed by a second model (Fable 5 running the senior-debugger and fix-plan-reviewer checklists) on both the shipped diff and the follow-up plan.
Crisp
https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_6e4c63b7-90b7-43a4-a510-165a5670745b/