feat(mcp): show what a session's own MCP servers reported - #7366
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound, well-reasoned design; ships with spec drift on a documented subsystem and a "temp" screenshot binary committed into the source tree. Watch
Suggestions
[DESIGN-REVIEWED] 828c569 |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of UX-Verdict: CONCERNS Honest, well-worded session states — but the failure reason, the one actionable fact the feature captures, is reachable only by hovering an 8px dot. Watch
Suggestions
[UX-REVIEWED] 828c569 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks are done. Assembling the review. First-Principles-Verdict: CONCERNS Every load-bearing item traces to the reported confabulation defect, but the diff exports a helper nothing calls and leaves the dead duplicate of this very view in the tree. What this change shipsIntent: let a user see which MCP servers their chat session actually started, so a host-level "Online" badge stops standing in for a session fact — a FIX (of a reported misinformation defect) delivered mostly as new surface.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 828c569 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate claims switching the three MCP registration events to I verified the mechanical facts: But (c), an observable wrong outcome, does not survive:
Every step of the harm chain is a "could"/"if a caller were to" (lone subagent, sessionless own-server frame, arriving mid-turn, sole signal in the window) — the candidate's own note concedes it could not confirm the divergence window occurs. Below the 80 bar; dropped. No independently grounded new defect was found in the changed lines. No findings. [OPUS-REVIEWED] 828c569 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @buluoray overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
ec7eedc to
47a0f70
Compare
47a0f70 to
44ec479
Compare
44ec479 to
a76f1e2
Compare
Disposition — GPT 5.6 on
|
a76f1e2 to
8b1ee06
Compare
8b1ee06 to
235870d
Compare
Disposition — GPT 5.6 on
|
235870d to
6b2c5f1
Compare
Disposition — GPT 5.6 on
|
Rebased onto main (26 commits) —
|
|
| tree | warnings |
|---|---|
origin/main (clean throwaway worktree) |
660 |
| this branch | 660 |
Identical, so this diff contributes zero warnings — main itself is one over the ceiling it
pins. Every annotation on the failing job names a file this PR never touches
(apps/command-bar/CommandBarOverlay.tsx, apps/code-review-sage/*, apps/aws-control/*,
apps/auto-improvement/SetupPanel.tsx, app-sdk/ChatEmbed.tsx, App.tsx). The ratchet was
pinned at 659 by b2a16804a; a later merge added a warning without bumping it.
Not fixed here, and deliberately not by bumping the ceiling: the workflow's own comment says to
ratchet --max-warnings DOWN toward zero, so raising it to 660 inside an unrelated PR would hide
main's regression under this change. Two PRs are already in flight against exactly this —
#7508 (remove an unused eslint-disable pushing main over budget) and #7480 (burn one
warning down to the ceiling) — so the repo's own rule applies: don't open a third duplicate, let
the earliest land and rebase onto it.
Everything else on this SHA is green so far (36 success, 5 skipped, 1 neutral, zero other
failures) with 24 checks still running, including both blocking review lanes. The pre-rebase head
e4ed9ab01 had reached all checks green with GPT 5.6 ✅ and Opus 4.8 ✅ no blocking findings.
07ccf35 to
f49ab4f
Compare
Round 7 — all three GPT 5.6 BLOCKING findings were real, all three fixed.
|
f49ab4f to
0df57ce
Compare
Round 8 — the one remaining BLOCKING was real, and its fix moved the measurement.
|
Round 9 — third finding on the report-lifetime span. Root-cause disposition, not another patch.
|
|
Disposition for GPT 5.6 BLOCKING on The mechanism was confirmed against source: The suggested fix (check target capacity before removing) was not adopted verbatim: refusing the move leaves the server displayed in its old state — e.g. still
Both behaviors are pinned by tests mutation-verified against the original bug's exact shape (revert to remove-then-refuse → both tests red). |
|
Disposition for GPT 5.6 BLOCKING on The ambiguous window is real: a frame arriving while The two failure directions are not symmetric:
Within this protocol, the window is undecidable — registration frames carry no mode/epoch attribution. The pre-send measurement point was chosen (in response to this reviewer's earlier finding) to take the bounded, self-correcting failure over the permanent one. The real fix is protocol-level (frames carrying a mode epoch), which is out of this PR's scope. Flip-flopping the measurement point between the two directions this review has now demanded in successive rounds would leave whichever bug the latest round happens to name. Keeping the documented tradeoff. |
|
/ai-review override gpt 9b5a841: the suggested post-set_mode snapshot reinstates this review's own earlier blocking finding (session_handle.py:3013 -- new-mode registrations consumed unrecorded, permanent false no-report); the current pre-send point takes the bounded self-correcting failure over the permanent one, documented at the measurement site. |
Human judgment recorded@buluoray marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
|
/ai-review override gpt 0184c95: re-posting the drain-watermark disposition after rebase (override is SHA-scoped) -- the suggested post-set_mode snapshot reinstates this review's own earlier blocking finding (new-mode registrations consumed unrecorded, permanent false no-report); the pre-send point takes the bounded self-correcting failure, documented at the measurement site. |
Human judgment recorded@buluoray marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
|
Frontend Lint & Type Check on |
The dashboard's MCP views answer host questions -- what an agent spec declares, and whether the gateway itself can start a server -- so nothing could say what a particular session actually mounted. The frames that would answer it already arrive at session init on both ACP transports, but the happy path collapsed them into one log line and dropped them. Keep them instead: a per-session report (ready / failed / awaiting-auth, the roster sent on the wire, redacted failure reasons) captured in each transport's own init drain, kept current from the events both transports already emit, published onto the slot and invalidated on session reset. The chat MCP panel renders it beside the configured list rather than merged into it, since conflating the two is the defect. An unreported server shows as unreported, never as absent: both drains are time bounded and a late frame still arrives. The Settings Online badge gains the caveat that it is a host check, not a session fact.
|
/ai-review override gpt 828c569: re-posting the drain-watermark disposition after conflict rebase (override is SHA-scoped) -- the suggested post-set_mode snapshot reinstates this review's own earlier blocking finding (new-mode registrations consumed unrecorded, permanent false no-report); the pre-send point takes the bounded self-correcting failure, documented at the measurement site. |
Human judgment recorded@buluoray marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
chenmingwei23
left a comment
There was a problem hiding this comment.
Approving. Reviewed the diff in three parallel passes (accumulator, frontend, dashboard
integration) and then separately adjudicated the two findings a bot had already
disposed of, since approving endorses those dispositions too.
What I think is the strongest part of this
Two places choose "structurally impossible to get wrong" over "enumerate every case",
and both land correctly.
The ownership test. fanout_no_owner is only set when more than one queue is
registered (runtime.py), so a LONE session receiving a sessionless frame leaves that
flag False -- trusting it would credit a co-tenant's server to a solo session. Using
the positive sessionId == self._session_id test instead is the only correct read, and
it explains why the two capture sites look inconsistent (owned=True hardcoded on the
exclusive transport, the strict test on the shared runtime) when they are in fact the
same rule applied to two different tenancy models.
Report invalidation. Rather than clearing at every teardown point, the report is
identity-stamped and validated at the single read-time projector
_drop_orphaned_mcp_report. Because both /api/chat/slots and the WS snapshot go
through serialize_slots, no teardown path can bypass it -- including reset-recreates-
under-the-same-key, which a liveness check would have missed. The clear_mcp_report()
calls correctly demote to courtesy delta pushes.
Also confirmed, since a report carrying server names and failure reasons invites the
question: mcp_report is not written to history.py or chat_persistence.py or any
transcript, so none of it reaches disk. No cross-slot path. mcp_report_update lands
in Tier-1 slot-scoped filtering like todo_update. And appSdkEventScope.test.ts
genuinely enforces the two-table parity by reading ws_event_scope.py at runtime, so
adding the event to one side only would have failed.
The two dispositions, independently checked
The GPT override is justified. I recovered the original finding text from the
disposition thread rather than reasoning from the override wording. GPT wanted the
watermark snapshot after set_mode returns. That position loses new-mode registration
frames PERMANENTLY: kiro-cli can emit the switched-to agent's registrations before it
answers set_mode, those frames get counted as pre-switch, if not stale_backlog:
skips recording them, and registration frames are one-shot -- the drain has already
consumed the frame, so nothing replays it and the panel reads "no report" for the rest
of the session. The kept pre-send position fails only transiently, and self-corrects
because _record removes a name from every bucket before placing it, so the new mode's
own registration overwrites the misattributed entry. The tradeoff is documented at both
the measurement site and the drain site, more fully than the override reason claims.
There is no third position: frames carry no mode epoch, so the set_mode round-trip
window is genuinely undecidable inside this protocol, and deferring that to a protocol
change is the right scope call.
The Opus dismissal reaches the right disposition on incomplete verification. The
candidate it dismissed -- that routing these three MCP events through
runtime_global = not _owns_mcp_frame(msg) makes a lone session skip _touch_activity
-- is mechanically REAL. Before this diff the three events yielded with no
runtime_global field at all, defaulting to False, so run.py:1121 touched. After, a
lone session with a sessionless frame yields True and skips. Opus confirmed the guard
exists but did not evaluate that the guard's input value flipped for this event class.
It is still not a defect, for a reason worth stating explicitly because it is the
opposite of the candidate's fear: skipping a touch cannot INVENT a stall, it can only
fail to reset the clock -- and before this diff these frames were counted as this
session's activity incorrectly, so a co-tenant's broadcast could reset a genuinely
wedged subagent's clock. That is a false negative this change removes. Net safety
improves. The SubagentInfo.last_activity writers are turn-start, this gated touch,
and three tool-approval finally blocks, so the quiet MCP-init window is indeed
uncovered now -- but a session that is actually working emits other stream events that
feed the clock, and one that is truly idle should measure as idle.
Notes -- all non-blocking, none a change request
None of these five is introduced by this diff. Recording them so they are not
rediscovered from scratch.
-
Session state is title-only. The per-server mark conveys started / failed /
awaiting-auth / no-report by colour plus ring shape plus atitle, with no
aria-label,sr-onlyorrole. Thespanis not focusable, so a keyboard user
gets no tooltip, the button's accessible name is just the server name, and the
failure reason never reaches a screen reader. The pre-existing tool rows use the
identical title-only pattern, so this matches the panel's convention rather than
regressing it -- which is also why I would scope a fix to the WHOLE panel rather
than to the half this PR adds. Fixing only the new marks would leave a stranger
inconsistency than either end state. -
A bare filesystem path survives redaction.
_cleanruns
redact_exfiltration_urlsandredact_credentials, neither of which touches an
ordinary path, sofailed to open <a path under the user's home>reaches the DOM
verbatim (capped). Information disclosure, not credential leak -- the credential
side is thorough and, importantly, redaction runs BEFORE the cap, so a credential
cannot be split across the truncation boundary and survive in halves. If reason
strings are considered sensitive that belongs insecurity.py, not here. -
The key derivation is duplicated.
_drop_orphaned_mcp_reportreimplements the
active-turn / linked-session /dashboard:fallback chain instead of sharing
chat_utils.effective_session_key, due to an import cycle. The whole no-leak
guarantee depends on that derivation matching the one the report was stamped under,
so a future edit to one will not propagate. A cross-referencing comment at both
sites would be cheap insurance. -
The pre-send watermark has one narrower residual than the documented one. Its
self-correction relies on the new mode re-registering the same server to trigger the
bucket overwrite. If a transiently-misattributed pre-switch server is ABSENT from
the new mode's roster, the overwrite never fires and the entry persists until some
later frame touches it. Still the safe direction -- shows a server that is not there
rather than a false all-clear -- and bounded to a mode switch that drops a server,
so much narrower than the permanent bug the alternative position creates. Worth a
line at the measurement site, which already documents the wider case well. -
The
runtime_globalchange has no behavioural test. The wiring guards assert
the three sites use the strict test and that exactly three do, but by source
inspection -- nothing exercises whether a lone-session MCP frame trips the reaper.
Given the analysis above the answer is no, but that answer currently lives in review
threads rather than in a test.
The bug this fixes is worth the size: an agent confabulating an architectural
explanation, citing its own earlier turns as evidence, and sending the user to change
unrelated settings, with a green Online badge supplying the false anchor. Rendering
an unreported server as "not reported" rather than "not mounted" is the property that
keeps the fix from just relocating the wrong answer.
Problem / Motivation
The dashboard has three MCP views and none of them answers the question a user
actually asks.
/api/mcp/activereads an agent spec off disk;/api/mcp/proberecords whether the gateway can start a server in its own process. Both
describe the HOST. Nothing describes a session.
The reported symptom: a user's MCP server showed a
Kiro Crewscope badge andOnlinein Settings → MCP, while their chat session insisted the tool did notexist. With no signal saying why, the agent invented an architectural
explanation ("that server is only injected into Kiro CLI sessions, Kiro Crew runs
its own MCP set"), repeated it across turns, cited its own earlier answer as
evidence, and sent the user to change environment settings to fix a problem that
was not there.
The frames that could have answered it already arrive. Both ACP transports
receive
_kiro.dev/mcp/server_initialized/server_init_failure/oauth_requestat session init and both already reduce them — but only to builda timeout error string. On the happy path
AcpClient._drain_notificationscollapsed them into one log line (
"ACP: MCP servers loaded: %s", not evendistinguishing the three kinds) and cleared the buffer.
Why it matters
A confabulated architectural explanation is worse than a missing tool. It sounds
authoritative, cannot be refuted from inside the session, and the dashboard was
supplying it an objective-looking anchor: a green
Onlinebadge that means"the gateway started this server itself" and nothing about any session. The user
in this report believed the agent because the UI appeared to confirm it.
What changed (motivation → approach → change)
Goal: make a session able to state what it actually mounted, and stop a
host-level probe from reading as a session fact.
Approach. Capture the registration frames where they are otherwise dropped,
keep them as per-session state, and render them beside the configured list rather
than merged into it — the two facts stay distinguishable because conflating them
is the defect.
Frames carry
params.sessionIdand the shared runtime already de-interleavesthem per session (
_finish_session_init), so this is genuinely per-session onboth transports, not per-process. The one exception is a
session/newtimeout,where no sessionId exists yet; that path keeps its existing runtime-wide error
string untouched.
Built:
src/kiro_crew/acp/mcp_session_report.py— one accumulator (ready / failed /awaiting-auth, the roster actually sent on the wire, redacted failure reasons).
It owns all normalization, so the two capture sites are one line each.
AcpClient._drain_notifications,AcpSessionHandle.drain_init) because that is where the frames are consumed;live updates ride the
AcpEvents both transports already emit, so themid-turn path stays single.
mcp_report_updatedelta, followingthe
todo_updateprecedent, and invalidated on session reset — deliberatelyunlike that precedent, which never clears (
_todohas no reset anywhere instate.py). A report is evidence; one describing a torn-down session is worsethan none.
reported. The mark is a ring where the tool rows use filled dots, because
both vocabularies live in this one panel and colour is already spent on state —
without a shape difference "loaded this session" and "started in this session"
are the same green dot, and "deferred" and "no report yet" are the same hollow
one. Its own legend sits beside the tool legend, plus a line naming where each
half of the view comes from and a line for servers the session started that the
configured list cannot show.
Onlinebadge gains the caveat two words cannot carry.Three properties are load-bearing and tested:
drains are time-bounded and a late frame still arrives mid-turn. Claiming
absence here would just move the wrong answer to a new surface.
(
JsonRpcMessage.fanout_no_owner): at most one recipient produced it andnothing says which, so recording it would credit this session with another
session's server.
server that needed authorization, so a name lives in exactly one bucket and a
recovered server stops showing the reason it failed with.
What this deliberately does NOT do
A per-session tool list. ACP's
initialize/session/newresponses carry noadvertised tool names (a repo-wide grep for
availableTools|tool_names|tools/listfinds nothing), so that needs a kiro-cli protocol change or Kiro Crew issuing
tools/listitself, plus a way to separate resident from Tool-Search-deferredtools. Server granularity answers the reported case; tool granularity is not
worth that cost yet.
Also untouched:
website/src/pages/chat/McpInfoButton.tsx, which reimplementsthis view inline and mislabels agent config as "Session MCP servers". Nothing in
the app renders it — the only references are its own test file — so fixing it
would grow this diff for a surface no user sees.
Tests
test/test_mcp_session_report.py(27) — accumulation rules: bucket movement,stale-reason clearing, the unowned-fanout refusal, nameless/non-registration
frames ignored, redaction and caps,
Nonevs empty payload, superset semantics.test/test_mcp_session_report_wiring.py(21) — both capture paths, the accessornot draining,
_reset_statedropping it, slot set/clear, projection key,publish + broadcast, live-event folding, and that a declined reset keeps
the report (the session it describes is still live).
test/test_acp_runtime.py(+4) —drain_initrecords; a pre-switch stalebacklog is drained but NOT credited; a post-backlog report is;
create_sessionrecords the wire roster.
website/src/test/mcpSessionReport.test.tsx(24) — derivation, the per-statemark (ring colour, and a dashed ring for unreported — not just tooltips), the
extras line, the reducer storing a
nullreport rather than ignoring it, and aguard asserting the RULE that the session vocabulary shares no mark with the
tool vocabulary, so this collision cannot come back by a colour edit.
test/test_chat_slot_facade_contract.py(new ordered projection key),src/kiro_crew/dashboard/ws_event_scope.py(the new event classifiedslot-scoped, like
todo_update) and its frontend mirror inwebsite/src/app-sdk/index.ts— three gates caught this diff, and each is acontract change that should be reviewed rather than waived. The app-SDK one is
the symmetric half: a test pins the two tables in sync, so classifying the
event on the backend alone would have left app tokens unable to subscribe.
McpTab.test.tsxasserted that onlyneeds_authcarries a hover explanation. That test sits in theneeds_authblock as an anti-scope-creep guard, and
Onlineoverclaiming is precisely whatthis PR fixes, so it now asserts the stronger property —
Onlineexplainsitself, and the other non-
needs_authstatuses still carry no hint.Mutation-verified (13/13 caught). Backend: unowned-fanout guard, stale
backlog, bucket movement, stale reason, retire clear, buffered capture, empty-vs-
absent payload, clearing on a declined reset. Frontend: unreported default, the
mark driven by session state rather than the configured flag, hidden extras,
roster-as-report, ignoring
null, and the vocabulary-collision guard (revertingstartedto the tool dots'bg-okreddens it). One frontend mutation initiallysurvived — the test asserted the tooltip but not the mark itself, which is the
signal a user actually reads; the assertion was strengthened until it failed.
Manual verification
Full backend suite run twice and compared as sets: the only failures that
disappeared between runs were the two this diff introduced (both fixed above),
and the sole new one (
test_browser_cli_install) passes 76/76 in isolation — aparallel-run flake in a file this diff does not touch. The remaining ~106 are
this host's pre-existing environmental set (
AF_UNIX path too long,classify_sourcereading the real HOME path shape, an assertion on a path thatcontains the data-home directory name). Frontend: 1699 test files green, 647 i18n
tests green including every per-language style test.
Screenshots / video
Both variants of the panel, dark theme.
github-mcpstands in for the reportedcase: it is in the agent's configuration, and this session never reported it —
previously indistinguishable from the servers that did start.
Captured from a harness rendering the real
McpToolsPanelwith backend-shapedprops, not from a live session — stated plainly so the evidence is not read as
more than it is. The payload shape it is given is the one pinned by the backend
tests and the
McpSessionReporttype; what the frame proves is the rendering andthe copy, not the end-to-end wiring (that is what the wiring tests cover).
The frame earned its place twice. Its first version rendered every label blank
(the harness had not initialized i18n), which hid the honest wording that is half
of this change. Its second version showed the marks — and that is how the
vocabulary collision above was found: two legends stacked with indistinguishable
swatches. Both were fixed before this frame.
Related Issues
Relates to #2946, which records the probe-vs-session divergence this makes
visible. Complementary to #7143 (servers that should be mounted and are not on
the KAS backend) and #7328 (an agent not knowing what to do when policy blocks a
call) — three parts of the same illegibility, no overlap.
Pattern harvest
Rule candidate: review-prompt
Pattern: a status label sourced from one subject (the host) rendered on a surface
the reader will attribute to another (their session), with no wording that names
the subject.
Checklist
the rationale lives in the new module's docstring