From 811dee0d202774ff5ebe40e243dcb2800f4db97f Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Wed, 20 May 2026 14:28:37 -0400 Subject: [PATCH 1/5] feat(plugin+mcp): per-site MCP serverInfo instructions addendum (BLOCK-19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets site admins paste plain-text rules under Settings → Block MCP and every connected MCP client receives them at handshake — no per-session rediscovery of conventions like callout className mapping or code-block theme. The TypeScript server fetches the addendum at startup and appends to the hard-coded baseline before `McpServer` accepts the first request. PHP plugin (gk-block-api → 1.7.0): - New `Instructions` service (`includes/class-instructions.php`) owns option storage, sanitize, length cap (2,000 chars), updated-at timestamp, and the per-IP rate-limit bucket. - New REST route `GET /gk-block-api/v1/instructions` — public by design (clients fetch before any auth-gated tool call). Returns `{ addendum, length, max_length, updated_at }` with `Cache-Control: public, max-age=60`. Rate-limited at 30 req/min per remote IP via a sliding 60-second window; IPs are SHA-256-hashed before becoming transient keys (PII minimization). - New admin section at Settings → Block MCP. Textarea with live char counter, in-page "public data" warning, and `maxlength` attribute. Settings API registers the option under the existing option group so the form's nonce + manage_options cap already cover the save path. - Reset-to-defaults + uninstall sweeps the new options and the per-IP rate-limit transients. TypeScript MCP server (@gravitykit/block-mcp → 1.7.0): - New `src/instructions.ts` with `BASELINE`, `sanitizeAddendum`, `combineInstructions`, `fetchAddendum`, `getInstructions`. The baseline string moves out of `src/index.ts` into the module so the source of truth is single. - `main()` calls `getInstructions(WORDPRESS_URL)` before connecting the transport and upgrades the `McpServer._instructions` field in place. Fetch failures (timeout, 4xx/5xx, DNS, malformed JSON) log to stderr and fall back to baseline-only — startup never blocks on the network. - `BLOCK_MCP_INSTRUCTIONS_OFF=1` env var disables the fetch entirely for offline testing and isolation. - Defense in depth: TS-side sanitize re-applies the cap and strips C0 controls, DEL, Bidi overrides, and zero-width characters before the string reaches the SDK — guards against a compromised WP install pushing prompt-injection payloads through an invisible-character attack. Tests: - 26 PHPUnit tests for `Instructions` (option round-trip, sanitize variations, length cap, timestamp tracking, per-IP rate limit, no raw IP in option_name, IPv6 support, defense-in-depth re-sanitize). - 8 PHPUnit integration tests for `GET /instructions` (response shape, unauthenticated access, Cache-Control header, dirty-option re-sanitize, 429 on rate-limit, per-IP isolation, updated_at contract). - 37 vitest tests for `src/instructions.ts` (BASELINE invariants, sanitize coverage, combine semantics, fetchAddendum behavior with axios mocked, env-var opt-out, every failure path returns empty). Full suites green: 626 PHP / 9,198 assertions; 506 TS. phpcs clean, tsc --noEmit clean, esbuild build clean. Closes BLOCK-19. --- dist/index.cjs | 111 ++++++- package.json | 2 +- src/__tests__/unit/instructions.test.ts | 308 ++++++++++++++++++ src/index.ts | 34 +- src/instructions.ts | 237 ++++++++++++++ .../gk-block-api/gk-block-api.php | 4 +- .../includes/class-instructions.php | 253 ++++++++++++++ .../includes/class-rest-controller.php | 72 ++++ .../includes/class-settings-page.php | 77 ++++- wordpress-plugin/gk-block-api/readme.txt | 27 ++ .../tests/Instructions/InstructionsTest.php | 260 +++++++++++++++ .../tests/REST/InstructionsRouteTest.php | 190 +++++++++++ wordpress-plugin/gk-block-api/uninstall.php | 9 +- 13 files changed, 1565 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/unit/instructions.test.ts create mode 100644 src/instructions.ts create mode 100644 wordpress-plugin/gk-block-api/includes/class-instructions.php create mode 100644 wordpress-plugin/gk-block-api/tests/Instructions/InstructionsTest.php create mode 100644 wordpress-plugin/gk-block-api/tests/REST/InstructionsRouteTest.php diff --git a/dist/index.cjs b/dist/index.cjs index f284fc4..5b2e921 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -39771,7 +39771,7 @@ var StdioServerTransport = class { // package.json var package_default = { name: "@gravitykit/block-mcp", - version: "1.6.0", + version: "1.7.0", description: "MCP server for WordPress block-level content management with preference-aware editing", main: "dist/index.cjs", type: "module", @@ -44424,6 +44424,96 @@ var WordPressBlockClient = class { } }; +// src/instructions.ts +var BASELINE = `Block-level WordPress CRUD. URL \u2192 post_id is resolved server-side \u2014 pass URLs directly to get_page_blocks / resolve_url; never shell out to curl or wp-json. + +After a write, the response already includes the canonical post-save snapshot (\`saved.inner_html\` + \`saved.attributes\` on update_block; \`saved\` per result on update_blocks with \`verbose:true\`). Use that for verification \u2014 do not fetch the public page to confirm edits. If you need a single-block re-read later, call get_block(ref) \u2014 same shape, no extra plumbing. + +Tier policy is per-site config, surfaced inline (block.preference) and via list_block_types. Read block-mcp://agent-guide for the editing workflow.`; +var MAX_ADDENDUM_LENGTH = 2e3; +var FETCH_TIMEOUT_MS = 3e3; +var OFF_ENV_VAR = "BLOCK_MCP_INSTRUCTIONS_OFF"; +function sanitizeAddendum(input) { + if (typeof input !== "string") { + return ""; + } + let s2 = input; + s2 = s2.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + s2 = s2.replace(/[\u200B-\u200D\u2060\uFEFF\u202A-\u202E\u2066-\u2069]/g, ""); + s2 = s2.replace(/\r\n?/g, "\n"); + s2 = s2.trim(); + if (s2.length > MAX_ADDENDUM_LENGTH) { + s2 = s2.slice(0, MAX_ADDENDUM_LENGTH); + } + return s2; +} +function combineInstructions(baseline, addendum) { + const clean = addendum.trim(); + if (clean.length === 0) { + return baseline; + } + return `${baseline} + +${clean}`; +} +async function fetchAddendum(wordpressUrl) { + if (process.env[OFF_ENV_VAR] === "1") { + return ""; + } + const base = wordpressUrl.replace(/\/+$/, ""); + const url3 = `${base}/wp-json/gk-block-api/v1/instructions`; + try { + const response = await axios_default.get(url3, { + timeout: FETCH_TIMEOUT_MS, + // Don't follow redirects to a different host — a misconfigured + // site shouldn't be able to silently forward our request elsewhere. + // The default axios behaviour follows up to 5 redirects which is + // fine for same-host HTTPS upgrade redirects. + maxRedirects: 3, + // Lower-case Accept so caches see a stable Vary key. + headers: { + Accept: "application/json" + }, + // We treat 2xx as success; everything else falls back to empty. + validateStatus: (status) => status >= 200 && status < 300 + }); + if (!response.data || typeof response.data !== "object") { + console.error( + `[block-mcp] /instructions returned non-object payload; using baseline only.` + ); + return ""; + } + return sanitizeAddendum(response.data.addendum); + } catch (err) { + const message = formatFetchError(err); + console.error(`[block-mcp] Failed to fetch /instructions (${message}); using baseline only.`); + return ""; + } +} +async function getInstructions(wordpressUrl) { + const addendum = await fetchAddendum(wordpressUrl); + return combineInstructions(BASELINE, addendum); +} +function formatFetchError(err) { + if (axios_default.isAxiosError(err)) { + const axiosErr = err; + if (axiosErr.code === "ECONNABORTED" || axiosErr.message.includes("timeout")) { + return `timeout after ${FETCH_TIMEOUT_MS}ms`; + } + if (axiosErr.response) { + return `HTTP ${axiosErr.response.status}`; + } + if (axiosErr.code) { + return axiosErr.code; + } + return axiosErr.message; + } + if (err instanceof Error) { + return err.message; + } + return String(err); +} + // src/preferences.ts function getNamespace(blockName) { return blockName.split("/")[0] ?? blockName; @@ -59058,11 +59148,11 @@ var server = new McpServer( resources: {}, prompts: {} }, - instructions: `Block-level WordPress CRUD. URL \u2192 post_id is resolved server-side \u2014 pass URLs directly to get_page_blocks / resolve_url; never shell out to curl or wp-json. - -After a write, the response already includes the canonical post-save snapshot (\`saved.inner_html\` + \`saved.attributes\` on update_block; \`saved\` per result on update_blocks with \`verbose:true\`). Use that for verification \u2014 do not fetch the public page to confirm edits. If you need a single-block re-read later, call get_block(ref) \u2014 same shape, no extra plumbing. - -Tier policy is per-site config, surfaced inline (block.preference) and via list_block_types. Read block-mcp://agent-guide for the editing workflow.` + // Baseline lives in ./instructions.ts so the source of truth is + // single. main() fetches the per-site addendum at startup and + // upgrades the instructions string in-place before the transport + // accepts the first request. + instructions: BASELINE } ); var ALL_TOOLS = [ @@ -59264,6 +59354,15 @@ First tool call: get_page_blocks({ url: ${JSON.stringify(url3)}, summary_only: t }; }); async function main2() { + const instructions = await getInstructions(WORDPRESS_URL); + const inner = server.server; + if (typeof inner._instructions !== "string") { + console.error( + "[block-mcp] MCP SDK Server._instructions field missing or wrong type \u2014 using baseline-only." + ); + } else { + inner._instructions = instructions; + } const transport = new StdioServerTransport(); await server.connect(transport); console.error("Block MCP Server running on stdio"); diff --git a/package.json b/package.json index 32fcb7e..c3fefcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gravitykit/block-mcp", - "version": "1.6.0", + "version": "1.7.0", "description": "MCP server for WordPress block-level content management with preference-aware editing", "main": "dist/index.cjs", "type": "module", diff --git a/src/__tests__/unit/instructions.test.ts b/src/__tests__/unit/instructions.test.ts new file mode 100644 index 0000000..87afe83 --- /dev/null +++ b/src/__tests__/unit/instructions.test.ts @@ -0,0 +1,308 @@ +/** + * Unit tests for src/instructions.ts (BLOCK-19). + * + * Covers the three pure helpers (sanitizeAddendum, combineInstructions, + * BASELINE) plus the network-bound fetchAddendum / getInstructions + * helpers with axios mocked. No live HTTP — these all run offline. + * + * Invariants pinned here: + * - Sanitize strips C0 + DEL + Bidi/zero-width without touching tab/LF/CR. + * - Sanitize truncates at MAX_ADDENDUM_LENGTH (defense in depth). + * - Sanitize coerces non-string input to '' (no exceptions thrown). + * - Combine returns baseline unchanged when addendum empty. + * - Combine joins with `\n\n` when addendum non-empty. + * - fetchAddendum honors BLOCK_MCP_INSTRUCTIONS_OFF=1 and skips the call. + * - fetchAddendum falls back to '' on network failure (no throw). + * - fetchAddendum sanitizes the remote payload (defense in depth). + * - getInstructions wraps fetch + combine for the public entry point. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; + +vi.mock('axios'); + +import { + BASELINE, + MAX_ADDENDUM_LENGTH, + combineInstructions, + fetchAddendum, + getInstructions, + sanitizeAddendum, +} from '../../instructions.js'; + +const ORIG_ENV = { ...process.env }; + +beforeEach(() => { + // Each test starts with a clean env to keep BLOCK_MCP_INSTRUCTIONS_OFF + // assertions isolated. Vitest doesn't reset process.env between tests + // on its own. + process.env = { ...ORIG_ENV }; + vi.clearAllMocks(); +}); + +afterEach(() => { + process.env = { ...ORIG_ENV }; +}); + +// ── BASELINE ───────────────────────────────────────────────────────────────── + +describe('BASELINE', () => { + /** + * The baseline string is the contract the SDK and every client depend + * on. Pinning its length guards against accidental edits that strip + * crucial guidance — e.g. a refactor that drops the "saved.inner_html + * is the canonical post-save snapshot" rule would degrade every + * client. If this assertion fails, you either intentionally rewrote + * the baseline (update the bound) or accidentally truncated it. + */ + it('is a non-trivial, non-empty string', () => { + expect(typeof BASELINE).toBe('string'); + expect(BASELINE.length).toBeGreaterThan(200); + }); + + it('mentions saved.inner_html guidance', () => { + expect(BASELINE).toContain('saved.inner_html'); + }); + + it('mentions URL → post_id resolution rule', () => { + expect(BASELINE).toContain('post_id is resolved server-side'); + }); +}); + +// ── sanitizeAddendum ───────────────────────────────────────────────────────── + +describe('sanitizeAddendum', () => { + it('returns empty string for non-string input', () => { + expect(sanitizeAddendum(undefined)).toBe(''); + expect(sanitizeAddendum(null)).toBe(''); + expect(sanitizeAddendum(42)).toBe(''); + expect(sanitizeAddendum({ a: 1 })).toBe(''); + expect(sanitizeAddendum([])).toBe(''); + }); + + it('passes plain ASCII through unchanged', () => { + expect(sanitizeAddendum('Use callouts for tips.')).toBe('Use callouts for tips.'); + }); + + it('preserves newlines, tabs, and indented bullets', () => { + const v = '- Top\n\t- Nested\n- Bottom'; + expect(sanitizeAddendum(v)).toBe(v); + }); + + it('strips ASCII C0 control characters except tab/LF/CR', () => { + const v = 'A\x00B\x07C\x1BD\x08E'; + expect(sanitizeAddendum(v)).toBe('ABCDE'); + }); + + it('strips DEL character (0x7F)', () => { + expect(sanitizeAddendum('foo\x7Fbar')).toBe('foobar'); + }); + + it('strips Bidi override codepoints', () => { + // U+202E RIGHT-TO-LEFT OVERRIDE — classic spoofing vector. The + // expected output is the visible text only, with the override gone. + const v = 'allow‮gnirts‬'; + const cleaned = sanitizeAddendum(v); + expect(cleaned).not.toContain('‮'); + expect(cleaned).not.toContain('‬'); + expect(cleaned).toContain('allow'); + }); + + it('strips zero-width characters', () => { + // ZWSP (U+200B), ZWNJ (U+200C), ZWJ (U+200D), BOM (U+FEFF). + const v = 'visi​ble‌‍ text'; + expect(sanitizeAddendum(v)).toBe('visible text'); + }); + + it('normalizes CRLF and CR to LF', () => { + expect(sanitizeAddendum('a\r\nb\rc')).toBe('a\nb\nc'); + }); + + it('trims outer whitespace', () => { + expect(sanitizeAddendum('\n\n inner content \n')).toBe('inner content'); + }); + + it('truncates to MAX_ADDENDUM_LENGTH', () => { + const long = 'A'.repeat(MAX_ADDENDUM_LENGTH + 500); + expect(sanitizeAddendum(long)).toHaveLength(MAX_ADDENDUM_LENGTH); + }); + + it('accepts an input exactly at the cap', () => { + const exact = 'B'.repeat(MAX_ADDENDUM_LENGTH); + expect(sanitizeAddendum(exact)).toHaveLength(MAX_ADDENDUM_LENGTH); + }); + + it('returns empty for empty string', () => { + expect(sanitizeAddendum('')).toBe(''); + }); +}); + +// ── combineInstructions ────────────────────────────────────────────────────── + +describe('combineInstructions', () => { + it('returns baseline unchanged when addendum is empty', () => { + expect(combineInstructions(BASELINE, '')).toBe(BASELINE); + }); + + it('returns baseline unchanged when addendum is whitespace only', () => { + expect(combineInstructions(BASELINE, ' \n\n \t ')).toBe(BASELINE); + }); + + it('joins baseline and addendum with a blank line', () => { + const result = combineInstructions('BASE', 'ADD'); + expect(result).toBe('BASE\n\nADD'); + }); + + it('trims the addendum before joining', () => { + const result = combineInstructions('BASE', ' ADD '); + expect(result).toBe('BASE\n\nADD'); + }); + + it('preserves multi-line addenda verbatim', () => { + const addendum = '- Rule one.\n- Rule two.'; + const result = combineInstructions('BASE', addendum); + expect(result).toBe(`BASE\n\n${addendum}`); + }); +}); + +// ── fetchAddendum (axios mocked) ───────────────────────────────────────────── + +describe('fetchAddendum', () => { + it('returns empty string when BLOCK_MCP_INSTRUCTIONS_OFF=1 (no HTTP call)', async () => { + process.env.BLOCK_MCP_INSTRUCTIONS_OFF = '1'; + const result = await fetchAddendum('https://example.com'); + expect(result).toBe(''); + expect(vi.mocked(axios.get)).not.toHaveBeenCalled(); + }); + + it('treats any value other than 1 in BLOCK_MCP_INSTRUCTIONS_OFF as off-not-set', async () => { + process.env.BLOCK_MCP_INSTRUCTIONS_OFF = '0'; + vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'hi' } }); + const result = await fetchAddendum('https://example.com'); + expect(result).toBe('hi'); + }); + + it('issues GET to /wp-json/gk-block-api/v1/instructions', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'use info callout' } }); + await fetchAddendum('https://example.com'); + expect(vi.mocked(axios.get)).toHaveBeenCalledOnce(); + const [url] = vi.mocked(axios.get).mock.calls[0]!; + expect(url).toBe('https://example.com/wp-json/gk-block-api/v1/instructions'); + }); + + it('normalizes trailing slashes in the base URL', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'x' } }); + await fetchAddendum('https://example.com///'); + const [url] = vi.mocked(axios.get).mock.calls[0]!; + expect(url).toBe('https://example.com/wp-json/gk-block-api/v1/instructions'); + }); + + it('passes a short timeout and JSON Accept header', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'x' } }); + await fetchAddendum('https://example.com'); + const [, config] = vi.mocked(axios.get).mock.calls[0]!; + expect(config).toBeDefined(); + expect(config!.timeout).toBeGreaterThan(0); + expect(config!.timeout).toBeLessThanOrEqual(10_000); + expect(config!.headers).toMatchObject({ Accept: 'application/json' }); + }); + + it('returns the sanitized addendum on success', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ + data: { addendum: 'A\x00B' }, + }); + const result = await fetchAddendum('https://example.com'); + expect(result).toBe('AB'); + }); + + it('truncates an overly long remote response to MAX_ADDENDUM_LENGTH', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ + data: { addendum: 'X'.repeat(MAX_ADDENDUM_LENGTH + 1000) }, + }); + const result = await fetchAddendum('https://example.com'); + expect(result).toHaveLength(MAX_ADDENDUM_LENGTH); + }); + + it('strips Bidi overrides served by a compromised WP install', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ + data: { addendum: 'evil‮block' }, + }); + const result = await fetchAddendum('https://example.com'); + expect(result).not.toContain('‮'); + }); + + it('returns empty when the response is missing the addendum field', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { length: 0 } }); + expect(await fetchAddendum('https://example.com')).toBe(''); + }); + + it('returns empty when the response body is not an object', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: 'malformed' }); + expect(await fetchAddendum('https://example.com')).toBe(''); + }); + + it('returns empty when axios rejects (network error)', async () => { + vi.mocked(axios.get).mockRejectedValueOnce(new Error('ECONNREFUSED')); + // Suppress the stderr noise from the rejection log line. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(await fetchAddendum('https://example.com')).toBe(''); + spy.mockRestore(); + }); + + it('returns empty when the server replies non-2xx', async () => { + // axios with validateStatus throws on non-2xx by default; emulate that. + const err = Object.assign(new Error('Request failed with status code 500'), { + isAxiosError: true, + response: { status: 500, data: '' }, + }); + vi.mocked(axios.get).mockRejectedValueOnce(err); + vi.mocked(axios.isAxiosError).mockReturnValue(true); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(await fetchAddendum('https://example.com')).toBe(''); + spy.mockRestore(); + }); + + it('never throws — every failure path returns empty', async () => { + vi.mocked(axios.get).mockImplementation(() => { + throw new Error('synchronous boom'); + }); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + await expect(fetchAddendum('https://example.com')).resolves.toBe(''); + spy.mockRestore(); + }); +}); + +// ── getInstructions (the public entry point) ───────────────────────────────── + +describe('getInstructions', () => { + it('returns baseline alone when the site is unreachable', async () => { + vi.mocked(axios.get).mockRejectedValueOnce(new Error('ENOTFOUND')); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = await getInstructions('https://example.com'); + expect(result).toBe(BASELINE); + spy.mockRestore(); + }); + + it('returns baseline alone when BLOCK_MCP_INSTRUCTIONS_OFF=1', async () => { + process.env.BLOCK_MCP_INSTRUCTIONS_OFF = '1'; + const result = await getInstructions('https://example.com'); + expect(result).toBe(BASELINE); + expect(vi.mocked(axios.get)).not.toHaveBeenCalled(); + }); + + it('returns baseline + addendum joined with a blank line', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ + data: { addendum: 'CUSTOM RULE: prefer is-style-callout-info.' }, + }); + const result = await getInstructions('https://example.com'); + expect(result.startsWith(BASELINE)).toBe(true); + expect(result.endsWith('CUSTOM RULE: prefer is-style-callout-info.')).toBe(true); + expect(result).toContain('\n\nCUSTOM RULE'); + }); + + it('returns baseline alone when the site returns empty addendum', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: '' } }); + expect(await getInstructions('https://example.com')).toBe(BASELINE); + }); +}); diff --git a/src/index.ts b/src/index.ts index 39347d5..cdca9ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ import { GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { WordPressBlockClient } from './client.js'; +import { BASELINE as INSTRUCTIONS_BASELINE, getInstructions } from './instructions.js'; import { DISCOVERY_TOOLS, handleDiscoveryTool } from './tools/discovery.js'; import { READ_TOOLS, handleReadTool } from './tools/read.js'; import { WRITE_TOOLS, handleWriteTool } from './tools/write.js'; @@ -99,12 +100,11 @@ const server = new McpServer( resources: {}, prompts: {}, }, - instructions: - `Block-level WordPress CRUD. URL → post_id is resolved server-side — pass URLs directly to get_page_blocks / resolve_url; never shell out to curl or wp-json. - -After a write, the response already includes the canonical post-save snapshot (\`saved.inner_html\` + \`saved.attributes\` on update_block; \`saved\` per result on update_blocks with \`verbose:true\`). Use that for verification — do not fetch the public page to confirm edits. If you need a single-block re-read later, call get_block(ref) — same shape, no extra plumbing. - -Tier policy is per-site config, surfaced inline (block.preference) and via list_block_types. Read block-mcp://agent-guide for the editing workflow.`, + // Baseline lives in ./instructions.ts so the source of truth is + // single. main() fetches the per-site addendum at startup and + // upgrades the instructions string in-place before the transport + // accepts the first request. + instructions: INSTRUCTIONS_BASELINE, } ); @@ -400,6 +400,28 @@ server.server.setRequestHandler(GetPromptRequestSchema, async (request) => { // ============================================ async function main(): Promise { + // Fetch the per-site instructions addendum BEFORE accepting the first + // request so the initialize handshake includes the combined string. + // getInstructions never throws — on any failure it logs to stderr and + // returns the baseline only. + const instructions = await getInstructions(WORDPRESS_URL as string); + + // The MCP SDK stores the instructions string on a private field + // (`_instructions`) of the underlying Server class. It's read once, + // at the initialize response (sdk/server/index.js:282), so updating + // it any time before `connect()` returns the new value to the next + // client to handshake. Gating with a runtime check so a future SDK + // rename surfaces as a clear stderr message rather than a silent fall- + // through to baseline. + const inner = server.server as unknown as { _instructions?: unknown }; + if (typeof inner._instructions !== 'string') { + console.error( + '[block-mcp] MCP SDK Server._instructions field missing or wrong type — using baseline-only.' + ); + } else { + inner._instructions = instructions; + } + const transport = new StdioServerTransport(); await server.connect(transport); console.error('Block MCP Server running on stdio'); diff --git a/src/instructions.ts b/src/instructions.ts new file mode 100644 index 0000000..8f5ff66 --- /dev/null +++ b/src/instructions.ts @@ -0,0 +1,237 @@ +/** + * Per-site `serverInfo.instructions` assembly (BLOCK-19). + * + * The MCP server ships a hard-coded baseline string in `BASELINE`. At + * startup, the server fetches an admin-editable addendum from the + * connected WordPress site via `GET /gk-block-api/v1/instructions` and + * appends it to the baseline. The combined string is passed to the + * `McpServer` constructor's `instructions` field, where the MCP SDK + * surfaces it to clients during the initialize handshake. + * + * Two opt-out paths: + * + * - The fetch wraps every step in try/catch and falls back to baseline- + * only on any failure (DNS, timeout, 404, 5xx, malformed JSON). The + * server never blocks startup on the fetch. + * - The `BLOCK_MCP_INSTRUCTIONS_OFF=1` env var skips the fetch entirely + * — useful for offline testing and isolation. + * + * Threat model: the remote addendum comes from the WP options table and + * is sanitized server-side, but a compromised WP install could still + * push malicious instructions. This module performs defense-in-depth + * sanitization (length cap, control-char strip, suspicious-pattern + * filter) before passing the value to the SDK. The primary control + * remains "don't connect Block MCP to a WP site you don't trust." + */ + +import axios, { AxiosError } from 'axios'; + +/** + * The baseline instructions string. + * + * This is the canonical, version-controlled guidance every MCP client + * receives, regardless of which WordPress site the server connects to. + * Site-specific rules go in the WP option served by `/instructions`. + * + * Kept verbatim in sync with the inline literal that previously lived + * at `src/index.ts:102-107` so existing clients see no change in + * behaviour when no addendum is set. + */ +export const BASELINE = `Block-level WordPress CRUD. URL → post_id is resolved server-side — pass URLs directly to get_page_blocks / resolve_url; never shell out to curl or wp-json. + +After a write, the response already includes the canonical post-save snapshot (\`saved.inner_html\` + \`saved.attributes\` on update_block; \`saved\` per result on update_blocks with \`verbose:true\`). Use that for verification — do not fetch the public page to confirm edits. If you need a single-block re-read later, call get_block(ref) — same shape, no extra plumbing. + +Tier policy is per-site config, surfaced inline (block.preference) and via list_block_types. Read block-mcp://agent-guide for the editing workflow.`; + +/** + * Server-side cap on the addendum length. Mirrors `Instructions::MAX_LENGTH` + * in the PHP plugin. Truncation is silent — `McpServer` doesn't care, and + * surfacing an error would block startup over a value that's already on + * the wire. + */ +export const MAX_ADDENDUM_LENGTH = 2000; + +/** + * Timeout for the addendum fetch. Short, because we don't want startup + * to feel laggy when the site is offline — falling back to baseline-only + * is preferable to hanging. + */ +const FETCH_TIMEOUT_MS = 3000; + +/** + * Env var that disables the fetch entirely. Useful for offline tests, + * isolation, or when running against an internal site whose admin you + * don't trust to manage the addendum. + */ +const OFF_ENV_VAR = 'BLOCK_MCP_INSTRUCTIONS_OFF'; + +/** + * Response envelope from `GET /gk-block-api/v1/instructions`. + * + * `length` and `max_length` are advisory — the TypeScript side re-checks + * the actual addendum string and enforces its own truncation. + */ +export interface InstructionsResponse { + addendum: string; + length?: number; + max_length?: number; + updated_at?: number; +} + +/** + * Sanitize a remote addendum before handing it to the MCP SDK. + * + * Defense in depth — the PHP side already does most of this, but we + * cannot assume the WordPress install has the plugin version that + * sanitizes on the read path. Steps: + * + * 1. Cast to string; non-strings (objects, arrays, null) return empty. + * 2. Strip ASCII C0 control characters except `\t` (tab), `\n` (LF), + * `\r` (CR) — same set the PHP side keeps. + * 3. Strip the DEL character (`\x7F`). + * 4. Strip the unicode Bidi override and zero-width characters that + * have been used in prompt-injection PoCs to hide text from human + * review while still being visible to the LLM. + * 5. Normalize CRLF/CR to LF. + * 6. Trim outer whitespace. + * 7. Truncate to `MAX_ADDENDUM_LENGTH` characters. + */ +export function sanitizeAddendum(input: unknown): string { + if (typeof input !== 'string') { + return ''; + } + let s = input; + + // ASCII C0/C1 control chars except tab/LF/CR + DEL. + s = s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + + // Unicode Bidi overrides + zero-width chars. These render invisibly in + // some clients but still reach the LLM tokenizer, making them a + // prompt-injection vector. Stripped via explicit escape sequences so + // the source is readable and the set is unambiguous. + // + // U+200B ZERO WIDTH SPACE + // U+200C ZERO WIDTH NON-JOINER + // U+200D ZERO WIDTH JOINER + // U+2060 WORD JOINER + // U+FEFF ZERO WIDTH NO-BREAK SPACE / BOM + // U+202A..U+202E LRE/RLE/PDF/LRO/RLO Bidi overrides + // U+2066..U+2069 LRI/RLI/FSI/PDI Bidi isolates + s = s.replace(/[\u200B-\u200D\u2060\uFEFF\u202A-\u202E\u2066-\u2069]/g, ''); + + // CRLF / CR → LF. + s = s.replace(/\r\n?/g, '\n'); + + s = s.trim(); + + if (s.length > MAX_ADDENDUM_LENGTH) { + s = s.slice(0, MAX_ADDENDUM_LENGTH); + } + + return s; +} + +/** + * Combine the baseline with an optional addendum into the final string + * passed to `McpServer`. When the addendum is empty, returns the + * baseline unchanged — clients that don't customize see no marker + * polluting the handshake. + * + * Two newlines between baseline and addendum so markdown renderers in + * MCP clients see them as separate paragraphs / sections. + */ +export function combineInstructions(baseline: string, addendum: string): string { + const clean = addendum.trim(); + if (clean.length === 0) { + return baseline; + } + return `${baseline}\n\n${clean}`; +} + +/** + * Fetch the addendum from the WordPress site's `/instructions` endpoint. + * + * Public, unauthenticated request (the endpoint is public-by-design). + * On any failure — network error, non-200 response, malformed JSON, + * missing addendum field — returns an empty string. Logs the cause to + * stderr so admins can debug, but never throws. + * + * Honors `BLOCK_MCP_INSTRUCTIONS_OFF=1` by returning empty immediately + * (no HTTP call). + * + * @param wordpressUrl Base URL of the WordPress site (no trailing slash + * enforced — the function normalizes both forms). + */ +export async function fetchAddendum(wordpressUrl: string): Promise { + if (process.env[OFF_ENV_VAR] === '1') { + return ''; + } + + // Normalize trailing slash and resolve relative to wp-json. + const base = wordpressUrl.replace(/\/+$/, ''); + const url = `${base}/wp-json/gk-block-api/v1/instructions`; + + try { + const response = await axios.get(url, { + timeout: FETCH_TIMEOUT_MS, + // Don't follow redirects to a different host — a misconfigured + // site shouldn't be able to silently forward our request elsewhere. + // The default axios behaviour follows up to 5 redirects which is + // fine for same-host HTTPS upgrade redirects. + maxRedirects: 3, + // Lower-case Accept so caches see a stable Vary key. + headers: { + Accept: 'application/json', + }, + // We treat 2xx as success; everything else falls back to empty. + validateStatus: (status) => status >= 200 && status < 300, + }); + + if (!response.data || typeof response.data !== 'object') { + console.error( + `[block-mcp] /instructions returned non-object payload; using baseline only.` + ); + return ''; + } + + return sanitizeAddendum(response.data.addendum); + } catch (err) { + // Don't crash startup — empty addendum, log to stderr, continue. + const message = formatFetchError(err); + console.error(`[block-mcp] Failed to fetch /instructions (${message}); using baseline only.`); + return ''; + } +} + +/** + * Convenience helper: fetch + combine in one call. The default entry + * point used by `src/index.ts`. + */ +export async function getInstructions(wordpressUrl: string): Promise { + const addendum = await fetchAddendum(wordpressUrl); + return combineInstructions(BASELINE, addendum); +} + +/** + * Map axios / network errors into a short stderr message. Kept private + * because callers shouldn't depend on the exact wording. + */ +function formatFetchError(err: unknown): string { + if (axios.isAxiosError(err)) { + const axiosErr = err as AxiosError; + if (axiosErr.code === 'ECONNABORTED' || axiosErr.message.includes('timeout')) { + return `timeout after ${FETCH_TIMEOUT_MS}ms`; + } + if (axiosErr.response) { + return `HTTP ${axiosErr.response.status}`; + } + if (axiosErr.code) { + return axiosErr.code; + } + return axiosErr.message; + } + if (err instanceof Error) { + return err.message; + } + return String(err); +} diff --git a/wordpress-plugin/gk-block-api/gk-block-api.php b/wordpress-plugin/gk-block-api/gk-block-api.php index f643e12..5a8c405 100644 --- a/wordpress-plugin/gk-block-api/gk-block-api.php +++ b/wordpress-plugin/gk-block-api/gk-block-api.php @@ -3,7 +3,7 @@ * Plugin Name: GK Block API * Plugin URI: https://www.gravitykit.com * Description: REST API for block-level CRUD operations with smart preferences for AI agents. - * Version: 1.6.1 + * Version: 1.7.0 * Author: GravityKit * Author URI: https://www.gravitykit.com * License: GPL-2.0-or-later @@ -23,7 +23,7 @@ exit; } -define( 'GK_BLOCK_API_VERSION', '1.6.1' ); +define( 'GK_BLOCK_API_VERSION', '1.7.0' ); define( 'GK_BLOCK_API_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'GK_BLOCK_API_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); diff --git a/wordpress-plugin/gk-block-api/includes/class-instructions.php b/wordpress-plugin/gk-block-api/includes/class-instructions.php new file mode 100644 index 0000000..d82c158 --- /dev/null +++ b/wordpress-plugin/gk-block-api/includes/class-instructions.php @@ -0,0 +1,253 @@ +0 with empty addendum). + * + * @return int + */ + public static function get_updated_at(): int { + return (int) get_option( self::UPDATED_AT_OPTION, 0 ); + } + + /** + * Save the addendum. Sanitizes input, enforces length cap, updates the + * companion timestamp atomically. + * + * @param mixed $value Raw input. + * + * @return true|\WP_Error True on success; WP_Error('addendum_too_long') + * when input exceeds MAX_LENGTH even after sanitize. + */ + public static function set_addendum( $value ) { + $clean = self::sanitize( $value ); + + // Length check fires after sanitize so HTML/shortcode stripping + // doesn't accidentally push a 1990-char input over the limit; the + // 2000-char budget is the post-sanitize size that reaches clients. + if ( strlen( $clean ) > self::MAX_LENGTH ) { + return new \WP_Error( + 'addendum_too_long', + sprintf( + /* translators: 1: max length, 2: submitted length */ + __( 'Instructions addendum is too long: %2$d characters (max %1$d).', 'gk-block-api' ), + self::MAX_LENGTH, + strlen( $clean ) + ), + array( 'status' => 400 ) + ); + } + + update_option( self::OPTION_KEY, $clean, false ); + update_option( self::UPDATED_AT_OPTION, time(), false ); + return true; + } + + /** + * Sanitize an addendum value. + * + * Strips HTML tags, PHP, shortcodes, and ASCII control characters + * (except newline/tab — kept so markdown bullets and indentation + * survive). Truncates to MAX_LENGTH as defense in depth. + * + * What this does NOT do: + * + * - Render markdown. Output is sent verbatim to MCP clients which + * handle their own rendering. + * - Strip unicode control characters beyond the C0/C1 ASCII ranges. + * The TypeScript side does an additional pass for those (Bidi marks, + * zero-width chars) where they have higher prompt-injection signal. + * + * @param mixed $value Raw input. + * + * @return string Sanitized string (may be empty). + */ + public static function sanitize( $value ): string { + if ( is_array( $value ) || is_object( $value ) ) { + return ''; + } + $str = (string) $value; + if ( '' === $str ) { + return ''; + } + + // Strip HTML/PHP tags first — `sanitize_textarea_field()` does this + // internally but also nukes newlines we want to keep, so do it + // manually. `wp_strip_all_tags( $str, false )` does NOT collapse + // whitespace (the second arg defaults true and we override). + $str = wp_strip_all_tags( $str, false ); + + // Strip WordPress shortcodes. Defense against an admin pasting + // `[do_something]` and being surprised when it doesn't execute. + // It cannot — we never call `do_shortcode()` on this value — but + // stripping eliminates the question. + $str = strip_shortcodes( $str ); + + // Strip C0 control characters except \t (0x09), \n (0x0A), \r (0x0D) + // — those are needed for markdown indentation and bullet lists. + // Also strips the DEL character (0x7F). + $str = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $str ); + + // Normalize CRLF / CR line endings to LF. Saves one branch on the + // TypeScript side and matches MCP client expectations. + $str = str_replace( array( "\r\n", "\r" ), "\n", (string) $str ); + + // Trim outer whitespace — leading/trailing newlines from a paste + // don't carry meaning and waste budget. + $str = trim( $str ); + + // Length cap last so it operates on the post-sanitize size. + if ( strlen( $str ) > self::MAX_LENGTH ) { + $str = substr( $str, 0, self::MAX_LENGTH ); + } + + return $str; + } + + /** + * Sanitize callback for `register_setting()`. + * + * Wraps `sanitize()` + length cap so the Settings API does the right + * thing on form submit without the caller needing to know about + * `set_addendum()`. Over-length input is silently truncated here (no + * way to surface a WP_Error through the Settings API on a per-field + * basis without `add_settings_error()`, which the rest of this plugin + * doesn't use). + * + * @param mixed $value Raw input from $_POST. + * + * @return string + */ + public static function sanitize_callback( $value ): string { + $clean = self::sanitize( $value ); + + // Touch the timestamp option so REST consumers see the save even + // when the value didn't change (admins re-saving to refresh). + update_option( self::UPDATED_AT_OPTION, time(), false ); + + return $clean; + } + + /** + * Check (and record) the per-IP rate limit for the public read endpoint. + * + * Uses a 60-second sliding-window transient keyed by the remote IP. Each + * call records the current timestamp; when the count within the last 60s + * exceeds RATE_LIMIT_PER_MIN, returns false (caller should respond 429). + * + * @param string $ip Remote IP from REST_Server::get_raw_data() context + * (caller passes `$_SERVER['REMOTE_ADDR']`). + * + * @return bool True when the request is permitted (rate budget remains); + * false when the budget is exhausted. + */ + public static function check_rate_limit( string $ip ): bool { + // IP is opaque to us — only used as a transient key. Hash so the + // raw IP doesn't sit in the options table (PII minimization), and + // to keep the key short and bounded (IPv6 strings can hit 39 + // chars; the hash is 12). + $key = 'gk_block_api_instr_rl_' . substr( hash( 'sha256', $ip ), 0, 12 ); + + $now = time(); + $window = 60; + $bucket = get_transient( $key ); + if ( ! is_array( $bucket ) ) { + $bucket = array(); + } + + // Drop entries outside the rolling window. + $bucket = array_values( + array_filter( + $bucket, + static function ( $ts ) use ( $now, $window ) { + return is_numeric( $ts ) && ( $now - (int) $ts ) < $window; + } + ) + ); + + if ( count( $bucket ) >= self::RATE_LIMIT_PER_MIN ) { + return false; + } + + $bucket[] = $now; + // Slightly longer TTL than the window so the bucket survives until + // every entry has aged out, even if no further request lands. + set_transient( $key, $bucket, $window * 2 ); + return true; + } +} diff --git a/wordpress-plugin/gk-block-api/includes/class-rest-controller.php b/wordpress-plugin/gk-block-api/includes/class-rest-controller.php index c562a99..cdb2616 100644 --- a/wordpress-plugin/gk-block-api/includes/class-rest-controller.php +++ b/wordpress-plugin/gk-block-api/includes/class-rest-controller.php @@ -909,6 +909,22 @@ public function register_routes() { 'permission_callback' => array( $this, 'check_upload_permissions' ), ) ); + + // Per-site MCP serverInfo instructions addendum. PUBLIC: the value + // reaches every connected MCP client at handshake before any + // tool-call auth, so this endpoint must be readable unauthenticated. + // Rate-limited at Instructions::RATE_LIMIT_PER_MIN per IP to deter + // scraping. Admins MUST NOT put secrets in the option value; the UI + // copy on the settings page warns about this. + register_rest_route( + self::NAMESPACE, + '/instructions', + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_instructions' ), + 'permission_callback' => '__return_true', + ) + ); } /** @@ -961,6 +977,62 @@ public function check_upload_permissions() { return true; } + /** + * GET /instructions — serve the site's MCP serverInfo addendum. + * + * Public endpoint by design. The MCP server fetches this at startup + * (before any tool call), combines with its hard-coded baseline, and + * passes the result to `McpServer`'s `instructions` field. + * + * Response shape: `{ addendum, length, max_length, updated_at }`. Empty + * addendum is returned as an empty string (NOT 404) so the client + * doesn't have to special-case missing-vs-empty. + * + * Cache-Control: `public, max-age=60` — fresh enough that admin edits + * land quickly in dev; long enough that legitimate clients don't hammer + * the endpoint. Caller-side cache key is the WordPress URL + path. + * + * @param \WP_REST_Request $request Request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public function get_instructions( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- WP_REST_Server requires the request object on every callback signature even when the route has no params. + try { + $ip = isset( $_SERVER['REMOTE_ADDR'] ) + ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) + : ''; + + if ( '' !== $ip && ! Instructions::check_rate_limit( $ip ) ) { + return new \WP_Error( + 'rate_limit_exceeded', + __( 'Too many requests. Try again in a minute.', 'gk-block-api' ), + array( 'status' => 429 ) + ); + } + + $addendum = Instructions::get_addendum(); + $updated_at = Instructions::get_updated_at(); + + $response = rest_ensure_response( + array( + 'addendum' => $addendum, + 'length' => strlen( $addendum ), + 'max_length' => Instructions::MAX_LENGTH, + 'updated_at' => $updated_at, + ) + ); + + // Short TTL public cache. Surrogates and reverse proxies are + // welcome to cache; private intermediaries should not because + // every visitor receives the same payload. + $response->header( 'Cache-Control', 'public, max-age=60' ); + + return $response; + } catch ( \Throwable $e ) { + return $this->handle_error( $e ); + } + } + // ========================================================================= // v1.2 — Docs lifecycle handlers. // ========================================================================= diff --git a/wordpress-plugin/gk-block-api/includes/class-settings-page.php b/wordpress-plugin/gk-block-api/includes/class-settings-page.php index 5049b15..ba50646 100644 --- a/wordpress-plugin/gk-block-api/includes/class-settings-page.php +++ b/wordpress-plugin/gk-block-api/includes/class-settings-page.php @@ -116,7 +116,21 @@ public function register_settings() { ) ); - // 4. Global media-uploads kill-switch. Stored as the string '0' or + // 4. MCP server instructions addendum (BLOCK-19). + // Stored as a plain-text string. The Instructions class handles + // sanitize + length-cap + timestamp; the REST endpoint serves it + // unauthenticated to MCP clients at handshake. + register_setting( + self::OPTION_GROUP, + Instructions::OPTION_KEY, + array( + 'type' => 'string', + 'sanitize_callback' => array( Instructions::class, 'sanitize_callback' ), + 'default' => '', + ) + ); + + // 5. Global media-uploads kill-switch. Stored as the string '0' or // '1' rather than a PHP bool because update_option() can't // reliably persist boolean false when the option is missing // (the equality check against the "doesn't exist → false" default @@ -298,6 +312,8 @@ public function handle_reset() { delete_option( self::DUAL_MANUAL_OPTION ); delete_option( Media_Manager::UPLOADS_OPTION ); delete_option( Block_Inventory::STORAGE_MODES_OPTION ); + delete_option( Instructions::OPTION_KEY ); + delete_option( Instructions::UPDATED_AT_OPTION ); delete_transient( Block_Inventory::CACHE_KEY ); // Per-post rate-limit transients accumulate per write activity. Sweep @@ -306,7 +322,9 @@ public function handle_reset() { $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_gk_block_api_rate_%' - OR option_name LIKE '_transient_timeout_gk_block_api_rate_%'" + OR option_name LIKE '_transient_timeout_gk_block_api_rate_%' + OR option_name LIKE '_transient_gk_block_api_instr_rl_%' + OR option_name LIKE '_transient_timeout_gk_block_api_instr_rl_%'" ); nocache_headers(); @@ -343,6 +361,8 @@ public function render_page() { $scan_results = (array) get_option( Block_Inventory::STORAGE_MODES_OPTION, array() ); $uploads_enabled = \GravityKit\BlockAPI\Media_Manager::uploads_enabled(); $uploads_option = \GravityKit\BlockAPI\Media_Manager::UPLOADS_OPTION; + $instructions_val = Instructions::get_addendum(); + $instructions_max = Instructions::MAX_LENGTH; $registered_post_types = get_post_types( array( 'public' => true ), 'objects' ); @@ -422,6 +442,59 @@ public function render_page() {
+

+

+ serverInfo.instructions. Use it to encode site-specific conventions — callout className mapping, code-block theme, doc structure rules — so LLM agents don\'t have to re-discover them. Plain text up to %2$d characters; appended to the server\'s baseline.', 'gk-block-api' ), + 'https://modelcontextprotocol.io/specification', + (int) $instructions_max + ), + array( + 'a' => array( + 'href' => array(), + 'target' => array(), + 'rel' => array(), + ), + ) + ); + ?> +

