Minimal local MCP server for creating and managing WordPress drafts over REST API (STDIO, Basic auth).
wp-blog-poster-mcp
Language: English / 日本語
A minimal local MCP server that lets Claude Desktop / Claude Code create and manage WordPress drafts through the REST API, including image uploads. Intended for hobby blogs and small sites on shared hosting where the full-featured WordPress MCP plugins are either overkill or unavailable.
Features
Nine tools are registered on the wp-blog-poster MCP server:
| Tool | Purpose |
|------|---------|
| create_draft_post | Create a single draft. status is always pinned to draft; publishing is intentionally left to a human in the admin UI. Accepts tags by name (auto-created) and categories by ID and/or name (auto-created at the root level). |
| upload_media | Upload a PNG / JPEG / GIF / WebP image to the media library. Accepts either file_path (absolute path, recommended — the server reads the file directly, avoiding base64 transport) or file_base64 (inline, small images). Returns a media_id and source_url, with optional alt_text / caption / title. |
| update_post | Partially update an existing draft post. Any non-draft target (publish / pending / private / trash) is rejected to prevent accidental edits to published content. Unspecified fields are preserved. Supports category_names like create_draft_post. |
| list_drafts | List drafts owned by the authenticated user, ordered by last modified time, with limit and offset paging. |
| delete_draft_post | Move a draft to the trash (default) or fully delete it with force_delete: true. Non-draft targets are rejected. |
| get_post | Fetch a single post by ID with the raw Gutenberg content (not the rendered HTML), so Claude can reason about block markers and match the author's voice when composing new posts. Read-only, no status restriction. |
| list_posts | Discover posts by status / search / date. Returns lightweight summaries without the body (use get_post for the full text) to keep LLM context small. |
| list_categories | List categories with ID / name / slug / parent / count. Use before create_draft_post to see the existing classification (and to pick IDs for categories). Supports parent=0 to list only root categories. |
| list_tags | List tags with ID / name / slug / count. Use to check existing tags before adding new ones via create_draft_post, avoiding near-duplicates. |
Design principles:
- Human-in-the-loop for publishing. LLMs can create and iterate on drafts, but cannot hit Publish.
- Draft-only mutations.
update_postanddelete_draft_postrefuse to touch anything that isn't currently a draft. - No SSE / long-lived HTTP. STDIO transport works in constrained shared hosting environments (execution-time limits, FCGI, etc.).
Requirements
- Node.js 22+
- Claude Desktop, Claude Code, or any other MCP client that spawns STDIO servers
- WordPress 5.6+ (for built-in Application Password support)
WordPress setup
- In Users → Add New, create a dedicated account for the MCP.
- Suggested username:
claude-poster - Role: Editor (recommended — lets the tool auto-create tags)
- Do not use an Administrator account.
- Suggested username:
- Open the new user's profile, scroll to Application Passwords, create
one (e.g. name:
claude-desktop-mcp). Copy the generated value including spaces — it's only shown once. - Verify that
https://{your-domain}/?rest_route=/wp/v2/postsorhttps://{your-domain}/wp-json/wp/v2/postsreturns JSON in a browser. If it doesn't, a security plugin (SiteGuard, Wordfence, etc.) may be blocking the REST API. - If your host runs PHP under CGI / FCGI, the
Authorizationheader is often stripped before PHP sees it. Add the following line to the site root.htaccess:SetEnvIf Authorization "(.+)" HTTP_AUTHORIZATION=$1
Install & build
npm install
npm run build
Try it locally with MCP Inspector
npm run inspector
Opens the MCP Inspector in your browser. Pick a tool, supply arguments, and run it against your WordPress site.
Claude Desktop configuration
Edit %APPDATA%\Claude\claude_desktop_config.json (Windows) or
~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
{
"mcpServers": {
"wp-blog-poster": {
"command": "node",
"args": ["/absolute/path/to/wp-blog-poster-mcp/build/index.js"],
"env": {
"WP_API_URL": "https://your-domain.com",
"WP_USERNAME": "claude-poster",
"WP_APP_PASSWORD": "xxxx xxxx xxxx xxxx xxxx xxxx"
}
}
}
}
Fully quit Claude Desktop (from the tray / menu bar — closing the window is not enough) and relaunch.
Claude Code configuration
Add the same entry under the top-level mcpServers key of
%USERPROFILE%\.claude.json (Windows) or ~/.claude.json (macOS / Linux):
{
"mcpServers": {
"wp-blog-poster": {
"type": "stdio",
"command": "node",
"args": ["/absolute/path/to/wp-blog-poster-mcp/build/index.js"],
"env": {
"WP_API_URL": "https://your-domain.com",
"WP_USERNAME": "claude-poster",
"WP_APP_PASSWORD": "xxxx xxxx xxxx xxxx xxxx xxxx"
}
}
}
}
Restart Claude Code after editing — the MCP server list is read at startup.
Environment variables
| Variable | Required | Description |
|----------|----------|-------------|
| WP_API_URL | ✅ | Site base URL, no trailing slash, HTTPS only (the server refuses to start otherwise). |
| WP_USERNAME | ✅ | WordPress username of the dedicated MCP user. |
| WP_APP_PASSWORD | ✅ | Application Password value (quote it in .env — it contains spaces). |
| WP_REQUEST_TIMEOUT_MS | — | Per-request timeout in milliseconds. Integer in [1000, 600000]. Defaults to 60000 (60 s). Raise this if you upload large media over slow shared hosting. |
Example: image + draft workflow
-
Call
upload_mediawith either:file_path: absolute path to the image on the MCP server's filesystem (recommended for any non-trivial image — the server reads the file directly, so the caller doesn't pay the JSON-RPC transport cost of a base64-encoded payload). Exactly one offile_pathorfile_base64must be provided.file_base64: inline base64 payload, for small images or callers that don't share a filesystem with the server.
Plus
filename(always required) and optionalalt_text/caption/title. You get backmedia_idandsource_url. -
Call
create_draft_postwith the body including<img src="{source_url}" alt="..." class="wp-image-{media_id}" />, and optionallyfeatured_media_id: {media_id}to set it as the featured image.
SVG and other types are rejected — only PNG / JPEG / GIF / WebP are accepted to match WordPress's default upload MIME allowlist.
Example: maintaining an existing draft
- Call
list_draftsto find the post you want to edit. Paginate withlimitandoffsetas needed. - To edit: call
update_postwithpost_idplus only the fields you want to change. Unspecified fields keep their current value. - To remove: call
delete_draft_postwithpost_id. By default the post goes to the trash (recoverable from the WordPress admin). Passforce_delete: trueto hard-delete instead.
Both update_post and delete_draft_post refuse to operate on anything
that is not currently a draft. Published posts remain off-limits by design.
Example: categorizing and tagging
Categories and tags can both be given either by ID or by name:
- Call
list_categories(optionallylist_tags) to see what already exists. Prefer reusing existing IDs / names over minting new ones. - When calling
create_draft_postorupdate_post:categories: [6, 22]— existing category IDs.category_names: ["Windows", "AI"]— names; missing ones are auto-created at the root level (noparent).- Both may be combined; the server unions them and removes duplicates.
tags: ["Claude", "MCP"]— names; missing ones are auto-created.
The server intentionally does not expose separate create_category /
create_tag tools — auto-creation happens on demand as a side effect of
posting. This keeps the classification from growing unchecked while still
letting the LLM reuse or introduce terms naturally.
Example: referencing past posts for voice / style
When you want Claude to write a new post that matches your existing voice:
- Call
list_postswithsearchor plain pagination to find a few reference articles (summaries only, no body). Note the post IDs you're interested in. - Call
get_postwith eachpost_id. You get the raw content including Gutenberg block markers (<!-- wp:paragraph -->,<!-- wp:image -->,<!-- wp:more -->, etc.), so Claude can see the actual editorial structure instead of rendered HTML. - Ask Claude to draft a new article in a similar tone, then call
create_draft_post— optionally withupload_mediafor images.
list_posts never includes the body to protect LLM context. Always chain
through get_post for full text.
The "Read more" convention
To make an archive or front page show "intro + Read more" instead of the
full body, insert the Gutenberg more block after the opening paragraph
(and optional hero image):
<!-- wp:paragraph --><p>Intro paragraph.</p><!-- /wp:paragraph -->
<!-- wp:image {"id":123} --><figure class="wp-block-image"><img src="..." class="wp-image-123" /></figure><!-- /wp:image -->
<!-- wp:more --><!--more--><!-- /wp:more -->
<!-- wp:heading --><h2>First section</h2><!-- /wp:heading -->
<!-- wp:paragraph --><p>Body continues…</p><!-- /wp:paragraph -->
Without the marker, archive rendering falls back to the theme's
the_excerpt() implementation (typically the first 55 words).
Troubleshooting
- 401 Unauthorized — Application Password is wrong, or the
Authorizationheader isn't reaching PHP. Add the.htaccessrule above. - 403 Forbidden — A security plugin is blocking
/wp-json/or?rest_route=. Add an allowlist entry for the REST route. - Claude client doesn't see the server — Check the JSON syntax in
claude_desktop_config.json/.claude.json, then fully quit and relaunch the client (tray / menu bar, not just closing the window). - Newly added tool doesn't show up — You rebuilt but didn't restart the client. The MCP child process caches the build it was spawned from.
- JSON-RPC errors on the client side — Something wrote to
stdout. This project only usesconsole.error; check any fork / patch you made. - 500 on image upload — You're over PHP's
upload_max_filesize/post_max_size, or WordPress ran out of memory during thumbnail generation.
Security notes
- Use a dedicated Editor-role account, not an Administrator.
- New posts are always
status: "draft".update_postanddelete_draft_postrefuse non-draft targets as a second line of defence. WP_API_URLmust start withhttps://— the server refuses to start against plain HTTP.- Never hardcode secrets. The Application Password belongs in
env/ secret managers, not in source.
See SECURITY.md for vulnerability reporting.