[Security 4/9] MCP tools require confirmation unless positively known-safe
From the PR description
[Security 4/9] MCP tool calls pause for the user's approval unless positively trusted
Part of the split of #227 into single-topic PRs. Index: tracking comment on #227.
Rebased onto
main6a62d01a(2026-08-26); branch tip3577a54d. Clean rebase - no textual conflicts - but three things merged intomainin the meantime interact with this branch on purpose rather than by accident, and each got a deliberate reconciliation. See "Reconciliation with what merged intomain" below.This description was rewritten once before to match the branch (it originally described only the first commit, a one-function annotation flip). See "What changed since the first version of this description" at the bottom for the full commit ledger.
Housekeeping: between 2026-08-25 05:37 and 05:58 UTC this PR's body was accidentally overwritten with #351's description by a scripted
PATCH. The text below is the real #247 description, recovered from GitHub's edit history and brought up to date. #351 is unaffected.
TL;DR
On main, an MCP connector tool runs without asking anyone unless the external server
volunteers destructiveHint: true or readOnlyHint: false. A tool that says nothing about
itself - the common case - executes silently. And the handful of tools that do get flagged
are not gated, they are bricked: refresh force-disables them, the enable toggle throws,
and tool discovery filters them out. There is no confirmation UI anywhere in the product.
This PR replaces both halves:
- The policy flips to fail-safe. A tool may auto-run only if its annotations positively declare it safe and the user has separately marked that connector as trusted. Two independent signals, because the server writes its own annotations. Since the trust flag defaults to off, the shipped default is every MCP tool call asks - the annotation classification only starts mattering once a user opts a connector in.
- "Requires confirmation" becomes a real thing a user can do. The exact proposed call (tool + arguments) is written to a short-lived, single-use ledger row, streamed into the chat as an inline Approve / Decline prompt, and executed - if approved - from the stored payload.
Risk to user data
Severity: medium-high, and this is a key defense-in-depth layer behind prompt
injection (see [Security 5/9], spotlighting). Tool annotations (readOnlyHint /
destructiveHint / openWorldHint) are advisory and controlled by the external MCP
server - a hint, not a guarantee. If the LLM is tricked (or a connector is malicious or
mis-annotated) into calling a side-effecting tool, the cost in a legal product is severe:
exfiltrating a privileged document, mutating a matter, hitting an unknown external system.
Requiring positive proof-of-safety means a bad or missing annotation fails toward a
confirmation click, not silent execution.
This is the "human in the loop for consequential actions" mitigation from the OWASP Top 10 for LLM Applications (excessive agency / insecure output handling). The general lesson - never trust a security decision to a field the other side controls - is the same failure behind confused-deputy bugs.
Replicate the base case (on main)
The point of these steps is to see the two distinct failures on main: an unannotated tool
runs unasked, and an annotated-destructive tool is permanently unusable.
Setup (both cases): run the stack, sign in, go to Settings → Connectors, add any MCP server you control, and hit Refresh tools.
Case A - silent execution of an unannotated tool
- Point the connector at an MCP server whose tool declares no annotations
(
annotations: {}- the default for most real servers). git show origin/main:backend/src/lib/mcp/client.ts→toolRequiresConfirmation()(~line 174) returnstruthyAnnotation(annotations, "destructiveHint") || annotations?.readOnlyHint === false. With{}both terms are false, so the tool is cached withrequires_confirmation = false.- In a chat, ask the model to use that tool. Observe: it just runs. In the SSE stream
(DevTools → Network → the
/chatrequest → Response) you get anmcp_tool_callevent withstatus: "ok"and never anything resembling a confirmation. Confirm in the DB:select tool_name, requires_confirmation from user_mcp_connector_tools;→false.
Case B - a flagged tool is bricked, not gated
- Point the connector at a tool declaring
destructiveHint: true(orreadOnlyHint: false) and Refresh tools. - Watch it flip itself off in the tools list:
refreshUserMcpConnectorToolsinbackend/src/lib/mcp/servers.ts(~line 379) runs.update({ enabled: false }).eq("requires_confirmation", true)after every sync. - Try to switch it back on. The request fails with
"This MCP tool needs human confirmation before Mike can expose it to chat."(setUserMcpToolEnabled, ~line 428). - Ask the model to call it anyway.
buildUserMcpToolsselects with.eq("requires_confirmation", false), so the model was never told the tool exists; and if you invoke the name directly,resolveCallableTool(same.eq(...)filter) returns null and the model gets"MCP tool is not available or is disabled." - Grep the frontend for any approval surface -
git grep -l "mcp_confirmation_required\|mcp-pending-calls" origin/main→ no matches. There is nothing to click.
That combination is the actual problem: the only thing the old policy could do to a suspicious tool was disable it forever, which is why the default had to be lenient.
Replicate this PR
Setup: check out the branch, apply backend/migrations/20260802_01_mcp_pending_tool_calls.sql
(or bootstrap from backend/schema.sql, which now carries the table), start the stack, add an
MCP connector, Refresh tools.
1 - The gated tool is now usable, and says so
- Settings → Connectors → your connector. The unannotated tool is enabled and toggleable
- the toggle is live where on
mainit was permanently disabled, and refresh no longer silently flips it off. Its amber badge now reads "Asks for your approval before each run" (was "Confirmation required").
- the toggle is live where on
- New control in the connector details modal: "Trust this server's safety annotations", default off, with the copy "Off (recommended): every tool call asks for your approval." Leave it off.
2 - The approval prompt (the happy path)
- In a chat, ask the model to call that tool.
- The assistant message renders an inline block:
<connector>: <tool> wants to run, followed by a<pre>showing the exact arguments JSON that will execute, plus Approve and Decline buttons. - Proof in DevTools → Network → the streaming
/chatrequest → Response tab: an SSE framedata: {"type":"mcp_confirmation_required","id":"<uuid>","tool_name":..., "arguments_json":...,"expires_at":...}(emitted frombackend/src/lib/chat/tools/toolDispatcher.ts). - Proof in the DB, while the prompt is on screen:
select id, status, tool_name, arguments, expires_at from user_mcp_pending_tool_calls;→ one row,status = 'pending',expires_at≈ now + 2 min. - Click Approve. Network shows
POST /user/mcp-pending-calls/<id>with body{"decision":"approve"}→200 {"status":"approved"}. Note the request body carries only the verb - the payload is not resendable. The button readsApproving...and stays there until the backend notices: resolution is driven by the server's next poll (≤1.5 s), not by the POST response. - The block turns green and reads "Approved - running"; a second SSE frame
{"type":"mcp_confirmation_resolved","id":...,"decision":"approved"}arrives, then the normalmcp_tool_callresult. Re-query the row: it walkedpending → approved → executing → executedandexecuted_atis set.
3 - Deny actually blocks execution
- Ask for the tool again; this time click Decline.
POST /user/mcp-pending-calls/<id>with{"decision":"deny"}→{"status":"denied"}.- The block turns red and reads "Declined". The model receives the tool result
{"ok":false,"error":"The user declined this tool call."}and the MCP server is never contacted - confirm at the server's own log, or by pointing the connector at a URL that would fail loudly if reached. - DB row is terminal
denied; there is noexecutingrow and noexecuted_at.
4 - Timeout, replay, and ownership (the security properties)
- Timeout: trigger a prompt and walk away. After
MCP_APPROVAL_WAIT_MS(90 s) the turn stops waiting, the row is conditionally moved toexpired, the block reads "Expired without a decision", and the model gets"The user did not approve this tool call in time."A late click then returns 410 Gone -{"detail":"This tool call approval has expired."}. - Replay: approve a call, then re-issue the same
POSTwith the same id → 404, because the conditional UPDATE requiresstatus='pending'. An approval spends itself. - Ownership: copy a pending id and POST it from a second signed-in account →
404, never a decision. The UPDATE is keyed on
id + user_id + status + not expired. - Stale-cache bypass (the fix in
6380de05): hand-set a row torequires_confirmation = falsewithannotations = '{}'(exactly what a pre-upgrade cache looks like) and turn the trust toggle on. The call still pauses, becausetoolRowRequiresConfirmationrecomputes from the annotations at call time rather than reading the column.
5 - What "trusted" buys you
Turn "Trust this server's safety annotations" on for a tool whose server declares
{readOnlyHint: true, openWorldHint: false}. That call now runs with no prompt. Turn the
toggle off again → prompted. Neither signal alone is enough.
6 - The pause does not silently die behind a proxy (de8b56bf, 26c200f3)
While a prompt is open the turn writes no SSE data for up to 90 s. Trigger a prompt and
watch the /chat response in DevTools → Network → Response: every 15 s a line
: mcp-approval appears. It is an SSE comment, so no reader in the repo surfaces it and
nothing lands in the transcript - but it is bytes on the wire, which is what keeps an
intermediary from reaping the response as idle. curl -N against the same endpoint shows the
same frames.
7 - Declining does not brick the rest of the chat (a1d1ad9d)
- Decline a call, then send any follow-up message in the same chat.
- On the pre-fix branch both sends failed with the generic stream error, permanently: the
denied turn's narration lived only in its
events, so the client replayed it ascontent: "", and Anthropic rejects any request containing an empty assistant message. - Now the follow-up streams normally. Confirm the mechanism in the request payload
(Network → the
/chatrequest → Payload): the denied turn is either carried with its text recovered from itscontentevents, or dropped - never sent as"".
Reconciliation with what merged into main
The rebase itself was clean. These are the places where "no conflict" would have been the wrong answer, and what was decided instead.
#366 (Word add-in client tool loop) - shared the keep-alive instead of mirroring it
de8b56bf was written while #366 was still an open PR and deliberately mirrored its
keep-alive pattern. Now that #366 is on main, mirroring would leave the tree with two
independent answers to one question, and main has no shared helper to reuse - its cadence
is a module-private KEEP_ALIVE_INTERVAL_MS in wordClientTools.ts with the frame text
inline.
26c200f3 extracts backend/src/lib/sse.ts (SSE_KEEP_ALIVE_INTERVAL_MS,
sseKeepAliveFrame(reason)) and points both sites at it: the Word tool loop's pane
round-trip and this branch's approval wait. It sits at the lib root because both layers
consume it and neither should depend on the other.
The cadence is only correct relative to proxy idle timeouts, so it has to move as a unit - two copies means someone lowering one for a 20s-timeout deployment fixes half the product and leaves the other half failing in the hardest way to diagnose. The frame's comment-ness carries the same hazard: a "cleanup" that turns one into a data frame pushes an unknown event type into a user's transcript, and the untouched copy gives no hint the rule existed.
main's design wins on structure. Each site keeps its own ticker, because the ticker's
lifetime is site-specific and load-bearing in both (wordClientTools clears it in the
finally around one forwarded call; executeMcpToolCall clears it around the approval wait
so a throwing status poll cannot leave a timer writing into a dead stream). What is shared is
the contract, not the control flow. The MCP module also stays transport-agnostic: it still
takes onApprovalWaitKeepAlive as a callback, and the bytes are produced at the dispatcher,
which is the layer that owns write.
#379 (loud empty model completions) - complementary, and now pinned
main now emits a visible "The model returned an empty response. Try again, or pick a
different model." when a turn ends with no text and no error event. That lands directly on
this branch's newest surface: declining a tool call legitimately produces a turn with no
model prose. If the guard fired on it, every denial would tell the user the model broke and
invite them to retry or switch models - the one control whose purpose is to make refusal safe
would look like the thing that breaks the product, and the advice it gives is the opposite of
what a security decision should teach.
The two mechanisms are complementary, for different reasons, and neither subsumes the other:
- #379 is about the turn just produced: empty output with nothing to show for it means something went wrong upstream, so say so.
a1d1ad9d's filters (withoutEmptyAssistantReservations+runLLMStream) are about history: an assistant turn whose narration lives only ineventsmust not be replayed to a provider ascontent: "". #379 cannot stop an already-persisted turn from being replayed; the filters cannot tell a user that this turn came back empty.
Decision: keep both, unchanged, and pin the seam. What actually keeps #379 off a denial
is implicit - the guard skips any turn where some event has an error key, and the denial
path returns an mcp_tool_call event carrying
error: "The user declined this tool call." while the success path returns the same event
type without it. That coupling lives in a file neither PR touches and no test named.
3577a54d adds a route-level test asserting a denied-only turn does not emit the
empty-response error; it was verified to fail (and to produce the misleading message) when the
error field is removed from the event, so it guards the real dependency rather than the
current output.
#382 (HttpOnly cookie auth) - no change needed, verified
respondMcpPendingCall goes through mikeApi's apiRequest, which #382 rewrote to use
authenticatedFetch against the same-origin /api gateway. The approval POST therefore
inherits the cookie-based auth pattern with no branch-side change; the endpoint wrapper tests
and the 100% src/app/lib/** coverage floor both still pass against the rewritten module.
Also on this pass
7be91042 - the migration was missing the REVOKE ALL ... FROM anon, authenticated that
schema.sql already had and that every sibling MCP table got in
20260613_04_user_mcp_connectors.sql. Fresh installs were correct; upgrades were not, which
is both a hardening gap and a drift-check divergence. RLS-with-no-policies already denied the
rows, so this is defence in depth - but on the wrong table to rely on one control: arguments
holds the verbatim proposed payload and status is the authorization decision. The
filename is untouched (20260802_01 is early-sorted on purpose and already verified to land
after user_mcp_connectors); REVOKE is idempotent, so the file stays re-runnable.
Tradeoffs & design decisions (explicit)
1. The gating policy
| Option | Verdict |
|---|---|
Trust destructiveHint (main's policy) |
A poorly- or maliciously-annotated tool omits the flag and runs unconfirmed. Rejected. |
Gate on openWorldHint alone |
Almost every useful connector (Gmail, Slack, GitHub) is open-world, so this gates everything with no way to proceed. Rejected as the sole signal. |
| Confirm everything, always, with no escape hatch | Safe, but so much friction that users habituate and click through - which trains them to approve blindly. Rejected as a permanent state. |
| Two independent signals | Chosen. Auto-run requires (a) annotations positively safe and (b) the user's own per-connector trust flag. |
Read the implemented predicate honestly:
mcpCallNeedsApproval = requiresConfirmation || !connectorTrustsAnnotations(toolPolicy)
Because trust_annotations defaults to absent, the shipped default is "confirm everything,
always" - the friction concern above is real, and the escape hatch is the toggle, not a
lenient default. That is a deliberate choice for a legal product: the safe state is the one
you get by doing nothing.
The annotation half is deliberately strict, per the MCP spec's defaults:
readOnlyHint === true && openWorldHint === false && destructiveHint !== true
openWorldHint omitted defaults to true in the spec, so {readOnlyHint: true} alone is
an open-world reader and stays gated. Absence is never safety.
⚠️ Correction to the earlier version of this description, which advertised
readOnlyHint===true && !destructive && !openWorld(i.e. absence ofopenWorldHintcounted as closed-world). That was a misread of the spec;a2ad4f88reversed it, and reversed the test that had asserted the old behavior.
2. Annotations are never sufficient on their own
A malicious server can simply write readOnlyHint: true. So the second signal is
locally controlled: trust_annotations lives in the connector's tool_policy, defaults
to off, and is revocable from the UI. Rejected alternative: a global "trust all annotations"
preference - one wrong click would disarm every connector at once.
3. Approval is bound to the payload, not to the moment
The decision endpoint carries only {"decision":"approve"|"deny"}. It cannot alter the
tool or the arguments, and execution reads the arguments back out of the stored row. The
obvious alternative - echo the payload back with the approval - would let a compromised page
approve one thing and run another. Cost: an extra table.
4. Poll, don't push
waitForMcpApprovalDecision polls the row every 1.5 s inside the already-open streaming turn
(90 s bound, deliberately under the row's 120 s TTL). Rejected: a websocket/pubsub channel -
new infrastructure for a flow that already has an open SSE stream and a two-minute horizon.
Cost: up to 1.5 s of latency after a click.
Corrected: an earlier version of this note (and the comment in
approvals.ts) claimed the 90 s bound also sat "under the global stream watchdog". There is no such watchdog, and there was no keep-alive either - the claim was aspirational and it was hiding the real failure mode that reviewer item 2 used to flag.de8b56bfmakes the comment true instead of deleting it; see the next entry.
4b. The silent pause is covered by SSE comment frames, not a shorter timeout (de8b56bf)
The approval pause is the one point in a turn where the server deliberately goes quiet, and
that is exactly what a proxy, load balancer, or CDN reaps for inactivity (nginx's
proxy_read_timeout and an ELB idle timeout both default to 60 s; 30 s is a common hardened
setting). Reaping it would kill the confirmation the user is still looking at, and it fails in
the shape that reads as a product bug: "I clicked Approve and nothing happened."
Chosen: an SSE comment frame every 15 s for exactly the duration of the wait. Comment
frames are free - every reader in this repo keeps only lines starting with data: - and the
bytes do a second job: on a half-open TCP peer a periodic write is what surfaces the reset and
fires the abort path instead of parking the turn on a socket that will never answer. Rejected:
shortening the 90 s wait to fit the tightest plausible timeout, which would trade a rare
infrastructure failure for a common "you took too long" failure.
The ticker is injected as a callback rather than a writer, so the MCP module stays
transport-agnostic and a non-streaming caller simply omits it, and it is wrapped in
try/finally so a throwing poll cannot leak a timer into a stream whose turn has ended.
Cadence and frame shape are shared with #366's client tool loop via lib/sse.ts - see the
reconciliation section.
Still not addressed: a confirmation does not survive a page reload, because the pending state lives only in the open stream. That needs event persistence and is tracked separately (reviewer item 3).
5. Claim-then-record, not record-then-run (be520b9b)
Two jobs were originally squeezed into one status write. Single-use safety must happen before execution; truth about the outcome can only be known after. So the state machine has an intermediate claim:
pending → approved → executing → executed (call completed)
pending → approved → executing → failed (call errored)
pending → denied
pending → expired
executing is honest at every instant ("an attempt is in flight"). failed is terminal, so a
failed attempt still spends the approval - an error can never be retried into a replay.
6. Close the check-then-act race at the database (66ed8e31)
If the user's click commits between the last poll and the expiry UPDATE, the row would sit
approved forever while the chat reported "expired". The fix makes the act conditional
(UPDATE ... WHERE status='pending' RETURNING id) and uses the affected-row count as the race
detector: 0 rows ⇒ a decision won ⇒ re-read once and honor it.
7. Opportunistic sweep instead of a cron job (ad17dfcb)
Terminal rows retain the full argument payload - potentially privileged matter data - so they
get a bounded 24 h life. Cleanup piggybacks on the next INSERT (the same shape the repo
already uses for the OAuth state store) rather than standing up job infrastructure for a
low-traffic table. Best-effort: a sweep failure is logged and swallowed, because a user is
waiting on the prompt that insert serves. Deliberately global, not per-user, so retention
still holds for users who never return.
8. requires_confirmation demoted to a display cache (6380de05)
A security gate that reads a cached verdict is only as strong as its oldest entry. Rows
written under main's lenient policy would keep false. The column is still written (it drives
display/API summaries) but the authoritative decision recomputes from the stored
annotations at call time, and an explicit stored true is still honored - the gate can only
ever be strictly tighter than either signal alone.
9. Gated tools are now visible to the model - a deliberate widening
⚠️ Reviewers should decide on this one explicitly. On main, a gated tool was filtered
out of buildUserMcpTools and resolveCallableTool entirely: the model never learned it
existed. This PR removes both .eq("requires_confirmation", false) filters, so gated tools
are advertised and reachable - the runtime prompt is what stops them, not invisibility.
That is strictly more surface exposed to a prompt-injected model. It is the necessary price of having an approval flow at all (you cannot approve a call that was never proposed), and the mitigation is that the human is now the gate rather than a cache column. But it is a loosening as well as a tightening, and it belongs in the reviewer's ledger.
Gated tools carry an appended note in their description: "This tool pauses for the user's explicit approval before it executes; the user may decline." Hiding the gate would make the model treat a decline as an unexplained failure and retry.
10. Sequential dispatch: one pending approval stalls the rest of the turn
runToolCalls dispatches tool calls in a sequential for loop, so a pending approval blocks
every later tool call in the same turn for up to 90 s. Parallelising the loop was not attempted
here - it is a larger change to an existing code path, and a turn that is waiting on a human
is arguably supposed to stop.
Testing evidence
Local gates re-run on the rebased tip 3577a54d (on main 6a62d01a), all green:
| Gate | Result |
|---|---|
npm test --prefix backend |
851 passed / 25 skipped, 71 files |
npm run build --prefix backend |
pass (tsc, clean) |
npm run test:coverage --prefix backend |
57.07% stmts / 50.24% branches / 58.4% funcs / 58.64% lines - floors 52/46/53/54 |
npm test --prefix frontend |
620 passed, 97 files |
npm run test:coverage --prefix frontend |
100% stmts / 97.69% branches / 100% funcs / 100% lines - floors 100/97/100/100 |
npm run lint --prefix frontend |
0 errors, 35 warnings - byte-identical to the same command on the branch without these commits (all pre-existing no-unused-vars on node params) |
npm run build --prefix frontend |
pass |
CI's remaining checks (Supabase stack, fresh-vs-upgraded drift, playwright, CodeQL, eval
harness, gitleaks, CLA) were green on the pre-rebase tip and are not re-runnable locally;
they need a CI pass on 3577a54d. The drift check is the one to watch, since 7be91042
changes the migration - it should now converge more closely, because the missing
REVOKE was itself a fresh-vs-upgraded divergence.
The
97.61% → 97.69%frontend branch figure is not a change from this PR: #382's rewrite ofmikeApi.tsmoved it. The comment infrontend/vitest.config.mtsis refreshed to the new measurement; the floors are unchanged.
New tests added by this PR (exact counts):
backend/src/lib/mcp/__tests__/confirmation.test.ts- 20 cases: every annotation combination including the missing-hint case, the strict-type edges ("true"is nottrue), the reversed omitted-openWorldHintexpectation, and the both-signals approval matrix.backend/src/lib/mcp/__tests__/approvals.test.ts- 12 cases: ownership binding, decision single-use, expiry, exactly-one-winner execution claim, timeout retirement, the poll/expiry race, theexecuting → executedvsexecuting → failedsplit, and the retention sweep (aged terminal rows deleted; a recentdeniedand an old-but-livependingsurvive).backend/src/lib/mcp/__tests__/servers.approvalGate.test.ts- 7 cases: end-to-endexecuteMcpToolCallagainst an in-memory Supabase stand-in, driven with the exact regression row (requires_confirmation=false,annotations={}, trusted connector). The connector URL is a private IP on purpose - if the gate ever leaks, the SSRF guard fires and the test fails loudly rather than silently passing.de8b56bfadds the keep-alive contract on fake timers: frames appear on cadence while the row is pending, stop the moment it is decided, and the ticker is cleared even when the wait itself throws. Both were verified to fail against the unfixed code.backend/src/lib/sse.test.ts- 3 cases (26c200f3): the frame is a comment, is blank-line terminated, contains nodata:, and two ticks fit inside the tightest 30 s idle window. These invariants were previously asserted only against the MCP copy; #366's Word tool loop is now covered by them too.backend/src/lib/chat/__tests__/routeStreaming.test.ts- 3 cases (a1d1ad9d): null reservations, empty/whitespace assistant content, and the negative case (real content and all user messages survive).backend/src/__tests__/integration/chat.routes.test.ts- 1 case (3577a54d): a denied MCP turn is not reported as an empty upstream completion. See the #379 reconciliation above for why this one is load-bearing rather than decorative.frontend/src/app/lib/mikeApi.test.ts- 2 new rows in the table-driven endpoint-wrapper suite, pinningrespondMcpPendingCallfor bothapproveanddeny. Both arms deliberately: a wrapper that ignored its argument and always sent"approve"would pass a single-case test while converting every deny into an approval.
Coverage ratchet: the new export dropped src/app/lib/** below its 100% functions/lines
floor and turned the frontend job red; 5967d185 fixes it with tests, then raises the
statements floor 99 → 100 per the "floors only go up" policy in docs/frontend-testing.md.
Re-measured on the rebased tip: 741/741 statements, 509/521 branches, 235/235 functions,
629/629 lines.
Verified live: the approval, denial, expiry, replay, and ownership paths above were
exercised against a local stack during development. a1d1ad9d in particular was
live-replicated both ways - the previously bricked chat streams again, and a fresh
deny → follow-up → approve cycle completes end to end. Not yet verified against a
third-party production MCP server - see below.
What changed since the first version of this description
The original body described commit c5b0d048 only (one predicate in client.ts + a 9-case
test) and stated the openWorldHint rule incorrectly. Everything below was missing from it:
| Commit | What it added |
|---|---|
a2ad4f88 |
The whole approval flow: table, approvals.ts, SSE events, decision endpoint, chat UI, trust toggle; reversed the openWorldHint policy |
6380de05 |
Gate recomputed live from annotations, not the cached column |
66ed8e31 |
Poll/expiry race closed at the database |
be520b9b |
executed recorded only after the call actually ran; new terminal failed |
ad17dfcb |
24 h retention sweep for terminal rows carrying argument payloads |
199bbe7a |
Table mirrored into backend/schema.sql (fresh installs bootstrap from the snapshot, so a migration-only table would not exist where CI and new deployments create their schema) |
5967d185 |
Frontend coverage ratchet restored |
de8b56bf |
SSE keep-alive during the approval pause; corrected the false "under the global stream watchdog" comment (closes reviewer item 2) |
a1d1ad9d |
A denied call no longer bricks the rest of its chat - empty assistant turns are recovered from their events client-side and filtered server-side |
26c200f3 |
One shared keep-alive cadence and frame for both places a turn parks, now that #366 is merged |
7be91042 |
Migration revokes anon/authenticated grants on the ledger, matching schema.sql and the sibling MCP tables |
3577a54d |
Pins that declining a call is not reported as #379's "empty response" |
SHAs above are post-rebase. The pre-rebase equivalents were
c5b0d048,a2ad4f88,6380de05,66ed8e31,be520b9b,ad17dfcb,ef885201,6254f98f,4fbb5d8b,b101e57a; older comments on this PR reference those.
For a human reviewer to check
A real third-party MCP server. Everything above is proven against unit fakes and a local stack. One pass against a genuine connector (Drive/Slack/GitHub) would confirm the annotations real servers actually emit land on the side of the policy we expect - in particular how many useful tools declare
openWorldHint: false.Does a 90 s silent SSE gap survive your real infrastructure?Addressed byde8b56bf. The finding was right and the code comment was wrong: there is no global stream watchdog anywhere inbackend/src, and there was no keep-alive either. The pause now emits an SSE comment frame every 15 s for exactly its duration, sharing #366's cadence vialib/sse.ts(26c200f3). Still worth a reviewer's eye: confirm 15 s clears your deployment's tightest idle timeout - the constant is chosen against nginx/ELB defaults (60 s) and a common hardened setting (30 s), not against measurements of this org's infrastructure.Does the prompt survive a page reload?
mcp_confirmationevents are not among theMcpToolEvents persisted bystreaming.ts, so reloading mid-prompt probably loses the Approve/Decline block from chat history while the row is stillpending. Worth eyeballing in the browser - the row expires safely either way, but the UX is a dead end.Should approving require MFA step-up? The Settings trust toggle goes through
runSensitiveActionand itsPATCHcarriesrequireMfaIfEnrolled; the newPOST /user/mcp-pending-calls/:idcarries onlyrequireAuth. Defensible (it is a click inside an authenticated live chat, and a step-up modal over a streaming turn is nasty), but it is a product call, not an oversight to be assumed.Polling load. One row polled every 1.5 s per pending call per turn, for up to 90 s, against Supabase. Unmeasured under concurrency.
Migration date ordering.
20260802_01_...sorts before main's20260821-20260825migrations. Harmless here (an independentCREATE TABLE IF NOT EXISTS, and the drift check is green), but if the convention is that a merged migration is always newer than everything already deployed, the file should be re-dated before merge. The grants half of this item is now fixed -7be91042adds theREVOKE ALL ... FROM anon, authenticatedthe file was missing - but the RLS claim is still asserted in SQL text only: nothing here proves at runtime thatanon/authenticatedcannot read the ledger. A stack-level test against a real Postgres would.UX of the argument dump. The prompt shows raw arguments JSON in a
max-h-40scroll box, with no countdown even thoughexpires_atis carried in the event. For a large payload that is honest but not friendly; worth a product opinion on truncation, a summary line, and whether a visible timer would help.The #379 exemption is implicit, and the test is the only thing naming it.
3577a54dpins that a denied turn does not triggermain's empty-completion error, but the mechanism is that the denial'smcp_tool_callevent happens to carry anerrorkey while the success event does not. A cleaner contract - an explicit "this turn intentionally has no prose" signal, or #379 keying off event type rather than key presence - would be more honest than a test guarding a coincidence. Worth a maintainer's opinion on whether to do that here or as a follow-up onmain.
Reading
OWASP Top 10 for LLM Applications · Confused deputy problem · MCP tool annotations
Our analysis
Default MCP tools to confirmation — read the full analysis →
Think the analysis missed something the PR description covers?
Capture this PR into my fork
Download a Markdown prompt that tells Claude how to port every
commit in this PR into your working tree. Run it via
claude -p < capture-pull-247.md from
inside the repo you want the changes in.