Skip to content

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:

  1. The operator launched the server with MCP_DRY_RUN=false.
  2. The agent passed __confirm:true in 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.

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:

  1. MCP_DRY_RUN=false is set by the operator.
  2. The call includes __confirm:true.
  3. __confirm_hash exactly matches the latest payload_hash.
  4. __confirm_id exactly matches the unconsumed approval_id from 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.

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_RUN is the operator’s switch. They flip it once at boot to opt the deployment into “real execution mode.”
  • __confirm:true is 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.

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 fires

The 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.

__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.

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 handler
if (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. validateMiddleware parses against the zod shape, which does not declare __confirm, so the parsed args that handlers receive have it stripped. server.ts stashes the pre-validation payload in ctx.meta under rawArgs, 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.

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.ts
const dryRunActive = this.env.MCP_DRY_RUN !== 'false';

When DryRunPreview fires, its recovery_hint states the mechanical execution requirements:

Set MCP_DRY_RUN=false AND pass __confirm:true to 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.

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.

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.

Concern File
Precondition implementation preconditions/ConfirmRequired.ts
Error class + recovery hint errors/client.ts
Middleware that runs preconditions middleware/precondition.ts