Visitar URL original
fix(mcp): state the rule an MCP denial enforces and a next step that works by zchpeter · Pull Request #21535 · bytebase/bytebase · GitHub
Skip to content

fix(mcp): state the rule an MCP denial enforces and a next step that works - #21535

Draft
zchpeter wants to merge 2 commits into
mainfrom
cc/mcp-denial-messages
Draft

zchpeter wants to merge 2 commits into
mainfrom
cc/mcp-denial-messages

Conversation

@zchpeter

Copy link
Copy Markdown
Collaborator

Rewords the refusals an MCP agent, and the person it acts for, can receive. The trigger: ApproveIssue on an issue someone else created was refused with "an agent does not move its own change through that gate". That sentence tells the reader that approving someone else's change is fine, when the gate refuses approval decisions on every issue. An audit of every MCP-facing message found 36 with problems; this PR fixes the wording defects and the design gap behind them.

What a reader sees

Refusal Before After
Approving any issue …is not available to MCP sessions because it works the approval step meant to gate the change, and an agent does not move its own change through that gate. Perform this action signed in to the Bytebase console instead …is not available to MCP sessions, whatever the workspace's MCP access policy, because it approves, rejects, or re-checks an issue's approval, and AI agents may not make approval decisions on any issue, whoever created it. If you are an approver for this issue, approve or reject it in the Bytebase console.
A write under Read-only …is a WRITE method and this workspace's MCP capability ceiling is READ_ONLY, which serves READ methods. Ask a workspace admin to raise the MCP ceiling in the workspace settings, or perform this action signed in to the Bytebase console instead …is not available to MCP sessions in this workspace because it needs Read-write MCP access, and the workspace's MCP access policy is Read-only. Ask a workspace admin to switch the policy to Read-write under Integration > MCP > Access policy, or, if your role allows it, do this in the Bytebase console.
Writing a masked value back …Remove the literal, whatever the statement does with it. Perform this action signed in to the Bytebase console instead …writing it back would overwrite the real data, and filtering on it matches nothing. Remove "******" from the statement; if the change needs the real value, ask someone who can see it unmasked to make the change in the Bytebase console.
SwitchWorkspace …it hands back a login token that would outlive the MCP grant. Perform this action signed in to the Bytebase console instead …it returns a sign-in credential … that would keep working after this MCP connection is revoked. To use this MCP connection with another workspace, run the reauthorize tool and choose that workspace when you approve access again.
MCP turned off a workspace admin has turned MCP access off for this workspace. Ask them to raise the MCP ceiling in the workspace settings A workspace admin has turned off MCP access for this workspace. Ask a workspace admin to choose Read-only or Read-write under Integration > MCP > Access policy.

Also:

  • The prompt that asks a person to pick between ambiguous databases now names what the pick is for, such as "Which one should this change target?". The same prompt used to ask "Which one do you want to query?" before a change that may run.
  • A 30-second timeout is named as one, with a way to shorten the query. It used to say "check network connectivity" for a call that never leaves the process, and it showed an internal URL.
  • A database that is not found points to DatabaseService/ListDatabases rather than search_api, which lists API operations.
  • On the consent page, the Read-write caution reads "Approve only if you started this connection from an AI client you trust", since hosted clients don't run on the user's machine. The settings ladder's "Propose changes" detail no longer says "its own change".

Why they were wrong

The denials followed one rule, "state what the method does", and nothing more:

  1. Too narrow. A reason described a method's worst case, such as "its own change" or "the settings that bound this session". The gate refuses the whole method, for every caller, every argument and every resource owner.
  2. One next step for everything. Every refusal ended with "do it in the console". That's unsafe for a masked write: the same statement run in the console overwrites real data, because the guard only runs for MCP sessions. It's also wrong for sign-in flows, for switching workspace (the MCP way is reauthorize), and for approvals, which only an approver can make.
  3. Internal words. "Ceiling", READ_ONLY, ROLE_GRANT and "principal" appeared where the UI says "MCP access policy: Read-only / Read-write".

