Error relay: the missing scope, the field errors and the wait in Postulo's problem documents never reach the agent #15

Closed
opened 2026-10-01 10:48:04 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo-mcp at 0b29e70b9, reading how Postulo._call turns a refusal into a PostuloError, against the core's problem documents at postulo 95348908d.

Since postulo/postulo#296 the core refuses with RFC 9457 problem documents that carry what a client needs to act: the scope a token lacks, the list of field errors, the wait before retrying. The MCP client was written before that and keeps only a fixed sentence or the detail string, so the agent is told less than Postulo said, and in one case is pointed at data it is never given.

  • Where: src/postulo_mcp/client.py:117-125 (Postulo._call); src/postulo_mcp/client.py:134-143 (_detail); tests/test_server.py:347-354 (test_other_failures_carry_what_the_instance_said, which still feeds the pre-#296 shape {"detail": "status: not a status"})
  • What happens:
    • 403. The core's ScopedAuth (postulo api/auth.py:86-105) answers "This token does not have the 'write' scope." with kind="insufficient-scope" and scope. _call folds 401 and 403 into "Postulo refused the token. It may be revoked, expired, or without the scope this needs (403)." and drops the detail, so the person is not told which box to tick.
    • 422. A validation failure's detail is built by _validation_sentence (postulo api/problems.py:244-261) and reads "Refused: due_at. See errors for what is wrong with each."; the reasons are in the errors member (api/problems.py:234-241). _detail relays the sentence and drops errors, so an agent that sent add_reminder with a malformed due_at is told to look at a list it does not have, and guesses.
    • 429. The core sends retry_after in the body and Retry-After as a header (api/api.py:146-149, api/auth.py:29-37, api/problems.py:220-223). The detail names the rate but not the remaining wait, and _call keeps neither the member nor the header, so the agent cannot know when to try again.
    • 404. Every 404 becomes "Postulo has no such thing.", discarding a detail such as "No such application." (postulo api/routers/reminders.py:44), which says which of the ids in an add_reminder call was wrong.
  • Why it matters: the agent is the reader here, and it acts on what it is told. A refusal that names its remedy turns into a retry loop or a vague apology to the person. Related to #1, which asks that a write with a read-only token "fails with something a model can act on"; today it is a sentence, but one that leaves out the scope Postulo named.
  • Fix: in _call, parse the body as a problem document when the content type is application/problem+json (or the body is a JSON object with detail). Keep the 401 sentence generic. For 403, relay detail (and scope). For 422, append a short rendering of errors, one loc: msg per entry, capped at a few. For 429, add "try again in N seconds" from retry_after or the Retry-After header. For 404, use detail when there is one. Update the test fake's failure shapes to problem documents.

Done when

  • tests/test_server.py has tests that feed the client a 403 problem document naming scope: "write" and expect the PostuloError text to contain write; a 422 with errors: [{"loc": ["body", "payload", "due_at"], "msg": "..."}] and expect due_at and the message; a 429 with retry_after: 30 and expect 30. Each fails today.
Found on 2026-09-29 in a read-only review of postulo-mcp at 0b29e70b9, reading how `Postulo._call` turns a refusal into a `PostuloError`, against the core's problem documents at postulo 95348908d. Since postulo/postulo#296 the core refuses with RFC 9457 problem documents that carry what a client needs to act: the scope a token lacks, the list of field errors, the wait before retrying. The MCP client was written before that and keeps only a fixed sentence or the `detail` string, so the agent is told less than Postulo said, and in one case is pointed at data it is never given. - **Where:** `src/postulo_mcp/client.py:117-125` (`Postulo._call`); `src/postulo_mcp/client.py:134-143` (`_detail`); `tests/test_server.py:347-354` (`test_other_failures_carry_what_the_instance_said`, which still feeds the pre-#296 shape `{"detail": "status: not a status"}`) - **What happens:** - **403.** The core's `ScopedAuth` (postulo `api/auth.py:86-105`) answers "This token does not have the 'write' scope." with `kind="insufficient-scope"` and `scope`. `_call` folds 401 and 403 into "Postulo refused the token. It may be revoked, expired, or without the scope this needs (403)." and drops the detail, so the person is not told which box to tick. - **422.** A validation failure's `detail` is built by `_validation_sentence` (postulo `api/problems.py:244-261`) and reads "Refused: due_at. See `errors` for what is wrong with each."; the reasons are in the `errors` member (`api/problems.py:234-241`). `_detail` relays the sentence and drops `errors`, so an agent that sent `add_reminder` with a malformed `due_at` is told to look at a list it does not have, and guesses. - **429.** The core sends `retry_after` in the body and `Retry-After` as a header (`api/api.py:146-149`, `api/auth.py:29-37`, `api/problems.py:220-223`). The `detail` names the rate but not the remaining wait, and `_call` keeps neither the member nor the header, so the agent cannot know when to try again. - **404.** Every 404 becomes "Postulo has no such thing.", discarding a detail such as "No such application." (postulo `api/routers/reminders.py:44`), which says which of the ids in an `add_reminder` call was wrong. - **Why it matters:** the agent is the reader here, and it acts on what it is told. A refusal that names its remedy turns into a retry loop or a vague apology to the person. Related to #1, which asks that a write with a read-only token "fails with something a model can act on"; today it is a sentence, but one that leaves out the scope Postulo named. - **Fix:** in `_call`, parse the body as a problem document when the content type is `application/problem+json` (or the body is a JSON object with `detail`). Keep the 401 sentence generic. For 403, relay `detail` (and `scope`). For 422, append a short rendering of `errors`, one `loc: msg` per entry, capped at a few. For 429, add "try again in N seconds" from `retry_after` or the `Retry-After` header. For 404, use `detail` when there is one. Update the test fake's failure shapes to problem documents. ## Done when - `tests/test_server.py` has tests that feed the client a 403 problem document naming `scope: "write"` and expect the `PostuloError` text to contain `write`; a 422 with `errors: [{"loc": ["body", "payload", "due_at"], "msg": "..."}]` and expect `due_at` and the message; a 429 with `retry_after: 30` and expect `30`. Each fails today.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:48:04 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-mcp#15
No description provided.