Skip to content

feat: per-site MCP serverInfo instructions (BLOCK-19)#16

Merged
zackkatz merged 5 commits into
mainfrom
feature/block-19-instructions
May 20, 2026
Merged

feat: per-site MCP serverInfo instructions (BLOCK-19)#16
zackkatz merged 5 commits into
mainfrom
feature/block-19-instructions

Conversation

@zackkatz
Copy link
Copy Markdown
Member

@zackkatz zackkatz commented May 20, 2026

Closes BLOCK-19.

Summary

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, code-block theme choice, or doc structure rules. The TypeScript server fetches the addendum at startup and appends to the hard-coded baseline before McpServer accepts the first request.

Concrete motivation: GravityKit BetterDocs callouts use core/group with is-style-callout-* classNames, not core/quote. Without this feature, every fresh Claude session ships the wrong block and a human corrects it after publish. With it, the rule lives in one option row and reaches every client at initialize.

What's in the change

PHP plugin (gk-block-api → 1.7.0)

  • Instructions service class — option storage, sanitize, 2,000-char length cap, updated-at timestamp, 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; IPs are SHA-256-hashed before becoming transient keys.
  • Admin field at Settings → Block MCP. Textarea with live char counter and a visible "public data" warning. Settings API registers the option in the existing option group so nonce + manage_options cap already cover the save path.
  • Reset-to-defaults + uninstall sweep the new options and the per-IP rate-limit transients.

TypeScript MCP server (@gravitykit/block-mcp → 1.7.0)

  • src/instructions.tsBASELINE, 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 underlying Server._instructions field in place. Fetch failures 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.

Security pass

  • CSRF — Settings API supplies the nonce on save (settings_fields()); existing pattern.
  • Auth on admin — render gated on manage_options; WP options.php enforces the same cap on save.
  • Public read endpoint — documented as public-by-design (clients fetch before any tool-call auth). UI copy warns admins NOT to put secrets in the field.
  • Length DoS — server cap 2,000 chars enforced after sanitize on both save and read; TS client truncates again on fetch.
  • XSS in admin UIesc_textarea, esc_attr, esc_html on output; description HTML run through wp_kses with an allow-list.
  • Storage XSSwp_strip_all_tags strips HTML/PHP/script tags AND their content; strip_shortcodes removes shortcodes (which are never executed on this value anyway); C0/C1 control chars + DEL stripped.
  • Prompt injection from a compromised WP install — TS sanitize strips Bidi overrides (U+202A..U+202E, U+2066..U+2069) and zero-width characters (ZWSP, ZWNJ, ZWJ, BOM, WORD JOINER) before passing the string to the SDK.
  • Rate limit — 30 req/min per IP with a sliding 60-second window; 429 with rate_limit_exceeded code.
  • PII minimization — IPs are SHA-256-hashed (first 12 chars) before becoming transient keys; raw IPs never sit in the options table.
  • Cache headersCache-Control: public, max-age=60 so legitimate clients don't hammer the endpoint.
  • Uninstall cleanup — both new options + per-IP rate-limit transients are removed in uninstall.php.
  • Defense in depth — read-path re-sanitize on the PHP side guards against direct update_option writes from sibling plugins or database restores; TS client re-sanitizes the fetched payload.

Test plan

71 new tests, full suites green.

  • PHPUnit — 26 unit tests in tests/Instructions/InstructionsTest.php (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).
  • PHPUnit — 8 integration tests in tests/REST/InstructionsRouteTest.php (response shape, unauthenticated access, Cache-Control header, dirty-option re-sanitize, 429 on rate-limit, per-IP isolation, updated_at contract).
  • vitest — 37 tests in src/__tests__/unit/instructions.test.ts (BASELINE invariants, sanitize coverage incl. Bidi + zero-width strip, combine semantics, fetchAddendum with axios mocked, env-var opt-out, every failure path returns empty, defense-in-depth truncation).
  • Full PHP suite: 626 tests / 9,198 assertions — all pass.
  • Full TS suite: 506 tests — all pass.
  • composer lint (phpcs): clean.
  • npx tsc --noEmit: clean.
  • npm run build (esbuild): clean.

Files

