Production Realities

REST API + MCP Hybrid Architectures

⏱ 15 min

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.

What you'll learn in this lesson
When a feature genuinely needs both surfaces
REST for your own JS/app consumers, an ability for agent consumers, not duplication for its own sake.
A shared service layer pattern
One class or function set that both a REST controller and an ability call into.
Sharing schema definitions, not just logic
Defining validation once and referencing it from both an args array and an input_schema.
Where REST and MCP genuinely differ
Pagination, HTTP status codes, and caching headers don't map cleanly onto ability semantics.
Prerequisites

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

REST semantics versus MCP ability semantics
ConcernRESTMCP ability
PaginationHeaders (X-WP-Total) or _linksUsually a total field inside the returned object, since there’s no HTTP header concept
ErrorsHTTP status codes plus a WP_Error bodyWP_Error return from execute_callback, translated to an MCP-level error by the adapter
CachingCache-Control headers, ETagsNo transport-level caching concept; caching (Lesson 4) happens inside execute_callback itself
DiscoverabilityA human reading REST API docs, or a JS client hardcoded to the routeAn 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.

Don't let one surface's error handling leak into the shared service

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

← CI/CD Pipelines for MCP Plugins Future-Proofing for WordPress Core →