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 - 29 of the 192 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.

Tools that do not declare the precondition - including ordinary mutations like messages_send and members_add_role - are unaffected by both halves and execute immediately.

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.

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 is not part of any tool’s zod inputSchema. It is an authorization flag, not a handler parameter - handlers must never see it:

  • 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 __confirm into the published JSON Schema of the 29 gated tools only, after the zod → JSON Schema conversion:

// From server.ts - ListTools handler
if (tool.preconditions.includes('confirm_required')) {
jsonSchema.properties ??= {};
jsonSchema.properties.__confirm = { type: 'boolean', description: '...' };
}

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 ConfirmRequired reads the boolean from there. That is the one place we accept pre-validation state - narrowly scoped to one boolean.

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.

As of v0.12.0 that is exactly 29 tools out of 192 - 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):

CategoryGated tools
app_emojisapp_emojis_delete
automodautomod_delete_rule
channelschannels_delete
commandscommands_bulk_overwrite_global, commands_bulk_overwrite_guild, commands_delete_global, commands_delete_guild
emojisemojis_delete
eventsevents_delete
guildguild_begin_prune, guild_delete_integration
interactionsinteractions_delete_followup, interactions_delete_original_response
invitesinvites_delete
membersmembers_ban, members_bulk_ban, members_kick
messagesmessages_bulk_delete, messages_delete
monetizationentitlements_delete_test
reactionsreactions_delete_all
rolesroles_delete
soundboardsoundboard_delete_guild_sound
stage_instancesstage_instances_delete
stickersstickers_delete_guild_sticker
usersusers_leave_guild
webhookswebhooks_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.

ConcernFile
Precondition implementationpreconditions/ConfirmRequired.ts
Error class + recovery hinterrors/client.ts
Middleware that runs preconditionsmiddleware/precondition.ts