WP Manifestindependent plugin directory
manifest / ai / wordpress-mcp

WordPress MCP

MCP server for WordPress, built on the official modelcontextprotocol/php-sdk

by Sviatoslav · github.com/faneraiy14/wordpress-mcp

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/faneraiy14/wordpress-mcp/archive/refs/heads/main.zip

Readme

wordpress-mcp

MCP server for WordPress, built on the official mcp/sdk. Runs as a WordPress plugin, serves MCP over the REST API at /wp-json/wordpress-mcp/v1/mcp.

Goal: get WordPress added to the "PHP Libraries Using the MCP SDK" list in php-sdk's README - currently missing despite WordPress running roughly 40% of the web.

Status: v0 working end-to-end, five tools (read + write), CI running

A real MCP client (fetch() with a real WP nonce) gets a real initialize handshake, a real session, and real tools/call responses from a running WordPress site - not mocks, not stubs.

  • get_site_health - runs WordPress's own Site Health checks, returns a compact critical/recommended/good summary.
  • list_posts(limit, status) - recent posts (id, title, status, date, excerpt, url).
  • get_post(post_id) - one post's full content, not just an excerpt.
  • list_plugins() - installed plugins with name, version, description, and active/inactive.
  • update_post(post_id, title?, content?, status?) - the one write tool. Checks current_user_can('edit_post', $post_id) per call (not just "is authenticated" - an Author can edit their own posts but not someone else's). Errors are surfaced properly to the client via Mcp\Exception\ToolCallException (a plain exception gets sanitized by the SDK into a generic, unhelpful message).

What works (verified live against a real local WordPress install)

  • composer install pulls mcp/sdk + nyholm/psr7 cleanly.
  • SiteHealthSummarizer (pure PHP, no WP dependency) - 3/3 unit tests passing.
  • The REST route is registered, requires an authenticated WP user (is_user_logged_in()), and correctly bridges WP_REST_Request -> PSR-7 -> StreamableHttpTransport -> back to WP_REST_Response.
  • initialize handshake: real 200 response with correct MCP envelope.
  • Session persistence via FileSessionStore under uploads/wordpress-mcp-sessions/ (with a .htaccess deny-all so it's not web-readable, same lesson as the debug.log fix in luchezarik-arhiv).
  • tools/call on get_site_health returns real data: e.g. {"critical_count": 3, "recommended_count": 3, "good_count": 10, "critical_issues": [{"label": "You have plugins waiting to be updated", ...}, ...]}.

Bugs found and fixed along the way

  1. WP_REST_Request::get_headers() canonicalizes header names to lowercase+underscore (Mcp-Session-Id -> mcp_session_id); forwarding into PSR-7 needs str_replace('_', '-', $name) or the SDK's session-id header lookup never matches.
  2. Server::builder()->addTool() doesn't accept a bare invokable object, needs [$obj, '__invoke'] callable-array form.
  3. WP_Site_Health's test methods span several wp-admin-only files that aren't loaded outside a real wp-admin page request. SiteHealthTool::__invoke() now requires update.php, plugin.php, and misc.php before touching WP_Site_Health. Found by adding a setLogger() to Server::builder() that writes to WP's error_log() - each missing function named its own file via "Call to undefined function", then cross-checked with a one-shot script that greps every function class-wp-site-health.php calls and diffs against what's already loaded, to catch the rest in one pass instead of one HTTP round-trip per missing function.
  4. A plain \InvalidArgumentException thrown from a tool gets sanitized by the SDK's default catch-all into a generic -32603 Error while executing tool, hiding the real message from the client. Mcp\Exception\ToolCallException is the SDK's intended type for this - CallToolHandler catches it specifically and surfaces the real message as a proper isError: true tool result.

Architecture

Each tool follows the same split: a pure formatter/summarizer class (no WordPress dependency, unit-tested) plus a thin Tool\* glue class (calls real WP functions, needs a running WordPress to test).

  • src/SiteHealthSummarizer.php + src/Tool/SiteHealthTool.php - calls the real WP_Site_Health::get_tests() / get_test_*() methods (verified against the actual core source in luchezarik-arhiv/web/wp-admin/includes/class-wp-site-health.php, not guessed).
  • src/PostFormatter.php (format() for lists, formatDetailed() for a single post) + src/Tool/ListPostsTool.php, src/Tool/GetPostTool.php, src/Tool/UpdatePostTool.php - wrap get_posts(), get_post(), and wp_update_post() respectively. The two read tools require nothing beyond current_user_can('read_post', ...) (or nothing at all for the list); update_post requires current_user_can('edit_post', ...).
  • src/PluginFormatter.php + src/Tool/ListPluginsTool.php - wraps get_plugins() + the active_plugins option.
  • wordpress-mcp.php - plugin bootstrap: registers the REST route, builds the MCP server per-request (stateless PHP process model - session continuity comes entirely from FileSessionStore, not from anything held in memory between requests).
  • .github/workflows/tests.yml - runs phpunit on push/PR across PHP 8.1 and 8.3.

Next steps (roughly in order)

  1. Write a real test for the WP-runtime glue if feasible (currently only the pure formatters/summarizer are unit-tested; the Tool\* classes themselves need a running WordPress to test).
  2. Open a PR against modelcontextprotocol/php-sdk adding one line to the "PHP Libraries Using the MCP SDK" README section.

Testing locally

Symlinked into a real local WordPress install for live testing:

ln -s ~/Projects/wordpress-mcp ~/Projects/luchezarik-arhiv/web/wp-content/plugins/wordpress-mcp

(luchezarik-arhiv is unrelated to this project - it's just the local WP instance that happened to be running. Any local WP install works.)

Read the full README on GitHub →