The previous lesson kept content creation safe by never letting agents publish directly. Not everything reduces that cleanly to “just don’t build the risky ability.” Deleting a post, removing a user, changing a site setting, sometimes you genuinely want an agent to be able to do the thing, just not without a human explicitly saying “yes, do that one.” This lesson is about building that confirmation step into the ability itself, rather than hoping the client’s chat interface handles it for you.
Comfort with registering abilities and permission_callback from Course 2, and the draft-only pattern from the previous lesson. This lesson assumes you have at least one ability in mind that’s genuinely destructive or non-idempotent, deleting a post, removing a user, or similar, that’s a reasonable candidate for this pattern.
Why you can’t just rely on the client asking “are you sure?”
Lesson 3 showed that ChatGPT’s Developer Mode shows a confirmation prompt before write actions. Claude Desktop and Cursor have their own conventions for surfacing tool calls to the user before or as they run. That’s genuinely useful, but it’s a client-side behavior, not something your server controls or can guarantee. A different client, a future version of an existing one, or a client with confirmation disabled by the user, could all call your destructive ability with zero friction. If an action is dangerous enough to need a confirmation step, build that step on the server, where you control it.
The MCP spec lets you annotate a tool with hints like destructiveHint and
idempotentHint, and well-behaved clients use these to decide how much
friction to add before calling it. But these are advisory metadata, not access
control. A client is free to ignore them. Never treat an annotation as the thing
actually preventing an unwanted deletion, that job belongs to the pattern below.
The two-step pattern: propose, then confirm
The reliable version of “are you sure?” is two separate abilities: one that describes what would happen without doing it, and a second that actually executes, requiring a reference to the specific thing that was proposed.
Building it: the propose ability
// File: inc/abilities/delete-post-confirm.php
wp_register_ability( 'my-plugin/propose-delete-post', array(
'label' => 'Propose deleting a post',
'description' => 'Describes what deleting a specific post would do, and returns a '
. 'confirmation token. Does not delete anything. Always call this before '
. 'my-plugin/confirm-delete-post, and only proceed to confirm after the user '
. 'has explicitly agreed to delete this specific post.',
'input_schema' => array(
'type' => 'object',
'properties' => array( 'post_id' => array( 'type' => 'integer' ) ),
'required' => array( 'post_id' ),
),
'execute_callback' => 'my_plugin_propose_delete_post',
'permission_callback' => 'my_plugin_can_delete_posts',
'meta' => array( 'mcp' => array( 'public' => true ) ),
) );
function my_plugin_propose_delete_post( $input ) {
$post = get_post( $input['post_id'] );
if ( ! $post ) {
return new WP_Error( 'not_found', 'No post with that ID exists.' );
}
$token = wp_generate_password( 24, false );
set_transient(
'agent_delete_token_' . $token,
array( 'post_id' => $post->ID, 'user_id' => get_current_user_id() ),
5 * MINUTE_IN_SECONDS
);
return array(
'post_id' => $post->ID,
'title' => $post->post_title,
'status' => $post->post_status,
'effect' => 'This will permanently delete this post and cannot be undone.',
'confirmation_token' => $token,
);
}
Building it: the confirm ability
// File: inc/abilities/delete-post-confirm.php (continued)
wp_register_ability( 'my-plugin/confirm-delete-post', array(
'label' => 'Confirm and execute a proposed post deletion',
'description' => 'Executes a post deletion previously described by '
. 'my-plugin/propose-delete-post. Requires the confirmation_token from that '
. 'call, and only works within 5 minutes of the proposal, for the same post.',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'confirmation_token' => array( 'type' => 'string' ),
),
'required' => array( 'confirmation_token' ),
),
'execute_callback' => 'my_plugin_confirm_delete_post',
'permission_callback' => 'my_plugin_can_delete_posts',
'meta' => array( 'mcp' => array( 'public' => true ) ),
) );
function my_plugin_confirm_delete_post( $input ) {
$key = 'agent_delete_token_' . $input['confirmation_token'];
$pending = get_transient( $key );
if ( ! $pending || $pending['user_id'] !== get_current_user_id() ) {
return new WP_Error(
'invalid_or_expired_token',
'This confirmation has expired or was never issued. Propose the deletion again.'
);
}
delete_transient( $key ); // one-time use, invalidated immediately
$result = wp_delete_post( $pending['post_id'], true );
return array(
'deleted' => (bool) $result,
'post_id' => $pending['post_id'],
);
}
Two details here are doing the real safety work. First, the token is tied to the
same user_id that proposed it, so one user’s confirmation token can’t be used by a
session authenticated as someone else. Second, delete_transient() runs immediately
on use, before the deletion even happens, so the same token can never trigger two
deletions, whether by an accidental double call or a replay.
Test it: confirm the gate actually blocks a bare call
The real test isn’t that a confirmation message shows up, it’s that calling the confirm ability without a valid token, or with a stale one, genuinely fails.
You: "Delete the post titled 'Old announcement'."
Assistant: [calls propose-delete-post, relays the effect description, asks you to confirm]
You: "Yes, delete it."
Assistant: [calls confirm-delete-post with the token from the proposal]
Then verify the failure path directly, this is the part people skip:
# Context: simulating a stale/invalid token call directly against your endpoint,
# to confirm the ability actually rejects it rather than trusting client behavior
curl -s -u "agent-assistant:APP_PASSWORD" \
-X POST https://yoursite.com/wp-json/mcp/mcp-adapter-default-server \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"my-plugin/confirm-delete-post",
"arguments":{"confirmation_token":"not-a-real-token"}}}'
It’s tempting to skip building a real token system and instead just instruct the model, in the ability description, to “only call this after the user confirms.” That instruction can be followed correctly nearly every time and still fail exactly once, in exactly the wrong conversation. A model following a written instruction is not the same guarantee as a server that structurally cannot execute without a valid, single-use token it issued itself.
When this pattern is worth the extra complexity
Not every action needs two abilities and a transient table. Reserve this pattern for actions that are destructive (deletion, irreversible changes) or clearly non-idempotent in a way that matters (sending an email, charging a payment, publishing to an external channel). The draft-only pattern from the previous lesson already handles routine content creation without needing this much machinery, matching the pattern to the actual risk keeps your ability set understandable.
Recap
Confirmation you can actually rely on is built as two abilities: a propose step that describes the effect and issues a single-use, user-bound token, and a confirm step that only executes given a valid, unexpired token for that specific action. Client- side “are you sure” prompts are a nice-to-have on top of this, never a substitute for it, since you don’t control what every current or future MCP client chooses to show its user.
Resources & further reading
- MCP tool annotations, modelcontextprotocol.io
- Abilities API reference, developer.wordpress.org
- wp_delete_post() function reference, developer.wordpress.org