Give every MCP tool result one universal outcome key (ok) - #173
Conversation
Self-reviewNon-breaking is the whole design, so I pressure-tested itThe temptation with #66 is to replace every envelope with The choke point interacting with a re-shaping tool — caught it
The honest limit — listsA list-returning tool (e.g. list_clusters) still returns a bare JSON array with no Verified, not assumedFull suite 408 passed with the backend tree mounted (the Scope I did not takeNormalizing the success payload shape ( |
mwiget
left a comment
There was a problem hiding this comment.
One choke point instead of 151 edits is the right shape for this, and the additive rules (don't override an existing ok, leave success/message alone, keep ok out of the nested project entity) are all sound. But the stamp is unconditional, and that turns a class of existing responses into confident lies.
Blocking — ok: true gets stamped on bodies that say success: false.
_request only raises APIError on a non-2xx status, so _mark_ok runs for every HTTP 200. Several backend routes return 200 with an explicit failure body. backend/routes/helm.py:134, on GET /api/k8s/{cluster_id}/helm/releases — a path the MCP helm tools call:
if cel.failed():
return {"success": False, "releases": [], "count": 0, "task_id": cel.id, "status": "failed"}After this change the agent receives:
{"success": false, "releases": [], "count": 0, "status": "failed", "ok": true}The docstring's rule — "ok present and not False == success" — reads that as a success. It is a Celery task that failed.
This is not a one-off. "success": False on a 200 appears 12 times in helm.py alone (list, detail, history, values, manifest — both the failed and pending branches), plus alert_channels.py:241, cloud_auth.py:306, module_library.py, and operator_polling.py.
Worth being precise about why this is worse than the status quo rather than merely incomplete. Today an agent facing {"success": false, ...} has three inconsistent conventions and has to look at the body. After this change there is one universal, documented, authoritative-looking key — and on these routes it contradicts the body it is attached to. #66 is about giving agents a field they can trust; a field that is wrong on a real path is more dangerous than no field, because it removes the reason to look further.
The fix fits your existing design instead of fighting it — defer to an explicit success the same way you already defer to an explicit ok:
result.setdefault("ok", bool(result.get("success", True)))Bodies with no success key still get ok: true (the common case, unchanged). Bodies that already set ok are still not overridden. And the 200-with-success: false responses get ok: false, which is what an agent needs. The status: "pending" branches land on ok: false too, which reads correctly — the operation has not succeeded.
Worth a test pinning it, since nothing currently covers a 200 that carries success: false.
Second, procedural but consequential: this PR contains #172.
origin/staging.. on this branch is three commits, not one:
718bf98 fix: give every MCP tool result one universal outcome key (ok) (#66)
5b36209 fix: display the analyzer's computed weight in both BNK backend views (#8)
bf79783 fix: surface the analyzer's computed L4Route weights, not the declared spec weight
The base is staging, so merging this merges #172's two commits as well — including the analyzer-weight semantics I asked about an hour ago, which are still open. The changed-files list gives it away: topology.py, helpers.py, BackendsCollection.tsx, F5BNKTopologyViewer.tsx and their tests are #172's, not this PR's.
Either retarget this PR's base to #172's branch so the diff shows only the MCP change and the merge order is enforced, or rebase onto staging once #172 lands. As it stands a merge here would quietly bypass the review on #172.
The rest reads well. _mark_ok is correctly placed at the one point every verb flows through; setdefault gives you the non-override property for free; the list/scalar reasoning is right and worth having written down on the helper; and updating create_project's skip_keys so ok stays on the envelope rather than leaking into the project entity is exactly the sort of detail that gets missed. The non-vacuity check on test_success_dict_gets_universal_ok_true is the right kind of evidence.
| absence of ``ok: False`` rather than the presence of ``ok: True``. | ||
| """ | ||
| if isinstance(result, dict): | ||
| result.setdefault("ok", True) |
There was a problem hiding this comment.
Unconditional here is the problem. _request only raises on non-2xx, so this also runs for 200 responses that carry an explicit failure body — helm.py:134 returns {"success": False, "status": "failed", ...} with a 200, and the MCP helm tools call that path. The agent then sees success: false and ok: true side by side, and the documented rule tells it to believe ok.
Deferring to an explicit success mirrors the deference you already give an explicit ok:
result.setdefault("ok", bool(result.get("success", True)))Bodies without success are unaffected. There are 12 such returns in helm.py plus ones in alert_channels.py, cloud_auth.py, module_library.py and operator_polling.py.
|
|
||
| Additive and non-breaking: existing keys are left untouched, and a body | ||
| that already set ``ok`` (a structured passthrough) is not overridden. | ||
| Non-dict bodies (list/scalar collections) can't carry a key; they are |
There was a problem hiding this comment.
This rule is what makes the bug above dangerous rather than merely incomplete. Once it is documented that ok not being False means success, an agent has no reason to also read success — so on the 200-with-success: false routes it will confidently act on a failure. Worth restating here once the stamp defers to success.
MCP result envelopes signalled outcome inconsistently: success bodies used
`success: true` (or nothing), the error path used `ok: false`, and mutating
tools returned flat shapes like `{message, project_id}`. An agent had no single
field to branch on across the 151 tools -- it had to special-case per shape,
undercutting the outcome-derived-success contract (D-017) the agent path
depends on.
Every tool flows through one client choke point (get/post/put/patch/delete ->
_request_with_error_envelope), which already stamps `ok: false` on errors. This
adds the symmetric half: _mark_ok stamps `ok` on every dict success body at
that same point, so all 151 tools gain the same outcome key without touching
each tool's return.
`ok` is derived, not blindly true: `setdefault("ok", bool(result.get("success",
True)))`. That matters because `_request` only raises on non-2xx, so several
routes that return HTTP 200 with an explicit `{"success": false, ...}` (a Celery
task that failed or is pending -- helm list/detail/history/values/manifest,
alert_channels, cloud_auth, ...) reach here. An unconditional `ok: true` would
attach an authoritative-looking key that contradicts the body -- worse than no
key, since it removes the agent's reason to look further. Deferring to an
explicit `success` (the same way we defer to an existing `ok`) lands those on
`ok: false`, which is the truth; bodies with no `success` key still get
`ok: true`.
Additive and non-breaking:
- Existing keys (success, project, message, ...) are left in place.
- A body that already set `ok` is not overridden.
- List/scalar successes can't carry a key; they're unambiguously not the error
envelope, so success there is the absence of `ok: false`. The robust agent
rule is "ok is not False", documented on the helper.
create_project builds its own {success, project} envelope, so it's updated to
surface `ok` on the envelope and keep it out of the nested project entity.
Tests: ok derives from success on a 200 (success:false -> ok:false, pending ->
ok:false, success:true -> ok:true); dict success without a success key gets
ok:true; a backend ok is not overridden; lists pass through; success and error
share the ok key. The five client passthrough tests updated to the new shape;
create_project's contract test pins ok on the envelope and absent from project.
Full mcp suite 411 passed.
Fixes #66
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
718bf98 to
8c8752c
Compare
|
Both fixed. Thank you — the 1. result.setdefault("ok", bool(result.get("success", True)))So a 200 carrying You framed why this matters better than my original design did — "it removes the agent's reason to look further" — so I put that reasoning on the helper docstring, where the next person deciding whether to touch this will read it. 2. The branch contained #172 — fixed by rebase. You were exactly right, and it was worse than a stray diff: |
mwiget
left a comment
There was a problem hiding this comment.
Approving. Both items are handled, and the derivation is the right shape.
result.setdefault("ok", bool(result.get("success", True))) keeps every property the original had — additive, non-overriding, one choke point — while making ok agree with the body it's attached to. The three new tests cover the cases that matter and cover them at the boundary that broke: success: false on a 200 derives ok: false and leaves the original success untouched; a pending task reads as not-ok; an explicit success: true still stamps ok: true, so the common mutating-tool body is unchanged. Pinning result["success"] is False alongside result["ok"] is False in the first test is the detail I'd have asked for — it proves the derivation reads the key rather than rewriting it.
The docstring now carries the reasoning rather than just the rule, including why an unconditional stamp would have been worse than no key at all. That's the part worth having in the file: the next person to touch _mark_ok will see why the success lookup isn't incidental.
The rebase is clean. origin/staging.. is a single commit now, and the changed-file list is four MCP files — #172's topology and frontend work is gone from this branch entirely. Doing it now rather than waiting for #172 to land is the better call: the two PRs are independent again and can merge in either order.
I checked the MCP suite actually ran rather than trusting the rollup — P1 · MCP Server Tests (advisory) is green with 411 passed, up from the 408 you reported, which accounts for exactly the three tests added here. Worth noting that job is advisory, so a regression in it wouldn't have blocked the gate; since this PR is entirely MCP, that's the number that mattered and I wanted to see it directly.
create_project's envelope stays consistent under the new derivation: it computes success the same way _mark_ok now does, so ok and success on the envelope can't disagree, and ok still stays out of the nested project entity.
CI fully green including CI Gate.
What & why
Fixes #66 — MCP result envelopes signalled outcome three different ways, so an agent had no single field to check across tools:
Success used
success: true(or nothing); failure usedok: false; shapes differed per tool. That forces brittle multi-shape parsing in every agent and undercuts the outcome-derived-success contract (D-017) the agent path relies on. It's the success-side complement to #67 (which fixed the errordetailshape).The fix — one choke point, non-breaking
All 151 tools flow through
client.get/post/put/patch/delete→_request_with_error_envelope, which already stampsok: falseon errors via_error_payload. This adds the symmetric half:_mark_okstampsok: trueon every dict success body at that same point. One ~5-line helper gives all 151 tools the same outcome key without rewriting a single tool's return.Deliberately additive:
success,project,message, …) are untouched — nothing that parses them today breaks.ok(a structured passthrough) is not overridden.ok: false. The robust agent rule — "okis notFalse" — is documented on the helper.create_projectbuilds its own{success, project}envelope, so it's updated to surfaceokon the envelope and keep it out of the nestedprojectentity (it's meta, not project data).Testing
New client tests: dict success gets
ok: true; a backend-setokisn't overridden; lists pass through as-is; success and error now share theokkey. The five passthrough client tests are updated to the new shape, andcreate_project's contract test pinsokpresent on the envelope and absent fromproject. Full MCP suite: 408 passed.Non-vacuous:
test_success_dict_gets_universal_ok_trueKeyErrors against the unpatched client (raw body has nook).Fixes #66
https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4