Serving Your Abilities

Running Multiple MCP Servers on One Site

⏱ 14 min

Nothing about the MCP Adapter limits you to one server per site. McpAdapter::create_server() can be called as many times as you want, on the same mcp_adapter_init hook, each call producing an independent server with its own endpoint, its own ability list, and its own permission rules. This lesson is about when that’s genuinely useful, not just possible.

What you'll learn in this lesson
Why one server for everything is often the wrong default
Different audiences need different ability sets and different trust levels.
Registering two servers with different scopes
A narrow, public-facing server and a broader, admin-only one.
Keeping ability lists and permissions in sync deliberately
The same ability can be exposed on more than one server, with different transport permissions guarding each.
Naming and identifying servers clearly
So wp mcp-adapter list stays useful as the number of servers grows.
Prerequisites

The custom server pattern from Lesson 6, and the transport-level permission callback concept from that same lesson. At least two abilities registered (reuse my-plugin/create-draft-post plus a second, read-only one for this example).

Step 1: The case for more than one server

A single WordPress site often needs to serve genuinely different audiences over MCP. Consider a site that wants to let a customer-facing chat agent look up public product information, and separately wants an internal admin tool that can draft and manage posts. Cramming both into one server means every ability shares one transport permission callback, one blast radius if that callback has a bug, and one endpoint that now needs to be trusted by both an anonymous public integration and internal staff tooling. Splitting them is not extra ceremony, it’s matching your server topology to your actual trust boundaries.

Example: two servers, two purposes
ServerAbilities exposedTransport permission
my-plugin-public-serverRead-only, public-safe abilities only (e.g. look up published product info)Permissive, possibly allowing unauthenticated or API-key access
my-plugin-admin-serverContent-editing abilities, including the create-draft-post ability from earlier lessonsStrict, e.g. requires edit_posts at minimum

Step 2: Register both

Both calls happen inside the same mcp_adapter_init callback, they’re independent create_server() invocations with different IDs, routes, ability lists, and permissions:

// File: my-mcp-abilities.php
add_action( 'mcp_adapter_init', function ( $adapter ) {

	// A narrow, public-facing server: read-only, minimal trust required.
	$adapter->create_server(
		'my-plugin-public-server',
		'my-plugin',
		'public-mcp',
		'My Plugin Public Server',
		'Read-only product lookup abilities for external integrations.',
		'v1.0.0',
		array( \WP\MCP\Transport\HttpTransport::class ),
		\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
		\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class,
		array( 'my-plugin/get-published-products' ),
		array(),
		array(),
		function () {
			return true; // e.g. public, or gated by your own API-key check instead.
		}
	);

	// A broader, admin-only server: content-editing abilities, strict permission.
	$adapter->create_server(
		'my-plugin-admin-server',
		'my-plugin',
		'admin-mcp',
		'My Plugin Admin Server',
		'Content-editing abilities for internal admin tooling.',
		'v1.0.0',
		array( \WP\MCP\Transport\HttpTransport::class ),
		\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
		\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class,
		array( 'my-plugin/create-draft-post' ),
		array(),
		array(),
		function () {
			return current_user_can( 'edit_posts' );
		}
	);
} );

Each server gets its own REST route: /wp-json/my-plugin/public-mcp and /wp-json/my-plugin/admin-mcp. An AI client pointed at the public route physically cannot reach create-draft-post, it isn’t in that server’s tool list at all, not merely blocked by a permission check. That’s a stronger guarantee than one server with careful per-call gating, because a bug in one ability’s permission check on the admin server can’t leak into the public one.

Step 3: The same ability can live on more than one server

Nothing stops you listing the same ability in two create_server() calls if it genuinely belongs on both. Each server’s own transport permission callback still gates access independently, so you could expose a moderation-queue-reading ability on both an internal support-tool server (broad staff access) and a narrower automation server (a single service account only), without duplicating the ability itself.

Test it

Confirm both servers are registered and independently addressable:

# File: terminal
wp mcp-adapter list --format=table

You should see both my-plugin-public-server and my-plugin-admin-server as separate rows. Then confirm each exposes only what it should:

# File: terminal
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
  | wp mcp-adapter serve --user=admin --server=my-plugin-public-server
# Should list only the product-lookup ability

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
  | wp mcp-adapter serve --user=admin --server=my-plugin-admin-server
# Should list my-plugin-create-draft-post
Server IDs and routes colliding, or drifting out of sync

Every create_server() call needs a genuinely unique server ID and a unique namespace/route pair, reusing one by accident (easy to do when copy-pasting a registration block) means the second call silently overwrites, or conflicts with, the first, depending on adapter internals, rather than raising an obvious error. Give each server a clearly distinct, descriptive ID up front, and keep a short comment above each create_server() call stating its intended audience, it’s easy for a “public” server’s ability list to quietly grow to include something sensitive months later if nothing documents the boundary you originally intended.

Recap

McpAdapter::create_server() can be called more than once inside mcp_adapter_init, producing independent servers with their own routes, ability lists, and transport permission callbacks. Splitting servers by audience, public versus internal, read-only versus editing, gives you a hard boundary (an ability simply isn’t listed on a given server) rather than relying entirely on runtime permission checks to separate concerns. With multiple servers now in play, the next lesson covers a detail that applies to every ability on every server: how annotations become the hints an AI client’s UI actually shows a human before it acts.

Resources & further reading

← Serving Abilities Over STDIO via WP-CLI Mapping Ability Annotations to MCP Hints →