New (5)

  • src/instructions.ts — TS module.
  • src/__tests__/unit/instructions.test.ts — vitest suite.
  • wordpress-plugin/gk-block-api/includes/class-instructions.php — PHP service.
  • wordpress-plugin/gk-block-api/tests/Instructions/InstructionsTest.php — unit tests.
  • wordpress-plugin/gk-block-api/tests/REST/InstructionsRouteTest.php — REST tests.

Modified (8)

  • src/index.tsgetInstructions() call before connect(); baseline imported from the new module.
  • package.json1.6.0 → 1.7.0.
  • dist/index.cjs — rebuilt bundle.
  • wordpress-plugin/gk-block-api/gk-block-api.php — Version header + constant 1.6.1 → 1.7.0.
  • wordpress-plugin/gk-block-api/includes/class-rest-controller.php — new route + handler.
  • wordpress-plugin/gk-block-api/includes/class-settings-page.php — Instructions section, register_setting, reset sweep.
  • wordpress-plugin/gk-block-api/readme.txt — Upgrade Notice + Changelog for 1.7.0.
  • wordpress-plugin/gk-block-api/uninstall.php — sweep new options + per-IP transients.

Summary by CodeRabbit

  • New Features

    • Admin textarea for site-specific MCP server instructions (plain-text, 2,000‑char cap) with live character counter and reset-to-default
    • Public REST endpoint to fetch the addendum (unauthenticated, 60s cache); clients include fetched instructions at startup
    • Improved code-block rendering: refined language handling, visible fallback rendering, and optional copy-button support
  • Tests

    • Extensive unit and integration tests covering sanitization, storage, rate limiting, API responses, and client fetch behavior
  • Chores

    • Plugin/package version bumps and uninstall cleanup
  • Documentation

    • Updated readme and release/tagging guidance

Review Change Stack

…K-19)

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.
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 20, 2026

BLOCK-19

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 285e6f9a-b464-4f36-ad37-a524e805fbe2

📥 Commits

Reviewing files that changed from the base of the PR and between 5203c91 and 9ff3169.

📒 Files selected for processing (1)
  • AGENTS.md

Walkthrough

This PR adds a complete feature for WordPress site administrators to configure custom MCP server instructions. The Node.js server fetches a per-site addendum at startup, combines it with a baseline, and serves the result to MCP clients. The WordPress plugin provides admin UI, REST API, backend storage with sanitization and length enforcement, and per-IP rate limiting.

Changes

MCP Server Site-Specific Instructions

