Everything up to this point has lived entirely on your own machine, called from PHP or
WP-CLI directly. This lesson is where your abilities become reachable over the network,
as a real MCP server an AI client connects to over HTTP. You’ll register a custom server
with HttpTransport, understand the session protocol MCP clients follow, and call it
with curl the same way a real client would.
The my-plugin/create-draft-post ability from Lessons 2 to 5, with meta.mcp.public
set to true. The MCP Adapter active. A WordPress user with an Application Password
generated (Users → Profile → Application Passwords in wp-admin).
Step 1: Register a custom server
Custom servers are created on the mcp_adapter_init hook, which fires with the
McpAdapter singleton instance passed in:
// File: my-mcp-abilities.php
add_action( 'mcp_adapter_init', function ( $adapter ) {
$adapter->create_server(
'my-plugin-content-server', // Unique server ID
'my-plugin', // REST API namespace
'content-mcp', // REST API route
'My Plugin Content Server', // Server name
'Exposes content-editing abilities over MCP.', // Description
'v1.0.0', // Server version
array( \WP\MCP\Transport\HttpTransport::class ),
\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class,
array( 'my-plugin/create-draft-post' ), // Tools (abilities)
array(), // Resources
array(), // Prompts
function () { // Transport-level permission callback
return current_user_can( 'edit_posts' );
}
);
} );
Unlike the default server, a custom server lists its abilities explicitly as its 10th
argument, they’re exposed directly as named MCP tools, in the actual tools/list
response an AI client sees, no discover-abilities indirection required, and no
meta.mcp.public flag needed either. The endpoint this registers is
/wp-json/my-plugin/content-mcp.
Step 2: The transport-level permission callback, a second gate
The last argument is a server-wide permission callback, distinct from any single
ability’s permission_callback. If you omit it, the adapter defaults to
is_user_logged_in(), any authenticated user can reach the server, and each ability’s
own check still applies on top. Passing your own callback here, as above, means someone
who is merely logged in but lacks edit_posts never even reaches the ability layer for
this particular server.
| Layer | Scope | Default if omitted |
|---|---|---|
| Transport permission callback | Every request to this one server, before any specific ability is chosen. | is_user_logged_in() |
Ability’s own permission_callback | That one ability, every time it’s called, on any server that exposes it. | None, required per ability. |
Step 3: Understand the session protocol before calling it
The HTTP transport implements the MCP 2025-11-25 Streamable HTTP session protocol, meaning a client can’t just fire a single request. It has to complete a short handshake first:
Step 4: Call it with curl, using an Application Password
The MCP Adapter has no authentication system of its own, it relies entirely on WordPress’s existing REST API authentication: cookie/nonce auth for logged-in browser sessions, or Application Passwords for anything programmatic, like this. Generate one under your user’s profile in wp-admin, then:
# File: terminal
SITE="https://yoursite.test"
AUTH="your-username:xxxx xxxx xxxx xxxx xxxx xxxx"
# 1. Initialize and capture the session ID
SESSION_ID=$(curl -s -D - -X POST \
"$SITE/wp-json/my-plugin/content-mcp" \
--user "$AUTH" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0.0"}}}' \
| grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r')
# 2. Tell the server the client is ready
curl -s -X POST "$SITE/wp-json/my-plugin/content-mcp" \
--user "$AUTH" \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3. List tools
curl -s -X POST "$SITE/wp-json/my-plugin/content-mcp" \
--user "$AUTH" \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
Test it
The tools/list response should include an entry named my-plugin-create-draft-post,
note the slash from the ability’s registration name becomes a hyphen, MCP tool names
can’t contain /. Then close the session cleanly:
# File: terminal
curl -s -X DELETE "$SITE/wp-json/my-plugin/content-mcp" \
--user "$AUTH" \
-H "Mcp-Session-Id: $SESSION_ID"
Leaving the transport permission callback unset defaults to is_user_logged_in(),
which is a reasonable floor for a local dev site but is rarely the right default for a
production server exposing anything more sensitive than read-only content. Pass an
explicit callback checking the specific capability your server’s abilities actually
need, the same way you would for any REST API endpoint you’d be comfortable putting on
the public internet.
Recap
A custom MCP server is created with McpAdapter::create_server() on the
mcp_adapter_init hook, listing its transports, error and observability handlers,
abilities, and an optional transport-level permission callback. HTTP transport follows a
stateful session protocol: initialize, capture the Mcp-Session-Id header, then include
it on every subsequent request. Authentication is WordPress’s own Application Passwords,
there’s no separate OAuth layer to configure. The next lesson covers the other transport
this same server can use: STDIO, driven by WP-CLI.