diff --git a/AGENTS.md b/AGENTS.md index 67981e7..9d492f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -562,6 +562,17 @@ The MCP server version is informational; the plugin version is what site owners - If the MCP server side also moved, bump `package.json` in the same commit 3. Push. +### Tagging the release + +Every plugin version bump gets a matching annotated git tag. Tags live on the merge commit on `main`; never on a feature branch tip. + +- **Format**: `v{plugin-version}` — e.g. `v1.7.0`. Match the plugin version, not the MCP server version. (When the MCP server bumps independently without a plugin bump, no tag — server versions are informational.) +- **Annotated, not lightweight**: `git tag -a v1.7.0 -m ""`. Lightweight tags don't carry a message and don't show up in `git show`. +- **Tag message**: the same prose as the `readme.txt` `Upgrade Notice` for that version, plus a `Highlights:` bulleted list of the headline changes. Look at `git show v1.6.0` for the canonical shape. +- **Push the tag explicitly**: `git push origin v1.7.0`. Bare `git push` does not push tags. Without this step the tag exists locally only and `gh release create` can't find it. +- **When to tag**: after the version-bump commit has landed on `main` (via PR merge or direct push). Tagging on the feature branch and merging via squash strands the tag on a dead commit. +- **GitHub release**: optional, but if you create one, attach it to the tag (`gh release create v1.7.0 --notes-file …` or via the UI). The marketplace pin / Composer installer reads the tag, not the release. + ### Backfilling missing entries If a version was bumped without a corresponding `readme.txt` entry (it happens — historically `1.4.1`, `1.4.2`, and `1.5.0` all shipped without changelog updates), audit the commits that landed between the previous bump and the version bump commit (`git log .. -- wordpress-plugin/gk-block-api/`) and write the missing entries. Keep the Upgrade Notice and Changelog sections in strict reverse-chronological order across all versions. diff --git a/dist/index.cjs b/dist/index.cjs index f284fc4..129d01d 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.1", description: "MCP server for WordPress block-level content management with preference-aware editing", main: "dist/index.cjs", type: "module", @@ -44424,6 +44424,110 @@ 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 FETCH_MAX_BYTES = 16 * 1024; +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(); + const codePoints = Array.from(s2); + if (codePoints.length > MAX_ADDENDUM_LENGTH) { + s2 = codePoints.slice(0, MAX_ADDENDUM_LENGTH).join(""); + } + 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, + // Disable axios's automatic redirect following entirely. Without + // this, a compromised or misconfigured WP site could redirect us + // to a different origin (an exfil endpoint, an SSRF target on the + // internal network, etc.). Admins should configure + // WORDPRESS_URL with the canonical scheme + host so the first + // hop returns 200 directly. If the site needs an HTTP → HTTPS + // redirect, fix the env var instead of relying on axios to + // follow it for us. + // + // The corollary is a 3xx response now surfaces as an axios + // error and we fall back to baseline-only. Logged to stderr. + maxRedirects: 0, + // Hard cap the response body size. Primary defense against an + // unbounded payload — `sanitizeAddendum` still truncates after, + // but we never want raw bytes past this cap to hit our process. + maxContentLength: FETCH_MAX_BYTES, + // Lower-case Accept so caches see a stable Vary key. + headers: { + Accept: "application/json" + }, + // We treat 2xx as success; 3xx (any redirect) and 4xx/5xx fall + // back to empty via the catch block. + 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; @@ -57835,31 +57939,48 @@ registerBlockEnricher("kevinbatdorf/code-block-pro", async (block) => { const attrs = block.attributes ?? {}; const code = attrs.code; if (!code) return null; - const rawLang = (attrs.language || "plaintext").toLowerCase(); + const rawLangAttr = typeof attrs.language === "string" ? attrs.language.trim() : ""; + const rawLang = rawLangAttr.toLowerCase(); + const PLAINTEXT_ALIASES = /* @__PURE__ */ new Set(["plaintext", "text", "plain", "txt", "none"]); + const shouldInfer = rawLang === "" || rawLang === "auto"; + const lang254 = shouldInfer ? inferLanguage(code) : PLAINTEXT_ALIASES.has(rawLang) ? "plaintext" : rawLang; const { langs } = await getHighlighter(); - const lang254 = rawLang === "plaintext" || rawLang === "text" || rawLang === "" ? inferLanguage(code) : rawLang; const effectiveLang = langs.has(lang254) ? lang254 : "plaintext"; const themeName = attrs.theme || void 0; const codeHTML = await shikiHighlight(code, effectiveLang, themeName); const highestLineNumber = code.split("\n").length; - if (codeHTML === attrs.codeHTML && lang254 === rawLang) return null; + const incomingInnerHTML = block.innerHTML ?? ""; + if (codeHTML === attrs.codeHTML && lang254 === rawLang && incomingInnerHTML !== "") { + return null; + } const updatedAttrs = { ...attrs, language: lang254, codeHTML, highestLineNumber }; - let updatedInnerHTML = block.innerHTML; - if (updatedInnerHTML) { - updatedInnerHTML = updatedInnerHTML.replace( + const encodedCode = code.replace(/&/g, "&").replace(//g, ">"); + let updatedInnerHTML; + if (incomingInnerHTML !== "") { + updatedInnerHTML = incomingInnerHTML.replace( /
/g, ">");
     updatedInnerHTML = updatedInnerHTML.replace(
       /(]*>)([\s\S]*?)(<\/textarea>)/,