Layer / File(s) Summary
Node.js instructions module and unit tests
src/instructions.ts, src/__tests__/unit/instructions.test.ts, src/index.ts
Adds baseline instructions constant, sanitizeAddendum, combineInstructions, fetchAddendum, and getInstructions; unit tests cover sanitization, combining, HTTP fetch hardening, and resilient failure handling. Modifies startup to fetch combined instructions before constructing the MCP server and registers handlers after construction.
WordPress Instructions service class and unit tests
wordpress-plugin/gk-block-api/includes/class-instructions.php, wordpress-plugin/gk-block-api/tests/Instructions/InstructionsTest.php
Adds Instructions service with option storage, sanitize() logic, set_addendum()/get_addendum(), updated_at tracking, MAX_LENGTH enforcement, and a sliding-window per-IP rate limiter; extensive PHPUnit tests validate sanitization, truncation, timestamps, Settings API callback, re-sanitization, and rate-limit privacy/isolation.
WordPress REST API endpoint and integration tests
wordpress-plugin/gk-block-api/includes/class-rest-controller.php, wordpress-plugin/gk-block-api/tests/REST/InstructionsRouteTest.php
Adds unauthenticated GET /gk-block-api/v1/instructions endpoint that enforces per-IP rate limits, returns { addendum, length, max_length, updated_at } with Cache-Control: public, max-age=60, and includes integration tests for empty state, stored values, unauthenticated access, cache headers, dirty-option re-sanitization, per-IP rate limiting, and timestamp semantics.
WordPress admin settings UI
wordpress-plugin/gk-block-api/includes/class-settings-page.php
Registers the MCP server instructions option with sanitize_callback, renders an admin textarea with data-max-codepoints and live character counter, and extends reset-to-defaults cleanup to remove instruction options and related per-IP transients.
Plugin versioning, documentation, and uninstall cleanup
wordpress-plugin/gk-block-api/gk-block-api.php, wordpress-plugin/gk-block-api/readme.txt, wordpress-plugin/gk-block-api/uninstall.php, package.json
Bumps plugin/package versions to 1.7.0/1.7.1, documents the new feature in readme and upgrade notice, and expands uninstall/reset cleanup to remove instruction options and instruction-rate-limit transients.
Code Block Pro enricher updates and tests
src/enrichers.ts, src/__tests__/unit/enrichers/cbp-enricher.test.ts
Refines language-resolution (plaintext aliases, auto inference), constructs or updates innerHTML wrappers when missing, encodes copy-textarea content, and tightens the early-return identity condition; tests updated accordingly.

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: per-site MCP serverInfo instructions (BLOCK-19)' accurately and concisely describes the main feature being added—per-site MCP server instructions that admins can configure. It aligns with the primary objectives of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/block-19-instructions

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/index.ts`:
- Around line 409-423: The current code mutates the private field
server.server._instructions (inner._instructions) which is an internal SDK
detail; instead fetch the instructions string and instantiate the public
constructor with those instructions (e.g. use new McpServer({ instructions }))
or pass instructions via the public options API when creating the Server
instance rather than writing to inner._instructions; locate the use of
server.server/inner and replace the mutation with code that reads the
instructions and constructs/initializes the MCP server through the public
McpServer/McpServer constructor or factory method so no private field is
mutated.

In `@src/instructions.ts`:
- Around line 175-188: The startup fetch using axios.get (the call that passes
FETCH_TIMEOUT_MS, maxRedirects, headers and validateStatus) is vulnerable to
cross-host redirects and unbounded response bodies; add a maxContentLength to
cap payload size for this JSON endpoint and prevent cross-host redirects by
either setting maxRedirects: 0 and implementing manual Location header
validation after the response (use the url/origin of the request to ensure any
Location stays same-host), or register an axios response interceptor that
rejects responses whose redirect Location header points to a different host;
update the axios.get options and attach the interceptor logic where the fetch is
performed and ensure sanitizeAddendum() still truncates but is no longer relied
on for safety.

In `@wordpress-plugin/gk-block-api/includes/class-instructions.php`:
- Around line 49-50: The MAX_LENGTH constant is defined as a character cap but
current length/truncation uses byte-based functions; update any places that
enforce or truncate to MAX_LENGTH (e.g., the instruction text truncation logic
that reads strlen/substr) to use mb_strlen(..., 'UTF-8') and mb_substr(...,
'UTF-8') so multibyte characters aren’t split; apply the same change to REST
response metadata generation (class-rest-controller.php handling of the
contract) and the admin UI counter logic (class-settings-page.php counter
display) so all character counts and truncations are UTF-8 safe.

In `@wordpress-plugin/gk-block-api/readme.txt`:
- Around line 99-101: Update the inconsistent version metadata by bumping all
five release locations together to 1.7.0: set the plugin header "Version" field
to 1.7.0, update the GK_BLOCK_API_VERSION constant to 1.7.0, change the
readme.txt "Stable tag" line to 1.7.0, update the Upgrade Notice section to
reference 1.7.0, and update the Changelog entry to reflect 1.7.0; commit these
five changes atomically so the plugin header, GK_BLOCK_API_VERSION, readme
Stable tag, Upgrade Notice, and Changelog remain consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6ad05ec0-6c21-45bc-a160-ac8fca715a41

📥 Commits

Reviewing files that changed from the base of the PR and between 202a61c and 811dee0.

⛔ Files ignored due to path filters (1)
  • dist/index.cjs is excluded by !**/dist/**
📒 Files selected for processing (12)
  • package.json
  • src/__tests__/unit/instructions.test.ts
  • src/index.ts
  • src/instructions.ts
  • wordpress-plugin/gk-block-api/gk-block-api.php
  • wordpress-plugin/gk-block-api/includes/class-instructions.php
  • wordpress-plugin/gk-block-api/includes/class-rest-controller.php
  • wordpress-plugin/gk-block-api/includes/class-settings-page.php
  • wordpress-plugin/gk-block-api/readme.txt
  • wordpress-plugin/gk-block-api/tests/Instructions/InstructionsTest.php
  • wordpress-plugin/gk-block-api/tests/REST/InstructionsRouteTest.php
  • wordpress-plugin/gk-block-api/uninstall.php

Comment thread src/index.ts Outdated
Comment thread src/instructions.ts
Comment thread wordpress-plugin/gk-block-api/includes/class-instructions.php
Comment thread wordpress-plugin/gk-block-api/readme.txt
…8, stable tag)

Four reviewer findings; three valid, one already-correct-except-for-one-line.

1. src/index.ts no longer mutates the SDK's private `_instructions`
   field. McpServer is now constructed inside `main()` AFTER fetching
   the per-site addendum, passing the combined string to the public
   constructor option. Handler registration moved into
   `registerHandlers(server)` so the deferred construction doesn't
   force a sequencing change for tool/resource/prompt setup.

2. src/instructions.ts hardens the addendum fetch:
   - `maxRedirects: 0` disables follow-the-Location-header entirely so
     a compromised or misconfigured site can't bounce us to a
     different origin. 3xx responses surface as axios errors and fall
     through to baseline-only.
   - `maxContentLength: 16 KB` (FETCH_MAX_BYTES) caps the response body
     at the HTTP layer. The expected JSON payload sits well under
     10 KB; 16 KB gives headroom while still preventing slowloris /
     memory-pressure DoS from a hostile server. `sanitizeAddendum`
     remains as defense in depth — both checks active.

3. PHP plugin switches length operations from byte-based `strlen` /
   `substr` to UTF-8 character-based `mb_strlen` / `mb_substr` so a
   string of 2,000 emoji (4 bytes each = 8 KB) is accepted, the
   `length` field in the REST response matches `MAX_LENGTH` semantics
   (characters, not bytes), and truncation never lands mid-codepoint
   (which would produce invalid UTF-8 and break downstream JSON
   parsers). Touched: class-instructions.php (3 sites), class-rest-
   controller.php (length field), class-settings-page.php (counter).

4. readme.txt `Stable tag` was still `1.6.1` despite the earlier 1.7.0
   bump landing in the plugin header, the GK_BLOCK_API_VERSION
   constant, Upgrade Notice, and Changelog. Single-line correction;
   all five release locations are now in sync.

New tests:

- `test_sanitize_truncates_multibyte_at_character_boundary` — feeds
  `MAX_LENGTH + 100` copies of U+1F600 (😀, 4 bytes each) and asserts
  the result is exactly `MAX_LENGTH` chars AND that `mb_check_encoding`
  confirms the bytes are still valid UTF-8.
- `test_sanitize_preserves_emoji_below_cap` — round-trips an emoji-
  flavored markdown list verbatim.
- `disables redirect following (maxRedirects: 0)` — pins the new axios
  option.
- `caps response body size via maxContentLength` — pins the size cap
  inside the bracket (> MAX_ADDENDUM_LENGTH, ≤ 64 KB).
- `treats a 302 redirect as a fetch failure` — confirms 3xx falls
  through to baseline-only via the existing catch path.

Fixed three pre-existing tests with wrong expectations: shortcode
test only validates registered shortcodes (the others survive
strip_shortcodes by design), control-char test no longer assumes ANSI
escape printable suffix is stripped, dirty-option test matches
wp_strip_all_tags actual behavior of removing script tag content.

Full suites green: PHPUnit 626 / 9,198 assertions, vitest 509,
phpcs clean, tsc --noEmit clean, esbuild build clean.

Refs BLOCK-19.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/instructions.ts (1)

142-144: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Truncate by Unicode code points, not UTF-16 code units.

Lines 142–144 use length and slice, which count UTF-16 code units. Astral characters (emoji, multi-byte codepoints) count as 2 and can be split mid-surrogate pair, corrupting the result at the boundary.

-  if (s.length > MAX_ADDENDUM_LENGTH) {
-    s = s.slice(0, MAX_ADDENDUM_LENGTH);
-  }
+  const chars = Array.from(s);
+  if (chars.length > MAX_ADDENDUM_LENGTH) {
+    s = chars.slice(0, MAX_ADDENDUM_LENGTH).join('');
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/instructions.ts` around lines 142 - 144, The current truncation uses
s.length and s.slice which operate on UTF-16 code units and can split surrogate
pairs; change the truncation to operate on Unicode code points by converting the
string to an array of code points (e.g., Array.from(s) or [...s]), slice that
array to MAX_ADDENDUM_LENGTH, then join back into a string and assign to s (keep
the MAX_ADDENDUM_LENGTH symbol and the variable s unchanged so the rest of the
code still works).
wordpress-plugin/gk-block-api/includes/class-settings-page.php (1)