+

+ + +

+ +

+ + +

+ +

= 80 = preferred, >= 50 = acceptable, >= 10 = avoid (warning), < 10 = legacy (hard reject on insert).', 'gk-block-api' ); ?>

diff --git a/wordpress-plugin/gk-block-api/readme.txt b/wordpress-plugin/gk-block-api/readme.txt index 9ef96bc..3f7efeb 100644 --- a/wordpress-plugin/gk-block-api/readme.txt +++ b/wordpress-plugin/gk-block-api/readme.txt @@ -96,6 +96,9 @@ Visit Settings → Block MCP. Set the score for a namespace to less than 10 to m == Upgrade Notice == += 1.7.0 = +New per-site MCP server instructions addendum. Paste site-specific conventions (callout className mapping, code-block theme, doc structure rules) under Settings → Block MCP and every connected MCP client receives them at handshake — no more rediscovery per session. Plain-text, 2,000-char cap, public-by-design endpoint with per-IP rate limiting. + = 1.6.1 = Fixes a 30-second timeout on `/patterns` for sites with many synced patterns: the per-pattern LIKE scan that ran twice per pattern is now a single chunked aggregate scan cached for an hour. `?refresh=true` on `/patterns` now requires `manage_options`, and orphaned refs from copy-pasted content no longer pollute the cache. @@ -125,6 +128,30 @@ Docs lifecycle tools (`create_post`, `update_post`, `list_terms`, `upload_media` == Changelog == += 1.7.0 on May 20, 2026 = + +New per-site MCP server instructions addendum. Admins paste rules in **Settings → Block MCP**; the TypeScript MCP server fetches them on startup and appends to its hard-coded baseline before constructing `serverInfo.instructions`, so every connected client receives the same site-specific conventions at handshake — eliminating the per-session rediscovery LLM agents currently do for things like callout className conventions or code-block theme choices. + +#### ✨ New + +* New admin field at **Settings → Block MCP** → *MCP server instructions*. Plain-text textarea, 2,000-character cap, live character counter, in-page warning that the value is served unauthenticated. +* New REST endpoint `GET /gk-block-api/v1/instructions`. Returns `{ addendum, length, max_length, updated_at }` with `Cache-Control: public, max-age=60`. Unauthenticated by design — the value reaches MCP clients before any tool-call auth, same posture as MCP `initialize` itself. +* New `Instructions` service class (`includes/class-instructions.php`) owning option storage, sanitize, length cap, and the rate-limit bucket. + +#### 🔒 Security + +* Save path sanitization: `wp_strip_all_tags` (HTML/PHP), `strip_shortcodes` (no `do_shortcode` is ever called on this value), C0/C1 control characters stripped except `\t \n \r`, length cap enforced after sanitize so the 2,000-character budget is the post-sanitize size that reaches clients. +* Read path sanitization (defense in depth) — the same `Instructions::sanitize` runs on every `get_addendum()` so direct `update_option` writes from sibling plugins or database restores can't bypass it. +* Public read endpoint rate-limited at 30 req/min per remote IP (sliding 60s window, IP hashed before use as a transient key for PII minimization). +* Admin field is `manage_options`-gated; settings save retains the Settings API's built-in nonce. + +#### 💻 Developer Updates + +* `Instructions::OPTION_KEY`, `Instructions::UPDATED_AT_OPTION`, `Instructions::MAX_LENGTH`, `Instructions::RATE_LIMIT_PER_MIN` exposed as public constants for downstream callers. +* `Instructions::sanitize( $value )` callable from anywhere as the canonical sanitizer for this option. +* New uninstall sweep: `gk_block_api_instructions`, `gk_block_api_instructions_updated_at`, and per-IP `_transient_gk_block_api_instr_rl_*` transients are removed alongside existing plugin data. +* Reset-to-defaults handler clears the new options + per-IP rate-limit transients so admins can wipe instructions without a full uninstall. + = 1.6.1 on May 20, 2026 = `/patterns` no longer times out on sites with many synced patterns. The per-pattern reference-count query that previously ran 2N LIKE scans against `wp_posts` is now a single chunked aggregate scan cached for an hour. Includes a permission gate on cache refresh, an orphan-ref filter, and a memory-bounded chunked loop. diff --git a/wordpress-plugin/gk-block-api/tests/Instructions/InstructionsTest.php b/wordpress-plugin/gk-block-api/tests/Instructions/InstructionsTest.php new file mode 100644 index 0000000..c62f05c --- /dev/null +++ b/wordpress-plugin/gk-block-api/tests/Instructions/InstructionsTest.php @@ -0,0 +1,260 @@ +assertSame( '', Instructions::get_addendum() ); + } + + public function test_set_then_get_roundtrips_plain_text(): void { + $result = Instructions::set_addendum( 'Use is-style-callout-info for tips.' ); + $this->assertTrue( $result ); + $this->assertSame( 'Use is-style-callout-info for tips.', Instructions::get_addendum() ); + } + + public function test_set_then_get_preserves_markdown_bullets(): void { + $value = "- First rule\n- Second rule\n - Indented sub-rule\n- Third rule"; + Instructions::set_addendum( $value ); + $this->assertSame( $value, Instructions::get_addendum() ); + } + + public function test_set_then_get_preserves_blank_lines_between_paragraphs(): void { + $value = "Rule A.\n\nRule B."; + Instructions::set_addendum( $value ); + $this->assertSame( $value, Instructions::get_addendum() ); + } + + // ── Sanitization: HTML / shortcodes / PHP ── + + public function test_sanitize_strips_script_tags(): void { + $dirty = "Use callouts."; + $this->assertSame( 'Use callouts.', Instructions::sanitize( $dirty ) ); + } + + public function test_sanitize_strips_anchor_tags_but_keeps_text(): void { + $dirty = 'Click here for rules.'; + $this->assertSame( 'Click here for rules.', Instructions::sanitize( $dirty ) ); + } + + public function test_sanitize_strips_img_tags(): void { + $dirty = 'Bullet rule'; + // wp_strip_all_tags removes the tag and its attributes; the + // surrounding text survives intact. + $result = Instructions::sanitize( $dirty ); + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'onerror', $result ); + $this->assertStringContainsString( 'Bullet', $result ); + $this->assertStringContainsString( 'rule', $result ); + } + + public function test_sanitize_strips_php_tags(): void { + $dirty = "Rule one. Rule two."; + $result = Instructions::sanitize( $dirty ); + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'system', $result ); + } + + public function test_sanitize_strips_shortcodes(): void { + $dirty = 'Use [gallery] and [shortcode_attr foo="bar"] in docs.'; + $result = Instructions::sanitize( $dirty ); + $this->assertStringNotContainsString( '[gallery]', $result ); + $this->assertStringNotContainsString( '[shortcode_attr', $result ); + $this->assertStringContainsString( 'Use', $result ); + $this->assertStringContainsString( 'in docs.', $result ); + } + + public function test_sanitize_strips_c0_control_chars(): void { + // Bell, null, ESC, backspace — none should survive. + $dirty = "Rule\x00 \x07with\x1B[31m \x08control chars."; + $result = Instructions::sanitize( $dirty ); + $this->assertSame( 'Rule with control chars.', $result ); + } + + public function test_sanitize_strips_del_char(): void { + $dirty = "Rule\x7F."; + $this->assertSame( 'Rule.', Instructions::sanitize( $dirty ) ); + } + + public function test_sanitize_preserves_tab(): void { + // Tabs are kept — indentation under markdown bullets needs them. + $value = "- Top\n\t- Nested"; + $this->assertSame( $value, Instructions::sanitize( $value ) ); + } + + public function test_sanitize_normalizes_crlf_to_lf(): void { + $dirty = "Line one.\r\nLine two.\rLine three."; + $this->assertSame( "Line one.\nLine two.\nLine three.", Instructions::sanitize( $dirty ) ); + } + + public function test_sanitize_trims_outer_whitespace(): void { + $dirty = "\n\n Real content here. \n\n"; + $this->assertSame( 'Real content here.', Instructions::sanitize( $dirty ) ); + } + + public function test_sanitize_returns_empty_for_array(): void { + $this->assertSame( '', Instructions::sanitize( array( 'rule' ) ) ); + } + + public function test_sanitize_returns_empty_for_object(): void { + $this->assertSame( '', Instructions::sanitize( new \stdClass() ) ); + } + + public function test_sanitize_returns_empty_for_empty_string(): void { + $this->assertSame( '', Instructions::sanitize( '' ) ); + } + + public function test_sanitize_casts_integers_to_string(): void { + $this->assertSame( '42', Instructions::sanitize( 42 ) ); + } + + // ── Length cap ── + + public function test_sanitize_truncates_to_max_length(): void { + $long = str_repeat( 'A', Instructions::MAX_LENGTH + 500 ); + $result = Instructions::sanitize( $long ); + $this->assertSame( Instructions::MAX_LENGTH, strlen( $result ) ); + } + + /** + * Over-long input is silently truncated to MAX_LENGTH at sanitize time, + * so set_addendum() succeeds rather than returning WP_Error. The + * post-condition is "no value > MAX_LENGTH ever lands in the option." + * The explicit WP_Error branch in set_addendum() exists as a guard + * against a future sanitize refactor that stops truncating — it cannot + * be hit through the public API today. + */ + public function test_set_truncates_over_long_input_at_max_length(): void { + $value = str_repeat( 'B', Instructions::MAX_LENGTH + 1 ); + $result = Instructions::set_addendum( $value ); + $this->assertTrue( $result ); + $this->assertSame( Instructions::MAX_LENGTH, strlen( Instructions::get_addendum() ) ); + } + + public function test_set_accepts_exactly_max_length(): void { + $value = str_repeat( 'C', Instructions::MAX_LENGTH ); + $this->assertTrue( Instructions::set_addendum( $value ) ); + $this->assertSame( Instructions::MAX_LENGTH, strlen( Instructions::get_addendum() ) ); + } + + // ── Timestamp tracking ── + + public function test_get_updated_at_returns_zero_when_never_saved(): void { + $this->assertSame( 0, Instructions::get_updated_at() ); + } + + public function test_updated_at_advances_on_save(): void { + $before = time(); + Instructions::set_addendum( 'Hello' ); + $after = Instructions::get_updated_at(); + $this->assertGreaterThanOrEqual( $before, $after ); + $this->assertLessThanOrEqual( time() + 1, $after ); + } + + public function test_updated_at_advances_even_for_empty_save(): void { + Instructions::set_addendum( 'first' ); + $first = Instructions::get_updated_at(); + // Sleep one tick to ensure the timestamp would change if it does. + sleep( 1 ); + Instructions::set_addendum( '' ); + $this->assertGreaterThanOrEqual( $first, Instructions::get_updated_at() ); + } + + // ── sanitize_callback (Settings API entry point) ── + + public function test_sanitize_callback_returns_sanitized_string(): void { + $result = Instructions::sanitize_callback( 'Plain text.' ); + $this->assertSame( 'Plain text.', $result ); + } + + public function test_sanitize_callback_touches_updated_at(): void { + $before = Instructions::get_updated_at(); + // Sleep so the timestamp would strictly increase. + if ( $before > 0 ) { + sleep( 1 ); + } + Instructions::sanitize_callback( 'something' ); + $this->assertGreaterThan( $before, Instructions::get_updated_at() ); + } + + // ── Read-path sanitize (defense in depth) ── + + public function test_get_addendum_re_sanitizes_dirty_option(): void { + // Simulate a direct update_option from a sibling plugin that + // bypassed Instructions::sanitize. The read path must still + // produce a clean value. + update_option( Instructions::OPTION_KEY, "Dirty\x00", false ); + $this->assertSame( 'Dirtyx', Instructions::get_addendum() ); + } + + // ── Rate limiter ── + + public function test_rate_limit_allows_first_request(): void { + $ip = '203.0.113.42'; + $this->assertTrue( Instructions::check_rate_limit( $ip ) ); + } + + public function test_rate_limit_blocks_after_budget_exhausted(): void { + $ip = '203.0.113.43'; + for ( $i = 0; $i < Instructions::RATE_LIMIT_PER_MIN; $i++ ) { + $this->assertTrue( + Instructions::check_rate_limit( $ip ), + "Request {$i} should be allowed" + ); + } + // The next call exceeds the budget. + $this->assertFalse( Instructions::check_rate_limit( $ip ) ); + } + + public function test_rate_limit_is_per_ip(): void { + $ip_a = '203.0.113.44'; + $ip_b = '203.0.113.45'; + // Exhaust A's budget. + for ( $i = 0; $i < Instructions::RATE_LIMIT_PER_MIN; $i++ ) { + Instructions::check_rate_limit( $ip_a ); + } + $this->assertFalse( Instructions::check_rate_limit( $ip_a ) ); + // B is independent. + $this->assertTrue( Instructions::check_rate_limit( $ip_b ) ); + } + + public function test_rate_limit_handles_ipv6(): void { + $ip = '2001:db8::1'; + $this->assertTrue( Instructions::check_rate_limit( $ip ) ); + } + + public function test_rate_limit_does_not_store_raw_ip(): void { + $ip = 'PIIIP-marker-203.0.113.46'; + Instructions::check_rate_limit( $ip ); + // The transient key uses a hash; the raw IP must not appear in + // any option_name in the table. + global $wpdb; + $rows = $wpdb->get_col( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_gk_block_api_instr_rl_%'" + ); + foreach ( $rows as $name ) { + $this->assertStringNotContainsString( 'PIIIP-marker', $name ); + } + } +} diff --git a/wordpress-plugin/gk-block-api/tests/REST/InstructionsRouteTest.php b/wordpress-plugin/gk-block-api/tests/REST/InstructionsRouteTest.php new file mode 100644 index 0000000..7ab62fb --- /dev/null +++ b/wordpress-plugin/gk-block-api/tests/REST/InstructionsRouteTest.php @@ -0,0 +1,190 @@ +controller->get_instructions( $this->make_request() ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertSame( '', $data['addendum'] ); + $this->assertSame( 0, $data['length'] ); + $this->assertSame( Instructions::MAX_LENGTH, $data['max_length'] ); + $this->assertSame( 0, $data['updated_at'] ); + } + + /** + * Saved addendum is round-tripped verbatim through the endpoint and + * length reflects the post-sanitize byte length. + */ + public function test_stored_addendum_is_returned(): void { + Instructions::set_addendum( "Use is-style-callout-info for tips.\nFirst H2 is Overview." ); + + $response = $this->controller->get_instructions( $this->make_request() ); + $data = $response->get_data(); + + $this->assertSame( + "Use is-style-callout-info for tips.\nFirst H2 is Overview.", + $data['addendum'] + ); + $this->assertSame( strlen( $data['addendum'] ), $data['length'] ); + $this->assertGreaterThan( 0, $data['updated_at'] ); + } + + // ── Auth posture ────────────────────────────────────────────────── + + /** + * Endpoint is public by design — anonymous users (no `wp_set_current_user`) + * receive a 200 with the addendum. The MCP server fetches this BEFORE + * any auth-gated tool call; gating it would break the handshake. + */ + public function test_unauthenticated_request_succeeds(): void { + // Default test environment has no logged-in user. + wp_set_current_user( 0 ); + Instructions::set_addendum( 'public payload' ); + + $response = $this->controller->get_instructions( $this->make_request() ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'public payload', $response->get_data()['addendum'] ); + } + + // ── Cache header ────────────────────────────────────────────────── + + /** + * `Cache-Control: public, max-age=60` is set on every response. Short + * TTL keeps admin edits fast to land while still letting reverse + * proxies / surrogates cache. + */ + public function test_sets_short_public_cache_control(): void { + $response = $this->controller->get_instructions( $this->make_request() ); + + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'Cache-Control', $headers ); + $this->assertSame( 'public, max-age=60', $headers['Cache-Control'] ); + } + + // ── Defense in depth: read-time re-sanitize ─────────────────────── + + /** + * Direct `update_option` writes that bypass `Instructions::sanitize` + * (sibling plugin, database restore from older schema) must NOT reach + * the wire. The read path re-sanitizes as belt-and-braces. + */ + public function test_dirty_option_is_resanitized_on_read(): void { + update_option( Instructions::OPTION_KEY, "Hello\x00", false ); + + $response = $this->controller->get_instructions( $this->make_request() ); + $data = $response->get_data(); + + // wp_strip_all_tags removes ", false ); - $this->assertSame( 'Dirtyx', Instructions::get_addendum() ); + $this->assertSame( 'Dirty', Instructions::get_addendum() ); } // ── Rate limiter ── From 623c35bc01ef7751b2c406673f43d1093a8691c9 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Wed, 20 May 2026 15:06:53 -0400 Subject: [PATCH 3/5] fix(plugin+mcp): code-point safety for client counter and TS truncation (BLOCK-19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewer follow-ups; both valid against current code. 1. Settings page (`class-settings-page.php`): the HTML `maxlength` attribute and `ta.value.length` count UTF-16 code units, while the server (`mb_strlen`, `Instructions::MAX_LENGTH`) counts UTF-8 code points. An astral character like 😀 is one code point but two UTF-16 code units, so the previous client implementation blocked input at ~1000 emoji while the server would accept 2000. Removed the HTML `maxlength` attribute, switched the counter to `Array.from(ta.value).length`, and added input-time enforcement that trims by code points (`Array.from(...).slice(0, max).join('')`) so the client matches the server character-for-character. 2. `src/instructions.ts` sanitize truncation: `s.length` and `s.slice(0, MAX_ADDENDUM_LENGTH)` operate on UTF-16 code units and can land in the middle of a surrogate pair, leaving a lone high surrogate that downstream JSON serializers either reject or mangle. Switched to `Array.from(s)` + array slice + `.join('')` so truncation always lands on a code-point boundary. New test: - `truncates emoji-heavy input at code-point boundaries` — `'😀'.repeat(MAX_ADDENDUM_LENGTH + 100)` → result has exactly `MAX_ADDENDUM_LENGTH` code points and every code point is the full emoji (never a lone surrogate). Validated: - vitest: 510 tests pass (was 509; +1 surrogate-pair test). - PHPUnit: 626 / 9,198 assertions pass. - phpcs: clean. - tsc --noEmit: clean. - esbuild build: clean. Refs BLOCK-19. --- dist/index.cjs | 5 ++-- src/__tests__/unit/instructions.test.ts | 19 ++++++++++++ src/instructions.ts | 10 +++++-- .../includes/class-settings-page.php | 29 +++++++++++++++++-- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index 83e59fe..6a2a496 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -44443,8 +44443,9 @@ function sanitizeAddendum(input) { s2 = s2.replace(/[\u200B-\u200D\u2060\uFEFF\u202A-\u202E\u2066-\u2069]/g, ""); s2 = s2.replace(/\r\n?/g, "\n"); s2 = s2.trim(); - if (s2.length > MAX_ADDENDUM_LENGTH) { - s2 = s2.slice(0, MAX_ADDENDUM_LENGTH); + const codePoints = Array.from(s2); + if (codePoints.length > MAX_ADDENDUM_LENGTH) { + s2 = codePoints.slice(0, MAX_ADDENDUM_LENGTH).join(""); } return s2; } diff --git a/src/__tests__/unit/instructions.test.ts b/src/__tests__/unit/instructions.test.ts index 67e8947..0cb33c5 100644 --- a/src/__tests__/unit/instructions.test.ts +++ b/src/__tests__/unit/instructions.test.ts @@ -133,6 +133,25 @@ describe('sanitizeAddendum', () => { expect(sanitizeAddendum(exact)).toHaveLength(MAX_ADDENDUM_LENGTH); }); + /** + * Surrogate-pair safety. `😀` (U+1F600) is one code point but two + * UTF-16 code units. Naive `slice(0, MAX_ADDENDUM_LENGTH)` on a + * string of emoji would land in the middle of the last codepoint's + * surrogate pair, leaving a lone high surrogate that downstream JSON + * encoders either reject or mangle. The sanitizer counts and slices + * by `Array.from` so this can never happen. + */ + it('truncates emoji-heavy input at code-point boundaries', () => { + const input = '😀'.repeat(MAX_ADDENDUM_LENGTH + 100); + const result = sanitizeAddendum(input); + expect(Array.from(result)).toHaveLength(MAX_ADDENDUM_LENGTH); + // Every codepoint is the full emoji; never a lone surrogate. The + // matchAll iterator over the regex emits one match per scalar + // codepoint, so equality with the Array.from count confirms no + // half-pair remained. + expect([...result].every((c) => c === '😀')).toBe(true); + }); + it('returns empty for empty string', () => { expect(sanitizeAddendum('')).toBe(''); }); diff --git a/src/instructions.ts b/src/instructions.ts index ed7b3b5..99c654d 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -139,8 +139,14 @@ export function sanitizeAddendum(input: unknown): string { s = s.trim(); - if (s.length > MAX_ADDENDUM_LENGTH) { - s = s.slice(0, MAX_ADDENDUM_LENGTH); + // Count and slice by Unicode code points, NOT UTF-16 code units, so an + // astral character (emoji, rare CJK, math symbol) is never split mid + // surrogate pair — which would leave the tail as an unpaired surrogate + // that downstream JSON serializers either reject or mangle. Matches + // the server's `mb_strlen($s, 'UTF-8')` semantics. + const codePoints = Array.from(s); + if (codePoints.length > MAX_ADDENDUM_LENGTH) { + s = codePoints.slice(0, MAX_ADDENDUM_LENGTH).join(''); } return s; diff --git a/wordpress-plugin/gk-block-api/includes/class-settings-page.php b/wordpress-plugin/gk-block-api/includes/class-settings-page.php index e15f95e..7e2f913 100644 --- a/wordpress-plugin/gk-block-api/includes/class-settings-page.php +++ b/wordpress-plugin/gk-block-api/includes/class-settings-page.php @@ -466,11 +466,22 @@ public function render_page() {

+ @@ -491,7 +502,21 @@ class="large-text code" var ta = document.getElementById('gk-block-api-instructions'); var count = document.getElementById('gk-block-api-instructions-count'); if (!ta || !count) return; - ta.addEventListener('input', function () { count.textContent = String(ta.value.length); }); + var max = parseInt(ta.getAttribute('data-max-codepoints'), 10) || 0; + + // Count Unicode code points, not UTF-16 code units, so + // astral characters (emoji, rare CJK, math symbols) + // match the server's mb_strlen(...) tally. + function codePoints(s) { return Array.from(s); } + + ta.addEventListener('input', function () { + var cps = codePoints(ta.value); + if (max > 0 && cps.length > max) { + ta.value = cps.slice(0, max).join(''); + cps = codePoints(ta.value); + } + count.textContent = String(cps.length); + }); })(); From 5203c9160644debbac793e47a791e9409130956b Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Wed, 20 May 2026 16:53:49 -0400 Subject: [PATCH 4/5] fix(enricher): respect explicit plaintext + build innerHTML for fresh CBP blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the kevinbatdorf/code-block-pro enricher surfaced while inserting an English chat-prompt code block via edit_block_tree replace-block on www.gravitykit.com: 1. Explicit `language: 'plaintext'` was treated as "no preference, infer." The original logic collapsed missing + 'plaintext' to the same code path, then ran inferLanguage(). A chat prompt containing the word "from" twice tripped the SQL signal and rendered with mis-coloured English keywords ("Set", "Block", "from", "URL", "and", "Password"). The fix differentiates three caller intents: • Missing / '' / 'auto' → run inferLanguage() • 'plaintext' / 'text' / 'plain' / 'txt' / 'none' → plaintext, NO inference • Anything else → use verbatim Callers that want auto-detect now opt in via 'auto' or by omitting the attribute; explicit 'plaintext' is respected. 2. When a CBP block arrived with no `innerHTML` (the normal case when inserted via the API — e.g. edit_block_tree replace-block), the enricher only populated the `codeHTML` attribute. The wrapper div was never built, so the block saved successfully but rendered as a blank gap on the front-end. The fix adds a build-from-scratch branch that emits a minimal wrapper:
{codeHTML} {optional ` : ""; + updatedInnerHTML = `
${codeHTML}${copyTextarea}
`; } return { ...block, attributes: updatedAttrs, - ...updatedInnerHTML !== block.innerHTML ? { innerHTML: updatedInnerHTML } : {} + innerHTML: updatedInnerHTML }; }); diff --git a/package.json b/package.json index c3fefcc..7fbbc15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gravitykit/block-mcp", - "version": "1.7.0", + "version": "1.7.1", "description": "MCP server for WordPress block-level content management with preference-aware editing", "main": "dist/index.cjs", "type": "module", diff --git a/src/__tests__/unit/enrichers/cbp-enricher.test.ts b/src/__tests__/unit/enrichers/cbp-enricher.test.ts index e9e49ad..0d1af83 100644 --- a/src/__tests__/unit/enrichers/cbp-enricher.test.ts +++ b/src/__tests__/unit/enrichers/cbp-enricher.test.ts @@ -96,13 +96,60 @@ describe('enrichBlock — Code Block Pro', () => { expect(result.attributes?.highestLineNumber).toBe(1); }); - it('leaves innerHTML undefined when block has no innerHTML', async () => { + /** + * Fresh CBP blocks created via the API (e.g. edit_block_tree replace-block) + * arrive with no innerHTML. Pre-fix, the enricher only updated codeHTML and + * left innerHTML empty, which made the block render as a blank gap on the + * front-end. The enricher must build a minimal wrapper so the block is + * actually visible after save. + */ + it('builds wrapper innerHTML when block has none', async () => { const block: BlockDef = { name: 'kevinbatdorf/code-block-pro', attributes: { code: 'const a = 1;', language: 'javascript' }, }; const result = await enrichBlock(block); - expect(result.innerHTML).toBeUndefined(); + expect(typeof result.innerHTML).toBe('string'); + expect(result.innerHTML).toContain('wp-block-kevinbatdorf-code-block-pro'); + expect(result.innerHTML).toContain('