Repository navigation
feat(mcp): let MCP sessions approve and reject issues after the user confirms - #21593
Conversation
…t apply MCP sessions now serve eleven reads that were EXCLUDED as workspace administration: the project and workspace IAM policies, groups, roles, org policies, and a project's access grants. An agent helping a user could not tell who owns a project, see masking exemptions, or work out who would approve a request, although every holder of these permissions reads the same data through the API. RBAC still runs after the MCP gate, so each read stays limited to the user's own permissions, and no response or policy type carries a credential. The Access policy page and consent screen gain a "Read members, roles and policies" row, and both mode summaries name it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reads The members row now names the policy types it serves (masking rules and exemptions; rollout, data query, tag and review policies) instead of "other policies", because the project's approval rules stay unreadable, and it drops its own permission bound, which the section's shared line already states. The ADMINISTERS_THE_WORKSPACE comment loses its sentence about reads, which nothing reads and which was false for some methods that keep the reason. TestTheApprovalReadsAreServed pins the eleven reads as READ, because the regenerated inventory records a reclassification without refusing one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApproveIssue and RejectIssue move from FORBIDDEN to WRITE. The MCP gate serves either only when the request context carries the decision the user confirmed in the MCP client, naming the same procedure and issue. call_api cannot set a context value, so it is refused with a way out that names decide_issue. RetryIssueApproval and ActivateAccessGrant stay FORBIDDEN, and the DRIVES_THE_APPROVAL_DECISION wording now describes only them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… confirms The tool summarizes the issue (what it changes and the step it decides), asks the user through MCP form elicitation in the same tool call, and on a checked accept calls ApproveIssue or RejectIssue with the confirmation on the request context, so the ceiling, RBAC and the approval workflow decide as for a person. Decline, cancel, an unchecked or malformed answer, and a failed elicitation change nothing. A client that declared no form elicitation gets the issue's console link instead. The comment records that the decision was confirmed in the MCP client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The floor no longer says approving issues is never allowed. A new Read-write row, Approve or reject issues, states the bound: each decision is confirmed in the user's MCP client, and the approval rules and the project's self-approval setting apply as in the console. The propose and request rows and the Read-write summary stop promising that only a person approves. The ladder doc's rows, counts and reasoning follow, in all five locales. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Approving a role request grants a project role, and role grants wait with the rest of access administration. decide_issue now serves database changes and access requests only: it reads the issue on both rounds and refuses a role request (ROLE_GRANT_NOT_SUPPORTED) or any other type (ISSUE_TYPE_NOT_SUPPORTED) before a form is sent or a confirmation is attached, with the issue's console link. The new row says role requests are decided in the console, so the floor's 'administer the workspace' stays true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iring check ApproveIssue and RejectIssue joined the request-shape table and are not masked-write doors, like CreateIssue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decide_issue acted on any answer a call carried. A client that declared form elicitation could put an accepted answer on its first call and approve with no form shown, and an answer to a form also applied to the issue as it read after the form closed, so a plan edited or a step decided while the form was open was approved unseen. The form now goes out with a request state: a digest of what the user is asked about (the issue, its approval step, the plan's specs or the access grant, and the comment), signed with a key derived from the server secret together with the workspace, user, client, decision and issue. An answer counts only with a state that verifies; without one the call is asked like a first one. An accepted answer whose digest no longer matches a fresh read decides nothing and says to call again. The SDK carries the state between rounds for today's clients; a 2026-07-28 client echoes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The form gave issue text three treatments: the title quoted, statements folded to one line, and the rest printed raw. A create-database name is free text, so a plan could add a line such as "Approval: not required" to the form, and folding "-- tidy up\nDROP TABLE users;" made the DROP read as part of a comment. Every value taken from the issue or the agent now enters through %q, which escapes line breaks. A cut says what it leaves out: the first 300 characters of a statement with its full size, "3 of N changes shown", and "statements not shown" for a release. The form shows the comment the decision records. It carries no link, because the MCP specification asks servers not to put clickable URLs in a form. A failed read of the plan, a statement or the access grant still sends no form, now pinned by a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decide_issue turned every refusal of 400 or more into DECISION_REFUSED with "nothing changed" and a suggestion that the approval rules decide. That contradicted the Read-only ceiling's own remedy, and "nothing changed" is not known for a 5xx: ReviewIssue commits before the handler can still fail. The server's message now passes through with the status and the issue link, asking the agent to check the issue's current state, as the transport-error path above it does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two comments argued the issueless-rollout guard from approval being FORBIDDEN to MCP, which decide_issue ends. The guard stays: without a linked issue, no approval check runs at all. Its reason now rests on that, stated once on rejectMCPOriginatedIssuelessRollout; the CreateRollout and BatchRunTasks comments point to it. query_database's guidance no longer says a person approves an access grant: a decision confirmed in the client is not proof that a person decided. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decide_issue refuses a role request, or a type it does not know, before any decision request, so no connect interceptor saw the refusal and it was neither stored nor streamed, unlike its creation-side twin. It is now written out of band, as a refused connection is. Also: the decision is held once, as its procedure; RequestIssue's comment is cut to its constraint; comments that restated the gate's provenance rule are gone; and the live test is renamed for the decision it now covers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decide_issue reports a decline before it checks whether the issue changed while the form was open, so an agent is told not to retry instead of being sent back to a user who said no. No test held that order; one does now. Also: RecordOutOfBandAudit's comment no longer counts its callers, and the CreateRollout comment drops a sentence that named a PR its earlier lines no longer introduce. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It cited "the rule the gate PR owes", a PR the CreateRollout comment no longer introduces; it now names rejectMCPOriginatedIssuelessRollout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6f0e899c70
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Review on #21593 found four gaps in how a confirmation binds to what it decides, and Claude Code's form dialog turned out to show at most four lines of a form's message. - The digest includes the update times of the issue, the plan and the access grant, so a plan edit the form does not show (prior backup) and a rejection followed by a new request both make an answer stale. - The confirmed decision carries the issue's update time, and the review workflow refuses it under the issue's row lock when it no longer matches. This closes the gap between the tool's check and the approval. - An access request shows its schema and container. - The form puts the decision, the issue name and the change first, and an issue with one change fits in four lines. Each cut note comes before the text it cuts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1114c34f88
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The count of a plan's changes came after the first three, so in Claude Code's four-line dialog a plan with three or more changes showed two of them and no count. Each change line of a plan with several now starts "Change 2 of 5", so the first visible line already says how many there are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project developer cannot get access grants, so decide_issue failed before asking when an approval rule names developers and the project allows self-approval, though the same user can approve in the console. The caller's own request is now read through SearchMyAccessGrants, as the console's issue page reads it. TestMCPDeveloperApprovesTheirOwnAccessRequest fails without the change. A new database's ClickHouse cluster and PostgreSQL owner change what its statement does, so the form now shows them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 16b6c98c98
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Claude Code cuts each line of a form six columns short of the terminal width, so a change on several targets, or on one long name, pushed the statement and its cut note out of view. Each line now does one job: the decision; the targets, counted before they are named; the statement; then the approval step, requester and comment. An access request's schema and container come before its databases, and its terms open the last line, so a request with a comment fits four lines too. A statement or query cut by the form is marked (partial) before it, with its full size after it. A new database's table, character set and collation are shown with its cluster and owner. A test checks what Claude Code shows at 80 and 120 columns: one long target, many targets, a long statement, several changes, an access request and wide characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#21588 landed as a squash. Keeps this branch's wording (approve in the Read-write summary, the Approve or reject row, the approval-reason comment) and takes main's GetReviewRun count: READ 68, WRITE 39, EXCLUDED 78, FORBIDDEN 34, total 219. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest Buf updates on your PR. Results from workflow CI / lint-protos (pull_request). |
On one database with a long name, the schema and container came after the name on the scope line, and Claude Code's dialog cut them off. They decide what the query's unqualified names read, so they now open the query line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b8e462b moved the schema and container in front of the query. The requester can set them to any length, so a long one pushed the whole query out of Claude Code's dialog. They go back on the scope line, after a single database's name, and the query line keeps only its label before the SQL. Tests pin long schema and container values and a long database name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1a26f6c287
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uest #21587 refuses an access grant in a project where just-in-time access is off, so the developer's request in this test now needs it turned on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

feat(mcp): let MCP sessions approve and reject issues after the user confirms
Part of BYT-9292 user journey 1 (the second of three PRs in
plans/2026-10-13-mcp-decision-matrix.md). The first, #21588, is merged.Today an agent can prepare a change or file an access request, but every approval waits for someone to open the console:
ApproveIssueandRejectIssueare refused to MCP in every mode. A refusal does not stop a user who wants their agent to approve, because they can hand it a browser with their own session. So the guard becomes asking the user. The question is asked by the client, not by the model in chat, so an instruction planted in something the agent reads cannot approve anything.What changes:
decide_issue(issue, decision, comment?), approves or rejects a database-change issue or an access request. First it asks the user through MCP elicitation, the MCP specification's way for a server to ask the user something. The form shows the issue's name and title, what it changes, the current approval step, the requester and the comment the decision records, with one checkbox. For an access request, what it changes is the targets, schema and container, the query, whether results are unmasked or exportable, and when the grant expires. For a new database, it is the instance and, when set, the cluster, owner, table, character set and collation. The tool acts only when the user ticks the box and accepts. Declining, cancelling or leaving the box unticked changes nothing.%q, which escapes line breaks. A cut says what it leaves out: "(partial)" before a statement or query that the form cuts at 300 characters, with its full size after it; "3 of N changes shown"; and "statements not shown" for a release. Without "(partial)", the message carries the whole statement, though a client may still cut the line. The form carries no link, because the MCP specification asks servers not to put clickable URLs in a form. The tool's result carries the issue link.bb.accessGrants.get(project developers do), so the tool reads the user's own request throughSearchMyAccessGrants, as the console's issue page does.ApproveIssueandRejectIssuemove from FORBIDDEN to WRITE, but onlydecide_issuecan use them. The tool attaches the confirmed decision (procedure, issue and the issue's update time) to the context of its internal request. The gate serves the call only when the procedure and issue match.call_apicannot set a context value, so it is refused and pointed todecide_issue.decide_issueserves database changes and access requests only. Any other type is refused with the console link, and the refusal is written to the audit log out of band, as a refused connection is.RetryIssueApprovalandActivateAccessGrantstay FORBIDDEN. The approval-decision reason now reads "it can let a change or an access request pass its approval without an approver deciding it".Copy: the "Never, in any mode" line drops "approve issues". A new Read-write row, "Approve or reject issues", says each decision is confirmed in the user's MCP client, role requests are decided in the console, and the approval rules and self-approval setting apply as in the console. The propose and request-access rows no longer say an agent never approves. The change tool's guidance points to
decide_issue, and the query tool's guidance no longer says a person approves an access grant. The comments behind the issueless-rollout guard no longer argue from approval being FORBIDDEN to MCP. The guard stays, because without a linked issue no approval check runs at all.Not in this change
ApproveIssueandRejectIssuenow appear insearch_api. Acall_apicall on either is refused and namesdecide_issue.tool_resolve.gohas the same go-sdk limit.%qescapes line breaks, not markup. No escaping works for every renderer and still keeps SQL readable.bb.accessGrants.getgets no form, because the tool cannot read the grant. The console shows that approver the issue without the grant.Size
+2490/−185 lines in 36 files: 866 production lines (
tool_decide.gois 743), 1,479 test lines, and the rest copy, proto comments and generated code. The classification change, the tool and thecall_apirefusal have to ship together, so the PR is not split further. The review fixes (1114c34f88,d437f8fc86,16b6c98c98,a80de2b578, and1a26f6c287, which reverses the layoutb8e462b3c5tried) add 695 lines, 496 of them tests.Tests and verification
tool_decide_test.go:GetAccessGrant, and a search that finds nothing sends no form. A new database's cluster, owner, table, character set and collation are shown.TestDecideIssueFormShowsTheChangeInClaudeCodesDialog) with a model of its dialog: four lines, or three and "… (+N more lines)"; each line cut six columns short of the width; wide characters take two columns. The cases are one long target, many targets, a long statement, several changes, an access request with a long query, an access request on one long database, with a long schema and with a long container, and wide characters. In each, the visible lines start with the decision, the targets with their count, the start of the statement (marked when cut), and the approval line or how much is left out. On 2026-10-20 the real dialog in Claude Code 2.1.302 matched the model line for line at 80 and 120 columns, on a 12-target change with a cut statement and on a three-change plan with a Chinese title: 16 of 16 lines, including where wide characters are cut.TestReviewIssueAppliesAConfirmedActionOnlyToTheConfirmedVersionapproves on the confirmed version, and refuses with the new reason when the issue is edited between the workflow's read and its lock. Inbackend/api/v1,TestMapReviewErrorSaysWhenAConfirmedIssueChangedpins the refusal's words, andTestConfirmedUpdateTimeComesFromTheMCPConfirmationpins where the version comes from.TestMCPGateServesAnIssueDecisionOnlyWithItsConfirmationrefuses a call with no confirmation, the wrong issue, the other decision, or a Read-only ceiling. Its confirmations carry an update time, as the tool's do.TestCallAPICarriesNoConfirmationshows thatcall_apiattaches nothing.TestConfirmationReachesTheConnectInterceptorssends a marked request through the in-memory transport into a real Connect handler.TestMCPGrantIssuesAndAccessRequests(backend/tests, renamed fromTestMCPGateRefusesGrantIssues) showscall_apirefused. It then shows the requester's confirmed self-approval refused by the workflow with self-approval off, and the same approval succeeding with it on, with the grant going ACTIVE. That approval passes the update-time check, so the time survives protojson and Postgres exactly.TestMCPDeveloperApprovesTheirOwnAccessRequesthas a project developer, who cannot get access grants, file a request and approve it under an approval rule that names developers, with self-approval on. The grant goes ACTIVE. The test fails without the change.GetAccessGrant, accepting an empty search, leaving out a new database's cluster, owner, table or collation, the statement back on the targets line, the count after the names, the schema and container after several names or before the query, no "(partial)" on a statement or a query, the grant's terms on a line of their own, and the comment on a line of its own.buf format,buf lintandbuf generate,gofmt,go vet, golangci-lint (no findings on the lines this PR changes), the server build,pnpm --dir frontend fixandpnpm --dir frontend test(565 files, 6,228 tests, on the copy commit; the frontend has not changed since), comment share 15% at open (22% over 19 lines in the first review fixes, 18% over 13 lines ina80de2b578;b8e462b3c5and1a26f6c287together add 6 comment lines and no code), and the full Go suite as CI runs it after each review fix, last ata80de2b578: 120 packages ok each time, none failed. At1114c34f88the 11 packages the fixes reach ran fresh, among thembackend/tests,backend/api/v1,backend/api/mcpandbackend/component/review; at16b6c98c98,backend/tests,backend/api/v1,backend/api/mcp,backend/serverandbackend/bin/server/cmdran fresh; ata80de2b578, all 120. The rest came from Go's test cache, their inputs unchanged since the last fresh run.b8e462b3c5and1a26f6c287change one rendering function that onlybackend/api/mcpuses, so that package ran fresh at each. After main was merged in (fix(api)!: refuse an access grant where just-in-time access is off #21587 now refuses an access grant where just-in-time access is off, soc53249a176turns it on in one more test), the full suite ran fresh atc53249a176: 121 packages ok, none failed.Release note
MCP sessions in Read-write mode can approve and reject database changes and access requests with the new
decide_issuetool, after the user confirms each decision in their MCP client. Clients that cannot show the confirmation form get a link to the issue instead. The project's approval rules and self-approval setting apply as in the console, and role requests are still decided in the console.Receipts
backend/api/auth/mcp_confirmation.go(unexported context key)refuseUnconfirmedIssueDecisioninbackend/api/v1/mcp_gate.gobackend/api/mcp/tool_http.go(apiRequest,internalRoundTripper);TestConfirmationReachesTheConnectInterceptorsmcp/mrtr.go,serverMultiRoundTripMiddleware: the first pass gets the client's params; the retry gets the elicited answer and the handler's ownRequestStateRequestStateis the SDK's carrier between rounds, and servers bind elicitation to the usermcp/protocol.go(CallToolResult.RequestState); MCP 2025-11-25 elicitation, Security Considerations 1: "Servers MUST bind elicitation requests to the client and user identity"common.GetProjectResourceIDSheetSha256; the store keys sheets by content hashBP(text, columns, rows)withrowsat mostH5+1(H5=3).BPcuts each line withptatmax(20, columns − Yg), whereYg = max(2·bv, 2·(M6+OO))= 6, and keepsrows−1lines plus… (+N more lines)when there are more. No key expands it. Live on 2.1.302 (2026-10-18, a stub server): the seven-line layout this PR opened with showed three lines and… (+4 more lines); the four-line layout showed whole, its change line cut with…Text, after a sanitizer (Viin 2.1.300: tabs, control characters, runs of combining marks). There is no Markdown pass. Live on 2.1.302:**bold**, a Markdown link and<b>html</b>in the comment showed as typed, and the statement's line breaks showed as\nupdateIssuePayloadinbackend/component/review/workflow.go(review actions, re-requests), used by approval finding (approval.go) and submission;updateIssueinbackend/store/issue.goUpdatePlaninbackend/component/review/plan.goReviewIssueinbackend/component/review/workflow.go, right afterlockIssue(SELECT … FOR UPDATE)bb.accessGrants.getProjectDeveloperRoleinbackend/store/predefined_roles.go;TestMCPDeveloperApprovesTheirOwnAccessRequestasserts the 403SearchMyAccessGrantsfrontend/src/routes/project/issue-detail/components/IssueDetailAccessGrantDetails.tsxSearchMyAccessGrantsreturns only the caller's grantsSearchMyAccessGrantsinbackend/api/v1/access_grant_service.go(Creator: &user.Email); custom auth inaccess_grant_service.protoCreateAccessGrantinbackend/api/v1/access_grant_service.gobackend/api/v1/issue_hook.go(UpdateProjectPolicyFromRoleGrantIssue)backend/component/review/workflow.go(allow_self_approvalcheck)audit.RecordOutOfBandAudit;refuseConnectioninbackend/api/mcp/connection_audit.go🤖 Generated with Claude Code