From a0aebe9c17ca4db0e1a6bd59b18541797eb71a7e Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 23 Jul 2026 19:44:33 -0400 Subject: [PATCH 1/3] fix(client): fall back to X-HTTP-Method-Override when a host 405s PUT/PATCH/DELETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some managed hosts (Convesio among them) front WordPress with a WAF that answers PUT, PATCH, and DELETE with a bare nginx 405 before the request reaches PHP. GET and POST pass, so reads and create_post kept working while every editing tool failed: update_block, update_blocks, delete_block, rewrite_post_blocks, update_post, and the two Yoast writers — eight call sites in all. The `?rest_route=` form is what the WAF singles out: the same PUT against the pretty `/wp-json/` path reaches WordPress fine. Switching to pretty permalinks was rejected because `?rest_route=` exists precisely so tool calls don't 404 on plain-permalink sites (see src/rest-url.ts). WordPress core honours `X-HTTP-Method-Override` on a POST, so a rejected request is replayed in that shape and the host is remembered for the life of the client, leaving later writes a single round-trip. The fallback is adaptive rather than blanket: hosts that accept the real verbs never see an override header, so the change is inert everywhere it isn't needed. The editing routes are registered as literal PUT / PATCH / DELETE rather than the EDITABLE alias, so a plain POST would not match them; the override header is what carries the intended verb through. Verified against production: update_post on a docs post returned 405 before and succeeds after. Claude-Session: https://claude.ai/code/session_01Njh4D63XnZhsJHMbEU7vYq --- src/client.ts | 45 +++++ tests/client-method-override.test.ts | 171 ++++++++++++++++++ .../gk-block-mcp/assets/mcp-server/index.cjs | 27 +++ wordpress-plugin/gk-block-mcp/readme.txt | 10 + 4 files changed, 253 insertions(+) create mode 100644 tests/client-method-override.test.ts diff --git a/src/client.ts b/src/client.ts index a89c6bc..aa36074 100644 --- a/src/client.ts +++ b/src/client.ts @@ -39,6 +39,15 @@ const MAX_RETRIES = 2; */ const IDEMPOTENT_METHODS = new Set(['get', 'head', 'options']); +/** + * Verbs that some hosts reject at the edge before the request reaches PHP. + * + * The plugin registers its editing routes as literal `PUT` / `PATCH` / `DELETE` + * rather than the `EDITABLE` alias, so a plain POST does not match them — the + * `X-HTTP-Method-Override` header is what carries the intended verb through. + */ +const METHOD_OVERRIDE_VERBS = new Set(['put', 'patch', 'delete']); + /** Sleep for `ms` milliseconds. */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -175,6 +184,18 @@ interface PageBlocksResponse { export class WordPressBlockClient { private client: AxiosInstance; + /** + * Set once a host proves it rejects PUT / PATCH / DELETE at the edge. + * + * Some managed hosts front WordPress with a WAF that answers those verbs with + * a 405 before the request reaches PHP, which breaks every editing tool while + * GET and POST keep working. WordPress core honours `X-HTTP-Method-Override` + * on a POST, so a rejected request is replayed in that shape. The flag makes + * the fallback sticky for the life of the client: only the first write pays + * for the rejected round-trip. + */ + private useMethodOverride = false; + /** * Create a new WordPress Block API client. * @@ -216,6 +237,18 @@ export class WordPressBlockClient { timeout: 30000, }); + // Request interceptor: once a host is known to reject real verbs, send the + // intended method as an override header on a POST instead. + this.client.interceptors.request.use((config) => { + const method = (config.method ?? 'get').toLowerCase(); + const needsOverride = this.useMethodOverride && METHOD_OVERRIDE_VERBS.has(method); + if (needsOverride) { + config.headers.set('X-HTTP-Method-Override', method.toUpperCase()); + config.method = 'post'; + } + return config; + }); + // Response interceptor: retry transient errors with exponential backoff, // then format any final error so it carries wpCode/wpData/wpStatus for // the server-level catch in src/index.ts. @@ -223,6 +256,18 @@ export class WordPressBlockClient { (r) => r, async (error: AxiosError) => { const config = error.config as (AxiosRequestConfig & { __retryCount?: number }) | undefined; + + // A 405 on a real verb comes from the edge, not WordPress: the plugin + // answers these paths. The replay carries method `post`, so a second + // 405 cannot loop back here. + const method = (config?.method ?? 'get').toLowerCase(); + const edgeRejectedVerb = + error.response?.status === 405 && METHOD_OVERRIDE_VERBS.has(method); + if (config && edgeRejectedVerb && !this.useMethodOverride) { + this.useMethodOverride = true; + return this.client.request(config); + } + if (config && isRetryable(error)) { const attempt = (config.__retryCount ?? 0) + 1; if (attempt <= MAX_RETRIES) { diff --git a/tests/client-method-override.test.ts b/tests/client-method-override.test.ts new file mode 100644 index 0000000..54ea7c2 --- /dev/null +++ b/tests/client-method-override.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as http from 'node:http'; +import { WordPressBlockClient } from '../src/client.js'; + +/** + * Coverage for the PUT/PATCH/DELETE fallback to `X-HTTP-Method-Override`. + * + * Some managed hosts front WordPress with a WAF that answers those verbs with a + * bare 405 before the request reaches PHP. GET and POST pass, so reads and + * create_post work while every editing tool fails. WordPress core honours + * `X-HTTP-Method-Override` on a POST, and the plugin registers its editing + * routes as literal PUT / PATCH / DELETE, so the header (not a plain POST) is + * what carries the intended verb through. + * + * These tests drive the real client against loopback servers that reproduce + * both host shapes and assert what goes on the wire. + */ + +interface Seen { + method: string; + url: string; + override?: string; +} + +/** A host whose edge rejects real PUT/PATCH/DELETE with a bare 405. */ +function wafServer(seen: Seen[]): http.Server { + return http.createServer((req, res) => { + const method = (req.method ?? '').toUpperCase(); + const override = req.headers['x-http-method-override'] as string | undefined; + seen.push({ method, url: req.url ?? '', override }); + + req.resume(); + req.on('end', () => { + if (method === 'PUT' || method === 'PATCH' || method === 'DELETE') { + // nginx-style rejection: HTML body, no WordPress involved. + res.writeHead(405, { 'Content-Type': 'text/html' }); + res.end('405 Not Allowed'); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }); + }); +} + +/** A host that accepts every verb, like stock WordPress behind no WAF. */ +function permissiveServer(seen: Seen[]): http.Server { + return http.createServer((req, res) => { + seen.push({ + method: (req.method ?? '').toUpperCase(), + url: req.url ?? '', + override: req.headers['x-http-method-override'] as string | undefined, + }); + req.resume(); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }); + }); +} + +async function listen(server: http.Server): Promise { + await new Promise((r) => server.listen(0, '127.0.0.1', () => r())); + return (server.address() as { port: number }).port; +} + +const clientFor = (port: number) => + new WordPressBlockClient({ + wordpress_url: `http://127.0.0.1:${port}`, + auth: { username: 'u', application_password: 'p' }, + }); + +describe('method override fallback on hosts that reject PUT/PATCH/DELETE', () => { + let server: http.Server; + let port = 0; + const seen: Seen[] = []; + + beforeAll(async () => { + server = wafServer(seen); + port = await listen(server); + }); + + afterAll(async () => { + await new Promise((r) => server.close(() => r())); + }); + + it('replays a rejected PATCH as POST carrying the override header', async () => { + seen.length = 0; + const client = clientFor(port); + + const result = await client.updatePost(123, { title: 'T' }); + expect(result).toEqual({ success: true }); + + expect(seen).toHaveLength(2); + // First attempt uses the real verb — the fallback is adaptive, not blanket. + expect(seen[0]).toMatchObject({ method: 'PATCH', override: undefined }); + // Replay carries the intent in the header so the literal PATCH route matches. + expect(seen[1]).toMatchObject({ method: 'POST', override: 'PATCH' }); + expect(seen[1].url).toBe(seen[0].url); + }); + + it('remembers the host, so later writes skip the rejected attempt', async () => { + seen.length = 0; + const client = clientFor(port); + + await client.updatePost(123, { title: 'first' }); + expect(seen.filter((s) => s.method === 'PATCH')).toHaveLength(1); + + seen.length = 0; + await client.updatePost(456, { title: 'second' }); + + // No rejected round-trip the second time. + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'POST', override: 'PATCH' }); + }); + + it('applies the override to DELETE as well as PATCH', async () => { + seen.length = 0; + const client = clientFor(port); + + await client.deleteBlock(123, 0); + + expect(seen[0]).toMatchObject({ method: 'DELETE', override: undefined }); + expect(seen[seen.length - 1]).toMatchObject({ method: 'POST', override: 'DELETE' }); + }); + + it('surfaces a genuine 405 rather than looping', async () => { + seen.length = 0; + const client = clientFor(port); + + // The replay is a POST, which this server answers 200, so the call resolves. + // What matters is that exactly one replay happens: no repeated fallback. + await client.updatePost(789, { title: 'x' }); + expect(seen.filter((s) => s.method === 'POST')).toHaveLength(1); + }); +}); + +describe('hosts that accept real verbs are left alone', () => { + let server: http.Server; + let port = 0; + const seen: Seen[] = []; + + beforeAll(async () => { + server = permissiveServer(seen); + port = await listen(server); + }); + + afterAll(async () => { + await new Promise((r) => server.close(() => r())); + }); + + it('sends a real PATCH with no override header', async () => { + seen.length = 0; + const client = clientFor(port); + + await client.updatePost(123, { title: 'T' }); + + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'PATCH', override: undefined }); + }); + + it('sends a real DELETE with no override header', async () => { + seen.length = 0; + const client = clientFor(port); + + await client.deleteBlock(123, 0); + + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'DELETE', override: undefined }); + }); +}); diff --git a/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs b/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs index 1eeb643..fb3752d 100755 --- a/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs +++ b/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs @@ -40076,6 +40076,7 @@ function mimeForFilename(filename) { } var MAX_RETRIES = 2; var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["get", "head", "options"]); +var METHOD_OVERRIDE_VERBS = /* @__PURE__ */ new Set(["put", "patch", "delete"]); function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -40102,6 +40103,17 @@ function isRetryable(error2) { } var WordPressBlockClient = class { client; + /** + * Set once a host proves it rejects PUT / PATCH / DELETE at the edge. + * + * Some managed hosts front WordPress with a WAF that answers those verbs with + * a 405 before the request reaches PHP, which breaks every editing tool while + * GET and POST keep working. WordPress core honours `X-HTTP-Method-Override` + * on a POST, so a rejected request is replayed in that shape. The flag makes + * the fallback sticky for the life of the client: only the first write pays + * for the rejected round-trip. + */ + useMethodOverride = false; /** * Create a new WordPress Block API client. * @@ -40135,10 +40147,25 @@ var WordPressBlockClient = class { }, timeout: 3e4 }); + this.client.interceptors.request.use((config3) => { + const method = (config3.method ?? "get").toLowerCase(); + const needsOverride = this.useMethodOverride && METHOD_OVERRIDE_VERBS.has(method); + if (needsOverride) { + config3.headers.set("X-HTTP-Method-Override", method.toUpperCase()); + config3.method = "post"; + } + return config3; + }); this.client.interceptors.response.use( (r4) => r4, async (error2) => { const config3 = error2.config; + const method = (config3?.method ?? "get").toLowerCase(); + const edgeRejectedVerb = error2.response?.status === 405 && METHOD_OVERRIDE_VERBS.has(method); + if (config3 && edgeRejectedVerb && !this.useMethodOverride) { + this.useMethodOverride = true; + return this.client.request(config3); + } if (config3 && isRetryable(error2)) { const attempt = (config3.__retryCount ?? 0) + 1; if (attempt <= MAX_RETRIES) { diff --git a/wordpress-plugin/gk-block-mcp/readme.txt b/wordpress-plugin/gk-block-mcp/readme.txt index ae77ea7..d7f8ae9 100644 --- a/wordpress-plugin/gk-block-mcp/readme.txt +++ b/wordpress-plugin/gk-block-mcp/readme.txt @@ -120,6 +120,16 @@ Visit Settings → Block MCP. Set the score for a namespace to less than 10 to m == Changelog == += develop = + +#### 🐛 Fixed + +* Editing tools now work on hosts whose firewall rejects the PUT, PATCH, and DELETE request types. On those hosts every editing tool failed with a "405 Not Allowed" error while reading and creating content kept working, because the firewall turned the request away before WordPress ever saw it. The assistant now retries such a request in a form those firewalls accept, and remembers the result so later edits go through on the first try. + +#### 💻 Developer Updates + +* `WordPressBlockClient` falls back to `X-HTTP-Method-Override` on a POST when a PUT, PATCH, or DELETE returns a 405, and caches that per client instance. Hosts that accept the real verbs are unaffected: the fallback engages only after a rejection. The editing routes are registered as literal `PUT` / `PATCH` / `DELETE` rather than `EDITABLE`, so the override header (not a plain POST) is what carries the intended verb. + = 2.2.0 on July 22, 2026 = This release adds tools for browsing and safely editing your theme's Full Site Editing templates, creating reusable patterns, and listing block binding sources, along with richer block-type and pattern discovery. From 4d071c304ca2011d2912df3eac55c7e47ccda9e8 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 23 Jul 2026 20:42:21 -0400 Subject: [PATCH 2/3] test(client): cover method-override edge cases; document 405 in the README Broadens the fallback coverage from 6 cases to 16. New cases pin the parts a naive replay gets wrong: the request body, query parameters, and Basic auth all have to survive the replay, or a write silently lands empty or unauthenticated. Also pins the boundaries. The fallback must not engage for a 405 on POST (not an override verb) or for a non-405 failure on PATCH, since masking a real 403 behind a replay would hide a permission problem. When a host rejects the replay too, the error surfaces after exactly two attempts rather than looping, and the override survives a 429 backoff retry so the two interceptors compose. Reverting src/client.ts fails 11 of the 16; the 5 that still pass are the ones asserting the fallback stays dormant (three permissive-host cases, two scope guards), which is the signature we want. README: adds a 405 section to Error Codes explaining that the status comes from the host's firewall rather than the plugin, that the client replays such requests itself, and what to ask the host for when even the replay is rejected. Test counts were stale (257/335); verified counts are 885 Vitest and 1,440 PHPUnit. Claude-Session: https://claude.ai/code/session_01Njh4D63XnZhsJHMbEU7vYq --- README.md | 15 +- tests/client-method-override.test.ts | 242 ++++++++++++++++++++------- 2 files changed, 192 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 76a908d..f0225c3 100644 --- a/README.md +++ b/README.md @@ -468,10 +468,10 @@ With path-based addressing, the agent would need to re-fetch between every step. Run all suites locally: ```bash -# TypeScript (Vitest) — 257 tests +# TypeScript (Vitest): 885 tests npm test -# PHP (PHPUnit, stub WP bootstrap) — 335 tests +# PHP (PHPUnit, stub WP bootstrap): 1,440 tests cd wordpress-plugin/gk-block-mcp && phpunit -c tests/phpunit.xml ``` @@ -616,6 +616,17 @@ Every REST endpoint returns errors as JSON in the standard WordPress shape `{ co | `rate_limit_exceeded` | Per-post write budget exhausted (10 writes/min, or 2 full-rewrites/min) | Wait up to 60 s and retry; consider batching with `update_blocks` | | `scan_rate_limited` | Settings-page scan triggered too frequently | Wait; this affects admin-side scans only | +### Method not allowed (HTTP 405) + +Not a plugin error: a 405 comes from the host's firewall or web server, ahead of WordPress. Some managed hosts reject `PUT`, `PATCH`, and `DELETE` outright, which is why reads and `create_post` succeed on such a host while every editing tool fails. + +The client handles this on its own. When one of those verbs is rejected, it replays the request as a `POST` carrying an `X-HTTP-Method-Override` header (the form WordPress core accepts), and remembers the host, so later edits go through on the first attempt. Hosts that accept the real verbs never see the header. + +| Symptom | What it means | How to recover | +|---|---|---| +| `Block API Error (405)` with an HTML body (e.g. `nginx`) on an editing tool | The firewall rejected both the real verb and the override replay | Ask the host to allow `PUT`, `PATCH`, and `DELETE`, or to stop stripping `X-HTTP-Method-Override`, on the WordPress REST path | +| Reads work, edits fail immediately after install | The host rejects editing verbs; the fallback could not complete | Same as above; confirm with `curl -X PATCH` against `/wp-json/gk-block-api/v1/...` | + ### Upstream (HTTP 502) | Code | When it fires | How to recover | diff --git a/tests/client-method-override.test.ts b/tests/client-method-override.test.ts index 54ea7c2..3dea134 100644 --- a/tests/client-method-override.test.ts +++ b/tests/client-method-override.test.ts @@ -13,57 +13,69 @@ import { WordPressBlockClient } from '../src/client.js'; * what carries the intended verb through. * * These tests drive the real client against loopback servers that reproduce - * both host shapes and assert what goes on the wire. + * both host shapes and assert what actually goes on the wire. */ interface Seen { method: string; url: string; override?: string; + auth?: string; + contentType?: string; + body: string; } -/** A host whose edge rejects real PUT/PATCH/DELETE with a bare 405. */ -function wafServer(seen: Seen[]): http.Server { - return http.createServer((req, res) => { - const method = (req.method ?? '').toUpperCase(); - const override = req.headers['x-http-method-override'] as string | undefined; - seen.push({ method, url: req.url ?? '', override }); +/** Decides each response, so a test can shape the host's behaviour. */ +type Policy = (req: { method: string; override?: string; nth: number }) => { + status: number; + json?: unknown; +}; - req.resume(); +/** A host that answers real PUT/PATCH/DELETE with a bare nginx-style 405. */ +const wafPolicy: Policy = ({ method }) => { + const rejected = method === 'PUT' || method === 'PATCH' || method === 'DELETE'; + return rejected ? { status: 405 } : { status: 200, json: { success: true } }; +}; + +/** A host that accepts every verb, like stock WordPress behind no WAF. */ +const permissivePolicy: Policy = () => ({ status: 200, json: { success: true } }); + +function startServer(seen: Seen[], policy: Policy): Promise<{ server: http.Server; port: number }> { + let nth = 0; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c as Buffer)); req.on('end', () => { - if (method === 'PUT' || method === 'PATCH' || method === 'DELETE') { - // nginx-style rejection: HTML body, no WordPress involved. - res.writeHead(405, { 'Content-Type': 'text/html' }); + const method = (req.method ?? '').toUpperCase(); + const override = req.headers['x-http-method-override'] as string | undefined; + seen.push({ + method, + url: req.url ?? '', + override, + auth: req.headers.authorization as string | undefined, + contentType: req.headers['content-type'] as string | undefined, + body: Buffer.concat(chunks).toString('utf8'), + }); + + nth += 1; + const { status, json } = policy({ method, override, nth }); + if (json === undefined) { + // Edge rejection: HTML body, no WordPress involved. + res.writeHead(status, { 'Content-Type': 'text/html' }); res.end('405 Not Allowed'); return; } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: true })); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(json)); }); }); -} - -/** A host that accepts every verb, like stock WordPress behind no WAF. */ -function permissiveServer(seen: Seen[]): http.Server { - return http.createServer((req, res) => { - seen.push({ - method: (req.method ?? '').toUpperCase(), - url: req.url ?? '', - override: req.headers['x-http-method-override'] as string | undefined, - }); - req.resume(); - req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: true })); - }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => + resolve({ server, port: (server.address() as { port: number }).port }), + ); }); } -async function listen(server: http.Server): Promise { - await new Promise((r) => server.listen(0, '127.0.0.1', () => r())); - return (server.address() as { port: number }).port; -} - const clientFor = (port: number) => new WordPressBlockClient({ wordpress_url: `http://127.0.0.1:${port}`, @@ -76,8 +88,7 @@ describe('method override fallback on hosts that reject PUT/PATCH/DELETE', () => const seen: Seen[] = []; beforeAll(async () => { - server = wafServer(seen); - port = await listen(server); + ({ server, port } = await startServer(seen, wafPolicy)); }); afterAll(async () => { @@ -86,52 +97,157 @@ describe('method override fallback on hosts that reject PUT/PATCH/DELETE', () => it('replays a rejected PATCH as POST carrying the override header', async () => { seen.length = 0; - const client = clientFor(port); - - const result = await client.updatePost(123, { title: 'T' }); + const result = await clientFor(port).updatePost(123, { title: 'T' }); expect(result).toEqual({ success: true }); expect(seen).toHaveLength(2); // First attempt uses the real verb — the fallback is adaptive, not blanket. expect(seen[0]).toMatchObject({ method: 'PATCH', override: undefined }); - // Replay carries the intent in the header so the literal PATCH route matches. expect(seen[1]).toMatchObject({ method: 'POST', override: 'PATCH' }); expect(seen[1].url).toBe(seen[0].url); }); - it('remembers the host, so later writes skip the rejected attempt', async () => { + it('replays a rejected PUT, the verb behind a full rewrite', async () => { seen.length = 0; - const client = clientFor(port); + await clientFor(port).replaceAllBlocks(123, [{ name: 'core/paragraph', innerHTML: '

