Contract Testing

Contract-Testing MCP Tools Against Real Clients

⏱ 16 min

Everything so far has tested your ability’s own PHP directly, WP_Ability::execute(), no transport involved, on purpose. This lesson tests the other side of the boundary: does your server actually behave correctly once a real MCP client is talking to it over the wire, and does your own custom error or observability handler (from Courses 4, 8, and 19) actually get called with the data you expect. Those are two different questions, and this lesson uses two different, real tools for them, the MCP Adapter’s own test-double pattern for the first, and MCP Inspector for the second.

What you'll learn in this lesson
Where DummyObservabilityHandler and DummyErrorHandler actually live
Real fixtures in the MCP Adapter's own test suite, and why you can't composer require your way to them.
Building your own equivalent test double
Copying the same small, proven shape for your own custom handler's test suite.
Asserting your handler gets called with the right data
A contract test against the interface your handler implements, not against adapter internals.
Using MCP Inspector for real wire-level contract verification
Confirming what an actual client would see, complementary to the PHPUnit-level test.
Prerequisites

A custom McpErrorHandlerInterface or McpObservabilityHandlerInterface implementation from Course 4 or Course 19’s error-handler lesson, wired in through your server’s config filter. Node.js available for npx if you want to run Inspector in Step 4.

Step 1: Where the real Dummy handlers actually live, and why you can’t install them

The MCP Adapter’s own docs/guides/testing.md documents two real fixtures used inside its own test suite: DummyObservabilityHandler, which captures every record_event() call into a static array for assertions, and DummyErrorHandler, which does the same for log() calls. Both are genuinely useful to read, they’re the exact pattern the adapter’s own authors use to verify their internal handlers get invoked correctly. But they live at tests/phpunit/Fixtures/ inside the WordPress/mcp-adapter repository, and that whole tests/ directory is explicitly excluded from the distributed package (its .gitattributes marks /tests as export-ignore). A normal composer require wordpress/mcp-adapter in your own plugin never pulls these classes in at all, there’s nothing to use from your own test suite.

That’s not a limitation worth working around, it’s the correct boundary: those fixtures test the adapter’s own internals, ToolsHandler and friends, code you don’t own and shouldn’t be reaching into from your plugin’s tests. What you can, and should, take from them is the pattern itself.

Step 2: Build your own equivalent, for your own handler

Both real interfaces are small, stable, public contracts, the same ones Course 19’s error-handler lesson and Course 4’s observability lesson already have you implementing. Copy the same shape the adapter’s own fixtures use, a static array, a reset() method, and the interface’s one method recording into it:

// File: tests/Unit/Fixtures/TestObservabilityHandler.php
class TestObservabilityHandler implements McpObservabilityHandlerInterface {

	/** @var list<array{event:string,tags:array,duration_ms:?float}> */
	public static array $events = array();

	public static function reset(): void {
		self::$events = array();
	}

	public function record_event( string $event, array $tags = array(), ?float $duration_ms = null ): void {
		self::$events[] = array(
			'event'       => $event,
			'tags'        => $tags,
			'duration_ms' => $duration_ms,
		);
	}
}

This isn’t a copy of anything you had to install, it’s a small class you own, written once, that follows a proven, documented shape. The value of having read the real DummyObservabilityHandler is knowing this exact shape already works for this exact purpose, not needing to invent your own from nothing.

Step 3: Contract-test your production handler, not the double

The test double above exists to verify your production handler, the one that writes to a database table or fires an alert, actually gets called correctly by whatever production code path is supposed to call it. Test that path directly, wherever your own code invokes log() or record_event(), the audit-log wrapper around execute_callback from Course 4, or the error handling inside your own custom McpErrorHandlerInterface implementation from Course 19:

// File: tests/Unit/MyPluginErrorHandlerTest.php
<?php
class MyPluginErrorHandlerTest extends WP_UnitTestCase {

	public function test_log_writes_expected_row_to_the_error_table(): void {
		global $wpdb;

		$handler = new My_Plugin_Mcp_Error_Handler();
		$handler->log( 'Tool execution failed', array( 'tool_name' => 'my-plugin/list-tickets' ), 'error' );

		$row = $wpdb->get_row(
			"SELECT type, message FROM {$wpdb->prefix}mcp_error_log ORDER BY id DESC LIMIT 1",
			ARRAY_A
		);

		$this->assertSame( 'error', $row['type'] );
		$this->assertSame( 'Tool execution failed', $row['message'] );
	}
}

This is the contract that actually matters for your plugin: given the exact log() signature the McpErrorHandlerInterface contract defines, does your implementation do the right thing with it. It needs no adapter internals at all, only the interface your own class already implements.

What each layer of this contract test actually confirms
1
Your handler implements the interface correctly
Method signature, argument types, matching what the adapter is documented to call.
2
Your handler's side effect is correct
The row lands in your table, the alert fires, whatever your production log() actually does.
3
Your server config filter wires the right class
Confirmed separately, by reading the filter's registered value in a smaller, focused test.

Step 4: MCP Inspector for the one thing PHPUnit can’t confirm, the real wire

Everything above tests your PHP directly. It doesn’t confirm what an actual MCP client sees on the wire, the tool list, each tool’s schema, a real call’s response shape. That’s what MCP Inspector, from Course 19, is for, and it’s the right complement here, not a replacement:

# File: terminal
npx @modelcontextprotocol/inspector \
  wp --path=/path/to/your/wordpress/site mcp-adapter serve \
  --server=my-plugin-content-server --user=admin

Use it after a schema change or a new ability registration, before assuming the PHPUnit suite alone proves a client would actually see what you expect. A schema that validates correctly inside a PHPUnit test can still render oddly to a specific client’s UI, or expose a field you didn’t mean to make public, differences only visible by looking at the actual wire contract, not by asserting against PHP objects.

Don't try to instantiate adapter internals like ToolsHandler in your own tests

It’s technically possible to require the MCP Adapter’s dev dependencies and reach for classes like WP\MCP\Handlers\Tools\ToolsHandler directly, since PHP doesn’t stop you from instantiating a class you can autoload. Resist it. Those classes are internal implementation details of the adapter’s own contributor test suite, not a public contract for downstream plugins, and they can change shape between adapter releases with no deprecation notice, since they were never a supported integration point. Contract-test your own handler against the interface, and contract-test the wire with Inspector, that’s the full, real surface available to you.

Test it: break your handler’s wiring and confirm both layers catch it differently

Comment out the line in your server config filter that sets $config['error_handler'], then run both checks. Your PHPUnit contract test for My_Plugin_Mcp_Error_Handler::log() still passes, since that class works correctly in isolation, it was never wired in wrong at that level. Trigger a real failure through Inspector instead, and confirm wp-content/debug.log shows the adapter’s default ErrorLogMcpErrorHandler output rather than a row in your custom table. That gap between “the class works” and “the class is actually being used” is exactly what having both layers is for.

Recap

DummyObservabilityHandler and DummyErrorHandler are real, documented fixtures inside the MCP Adapter’s own test suite, but they live in a tests/ directory that’s excluded from the distributed package, so the value they offer a downstream plugin is the pattern, not an installable class. Copy that same shape for your own custom handler’s test double, contract-test your production log() or record_event() implementation directly against the interface it implements, and use MCP Inspector separately to confirm the real wire contract, since no amount of PHPUnit against your own PHP classes can substitute for looking at what an actual client sees.

Resources & further reading

← Writing Evals to Catch AI-Output Regressions Wiring It Into CI/CD →