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:
- 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.
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.
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.
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 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.
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 __confirm into the published JSON Schema of the
29 gated tools only, after the zod → JSON Schema conversion:
// From server.ts - ListTools handlerif (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.
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, andConfirmRequiredreads the boolean from there. That is the one place we accept pre-validation state - narrowly scoped to one boolean.
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.
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):
| 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 |
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 |
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.
- Operations → Client capability matrix - what each client supports; the
__confirmcontract is universal. moderation-bulk-banrecipe - worked example using the__confirm:trueflow on a high-impact tool.