How it is kept true

  • The rules live on the reason table in mcp_gate.go.
    • A reason says what the method can do at the gate's grain.
    • Each reason carries its own next step, and a method its step would misdirect overrides it.
    • Messages use the Access policy page's words.
    • The templates say which kind of refusal it is: unavailable whatever the policy, available under no policy, or needing Read-write. Only the last is worth asking an admin about.
    • Every gate refusal keeps the phrase "not available to MCP sessions", which tests and anyone searching the audit log rely on.
  • docs/design/mcp-capability-ladder.md D13 records the decision. The doc's own "never approves its own change" is corrected, along with its approval sentence: whether a human must approve depends on the workspace's approval rules and the project's settings.
  • TestMCPDenialWording renders every reason and refusal path and checks what a check can. A reason must continue "because", a next step must be its own sentence, a rendered denial must be complete sentences, and banned words such as "ceiling", enum names and "own change" must not appear. Put the old ApproveIssue sentence back and the test fails.
  • The classification inventory now prints each reason's rendered denial beside every method it covers. A reviewer reads the sentence where it has to be true.
  • TestMCPRefusalsNameThemselvesToTheQueryTool now also covers the permanently-refused and no-policy templates, an unsupported policy value, and Disabled. IsPolicyRefusal keys on "MCP session" or "MCP access policy".
  • The ceiling verdicts are complete ASCII sentences. Every door but the gate shows them alone, and two of those doors carry them in an OAuth error_description.

Verification

  • go test passes for backend/api/v1, backend/api/auth, backend/api/oauth2, backend/api/mcp and backend/utils.
  • The 43 integration tests in backend/tests that pin or exercise MCP refusal wording pass. They span the gate, the credential and workspace-escape guards, the SQL clamp, the capability setting, OAuth2 grants, migration, denial audit and read-path redaction.
  • golangci-lint v2.13.2 (the CI version) is clean, gofmt is applied, and the server builds.
  • buf format, buf lint and buf generate ran for the enum comments.
  • The frontend gate is green: biome, the guard scripts, tsc, the release bundle, and 531 files / 5790 tests.

Not included

These findings need logic changes rather than wording, so they're left for follow-ups:

  • query_database reads only the first result, so an error in a later statement is dropped.
  • Permission advice should be keyed on the error's PermissionDeniedDetail rather than on missing wording, which would let the tools stop matching text.
  • SQLService/AdminExecute answers "unknown operation" because it's never indexed.
  • A missing external URL returns a 503 "discovery is unavailable" instead of saying what to configure.
  • Loopback redirect URIs are matched exactly, including the port, where RFC 8252 §7.3 allows any port.
  • search_api still describes a refused operation as if it were callable.
  • CreateAccessGrant is classed as workspace administration, but its real risk is an auto-approved grant.

Audit-log rows record the refusal text, so rows written after this change carry the new wording. Searching the audit log for the old phrases won't find them.

🤖 Generated with Claude Code

…works

An agent reads a denial and relays it to the person it acts for. The
denials were held only to "state what the method does", and an audit of
every MCP-facing message found 36 that misled or confused. ApproveIssue
on an issue someone else created was refused with "an agent does not move
its own change through that gate", which tells the reader that approving
another person's change is fine.

Each reason now states what the method can do at the gate's grain and
carries its own next step, overridden per method where the step would
misdirect. The templates say whether any MCP access policy could help,
in the Access policy page's words, and every gate refusal keeps "not
available to MCP sessions". The ceiling verdicts are complete ASCII
sentences, since an OAuth error_description carries them. The same scope
fix reaches the proto comments, the ladder and consent copy in every
locale, and the design doc (D13). The MCP tools stop reporting a timeout
as a network error, point a not-found database at ListDatabases, and
say what a database pick is for before a change.

TestMCPDenialWording renders every refusal path and checks the rules,
the classification inventory prints each reason's denial beside its
methods, and the policy-refusal contract covers every producer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow CI / lint-protos (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed⏩ skippedSep 24, 2026, 4:18 PM

The timeout advice told the agent to narrow the load "with the filter
argument", but get_schema takes schema and table and builds the internal
filter from them, so the advice could not be followed. Name the two
arguments it does take, and pin that the advice names only arguments
SchemaInput accepts.

Found by Codex review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cbee694222

ℹ️ 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".


// listDatabasesHint is where a not-found answer sends the agent. search_api
// lists API operations, not databases.
const listDatabasesHint = `call call_api with operationId "DatabaseService/ListDatabases" to list the databases you can access; ones you cannot access are not listed`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the required parent in database-list guidance

When a database lookup returns DATABASE_NOT_FOUND, this hint directs the agent to invoke call_api with only an operation ID. call_api forwards an empty body, but DatabaseService/ListDatabases requires parent and rejects an empty value as invalid parent "" (backend/api/v1/database_service.go:346-347), so the prescribed next step cannot list anything. Include a usable parent or direct the agent through a tool that derives the current workspace parent.

Useful? React with 👍 / 👎.

@zchpeter
zchpeter marked this pull request as draft September 24, 2026 16:22

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant