Revalidate MCP actions before dispatch - #198
Conversation
|
The I've completed my review. The change is well-structured and thoroughly tested. Let me summarize my findings. Review SummaryThis PR revalidates approved MCP actions immediately before dispatch, so a stale action (connection/grant/policy changed while awaiting approval) is never sent. The design is sound and consistent with the kernel's capability model, and the test coverage is substantial (action-store, facet, session, auto-approval, Activity). I reviewed the full diff ( Findings — no blocking issues. A few observations, none actionable enough to block:
LGTM! |
cc21770 to
715cc94
Compare
04fb946 to
f043907
Compare
|
The interface declares I've thoroughly traced all the key flows. The existing bonk review already exists with a detailed analysis and LGTM verdict. My independent review confirms the same conclusions:
I found no discrete, actionable problems introduced by this change beyond the by-design observations the existing review already documented (extra remote round-trip per tool call; the compat LGTM! |
715cc94 to
972434f
Compare
f043907 to
b8fffc8
Compare
|
The read path throws I've completed a thorough independent review. My review confirms the design is sound and I found no discrete, actionable problems introduced by this change beyond the by-design observations the two existing bonk reviews already documented (extra remote round-trip per tool call; the defensive
No inline findings to submit. LGTM! |
|
The frontend tests confirm the invalidation reason renders in history and that changing I've completed a thorough independent review. Let me summarize. Review SummaryThis PR revalidates an approved MCP action immediately before dispatch, so a stale action (whose connection generation, tool scope, or effective policy changed while awaiting approval) is never sent. I reviewed the full diff ( Verified correct:
By-design observations (not blocking):
I found no discrete, actionable problems introduced by this change. The earlier bonk comments referencing a LGTM! |
2846cdc to
f5c6473
Compare
b8fffc8 to
90f8ab6
Compare
|
Test coverage is thorough, including the read-path generation-change guard. My independent review confirms the design is sound and the implementation is correct. I traced all the key flows independently:
All callers of the changed signatures ( LGTM! |
90f8ab6 to
b77136c
Compare
Preview:
|
ca9faa6 to
ac3b2f8
Compare
a6394d8 to
d90a456
Compare
ac3b2f8 to
6e32eaa
Compare
d90a456 to
7a2a950
Compare
6e32eaa to
405da7a
Compare
e97c4f4 to
a4c0bb9
Compare
a22b59a to
295d35c
Compare
| if (stored.connectionGeneration === undefined || stored.policyFingerprint === undefined | ||
| || connectionGeneration !== stored.connectionGeneration) { | ||
| throw new ActionInvalidatedError( | ||
| "This MCP connection changed after approval was requested. Stage the call again."); | ||
| } |
There was a problem hiding this comment.
🟡 Approving a request queued before this release fails it and silently deletes every automatic-approval rule for that connection
Any request that was queued for approval before this release lacks the newly captured approval snapshot and is therefore always refused at dispatch (ActionInvalidatedError at packages/mcp-shared/src/facet.ts:336-340), which in the Workshop also erases all of that connection's automatic-approval rules.
Impact: After deploying, a user who approves an already-waiting request sees it fail with a misleading "connection changed" message and loses every auto-approval setting they had configured for that connection.
Why a pre-upgrade action always invalidates, and what invalidation costs
ActionStore.stage only started persisting policy_fingerprint/connection_generation in this PR, and StoredAction documents both as "absent on actions staged by older code" (packages/mcp-shared/src/session.ts:50-53). Rows inserted before the deploy have both columns NULL, so applyAction takes the first branch (stored.connectionGeneration === undefined || stored.policyFingerprint === undefined) and throws the "This MCP connection changed after approval was requested" error even though nothing changed.
On the Workshop side applyPendingAction treats every invalidation as a barrier: it calls clearAutoApprovalRules(this.storage, record.gatekeeperId) (packages/workshop-backend/src/overseer.ts:2675-2686), deleting every autoApproveTags row for that gatekeeper. The rules are not restored automatically; the user must re-enable each one. The same collateral applies to any transient invalidation (for example an aggregator that momentarily omits the tool from tools/list).
A cheaper fail-closed option for the legacy case would be to keep the action retryable, or at least to use a reason that does not claim the connection changed and does not trip the rule-clearing barrier.
Prompt for agents
Actions staged before this PR have NULL policy_fingerprint/connection_generation, so McpFacetBase.applyAction (packages/mcp-shared/src/facet.ts) always throws ActionInvalidatedError for them with the message "This MCP connection changed after approval was requested." In the Workshop, OverseerImpl.applyPendingAction (packages/workshop-backend/src/overseer.ts) treats any invalidation as a barrier and calls clearAutoApprovalRules(), wiping every auto-approval rule the user configured for that connection. The result is that a routine deploy turns each already-queued approval into a failure that also destroys unrelated user configuration. Consider distinguishing the legacy-snapshot case: either resolve the current policy and treat a matching tool as still valid, or fail the action with a distinct message that does not trigger the auto-approval barrier (and does not misattribute the cause to a connection change). Also consider whether clearing every rule is the right response to a single invalidation, versus only stopping the current drain.
Was this helpful? React with 👍 or 👎 to provide feedback.
295d35c to
04bbfee
Compare
a4c0bb9 to
5295e5d
Compare
| async applyPendingAction(record: ActionRecord & {type: "action"}, | ||
| resolvedBy: AiChatAuthorInfo, autoApproved: boolean): Promise<void> { | ||
| resolvedBy: AiChatAuthorInfo, autoApproved: boolean) | ||
| : Promise<"approved" | "invalidated"> { | ||
| let gatekeeper = this.getGatekeeperFacet(record.gatekeeperId); | ||
| await gatekeeper.applyAction(record.action); | ||
| let invalidationReason: string | undefined; | ||
| try { | ||
| await gatekeeper.applyAction(record.action); | ||
| } catch (error) { | ||
| invalidationReason = getActionInvalidationReason(error); | ||
| if (invalidationReason === undefined) throw error; | ||
| } | ||
| if (invalidationReason !== undefined) { | ||
| // A rolled-back backend will not recognize the reason as an in-order barrier. Removing every | ||
| // rule makes its next drain stop at a manual gate instead of applying a successor past it. | ||
| clearAutoApprovalRules(this.storage, record.gatekeeperId); | ||
| // Keep the established wire state so an older browser renders this as denied rather than | ||
| // claiming the undispatched action was approved. The reason distinguishes invalidation. | ||
| record.state = "rejected"; | ||
| record.appliedAt = new Date(); | ||
| record.resolvedBy = resolvedBy; | ||
| record.invalidationReason = invalidationReason; | ||
| this.storage.actions.put(record); | ||
| return "invalidated"; | ||
| } | ||
| record.state = "approved"; | ||
| record.appliedAt = new Date(); | ||
| record.resolvedBy = resolvedBy; | ||
| record.autoApproved = autoApproved; | ||
| this.storage.actions.put(record); | ||
| return "approved"; |
There was a problem hiding this comment.
🟡 Kernel and UI changes are shipped together in a single unsplit commit
The change bundles kernel edits (packages/workshop-backend/src/overseer.ts, packages/workshop-backend/src/auto-approval.ts, packages/workshop-shared/src/api.ts, packages/workshop-shared/src/gatekeeper.ts) with Workshop UI edits (packages/workshop-frontend/src/Activity.tsx, ChatInterface.tsx, useResolveAction.ts) in one commit, which REVIEW.md forbids: a kernel change must at minimum be grouped into commits that let workshop-backend/workshop-shared be reviewed apart from UI.
Impact: Reviewers cannot read the higher-bar kernel diff separately from the UI diff, which is exactly what the repository's review rules require.
Where the rule applies
REVIEW.md, "High-scrutiny areas": "A large kernel change should be split by concern into separate PRs, and at minimum grouped into commits that let workshop-backend/workshop-shared be reviewed apart from UI. Flag PRs that bundle both." git log shows a single commit (Revalidate MCP actions before dispatch) touching packages/workshop-backend/src/overseer.ts:2664-2693, packages/workshop-backend/src/auto-approval.ts:27-36, packages/workshop-shared/src/gatekeeper.ts:21-48 and the three frontend files together.
Prompt for agents
REVIEW.md requires that kernel changes (packages/workshop-backend and packages/workshop-shared) be reviewable apart from UI changes. This PR lands overseer.ts, auto-approval.ts, api.ts and gatekeeper.ts together with workshop-frontend's Activity.tsx, ChatInterface.tsx and useResolveAction.ts in a single commit. Split the history so the kernel/protocol change (invalidation error contract, ActionRecord.invalidationReason, auto-approval drain semantics) is one commit and the frontend rendering of the new state is another, or move the UI portion to a follow-up PR.
Was this helpful? React with 👍 or 👎 to provide feedback.
5295e5d to
638ac69
Compare
04bbfee to
98a2485
Compare
638ac69 to
8624ef7
Compare
An MCP action may wait for approval while its connection, grant, or effective policy changes. This captures the approved connection generation and policy, then rechecks them within the bounded discovery path immediately before
tools/call. A stale action is never dispatched; it is recorded using the existing rejected wire state plus an invalidation reason, and auto-approval rules are cleared so current and rolled-back backends stop the current drain.Invalidation uses a thrown, message-preserved sentinel so older Workshops fail closed. Re-enabling a cleared rule explicitly allows future auto-approval rather than leaving a permanent historical barrier.
Stacked on #199. Verified with 316 MCP tests, 289 backend tests plus integration tests, 152 frontend tests, both MCP connector suites, and full
pnpm lint.