Lesson 5 covered migrating an existing REST endpoint into an ability without breaking it. This lesson is the forward-looking version of that question: when you’re building something new, should it be a REST endpoint, an ability, or both, and if both, how do you avoid maintaining two copies of the same validation and business logic forever. The answer, consistently, is a shared service layer that neither surface owns, with a REST controller and an ability registration as two thin, independent translators sitting on top of it.
This lesson assumes the shared-function extraction pattern from Lesson 5. Here we’re applying the same idea to new features, not migrated legacy code.
Step 1: Decide if you actually need both surfaces
Not every ability needs a REST twin, and not every REST endpoint needs an ability twin. Build both only when you have two genuinely different consumers: your own JavaScript (block editor, admin UI, a mobile app) needs REST’s HTTP semantics (status codes, pagination headers, caching), while an AI agent needs MCP’s schema-first, self-describing tool semantics. If only one consumer exists today, build only the one surface it needs, the shared-service pattern below means adding the second surface later is cheap, not a reason to build it speculatively now.
Step 2: One service class, two thin translators
// File: includes/class-my-plugin-ticket-service.php
class My_Plugin_Ticket_Service {
public static function list_tickets( string $status = 'open', int $limit = 20, int $page = 1 ): array {
$query = new WP_Query( array(
'post_type' => 'ticket',
'post_status' => $status === 'open' ? 'publish' : 'draft',
'posts_per_page' => $limit,
'paged' => $page,
) );
return array(
'items' => array_map( array( self::class, 'format_ticket' ), $query->posts ),
'total' => (int) $query->found_posts,
);
}
private static function format_ticket( WP_Post $post ): array {
return array( 'id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status );
}
}
The REST controller becomes a translator from HTTP semantics to the service call, and back to an HTTP response with the headers REST consumers expect:
// File: includes/class-my-plugin-rest-controller.php
function my_plugin_rest_list_tickets( WP_REST_Request $request ) {
$result = My_Plugin_Ticket_Service::list_tickets(
$request->get_param( 'status' ) ?: 'open',
(int) ( $request->get_param( 'per_page' ) ?: 20 ),
(int) ( $request->get_param( 'page' ) ?: 1 )
);
$response = rest_ensure_response( $result['items'] );
$response->header( 'X-WP-Total', (string) $result['total'] );
return $response;
}
And the ability is a translator from MCP’s schema-described input to the same service call, with no pagination headers because that’s not a concept MCP tool results have:
// File: includes/class-my-plugin-abilities.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability( 'my-plugin/list-tickets', array(
'label' => __( 'List tickets', 'my-plugin' ),
'description' => __( 'Returns support tickets, paginated.', 'my-plugin' ),
'input_schema' => My_Plugin_Ticket_Service::input_schema(),
'output_schema' => array( 'type' => 'object', 'properties' => array(
'items' => array( 'type' => 'array' ), 'total' => array( 'type' => 'integer' ),
) ),
'permission_callback' => function () { return current_user_can( 'edit_others_posts' ); },
'execute_callback' => function ( array $input ) {
return My_Plugin_Ticket_Service::list_tickets(
$input['status'] ?? 'open', $input['limit'] ?? 20, $input['page'] ?? 1
);
},
'meta' => array( 'mcp' => array( 'public' => false ) ),
) );
} );
Step 3: Share the schema definition itself, not just the logic
Both register_rest_route()’s args array and an ability’s input_schema describe
the same shape of input, in similar but not identical formats. Define the shared parts
once, as a method on the service class, and derive both from it rather than hand-writing
two schemas that will eventually drift apart:
// File: includes/class-my-plugin-ticket-service.php
class My_Plugin_Ticket_Service {
// ... list_tickets(), format_ticket() as above ...
public static function input_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'status' => array( 'type' => 'string', 'enum' => array( 'open', 'closed' ), 'default' => 'open' ),
'limit' => array( 'type' => 'integer', 'default' => 20 ),
'page' => array( 'type' => 'integer', 'default' => 1 ),
),
);
}
public static function rest_args(): array {
// Derived from the same source, translated to REST's args format.
$schema = self::input_schema();
return array(
'status' => array( 'type' => 'string', 'enum' => array( 'open', 'closed' ), 'default' => 'open' ),
'per_page' => array( 'type' => 'integer', 'default' => $schema['properties']['limit']['default'] ),
'page' => array( 'type' => 'integer', 'default' => 1 ),
);
}
}
Step 4: Know where the two surfaces genuinely diverge
| Concern | REST | MCP ability |
|---|---|---|
| Pagination | Headers (X-WP-Total) or _links | Usually a total field inside the returned object, since there’s no HTTP header concept |
| Errors | HTTP status codes plus a WP_Error body | WP_Error return from execute_callback, translated to an MCP-level error by the adapter |
| Caching | Cache-Control headers, ETags | No transport-level caching concept; caching (Lesson 4) happens inside execute_callback itself |
| Discoverability | A human reading REST API docs, or a JS client hardcoded to the route | An AI model reading label and description to decide whether to call it at all |
That last row is worth dwelling on: a REST endpoint’s description exists for human
documentation. An ability’s description is functionally load-bearing, since it’s
what a model reads to decide relevance. Writing the same wording for both is fine as a
starting point, but don’t assume a REST-appropriate description is automatically a
good ability description.
Test it
Call both surfaces with equivalent input and confirm they return the same underlying data, just shaped differently:
curl -s "https://example.com/wp-json/my-plugin/v1/tickets?status=open&per_page=5" | jq '.[0]'
wp-env run cli wp eval 'var_dump( wp_get_ability( "my-plugin/list-tickets" )->execute( array( "status" => "open", "limit" => 5 ) )["items"][0] );'
Both should describe the same ticket.
Keep WP_Error handling and HTTP-status translation in the REST controller, and
MCP-appropriate error returns in the ability’s execute_callback, not inside the
shared service class itself. If the service class starts returning WP_REST_Response
objects, you’ve quietly coupled it back to one surface, defeating the entire point of
extracting it.
Recap
A shared service class (or set of functions) holding the real business logic, with a REST controller and an ability registration as two independent, thin translators on top of it, is how you support both traditional consumers and AI agents without maintaining two copies of the same logic. Share the schema definition’s source of truth too, not just the code path, and keep HTTP-specific and MCP-specific concerns (pagination headers, error translation) out of the shared layer entirely.
Resources & further reading
- REST API handbook, developer.wordpress.org
- Abilities API reference, developer.wordpress.org
- MCP Adapter repository, GitHub