Confirmation
discord-mcp’s confirmation contract is a mechanical two-key guard for
selected high-risk operations. A tool that declares the confirm_required
precondition - 31 of the 209 tools, listed
below - does not fire unless both of these
hold at the same time:
- The operator launched the server with
MCP_DRY_RUN=false. - The agent passed
__confirm:truein the tool’s arguments.
If either is missing, the tool throws a DRY_RUN_PREVIEW error carrying the
redacted arguments instead of executing. The MCP client may show that preview
to a human before re-issuing the call, but the server does not require or
verify human approval.
Payload-bound approval for Components V2
Section titled “Payload-bound approval for Components V2”components_v2_send, components_v2_edit, and
components_v2_send_from_template use a stricter approval contract
because their layout can contain mentions, external links, and interactive
controls. The first validated call returns PAYLOAD_CONFIRMATION_REQUIRED
with a redacted payload, bounded component review, risk_flags, and a SHA-256
payload_hash, a one-time approval_id, and a short expiry; it never calls
Discord. A later call executes only when all four conditions hold:
MCP_DRY_RUN=falseis set by the operator.- The call includes
__confirm:true. __confirm_hashexactly matches the latestpayload_hash.__confirm_idexactly matches the unconsumedapproval_idfrom that preview and has not expired.
If the payload changes, the server returns PAYLOAD_CONFIRMATION_MISMATCH
and refuses the request. The digest excludes only the three authorization fields,
so channel/message targets and every component value remain bound to the
approval. For template sends, the digest covers the fully interpolated tree.
When DISCORD_EXPECTED_BOT_ID is configured, the approval record also binds to
that exact bot identity; an embedding that tries to apply it through another
bot-scoped server fails closed. The preview intentionally omits untrusted text
and raw identifiers;
the caller approves the exact payload it supplied, using the digest as the
binding. Use MCP_WRITE_MODE=preview to block any other ordinary writes at
deployment level.
Tools that do not declare the precondition - including ordinary mutations
like messages_send and members_add_role - are generally unaffected by both
halves and execute immediately; the Components V2 tools are the explicit
payload-bound exception.
Source: packages/mcp-core/src/preconditions/ConfirmRequired.ts.
Why two halves
Section titled “Why two halves”A single switch would make accidental activation easier. Splitting launch-time arming from a per-call assertion prevents either an environment change or an ordinary unconfirmed call from executing a gated tool by itself.
Splitting it gives you the two-key launch model:
MCP_DRY_RUNis the operator’s switch. They flip it once at boot to opt the deployment into “real execution mode.”__confirm:trueis the caller’s per-call assertion that this specific gated call should execute.
Both must be set. Either alone fails closed.
Components V2 adds a payload hash and one-time approval ID to that two-key model. The approval is consumed immediately before the handler is entered, so an uncertain transport result cannot be made safe by blindly retrying the same call. Use the target’s readback/reconciliation path before obtaining a new approval.
The flow
Section titled “The flow”Agent → tool call (without __confirm) ↓ Preconditions middleware runs ConfirmRequired ↓ Throws DryRunPreview { tool, preview: <args minus __confirm> } ↓Agent ← { isError: true, code: DRY_RUN_PREVIEW, recovery_hint: "..." }
Client or agent (optionally after human approval, re-issues with __confirm:true) ↓ Operator has MCP_DRY_RUN=false set ? ├─ no → DryRunPreview again (the env-var half failed) └─ yes → ConfirmRequired returns; handler runs; Discord call firesThe middleware never inspects what the tool does; it only checks the two halves and either passes through or raises. The gate therefore behaves identically everywhere it is attached - but it only runs on tools that declare it. See Which tools require it for the exact list, and do not assume a tool is covered because it mutates Discord.
Why double-underscore
Section titled “Why double-underscore”__confirm, __confirm_hash, and __confirm_id are not part of any tool’s
zod inputSchema. They are authorization fields, not handler parameters -
handlers must never see them:
- If it were in a tool’s zod shape, every destructive handler would receive it as a business argument and each one would have to remember to ignore it.
- If it were a single-underscore key (
_confirm), it would clash with legitimate tool args (e.g. some Discord fields use leading underscores). - If it were a positional flag, you couldn’t pass it through clients that serialize args as JSON only.
Double-underscore signals “this is a meta-level argument, not part of the business payload.” The same convention is used elsewhere in the MCP ecosystem for transport-level metadata.
It IS advertised in tools/list
Section titled “It IS advertised in tools/list”z.toJSONSchema emits additionalProperties: false for every tool, so a
spec-conforming client could not legally send an undeclared key, and an
agent reading tools/list would have no way to discover the flag at all.
So server.ts injects the relevant authorization fields into the published
JSON Schema after the zod → JSON Schema conversion: __confirm for the 31
legacy-gated tools, and all three fields for the Components V2 payload-gated
tools:
// From server.ts - ListTools handlerif (tool.preconditions.includes('confirm_required')) { jsonSchema.properties ??= {}; jsonSchema.properties.__confirm = { type: 'boolean', description: '...' };}if (tool.confirmation === 'payload_hash') { jsonSchema.properties.__confirm_hash = { type: 'string', pattern: '^[a-f0-9]{64}$' }; jsonSchema.properties.__confirm_id = { type: 'string', format: 'uuid' };}Two consequences worth being precise about:
- Agents can discover the flag, and schema-validating clients can send it, on exactly the tools where it does something.
- It still never enters the zod parse path and never reaches a handler.
validateMiddlewareparses against the zod shape, which does not declare__confirm, so the parsed args that handlers receive have it stripped.server.tsstashes the pre-validation payload inctx.metaunderrawArgs, and the approval middleware reads the authorization fields from there. That is the one place we accept pre-validation state - narrowly scoped to these transport-level fields.
DRY_RUN default: safe by default
Section titled “DRY_RUN default: safe by default”MCP_DRY_RUN defaults to true (safe). The operator must explicitly
set it to false (i.e. MCP_DRY_RUN=false) to enable real execution.
This is “fail closed” - a misconfigured deployment never accidentally
mutates Discord state.
The literal-string check matters: MCP_DRY_RUN=0, MCP_DRY_RUN=no, and
MCP_DRY_RUN=disabled are all treated as truthy (= dry-run still active).
Only the literal string false flips the switch off. This is deliberate
to prevent typos (MCP_DRY_RUN=fasle) from silently enabling production
mode.
// From ConfirmRequired.tsconst dryRunActive = this.env.MCP_DRY_RUN !== 'false';The recovery hint
Section titled “The recovery hint”When DryRunPreview fires, its recovery_hint states the mechanical execution
requirements:
Set MCP_DRY_RUN=false AND pass
__confirm:trueto actually execute
The hint is operational guidance, not a secret or an approval challenge. It makes both required inputs explicit so clients do not loop on an incomplete retry.
Re-issuing with __confirm:true is the only path through
ConfirmRequired: server-initiated MCP elicitation is not implemented, so
any “are you sure?” prompt has to be raised by the client itself before it
re-issues the call.
Which tools require it
Section titled “Which tools require it”There is no automatic rule. confirm_required is attached explicitly,
per tool, in each tool’s own defineTool({ preconditions: ['confirm_required'] })
call. A tool is gated if and only if it names the precondition itself.
The current source registry has exactly 31 tools out of 209 - every irreversible delete, plus ban / kick / prune / leave-guild and the two command bulk-overwrites (which replace a guild’s entire command set in one call):
| Category | Gated tools |
|---|---|
app_emojis |
app_emojis_delete |
automod |
automod_delete_rule |
channels |
channels_delete |
commands |
commands_bulk_overwrite_global, commands_bulk_overwrite_guild, commands_delete_global, commands_delete_guild |
emojis |
emojis_delete |
events |
events_delete |
guild |
guild_begin_prune, guild_delete_integration |
guild |
guild_blueprint_apply |
interactions |
interactions_delete_followup, interactions_delete_original_response |
invites |
invites_delete |
members |
members_ban, members_bulk_ban, members_kick |
messages |
messages_bulk_delete, messages_delete |
monetization |
entitlements_delete_test |
reactions |
reactions_delete_all |
roles |
roles_delete |
soundboard |
soundboard_delete_guild_sound |
stage_instances |
stage_instances_delete |
stickers |
stickers_delete_guild_sticker |
templates |
templates_delete |
users |
users_leave_guild |
webhooks |
webhooks_delete, webhooks_delete_message, webhooks_delete_with_token |
Every auto-generated tool reference page carries a
Confirmation required row in its Annotations table, which is rendered
from the same __toolMetadata.preconditions array. That table is the
authoritative per-tool answer; the list above is the roll-up.
Testing confirmation
Section titled “Testing confirmation”There is no “skip confirmation in test mode” toggle. A direct
tool.run(args, ctx?) call exercises the tool handler only; it bypasses the
server middleware that evaluates preconditions.
Test ConfirmRequired directly when you need to verify preview and approval
behavior. Use an MCP/server-level invocation when the test must cover the full
path, including both MCP_DRY_RUN=false and __confirm: true. Handler-only
tests should pass the handler’s normal arguments and make their narrower scope
explicit.
Source map
Section titled “Source map”| Concern | File |
|---|---|
| Precondition implementation | preconditions/ConfirmRequired.ts |
| Error class + recovery hint | errors/client.ts |
| Middleware that runs preconditions | middleware/precondition.ts |
Related
Section titled “Related”- Architecture → Error handling -
DRY_RUN_PREVIEWis the most-thrown error in the hierarchy. - Architecture → Middleware chain - where preconditions sit in the chain.
- Choose a connection mode - select the transport and tool surface; the
__confirmcontract is universal. moderation-bulk-banrecipe - worked example using the__confirm:trueflow on a high-impact tool.

