Lesson 1 split the support and fulfillment roles into two separate MCP servers on purpose,
different audiences, different trust levels, different tool lists. That split creates a new
problem: the support agent’s order-ops/open-escalation call knows things the fulfillment
agent needs, what the customer actually said, which order, why it looks unusual, but the two
agents don’t share a conversation, a process, or even necessarily a request. This lesson is
about the handoff mechanism that connects them: post meta, written by one agent’s ability and
read by another’s.
The two-server split and the four order-ops abilities from Lesson 1. This lesson doesn’t require the planner from Lesson 2, though it composes cleanly with it, a planner could just as easily be the thing writing the handoff record.
Step 1: Why “just pass it in the conversation” doesn’t work here
If the support agent and the fulfillment agent were the same conversation with the same model, handoff would be trivial, the context is just still in the transcript. They aren’t. The support server and the fulfillment server are reached by different clients, likely at different times, possibly by different people entirely, a customer talking to a chat widget, then a staff member later opening their own internal tool. Nothing links those two interactions except whatever your own code deliberately records. Post meta, attached to the order post both agents already reference by ID, is that link.
Step 2: Writing the handoff from the support side
order-ops/open-escalation already existed as an ability in Lesson 1’s tool list. Here’s
what it actually does: it doesn’t just log a note, it writes a structured handoff record the
fulfillment agent can act on later.
// File: order-ops/class-open-escalation.php
wp_register_ability( 'order-ops/open-escalation', array(
'label' => 'Open an escalation for internal fulfillment review',
'description' => 'Records a customer-reported issue on an order for internal fulfillment '
. 'staff to review and act on. Does not itself process a return or reshipment.',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'order_id' => array( 'type' => 'integer' ),
'summary' => array( 'type' => 'string' ),
),
'required' => array( 'order_id', 'summary' ),
),
'execute_callback' => 'order_ops_open_escalation',
'permission_callback' => '__return_true',
'meta' => array( 'mcp' => array( 'public' => true ) ),
) );
function order_ops_open_escalation( $input ) {
$order = get_post( $input['order_id'] );
if ( ! $order ) {
return new WP_Error( 'not_found', 'No order with that ID exists.' );
}
$handoff = array(
'state' => 'pending', // pending -> claimed -> resolved
'summary' => sanitize_textarea_field( $input['summary'] ),
'from_agent' => 'support',
'claimed_by' => null,
'opened_at' => current_time( 'mysql', true ),
);
update_post_meta( $order->ID, '_order_ops_handoff', $handoff );
return array(
'order_id' => $order->ID,
'escalation_state' => 'pending',
);
}
Storing the handoff as a single serialized array under one meta key, rather than several
loose keys, keeps the state transitions (pending, claimed, resolved) atomic from your
own code’s point of view, one update_post_meta() call always replaces the whole record.
Step 3: Reading and claiming the handoff from the fulfillment side
The fulfillment agent needs two things: a way to see what’s pending, and a way to claim one without a second staff member (or a second agent call) picking up the same escalation at the same time.
// File: order-ops/class-fulfillment-handoffs.php
wp_register_ability( 'order-ops/list-pending-escalations', array(
'label' => 'List escalations awaiting fulfillment review',
'description' => 'Returns orders with a pending handoff from the support agent, '
. 'including the customer-reported summary, for fulfillment staff to triage.',
'input_schema' => array( 'type' => 'object', 'properties' => new stdClass() ),
'execute_callback' => 'order_ops_list_pending_escalations',
'permission_callback' => 'order_ops_can_resolve',
'meta' => array( 'mcp' => array( 'public' => true ) ),
) );
wp_register_ability( 'order-ops/claim-escalation', array(
'label' => 'Claim a pending escalation for handling',
'description' => 'Marks a pending escalation as claimed by the current user, so no one '
. 'else starts working the same order issue at the same time.',
'input_schema' => array(
'type' => 'object',
'properties' => array( 'order_id' => array( 'type' => 'integer' ) ),
'required' => array( 'order_id' ),
),
'execute_callback' => 'order_ops_claim_escalation',
'permission_callback' => 'order_ops_can_resolve',
'meta' => array( 'mcp' => array( 'public' => true ) ),
) );
function order_ops_claim_escalation( $input ) {
$handoff = get_post_meta( $input['order_id'], '_order_ops_handoff', true );
if ( empty( $handoff ) || 'pending' !== $handoff['state'] ) {
return new WP_Error(
'not_claimable',
'This escalation is not pending, it may already be claimed or resolved.'
);
}
$handoff['state'] = 'claimed';
$handoff['claimed_by'] = get_current_user_id();
update_post_meta( $input['order_id'], '_order_ops_handoff', $handoff );
return array( 'order_id' => $input['order_id'], 'escalation_state' => 'claimed' );
}
Checking 'pending' !== $handoff['state'] before claiming is what actually prevents a race:
whichever call reads pending and writes claimed first wins, and the second call, running
even a moment later, reads claimed and fails cleanly instead of silently double-processing
the same escalation.
Test it
# File: terminal
wp-env run cli wp eval '
wp_get_ability( "order-ops/open-escalation" )->execute( array(
"order_id" => 4821,
"summary" => "Customer says package shows delivered but never arrived.",
) );
var_dump( wp_get_ability( "order-ops/list-pending-escalations" )->execute( array() ) );
wp_get_ability( "order-ops/claim-escalation" )->execute( array( "order_id" => 4821 ) );
// A second claim attempt on the same order should now fail:
var_dump( wp_get_ability( "order-ops/claim-escalation" )->execute( array( "order_id" => 4821 ) ) );
'
The first claim should succeed and return escalation_state: claimed. The second, identical
call should return the not_claimable WP_Error, confirming the state check is actually
doing its job rather than just being decorative.
update_post_meta() doesn’t prevent a race between two near-simultaneous reads of the same
key, two calls to claim-escalation a few milliseconds apart can both read pending before
either writes claimed. On most sites, with human-driven or moderately-paced agent traffic,
this is rare enough to be an acceptable risk. If your fulfillment agent runs at genuinely
high concurrency, reach for wp_cache_add() with a short-lived lock key, or a dedicated
custom table with a unique constraint, instead of trusting a plain meta read-then-write to
be atomic.
Recap
Two agents on two separate MCP servers share no memory of each other’s conversations, so handing context between them requires a deliberate, durable store. Post meta, keyed to the same order post both roles already reference, works well for this: one agent writes a structured record with an explicit state field, the other reads it, claims it by flipping that state, and resolves it using abilities already scoped to its own server. The state field itself, not just the presence of the record, is what prevents two workers from processing the same handoff twice.
Resources & further reading
- Metadata API reference, developer.wordpress.org
- Abilities API reference, developer.wordpress.org
- MCP Adapter repository, GitHub