-      (_m, open, _old, close) => `${open}${encoded}${close}`
+      (_m, open, _old, close) => `${open}${encodedCode}${close}`
     );
+  } else {
+    const styleParts = [];
+    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.trim() !== "" ? ` ${attrs.className.trim()}` : "";
+    const copyTextarea = attrs.copyButton ? `` : "";
+    updatedInnerHTML = `
${codeHTML}${copyTextarea}
`; } return { ...block, attributes: updatedAttrs, - ...updatedInnerHTML !== block.innerHTML ? { innerHTML: updatedInnerHTML } : {} + innerHTML: updatedInnerHTML }; }); @@ -59047,24 +59168,6 @@ var client = new WordPressBlockClient({ application_password: WORDPRESS_APP_PASSWORD } }); -var server = new McpServer( - { - name: "block-mcp", - version: package_default.version - }, - { - capabilities: { - tools: {}, - 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.` - } -); var ALL_TOOLS = [ ...DISCOVERY_TOOLS, ...READ_TOOLS, @@ -59147,123 +59250,141 @@ How to behave: - Reuse existing patterns before building from scratch \u2014 call \`list_patterns\` first. - For patterns that need per-page customization, use \`synced: false\` to inline them. - When you encounter legacy blocks on a page during a read, note them but do not replace unless asked.`; -server.server.setRequestHandler(ListToolsRequestSchema, async () => { - return { tools: ALL_TOOLS }; -}); -server.server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - const toolArgs = args ?? {}; - try { - const handle2 = TOOL_DISPATCH.get(name); - if (!handle2) { - throw new Error(`Unknown tool: ${name}`); +function registerHandlers(server) { + server.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: ALL_TOOLS }; + }); + server.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const toolArgs = args ?? {}; + try { + const handle2 = TOOL_DISPATCH.get(name); + if (!handle2) { + throw new Error(`Unknown tool: ${name}`); + } + const result = await handle2(name, toolArgs, client); + const toolDef = ALL_TOOLS.find((t) => t.name === name); + const response = { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) } + ] + }; + if (toolDef && toolDef.outputSchema !== void 0 && result !== null && typeof result === "object") { + response.structuredContent = result; + } + return response; + } catch (error2) { + const err = error2; + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + error: true, + tool: name, + message: err.message || "Unknown error occurred", + code: err.wpCode, + statusCode: err.wpStatus ?? err.response?.status, + hint: err.wpData ?? null + }, + null, + 2 + ) + } + ], + isError: true + }; } - const result = await handle2(name, toolArgs, client); - const toolDef = ALL_TOOLS.find((t) => t.name === name); - const response = { - content: [ - { type: "text", text: JSON.stringify(result, null, 2) } + }); + server.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: [ + { + uri: AGENT_GUIDE_RESOURCE_URI, + name: "Block MCP \u2014 Agent Guide", + description: "Editing workflow + how to discover the live block-preference policy on this site. Read this before editing pages.", + mimeType: "text/plain" + }, + // Legacy alias kept for one release; resolves to the same content. + { + uri: LEGACY_PREFERENCES_RESOURCE_URI, + name: "Block MCP \u2014 Agent Guide (legacy URI)", + description: "Renamed to block-mcp://agent-guide. Same content; kept for backwards compatibility.", + mimeType: "text/plain" + } ] }; - if (toolDef && toolDef.outputSchema !== void 0 && result !== null && typeof result === "object") { - response.structuredContent = result; + }); + server.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params; + if (uri === AGENT_GUIDE_RESOURCE_URI || uri === LEGACY_PREFERENCES_RESOURCE_URI) { + return { + contents: [ + { uri, mimeType: "text/plain", text: AGENT_GUIDE_CONTENT } + ] + }; } - return response; - } catch (error2) { - const err = error2; - return { - content: [ + throw new Error(`Unknown resource: ${uri}`); + }); + const PROMPTS = [ + { + name: "edit-block-page", + description: "Bundle: workflow guidance + reminder to call get_page_blocks first. Pass `url` to seed a specific page.", + arguments: [ { - type: "text", - text: JSON.stringify( - { - error: true, - tool: name, - message: err.message || "Unknown error occurred", - code: err.wpCode, - statusCode: err.wpStatus ?? err.response?.status, - hint: err.wpData ?? null - }, - null, - 2 - ) + name: "url", + description: "Optional. Full URL or path of the page being edited.", + required: false } - ], - isError: true - }; - } -}); -server.server.setRequestHandler(ListResourcesRequestSchema, async () => { - return { - resources: [ - { - uri: AGENT_GUIDE_RESOURCE_URI, - name: "Block MCP \u2014 Agent Guide", - description: "Editing workflow + how to discover the live block-preference policy on this site. Read this before editing pages.", - mimeType: "text/plain" - }, - // Legacy alias kept for one release; resolves to the same content. - { - uri: LEGACY_PREFERENCES_RESOURCE_URI, - name: "Block MCP \u2014 Agent Guide (legacy URI)", - description: "Renamed to block-mcp://agent-guide. Same content; kept for backwards compatibility.", - mimeType: "text/plain" - } - ] - }; -}); -server.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - const { uri } = request.params; - if (uri === AGENT_GUIDE_RESOURCE_URI || uri === LEGACY_PREFERENCES_RESOURCE_URI) { - return { - contents: [ - { uri, mimeType: "text/plain", text: AGENT_GUIDE_CONTENT } ] - }; - } - throw new Error(`Unknown resource: ${uri}`); -}); -var PROMPTS = [ - { - name: "edit-block-page", - description: "Bundle: workflow guidance + reminder to call get_page_blocks first. Pass `url` to seed a specific page.", - arguments: [ - { - name: "url", - description: "Optional. Full URL or path of the page being edited.", - required: false - } - ] - } -]; -server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: PROMPTS -})); -server.server.setRequestHandler(GetPromptRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - if (name !== "edit-block-page") { - throw new Error(`Unknown prompt: ${name}`); - } - const url3 = args?.url ?? ""; - const seed = url3 ? `Editing target: ${url3} + } + ]; + server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: PROMPTS + })); + server.server.setRequestHandler(GetPromptRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + if (name !== "edit-block-page") { + throw new Error(`Unknown prompt: ${name}`); + } + const url3 = args?.url ?? ""; + const seed = url3 ? `Editing target: ${url3} First tool call: get_page_blocks({ url: ${JSON.stringify(url3)}, summary_only: true }) for cheap orientation, then re-fetch with search/block_name filters as needed. ` : ""; - return { - description: "Workflow primer for editing a WordPress page via block-mcp.", - messages: [ - { - role: "user", - content: { - type: "text", - text: `${seed}${AGENT_GUIDE_CONTENT}` + return { + description: "Workflow primer for editing a WordPress page via block-mcp.", + messages: [ + { + role: "user", + content: { + type: "text", + text: `${seed}${AGENT_GUIDE_CONTENT}` + } } - } - ] - }; -}); + ] + }; + }); +} async function main2() { + const instructions = await getInstructions(WORDPRESS_URL); + const server = new McpServer( + { + name: "block-mcp", + version: package_default.version + }, + { + capabilities: { + tools: {}, + resources: {}, + prompts: {} + }, + instructions + } + ); + registerHandlers(server); 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..7fbbc15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gravitykit/block-mcp", - "version": "1.6.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('