x

' }]); + + expect(seen[0]).toMatchObject({ method: 'PUT', override: undefined }); + expect(seen[1]).toMatchObject({ method: 'POST', override: 'PUT' }); + }); + + it('replays a rejected DELETE', async () => { + seen.length = 0; + await clientFor(port).deleteBlock(123, 0); + + expect(seen[0]).toMatchObject({ method: 'DELETE', override: undefined }); + expect(seen[1]).toMatchObject({ method: 'POST', override: 'DELETE' }); + }); + + it('carries the request body through the replay unchanged', async () => { + seen.length = 0; + await clientFor(port).updatePost(123, { title: 'Keep me', excerpt: 'And me' }); + + // A dropped body would silently write nothing — assert both attempts match. + expect(JSON.parse(seen[1].body)).toEqual({ title: 'Keep me', excerpt: 'And me' }); + expect(seen[1].body).toBe(seen[0].body); + expect(seen[1].contentType).toMatch(/^application\/json/); + }); + + it('preserves query parameters through the replay', async () => { + seen.length = 0; + await clientFor(port).deleteBlock(123, 2, 3); + + expect(seen[0].url).toContain('count=3'); + expect(seen[1].url).toBe(seen[0].url); + }); + + it('preserves authentication through the replay', async () => { + seen.length = 0; + await clientFor(port).updatePost(123, { title: 'T' }); + + expect(seen[1].auth).toBeDefined(); + expect(seen[1].auth).toBe(seen[0].auth); + expect(seen[1].auth).toMatch(/^Basic /); + }); + it('remembers the host, so later writes skip the rejected attempt', async () => { + const client = clientFor(port); + seen.length = 0; await client.updatePost(123, { title: 'first' }); expect(seen.filter((s) => s.method === 'PATCH')).toHaveLength(1); seen.length = 0; await client.updatePost(456, { title: 'second' }); - - // No rejected round-trip the second time. expect(seen).toHaveLength(1); expect(seen[0]).toMatchObject({ method: 'POST', override: 'PATCH' }); }); - it('applies the override to DELETE as well as PATCH', async () => { + it('keeps the fallback per client, not global', async () => { + // One client learning the host must not silence the real-verb attempt for + // a client pointed at a different (possibly healthy) host. + const flagged = clientFor(port); + await flagged.updatePost(1, { title: 'flag me' }); + seen.length = 0; + await clientFor(port).updatePost(2, { title: 'fresh client' }); + expect(seen[0]).toMatchObject({ method: 'PATCH', override: undefined }); + }); + + it('leaves GET alone once the fallback is active', async () => { const client = clientFor(port); + await client.updatePost(1, { title: 'flag me' }); - await client.deleteBlock(123, 0); + seen.length = 0; + await client.getBlockTypes(); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'GET', override: undefined }); + }); +}); - expect(seen[0]).toMatchObject({ method: 'DELETE', override: undefined }); - expect(seen[seen.length - 1]).toMatchObject({ method: 'POST', override: 'DELETE' }); +describe('the fallback stays narrowly scoped', () => { + const seen: Seen[] = []; + let server: http.Server; + let port = 0; + + afterAll(async () => { + if (server) await new Promise((r) => server.close(() => r())); }); - it('surfaces a genuine 405 rather than looping', async () => { + it('does not engage for a 405 on POST', async () => { seen.length = 0; - const client = clientFor(port); + // A host that 405s everything, including POST. + ({ server, port } = await startServer(seen, () => ({ status: 405 }))); + + await expect(clientFor(port).createPost({ title: 'T' })).rejects.toThrow(/405/); + // POST is not an override verb: one attempt, no replay. + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'POST', override: undefined }); + await new Promise((r) => server.close(() => r())); + }); + + it('does not engage for non-405 failures on an override verb', async () => { + seen.length = 0; + ({ server, port } = await startServer(seen, () => ({ + status: 403, + json: { code: 'rest_forbidden', message: 'nope' }, + }))); + + await expect(clientFor(port).updatePost(1, { title: 'T' })).rejects.toThrow(/403/); + // A real permission error must surface, not get masked by a replay. + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'PATCH', override: undefined }); + await new Promise((r) => server.close(() => r())); + }); - // The replay is a POST, which this server answers 200, so the call resolves. - // What matters is that exactly one replay happens: no repeated fallback. - await client.updatePost(789, { title: 'x' }); - expect(seen.filter((s) => s.method === 'POST')).toHaveLength(1); + it('surfaces the error and stops when the replay is rejected too', async () => { + seen.length = 0; + // 405s the real verb AND the overridden POST — a host we cannot write to. + ({ server, port } = await startServer(seen, () => ({ status: 405 }))); + + await expect(clientFor(port).updatePost(1, { title: 'T' })).rejects.toThrow(/405/); + // Exactly one replay: the retry carries method POST, so it cannot loop. + expect(seen).toHaveLength(2); + expect(seen[0]).toMatchObject({ method: 'PATCH', override: undefined }); + expect(seen[1]).toMatchObject({ method: 'POST', override: 'PATCH' }); + await new Promise((r) => server.close(() => r())); + }); + + it('keeps the override header across a transient-error retry', async () => { + seen.length = 0; + // 405 the real verb, 429 the first replay, then succeed: the backoff retry + // must not strip the override the earlier fallback established. + ({ server, port } = await startServer(seen, ({ method, override }) => { + if (method === 'PATCH') return { status: 405 }; + if (override === 'PATCH' && seen.filter((s) => s.override === 'PATCH').length === 1) { + return { status: 429, json: { code: 'too_many_requests' } }; + } + return { status: 200, json: { success: true } }; + })); + + await expect(clientFor(port).updatePost(1, { title: 'T' })).resolves.toEqual({ success: true }); + const overridden = seen.filter((s) => s.method === 'POST' && s.override === 'PATCH'); + expect(overridden.length).toBeGreaterThanOrEqual(2); + await new Promise((r) => server.close(() => r())); }); }); @@ -141,8 +257,7 @@ describe('hosts that accept real verbs are left alone', () => { const seen: Seen[] = []; beforeAll(async () => { - server = permissiveServer(seen); - port = await listen(server); + ({ server, port } = await startServer(seen, permissivePolicy)); }); afterAll(async () => { @@ -151,21 +266,22 @@ describe('hosts that accept real verbs are left alone', () => { it('sends a real PATCH with no override header', async () => { seen.length = 0; - const client = clientFor(port); - - await client.updatePost(123, { title: 'T' }); - + await clientFor(port).updatePost(123, { title: 'T' }); expect(seen).toHaveLength(1); expect(seen[0]).toMatchObject({ method: 'PATCH', override: undefined }); }); it('sends a real DELETE with no override header', async () => { seen.length = 0; - const client = clientFor(port); - - await client.deleteBlock(123, 0); - + await clientFor(port).deleteBlock(123, 0); expect(seen).toHaveLength(1); expect(seen[0]).toMatchObject({ method: 'DELETE', override: undefined }); }); + + it('sends a real PUT with no override header', async () => { + seen.length = 0; + await clientFor(port).replaceAllBlocks(123, [{ name: 'core/paragraph', innerHTML: '