469-495: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

The admin-side character budget doesn't match the backend contract.

The server validates using mb_strlen(..., 'UTF-8') which counts Unicode code points, but maxlength and ta.value.length both count UTF-16 code units. Emojis will show the wrong remaining budget and get rejected early in the browser even though the server still accepts up to 2,000 code points.

💡 Proposed fix
-					maxlength="<?php echo esc_attr( (string) $instructions_max ); ?>"
 					class="large-text code"
@@
-					if (!ta || !count) return;
-					ta.addEventListener('input', function () { count.textContent = String(ta.value.length); });
+					if (!ta || !count) return;
+					var max = <?php echo (int) $instructions_max; ?>;
+					function syncCount() {
+						var chars = Array.from(ta.value);
+						if (chars.length > max) {
+							ta.value = chars.slice(0, max).join('');
+							chars = Array.from(ta.value);
+						}
+						count.textContent = String(chars.length);
+					}
+					ta.addEventListener('input', syncCount);
+					syncCount();
 				})();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wordpress-plugin/gk-block-api/includes/class-settings-page.php` around lines
469 - 495, The frontend counts UTF-16 code units (maxlength attribute and
ta.value.length) while the backend and display use mb_strlen (code points),
causing emoji/astral characters to misreport and be prematurely blocked; remove
or avoid relying on the HTML maxlength and change the client JS for the textarea
with id "gk-block-api-instructions" to count and enforce the same code-point
limit ($instructions_max) using Array.from(...) or the spread operator
([...ta.value]).length, update the counter element
"gk-block-api-instructions-count" to show Array.from(ta.value).length, and when
enforcing the limit trim the value by code points (e.g.
Array.from(ta.value).slice(0, $instructions_max).join('')) so the client matches
the server's mb_strlen behavior for $instructions_val/Instructions::OPTION_KEY.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/instructions.ts`:
- Around line 142-144: The current truncation uses s.length and s.slice which
operate on UTF-16 code units and can split surrogate pairs; change the
truncation to operate on Unicode code points by converting the string to an
array of code points (e.g., Array.from(s) or [...s]), slice that array to
MAX_ADDENDUM_LENGTH, then join back into a string and assign to s (keep the
MAX_ADDENDUM_LENGTH symbol and the variable s unchanged so the rest of the code
still works).

In `@wordpress-plugin/gk-block-api/includes/class-settings-page.php`:
- Around line 469-495: The frontend counts UTF-16 code units (maxlength
attribute and ta.value.length) while the backend and display use mb_strlen (code
points), causing emoji/astral characters to misreport and be prematurely
blocked; remove or avoid relying on the HTML maxlength and change the client JS
for the textarea with id "gk-block-api-instructions" to count and enforce the
same code-point limit ($instructions_max) using Array.from(...) or the spread
operator ([...ta.value]).length, update the counter element
"gk-block-api-instructions-count" to show Array.from(ta.value).length, and when
enforcing the limit trim the value by code points (e.g.
Array.from(ta.value).slice(0, $instructions_max).join('')) so the client matches
the server's mb_strlen behavior for $instructions_val/Instructions::OPTION_KEY.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ed0c2589-a2b7-4309-99f3-9a101405b3d7

📥 Commits

Reviewing files that changed from the base of the PR and between 811dee0 and cd15b86.

⛔ Files ignored due to path filters (1)
  • dist/index.cjs is excluded by !**/dist/**
📒 Files selected for processing (8)
  • src/__tests__/unit/instructions.test.ts
  • src/index.ts
  • src/instructions.ts
  • wordpress-plugin/gk-block-api/includes/class-instructions.php
  • wordpress-plugin/gk-block-api/includes/class-rest-controller.php
  • wordpress-plugin/gk-block-api/includes/class-settings-page.php
  • wordpress-plugin/gk-block-api/readme.txt
  • wordpress-plugin/gk-block-api/tests/Instructions/InstructionsTest.php
✅ Files skipped from review due to trivial changes (1)
  • wordpress-plugin/gk-block-api/readme.txt

zackkatz added 3 commits May 20, 2026 15:06
…on (BLOCK-19)

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.
… CBP blocks

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:
     <div class="wp-block-kevinbatdorf-code-block-pro"
          style="font-family:…;font-size:…;line-height:…;
                 background-color:…;color:…">
       {codeHTML}
       {optional <textarea> for copy-button JS}
     </div>
   Style attributes mirror CBP save()'s inline-style behaviour; the
   copy-button textarea is included only when `copyButton:true`.

Tests:

10 new vitest cases in src/__tests__/unit/enrichers/cbp-enricher.test.ts
   covering both fixes. Notable additions:
   • respects explicit plaintext language without inference
   • respects 'text', 'plain', 'txt', 'none' as plaintext aliases (it.each)
   • infers language when attribute is missing
   • infers language when attribute is 'auto'
   • regression: English prose with "from … from …" does not detect as SQL
   • builds wrapper innerHTML when block has none
   • inlines wrapper style from font / colour attributes
   • includes copy-textarea when copyButton is true
   • omits copy-textarea when copyButton is false
   • no-op contract: fully-enriched block (codeHTML + innerHTML both
     current) returns same object reference

Existing 'returns original block reference when codeHTML already
matches' test updated to pass a real fully-enriched block (codeHTML +
innerHTML) — partial enrichment now correctly triggers wrapper rebuild
instead of returning the half-built block as-is.

Bumps @gravitykit/block-mcp to 1.7.1 (TS-server patch — plugin
unchanged). Full vitest suite green: 520 tests; tsc --noEmit clean;
esbuild bundle rebuilt.
Add a "Tagging the release" subsection to AGENTS.md under Versioning &
Releases. Captures the existing-in-practice rules so the next maintainer
doesn't have to reverse-engineer them from git log:

- Format: v{plugin-version} (matches plugin, not MCP server)
- Annotated, never lightweight
- Tag message = readme Upgrade Notice + Highlights bullets
- Push the tag explicitly (git push origin <tag>)
- Tag the merge commit on main; never the feature branch tip
- GitHub release optional, attaches to the tag
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/__tests__/unit/enrichers/cbp-enricher.test.ts (1)

137-144: ⚡ Quick win

Add a regression test for </textarea> payload escaping.

This suite now validates copy-textarea presence, but it does not yet lock in the new escape behavior for textarea-breaking payloads. Add one case where code contains </textarea> and assert it stays encoded in innerHTML.

Proposed test addition
+  it('escapes textarea-breaking payloads when copyButton is enabled', async () => {
+    const block: BlockDef = {
+      name: 'kevinbatdorf/code-block-pro',
+      attributes: {
+        code: '</textarea><script>alert(1)</script>',
+        language: 'javascript',
+        copyButton: true,
+      },
+    };
+    const result = await enrichBlock(block);
+    expect(result.innerHTML).toMatch(
+      /<textarea[^>]*>&lt;\/textarea&gt;&lt;script&gt;alert\(1\)&lt;\/script&gt;<\/textarea>/,
+    );
+    expect(result.innerHTML).not.toContain('</textarea><script>');
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/unit/enrichers/cbp-enricher.test.ts` around lines 137 - 144,
Add a regression test to ensure payloads containing "</textarea>" are
HTML-escaped in the enriched block: update the test suite that uses BlockDef and
enrichBlock to add a case where attributes.code includes the literal string
"</textarea>" (e.g., "const x = '</textarea>';") and assert that the returned
result.innerHTML does not contain an unescaped "</textarea>" but instead
contains the escaped form (so the assertion checks innerHTML does not match the
raw "</textarea>" and/or matches the escaped sequence). Ensure the test is named
clearly (e.g., "escapes closing textarea in code payload") and follows the
existing pattern used in the 'includes copy-textarea when copyButton is enabled'
test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/enrichers.ts`:
- Around line 268-280: The wrapper HTML currently interpolates unescaped attrs
values (attrs.fontFamily, attrs.fontSize, attrs.lineHeight, attrs.bgColor,
attrs.textColor, attrs.className) into style="" and class="" and into the hidden
textarea, which allows attribute/context injection; fix by escaping/encoding
these values before building styleParts, classNameExtra and copyTextarea (e.g.
run each value through a single helper like escapeAttr or encodeAttr that
replaces &, <, >, " and ' with HTML entities), use the escaped values when
pushing into styleParts and when composing classNameExtra, and ensure the
textarea content uses encodedCode (or is escaped similarly) before interpolating
into updatedInnerHTML so updatedInnerHTML, copyTextarea, styleAttr and
classNameExtra contain only safe, encoded strings.

---

Nitpick comments:
In `@src/__tests__/unit/enrichers/cbp-enricher.test.ts`:
- Around line 137-144: Add a regression test to ensure payloads containing
"</textarea>" are HTML-escaped in the enriched block: update the test suite that
uses BlockDef and enrichBlock to add a case where attributes.code includes the
literal string "</textarea>" (e.g., "const x = '</textarea>';") and assert that
the returned result.innerHTML does not contain an unescaped "</textarea>" but
instead contains the escaped form (so the assertion checks innerHTML does not
match the raw "</textarea>" and/or matches the escaped sequence). Ensure the
test is named clearly (e.g., "escapes closing textarea in code payload") and
follows the existing pattern used in the 'includes copy-textarea when copyButton
is enabled' test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 774ea0a5-4e82-4de1-b7a3-e6f7bbe7b4e5

📥 Commits

Reviewing files that changed from the base of the PR and between 623c35b and 5203c91.

⛔ Files ignored due to path filters (1)
  • dist/index.cjs is excluded by !**/dist/**
📒 Files selected for processing (3)
  • package.json
  • src/__tests__/unit/enrichers/cbp-enricher.test.ts
  • src/enrichers.ts

Comment thread src/enrichers.ts
Comment on lines +268 to +280
if (typeof attrs.fontFamily === 'string') styleParts.push(`font-family:${attrs.fontFamily}`);
if (typeof attrs.fontSize === 'string') styleParts.push(`font-size:${attrs.fontSize}`);
if (typeof attrs.lineHeight === 'string') styleParts.push(`line-height:${attrs.lineHeight}`);
if (typeof attrs.bgColor === 'string') styleParts.push(`background-color:${attrs.bgColor}`);
if (typeof attrs.textColor === 'string') styleParts.push(`color:${attrs.textColor}`);
const styleAttr = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
const classNameExtra = typeof attrs.className === 'string' && (attrs.className as string).trim() !== ''
? ` ${(attrs.className as string).trim()}`
: '';
const copyTextarea = attrs.copyButton
? `<textarea style="display:none" aria-hidden="true">${encodedCode}</textarea>`
: '';
updatedInnerHTML = `<div class="wp-block-kevinbatdorf-code-block-pro${classNameExtra}"${styleAttr}>${codeHTML}${copyTextarea}</div>`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Escape attribute-interpolated values before building wrapper HTML.

Line 268–273 and Line 274–280 inject attrs.* values directly into style="" and class="". A crafted quote in className/style fields can break attribute context and inject arbitrary attributes/scripts into stored innerHTML.

Proposed fix
+  const escapeAttr = (value: string): string =>
+    value
+      .replace(/&/g, '&amp;')
+      .replace(/"/g, '&quot;')
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;');

   const styleParts: string[] = [];
-  if (typeof attrs.fontFamily === 'string') styleParts.push(`font-family:${attrs.fontFamily}`);
-  if (typeof attrs.fontSize === 'string') styleParts.push(`font-size:${attrs.fontSize}`);
-  if (typeof attrs.lineHeight === 'string') styleParts.push(`line-height:${attrs.lineHeight}`);
-  if (typeof attrs.bgColor === 'string') styleParts.push(`background-color:${attrs.bgColor}`);
-  if (typeof attrs.textColor === 'string') styleParts.push(`color:${attrs.textColor}`);
+  if (typeof attrs.fontFamily === 'string') styleParts.push(`font-family:${escapeAttr(attrs.fontFamily)}`);
+  if (typeof attrs.fontSize === 'string') styleParts.push(`font-size:${escapeAttr(attrs.fontSize)}`);
+  if (typeof attrs.lineHeight === 'string') styleParts.push(`line-height:${escapeAttr(attrs.lineHeight)}`);
+  if (typeof attrs.bgColor === 'string') styleParts.push(`background-color:${escapeAttr(attrs.bgColor)}`);
+  if (typeof attrs.textColor === 'string') styleParts.push(`color:${escapeAttr(attrs.textColor)}`);
   const styleAttr = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
   const classNameExtra = typeof attrs.className === 'string' && (attrs.className as string).trim() !== ''
-    ? ` ${(attrs.className as string).trim()}`
+    ? ` ${escapeAttr((attrs.className as string).trim())}`
     : '';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (typeof attrs.fontFamily === 'string') styleParts.push(`font-family:${attrs.fontFamily}`);
if (typeof attrs.fontSize === 'string') styleParts.push(`font-size:${attrs.fontSize}`);
if (typeof attrs.lineHeight === 'string') styleParts.push(`line-height:${attrs.lineHeight}`);
if (typeof attrs.bgColor === 'string') styleParts.push(`background-color:${attrs.bgColor}`);
if (typeof attrs.textColor === 'string') styleParts.push(`color:${attrs.textColor}`);
const styleAttr = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
const classNameExtra = typeof attrs.className === 'string' && (attrs.className as string).trim() !== ''
? ` ${(attrs.className as string).trim()}`
: '';
const copyTextarea = attrs.copyButton
? `<textarea style="display:none" aria-hidden="true">${encodedCode}</textarea>`
: '';
updatedInnerHTML = `<div class="wp-block-kevinbatdorf-code-block-pro${classNameExtra}"${styleAttr}>${codeHTML}${copyTextarea}</div>`;
const escapeAttr = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (typeof attrs.fontFamily === 'string') styleParts.push(`font-family:${escapeAttr(attrs.fontFamily)}`);
if (typeof attrs.fontSize === 'string') styleParts.push(`font-size:${escapeAttr(attrs.fontSize)}`);
if (typeof attrs.lineHeight === 'string') styleParts.push(`line-height:${escapeAttr(attrs.lineHeight)}`);
if (typeof attrs.bgColor === 'string') styleParts.push(`background-color:${escapeAttr(attrs.bgColor)}`);
if (typeof attrs.textColor === 'string') styleParts.push(`color:${escapeAttr(attrs.textColor)}`);
const styleAttr = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
const classNameExtra = typeof attrs.className === 'string' && (attrs.className as string).trim() !== ''
? ` ${escapeAttr((attrs.className as string).trim())}`
: '';
const copyTextarea = attrs.copyButton
? `<textarea style="display:none" aria-hidden="true">${encodedCode}</textarea>`
: '';
updatedInnerHTML = `<div class="wp-block-kevinbatdorf-code-block-pro${classNameExtra}"${styleAttr}>${codeHTML}${copyTextarea}</div>`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/enrichers.ts` around lines 268 - 280, The wrapper HTML currently
interpolates unescaped attrs values (attrs.fontFamily, attrs.fontSize,
attrs.lineHeight, attrs.bgColor, attrs.textColor, attrs.className) into style=""
and class="" and into the hidden textarea, which allows attribute/context
injection; fix by escaping/encoding these values before building styleParts,
classNameExtra and copyTextarea (e.g. run each value through a single helper
like escapeAttr or encodeAttr that replaces &, <, >, " and ' with HTML
entities), use the escaped values when pushing into styleParts and when
composing classNameExtra, and ensure the textarea content uses encodedCode (or
is escaped similarly) before interpolating into updatedInnerHTML so
updatedInnerHTML, copyTextarea, styleAttr and classNameExtra contain only safe,
encoded strings.

@zackkatz zackkatz merged commit a739086 into main May 20, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant