Repository navigation
Releases
v0.2.1
Security release. Published to npm as @instawp/mcp-wp@0.2.1.
Install or upgrade with npm i @instawp/mcp-wp@latest. Merging the fix did not reach anyone — this tag is what published it, and an already-installed copy stays vulnerable until it is upgraded.
If you pasted the WordPress endpoint example from README.md into your site, re-paste the current version — the old snippet was weaker than the client and is not updated by npm.
Reported privately by Syed Anas Mohiuddin.
Security
execute_sql_query's read-only gate no longer lets a SELECT reach the filesystem.INTO OUTFILE,
INTO DUMPFILEandLOAD_FILE()are valid SELECT syntax and matched none of the old checks (a
startsWith('SELECT')prefix test, a multi-statement test, and a fixed DDL/DML blocklist), so
SELECT LOAD_FILE('/etc/passwd')andSELECT '<?php … ?>' INTO DUMPFILE '…/uploads/pwn.php'both
passed validation while the tool description promised "only SELECT queries are allowed". All three
are now rejected. Every check also runs against a normalized query — string literals, quoted
identifiers and comments collapsed to whitespace — so a keyword hidden inside a literal is no longer
a false positive (SELECT … WHERE title = 'how to drop a table'used to be refused) and one split
by a comment is not a bypass. A query that cannot be read unambiguously is rejected rather than
guessed at: an unterminated string or comment; a backslash-escaped quote inside a literal (its
meaning depends on the server'sNO_BACKSLASH_ESCAPESsql_mode); a/*!MySQL or/*M!MariaDB
executable comment, whose contents the server runs; or a function called by a quoted name —
SELECT `LOAD_FILE`('/etc/passwd')and itsANSI_QUOTESdouble-quoted form resolve to the builtin
on both engines (verified on MariaDB 11.8 and MySQL 8.0.46), so blanking the identifier the way an
identifier is blanked would have erased the keyword before any check saw it. That test runs on the
finished normalized query rather than on the raw text, so it holds however the name is separated from
its(— including by a comment, and including a--comment started by a control character,
which both servers accept (my_isspace || my_iscntrl) and no whitespace class covers. The--
comment rule is therefore matched as[\x00-\x20\x7f]: too wide blanks text the server executes, and
too narrow leaves text the server drops sitting between the tokens being compared. Above0x7fthat
rule is decided bycharacter_set_client, and MySQL and MariaDB disagree with each other, so a high
byte straight after--is refused rather than classified — guessing is a bypass either way. The
separator between a quoted name and its(is a different class and is widened instead, since
widening there can only reject more. The example WordPress
endpoint inREADME.mdnow repeats these checks server-side using the same scanner, since the
client's validation is not a boundary — its previous regex-based normalizer stripped comments before
string literals, which madeSELECT '#' INTO OUTFILE '/tmp/x'read as harmless.- Credential-shaped request-body keys are redacted from debug logging too. The
Data:line one
below the header bag logged the request body verbatim, andcreate_user/update_userpass their
params straight through — so adebugrun put a WordPress user'spasswordin the clear in the same
stderr stream.password,token,secret,api_keyand their siblings now log as[REDACTED],
nested objects and arrays included. Query text and result rows are still not redacted; that is
documented rather than changed. - Credential headers are redacted from debug logging.
Authorization: Basic base64(user:app-password)
was logged verbatim atdebuglevel, and base64 is reversible — soWORDPRESS_LOG_LEVEL=debugput
the WordPress application password in the clear. Despite its namelogToFilewrites to stderr, which
for a stdio MCP server the host client captures into its own log files.Authorization,Cookieand
their siblings now log as[REDACTED].
Both reported privately by Syed Anas Mohiuddin.
Fixed
execute_sql_queryno longer refuses an identifier that merely ends in a DDL keyword. The
blocklist matchedUPDATE\s+rather than the whole word, soSELECT last_update FROM …came back as
"potentially dangerous SQL statement". It now matches on word boundaries, which also catches a
keyword at the very end of a query — something the trailing-whitespace form missed.INSERT,
TRUNCATEandREPLACEare exempted when a(follows, because all three are also ordinary
read-only functions (SELECT INSERT('Quadratic',3,4,'What'),SELECT TRUNCATE(1.234,2),
SELECT REPLACE(a,'x','y')); no statement form can reach that check anyway, since the query must
already start with SELECT/WITH/EXPLAIN.REPLACEis new to the list —REPLACE tbl SET …needs no
INTO, so nothing else there would have caught it.- The
README.mdendpoint acceptsWITH …andEXPLAIN …, which the client allows and the
example rejected with a 400, and returns a 400 rather than a PHP error whenqueryis not a string. README.mdno longer claims queries are logged tologs/wordpress-api.log. No such file is
written: logging goes to stderr, only whenWORDPRESS_LOG_LEVEL=debugis set (the default is
error). Same correction inCLAUDE.md.
Added
SECURITY.md— a private disclosure route (GitHub private vulnerability reporting, plus
support@instawp.com), response targets, and scope. The reporter above had to find us through the
support desk because the repository named no security contact.
v0.2.0
npm install @instawp/mcp-wp@0.2.0
Added
WORDPRESS_USER_AGENT— sets the user-agent on every outbound request: the WordPress REST client behind all tools, the SQL endpoint, both api.wordpress.org lookups, and remote media downloads. Leave it unset (the default) and nothing changes — axios keeps sending its ownaxios/<version>. Set it when a CDN or WAF in front of your site rejects that default. An empty or whitespace-only value is treated as unset, since some edges block an empty user-agent too. (#30, #35)
Changed
execute_sql_querynow explains an HTTP 403 rather than just reporting it. A 403 can mean WordPress rejected the credentials, or that a CDN/WAF returned a challenge page before the request ever reached WordPress. The error says so and prints the (truncated) response body, which is the only thing that distinguishes the two. The bareRequest failed with status code 403is what made #28 hard to diagnose. (#35)
Security
vitestbumped to^4.1.11, clearing GHSA-5xrq-8626-4rwp (critical,vitest < 3.2.6) and four moderate/high advisories in thevite/vite-node/esbuildchain. Dev-only — none of it ships in the published package, so no consumer was exposed. (#34)
v0.1.2
First release published to npm carrying the execute_sql_query fix.
npm install @instawp/mcp-wp@0.1.2
Fixed (from 0.1.1)
execute_sql_queryno longer sendsUser-Agent: Mozilla/5.0. That bare UA is a well-known bot signature, and CDNs/WAFs (WP Engine, Cloudflare bot protection) answer it with a 403 challenge page — so the tool failed against healthy, correctly authenticated SQL endpoints while every other tool worked. (#28, #29)
Added
- Automated npm publishing. A
vX.Y.Ztag now builds, tests and publishes with provenance, then verifies the registry actually serves the new version. 0.1.1 was tagged and released here but never reached npm because publishing was manual — this release is that gap being closed. (#32) repository,homepageandbugsinpackage.json(the first is required for provenance).
Note: 0.1.1 exists as a git tag and GitHub release only; it was never published to npm. Go straight from 0.1.0 to 0.1.2.
Follow-up tracked in #30.
v0.1.1
Fixed
execute_sql_queryno longer sendsUser-Agent: Mozilla/5.0. The bareMozilla/5.0is a well-known bot signature that CDNs and WAFs (WP Engine, Cloudflare bot protection) answer with a 403 challenge page, so the tool failed against healthy, correctly authenticated SQL endpoints while every other tool worked. It now sends noUser-Agentoverride, matching the rest of the package. (#28, #29)
Upgrading: npm install @instawp/mcp-wp@0.1.1. If any origin has an allow-rule matching Mozilla/5.0 specifically, it needs to allow the axios default user-agent instead.
Follow-up tracked in #30 (a package-wide user-agent setting, and a clearer 403 error).
v0.1.0
First feature release since 0.0.3 — partial content edits, content summaries, SEO/meta correctness, multi-site SQL, and the project's first test suite.
Added
- Partial content edits —
content_edit(append/prepend/insert_before/insert_after/replace) onupdate_contentandfind_content_by_url, plusinclude_raw_content/content_rawfor exact raw-content targeting (#26) get_content_summary— minimal fixed-shape summary byidorurlfor token-cheap audit/lookup workflows (#21)- Dropped-meta warnings — write tools warn when WordPress silently drops unregistered (
show_in_rest) meta keys, e.g. Yoast/Rank Math/AIOSEO (#17) - Multi-site
site_idforexecute_sql_query(#25) - Vitest test suite + GitHub Actions CI (#18)
Fixed
- Taxonomy tools for divergent
rest_base— correct resolution via/wp/v2/taxonomies;assign_terms_to_contentnow verifies the write and errors instead of falsely reporting success on a no-op (#23) execute_sql_queryendpoint URL corrected to/wp-json/mcp/v1/query, with hardened read-only validation (#25)
Changed
- Response trimming —
yoast_head/yoast_head_jsonstripped by default (~10KB/response saved), configurable viaMCP_WP_STRIP_FIELDS(#16) - BREAKING:
assign_terms_to_contenttermsnow accepts only integer term IDs (number[]); string slugs are rejected (#23)
Docs
Full changelog: https://github.com/InstaWP/mcp-wp/blob/main/CHANGELOG.md
Note: published to npm separately — run
npm publish(or it ships when authed).