x

' }]); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'PUT', override: undefined }); + }); }); From 46b1c6e42eaf31b0eafec762d82ebc3b54f7bc7b Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 23 Jul 2026 20:50:52 -0400 Subject: [PATCH 3/3] fix(client): replay every rejected write, not only the first Concurrent writes all go out as real verbs, because none of them has seen a 405 yet. Gating the replay on `!useMethodOverride` meant the first rejection set the flag and replayed while the rest fell through, surfacing a 405 the caller could do nothing about. The flag was never what prevented recursion: a replay carries method `post`, which is not an override verb, so it cannot re-enter the branch. Requests issued after the flag is set are converted by the request interceptor and likewise arrive as `post`. Dropping the guard therefore only affects the race window. Caught by CodeRabbit on #67. The added test fails against the guarded client and passes here. Claude-Session: https://claude.ai/code/session_01Njh4D63XnZhsJHMbEU7vYq --- src/client.ts | 8 +++++--- tests/client-method-override.test.ts | 18 ++++++++++++++++++ .../gk-block-mcp/assets/mcp-server/index.cjs | 2 +- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index aa36074..62240cc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -258,12 +258,14 @@ export class WordPressBlockClient { const config = error.config as (AxiosRequestConfig & { __retryCount?: number }) | undefined; // A 405 on a real verb comes from the edge, not WordPress: the plugin - // answers these paths. The replay carries method `post`, so a second - // 405 cannot loop back here. + // answers these paths. Replay every one, not just the first: concurrent + // writes all go out as real verbs, so each needs its own replay or it + // fails spuriously. A replay arrives here as `post`, which is not an + // override verb, so a rejected replay surfaces instead of looping. const method = (config?.method ?? 'get').toLowerCase(); const edgeRejectedVerb = error.response?.status === 405 && METHOD_OVERRIDE_VERBS.has(method); - if (config && edgeRejectedVerb && !this.useMethodOverride) { + if (config && edgeRejectedVerb) { this.useMethodOverride = true; return this.client.request(config); } diff --git a/tests/client-method-override.test.ts b/tests/client-method-override.test.ts index 3dea134..ef04fb6 100644 --- a/tests/client-method-override.test.ts +++ b/tests/client-method-override.test.ts @@ -162,6 +162,24 @@ describe('method override fallback on hosts that reject PUT/PATCH/DELETE', () => expect(seen[0]).toMatchObject({ method: 'POST', override: 'PATCH' }); }); + it('replays every concurrent rejection, not just the first', async () => { + // Writes issued together all go out as real verbs, because none of them has + // seen a 405 yet. Replaying only the first would leave the rest surfacing a + // 405 the caller can do nothing about. + seen.length = 0; + const client = clientFor(port); + + const results = await Promise.all([ + client.updatePost(1, { title: 'a' }), + client.updatePost(2, { title: 'b' }), + client.updatePost(3, { title: 'c' }), + ]); + + expect(results).toEqual([{ success: true }, { success: true }, { success: true }]); + expect(seen.filter((s) => s.method === 'PATCH')).toHaveLength(3); + expect(seen.filter((s) => s.method === 'POST' && s.override === 'PATCH')).toHaveLength(3); + }); + it('keeps the fallback per client, not global', async () => { // One client learning the host must not silence the real-verb attempt for // a client pointed at a different (possibly healthy) host. diff --git a/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs b/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs index fb3752d..f33f05d 100755 --- a/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs +++ b/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs @@ -40162,7 +40162,7 @@ var WordPressBlockClient = class { const config3 = error2.config; const method = (config3?.method ?? "get").toLowerCase(); const edgeRejectedVerb = error2.response?.status === 405 && METHOD_OVERRIDE_VERBS.has(method); - if (config3 && edgeRejectedVerb && !this.useMethodOverride) { + if (config3 && edgeRejectedVerb) { this.useMethodOverride = true; return this.client.request(config3); }