Most WordPress developers hit the same wall with AI tooling. The assistant can reason about code but can’t see it, so every review turns into paste, wait, switch windows, paste the next file, and somewhere in the middle the model loses track of which function calls which. That loop is why people end up running a local MCP server.
Emmimal P Alexander hit it while refactoring a scraper and wrote up the fix on Towards Data Science: one Python file, no pip installs, stdio and HTTP/SSE transports behind the same four file tools. The build is worth reading for the failure log alone. My take is narrower. The core finding is right, I would still never hand-roll the network half, and the sandbox question matters more than any of the code when the root is a WordPress install.
What the protocol is
MCP is JSON-RPC 2.0 over a transport. The client sends initialize, asks tools/list, then starts calling tools/call. That’s the whole handshake, and the JSON-RPC spec is a short page. Over stdio it’s one JSON object per line, flushed on every write. The author’s server exposes list_directory, read_file, search_files and get_file_info, and the demo reports protocol version 2024-11-05.
That last detail matters. The article states the spec defines exactly two transports, stdio and HTTP with Server-Sent Events. That was true of 2024-11-05. The 2025-03-26 revision deprecated HTTP+SSE and replaced it with Streamable HTTP, and the official transports page says so plainly.
Where hand-rolling bites you
The author’s conclusion, that the frameworks were solving HTTP problems MCP over stdio doesn’t have, holds up. What it skips is that a spec this young moves, and when you write your own transport you inherit the job of tracking it. The 2025-11-25 changelog kept reshaping Streamable HTTP, and the 2026-07-28 revision removed the standalone GET stream and protocol-level sessions. A hand-rolled HTTP+SSE server is on the deprecated list on the day it works.
There’s a second cost in the article that nobody prices in: the Windows stdio bug. Python rewrites every newline as carriage return plus newline on Windows text streams, which corrupts the JSON framing, and the author spent longer on the two-line msvcrt O_BINARY fix than on writing the transport itself.
So my line is: for stdio, hand-roll away. The format is stable and the whole thing is a read-a-line, write-a-line loop. Anything that listens on a socket gets the official SDK, because the SDK buys you spec tracking more than convenience.
The sandbox is the real decision
The source’s security model is one rule: resolve every path fully, refuse anything that doesn’t resolve under MCP_ROOT, no extension allowlists. For a scraper repository I’d agree with that. One boundary is easier to audit than ten special cases, and the author’s test suite does check traversal attempts, symlink escapes and Windows UNC paths on every build.
A WordPress tree changes the math, because the files you least want leaving the machine are ordinary files inside any reasonable root. wp-config.php holds database credentials and salts, and debug.log collects emails and stack traces when WP_DEBUG_LOG is on. uploads/ on a WooCommerce build has order PDFs and support attachments. If inside the root means readable, all of that gets sent to whichever provider runs the model, and it lands in whatever they log. It’s the same class of security debt AI tooling leaves in client code.
So I’d set MCP_ROOT at the plugin or theme repository, never at wp-content and never at the install root, and I’d still deny config-like filenames inside it. In PHP the guard looks like the Python one:
function bbioon_path_inside_root( string $root, string $file ): bool {
$real_root = realpath( $root );
$real_file = realpath( $file );
// false means dangling symlink or missing path, deny
if ( false === $real_root || false === $real_file ) {
return false;
}
return str_starts_with( $real_file, $real_root . DIRECTORY_SEPARATOR );
}
realpath() resolves symlinks and collapses dot-dot segments the same way Path.resolve() does in the source, so traversal tricks land on false. One honest gap: I’m not sure how this behaves across bind mounts on Linux, where a path can resolve inside the root while living on another filesystem, so don’t treat this function as the entire sandbox on shared hosting.
Defaults are the lesson
The best part of the source article is the failure log. A search tool defaulted to rglob(), got pointed at a user home directory, and scanned tens of thousands of cached files while the author hunted an infinite loop that didn’t exist. A test read a 200 MB SQLite database and froze the process for thirty seconds, so read_file got a 1 MB cap. Silent truncation got a truncated flag, because the client had no way to know it wasn’t seeing everything.
Every one of those is a default that behaved in testing and failed against a real machine. That maps straight onto plugin work. A recursive glob over uploads, a $wpdb->get_results() with no LIMIT, an option saved with autoload on because that’s the default argument, they all pass review on a small dataset and fall over on a store with real volume. The caller should have to ask for the expensive version, explicitly, every time.
What I’d ship in an MCP server
- stdio only, launched per project, no shared daemon.
- Read-only tools, no write_file and no shell. Anything generated comes back as a diff I apply myself.
- Root at the repository, config-like filenames denied, a 1 MB read cap, a truncated flag on every listing.
- If HTTP is ever needed, the official SDK with a real auth layer instead of http.server with a fixed thread pool.
The read-only rule is the one I’d defend hardest, because once a tool can write, an autocomplete engine has commit access to a client codebase, and the sandbox question becomes a recovery job. If you want this kind of layer set up for your WordPress or WooCommerce projects, with the boundary drawn in the right place, that’s work I do.