Production Realities

CI/CD Pipelines for MCP Plugins

⏱ 16 min

Everything in this course so far has been architecture you write once and trust forever, unless something breaks it silently. A permission_callback that quietly starts returning true for everyone after a refactor, or an execute_callback that starts returning the wrong shape after a schema change, is exactly the kind of bug that doesn’t show up until an agent (or a security review) hits it in production. This lesson wires ability registration, permission logic, and execution behavior into a real automated test suite, using WordPress’s own standard testing tools, not a custom framework.

What you'll learn in this lesson
Scaffolding a real PHPUnit + WP test suite
wp scaffold plugin-tests and the WP_UnitTestCase base class.
Testing ability registration itself
Confirming wp_has_ability() and schema shape, not just "it doesn't fatal."
Testing permission_callback as its own unit
Asserting true/false/WP_Error across different user contexts.
Testing execute_callback behavior
Input/output correctness, independent of any live MCP transport.
Running it all in GitHub Actions
A real, working workflow file, not a description of one.
Prerequisites

Comfortable with PHPUnit basics and having a local WordPress environment (wp-env, from Course 1). This lesson assumes you already have at least one plugin registering abilities to test against.

Step 1: Scaffold the test suite

WP-CLI generates the standard PHPUnit scaffolding for a plugin, a bootstrap file, a phpunit.xml.dist, and a starter test case:

wp-env run cli wp scaffold plugin-tests my-plugin

This produces tests/bootstrap.php (which loads the WordPress test suite itself before your plugin), phpunit.xml.dist, and a tests/test-sample.php you’ll replace. Run it inside wp-env, which already has the WordPress test library available:

wp-env run tests-cli --env-cwd=wp-content/plugins/my-plugin phpunit

Step 2: Test that registration actually happened, and matches expectations

Don’t just assert the plugin activates without a fatal error, assert the specific ability exists with the schema and permission you expect:

// File: tests/test-abilities-registration.php
class Test_Abilities_Registration extends WP_UnitTestCase {

	public function test_ability_is_registered(): void {
		$this->assertTrue( wp_has_ability( 'my-plugin/list-tickets' ) );
	}

	public function test_ability_has_expected_schema_shape(): void {
		$ability = wp_get_ability( 'my-plugin/list-tickets' );
		$schema  = $ability->get_input_schema();

		$this->assertSame( 'object', $schema['type'] );
		$this->assertArrayHasKey( 'status', $schema['properties'] );
	}

	public function test_ability_is_not_publicly_exposed_by_default(): void {
		$ability = wp_get_ability( 'my-plugin/list-tickets' );
		$meta    = $ability->get_meta();

		$this->assertFalse( $meta['mcp']['public'] ?? false );
	}
}

That last test matters more than it looks: it’s a regression test against someone accidentally flipping meta.mcp.public to true in a future pull request without anyone noticing during review.

Step 3: Test permission_callback across real user contexts

This is the single highest-value test in the whole suite, since a broken permission check is a security incident, not a functional bug:

// File: tests/test-permission-callback.php
class Test_Permission_Callback extends WP_UnitTestCase {

	public function test_editor_can_call_ability(): void {
		$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
		wp_set_current_user( $editor );

		$ability = wp_get_ability( 'my-plugin/list-tickets' );
		$this->assertTrue( $ability->has_permission( array() ) );
	}

	public function test_subscriber_cannot_call_ability(): void {
		$subscriber = self::factory()->user->create( array( 'role' => 'subscriber' ) );
		wp_set_current_user( $subscriber );

		$ability = wp_get_ability( 'my-plugin/list-tickets' );
		$this->assertFalse( $ability->has_permission( array() ) );
	}

	public function test_logged_out_user_cannot_call_ability(): void {
		wp_set_current_user( 0 );

		$ability = wp_get_ability( 'my-plugin/list-tickets' );
		$this->assertFalse( $ability->has_permission( array() ) );
	}
}

Step 4: Test execute_callback behavior directly, no transport involved

execute_callback is plain PHP, test it exactly like any other function, no MCP client, no HTTP request, no STDIO process needed:

// File: tests/test-execute-callback.php
class Test_Execute_Callback extends WP_UnitTestCase {

	public function test_list_tickets_returns_expected_shape(): void {
		self::factory()->post->create( array( 'post_type' => 'ticket', 'post_status' => 'publish', 'post_title' => 'Sample' ) );

		$ability = wp_get_ability( 'my-plugin/list-tickets' );
		$result  = $ability->execute( array( 'status' => 'open' ) );

		$this->assertIsArray( $result );
		$this->assertArrayHasKey( 'title', $result[0] );
	}

	public function test_list_tickets_respects_status_filter(): void {
		self::factory()->post->create( array( 'post_type' => 'ticket', 'post_status' => 'draft' ) );

		$ability = wp_get_ability( 'my-plugin/list-tickets' );
		$result  = $ability->execute( array( 'status' => 'open' ) );

		$this->assertCount( 0, $result );
	}
}

Step 5: Wire it into CI

A minimal MCP plugin CI pipeline
1
Lint
phpcs against WordPress Coding Standards, catching style and basic static issues before tests even run.
2
Unit and integration tests
The PHPUnit suite from Steps 2 to 4, run against the WordPress test library.
3
Matrix across PHP and WordPress versions
At minimum your plugin's stated minimum and the current release.
4
Fail the build on any red test
No merging a permission_callback regression, ever.
# File: .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
  phpunit:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.1', '8.3']
        wordpress: ['6.9', 'latest']
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
      - name: Start wp-env
        run: npx wp-env start
      - name: Run PHPUnit
        run: npx wp-env run tests-cli --env-cwd=wp-content/plugins/my-plugin phpunit

Test it

Deliberately break something (comment out current_user_can() in a permission_callback, for instance), rerun the suite locally, and confirm test_subscriber_cannot_call_ability fails loudly:

wp-env run tests-cli --env-cwd=wp-content/plugins/my-plugin phpunit --filter Test_Permission_Callback

A red test here is exactly the signal this whole pipeline exists to produce.

Don't test through the MCP transport in unit tests

Spinning up an actual MCP client connection inside a unit test is slow, flaky, and tests the adapter’s own transport code, not your plugin’s logic. Test permission_callback and execute_callback directly through WP_Ability::has_permission() and WP_Ability::execute(), and save real end-to-end MCP client tests (if you need them at all) for a small, separate, slower suite.

Recap

A CI pipeline for an MCP plugin is a standard WordPress PHPUnit setup (wp scaffold plugin-tests, WP_UnitTestCase, wp-env run tests-cli phpunit) pointed at three specific concerns: registration matches expectations (including meta.mcp.public staying where you left it), permission_callback behaves correctly across real user roles, and execute_callback returns the right shape for the right input. None of it needs a live MCP client, since abilities are testable as plain PHP.

Resources & further reading

← MCP Adapter Limitations and Workarounds REST API + MCP Hybrid Architectures →