`
+ becomes a small card with a label/value pair per ``. The label
+ comes from `data-label` on the td. Tables without this class still
+ scroll horizontally. Use for tables that are read-heavy and benefit
+ from the column-label being visible next to each cell on phones. */
+ .table.mobile-stack { min-width: 0; display: block; }
+ .table.mobile-stack thead { display: none; }
+ .table.mobile-stack tbody { display: block; }
+ .table.mobile-stack tr {
+ display: block;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+ }
+ .table.mobile-stack tr:last-child { border-bottom: none; }
+ .table.mobile-stack tr:hover td { background: transparent; }
+ .table.mobile-stack td {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ padding: 5px 0;
+ border: none;
+ min-width: 0;
+ }
+ .table.mobile-stack td[data-label]::before {
+ content: attr(data-label);
+ font-size: 10.5px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-subtle);
+ flex-shrink: 0;
+ }
+ /* The first cell typically holds the row identifier — give it a slightly
+ larger feel by emitting on its own line above the rest. */
+ .table.mobile-stack td.row-head {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ padding-bottom: 8px;
+ }
+ .table.mobile-stack td.row-head::before { font-size: 10.5px; }
+ /* Right-aligned cells (actions, numbers) stay right-aligned. */
+ .table.mobile-stack td > * { min-width: 0; }
+ .table.mobile-stack td.cell-actions {
+ justify-content: flex-end;
+ margin-top: 6px;
+ }
+ .table.mobile-stack td.cell-actions > div {
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ }
+ .table.mobile-stack td.cell-actions::before { display: none; }
+ /* When stacked, the card no longer needs horizontal scroll. */
+ .card:has(> .table.mobile-stack),
+ .card:has(> div > .table.mobile-stack) { overflow-x: visible; }
+
+ /* Onboarding-style stepper crashes at narrow widths too. */
+ .stepper { flex-direction: column; gap: 8px; }
+ .step { border-radius: var(--r-md); border-right: 1px solid var(--border) !important; }
+ .step:not(:last-child) { border-right: 1px solid var(--border) !important; }
+
+ /* Dialogs/modals go full-screen so wide forms (Create OAuth client,
+ Create API key) stop being unreachable. Targets the dialog primitive
+ used across pages. */
+ .dialog-backdrop { padding: 0 !important; }
+ .dialog {
+ width: 100% !important;
+ max-width: 100% !important;
+ min-height: 100dvh;
+ border-radius: 0 !important;
+ margin: 0 !important;
+ }
+
+ /* Settings page tabs + rows */
+ .setting-row {
+ flex-direction: column;
+ align-items: stretch !important;
+ gap: 10px;
+ }
+ .setting-row > * { width: 100%; }
+
+ /* Audit logs filter row — wide inputs should fill the row */
+ .audit-filter input,
+ .audit-filter .input,
+ .audit-filter .seg { width: 100% !important; min-width: 0 !important; }
+}
+
+/* (Desktop topbar defaults moved up next to `.topbar` so source order
+ * doesn't reinstate `display: flex` over the mobile `display: none`.) */
diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css
new file mode 100644
index 0000000..4d7690a
--- /dev/null
+++ b/web/src/styles/tokens.css
@@ -0,0 +1,70 @@
+/* Design tokens — ported from claude.ai/design prototype.
+ Dark-first; brand cyan-teal with warm amber accent.
+ The hue is dynamic via --brand-hue so the (admin-only) Tweaks panel can change it. */
+
+:root {
+ --brand-hue: 205;
+
+ --brand-500: oklch(0.74 0.14 var(--brand-hue));
+ --brand-400: oklch(0.80 0.13 var(--brand-hue));
+ --brand-300: oklch(0.86 0.11 var(--brand-hue));
+ --brand-600: oklch(0.66 0.15 var(--brand-hue));
+ --brand-700: oklch(0.42 0.13 calc(var(--brand-hue) + 5));
+ --brand-glow: oklch(0.74 0.14 var(--brand-hue) / 0.35);
+
+ --accent-500: oklch(0.78 0.15 75);
+ --accent-600: oklch(0.70 0.16 65);
+
+ --success: oklch(0.74 0.15 155);
+ --warning: oklch(0.80 0.15 85);
+ --danger: oklch(0.66 0.19 25);
+ --info: oklch(0.72 0.13 235);
+
+ --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ --font-mono: "Geist Mono", ui-monospace, "JetBrains Mono", monospace;
+ --font-serif: "Instrument Serif", "Times New Roman", serif;
+ --font-fa: "Vazirmatn", var(--font-sans);
+
+ --r-xs: 4px;
+ --r-sm: 6px;
+ --r-md: 10px;
+ --r-lg: 14px;
+ --r-xl: 20px;
+ --r-2xl: 28px;
+
+ --density: 1;
+}
+
+html[data-theme="dark"] {
+ --bg: oklch(0.14 0.012 250);
+ --bg-elevated: oklch(0.17 0.014 250);
+ --bg-sunken: oklch(0.11 0.012 250);
+ --surface: oklch(0.20 0.014 250);
+ --surface-2: oklch(0.24 0.015 250);
+ --border: oklch(0.28 0.015 250);
+ --border-strong: oklch(0.36 0.020 250);
+ --text: oklch(0.97 0.005 250);
+ --text-muted: oklch(0.72 0.012 250);
+ --text-subtle: oklch(0.55 0.012 250);
+ --shadow-1: 0 1px 0 oklch(1 0 0 / 0.04) inset, 0 1px 2px oklch(0 0 0 / 0.4);
+ --shadow-2: 0 1px 0 oklch(1 0 0 / 0.05) inset, 0 8px 24px oklch(0 0 0 / 0.45);
+ --shadow-pop: 0 1px 0 oklch(1 0 0 / 0.08) inset, 0 24px 60px oklch(0 0 0 / 0.55);
+ --dot: oklch(0.22 0.014 250);
+}
+
+html[data-theme="light"] {
+ --bg: oklch(0.985 0.003 250);
+ --bg-elevated: oklch(1 0 0);
+ --bg-sunken: oklch(0.965 0.004 250);
+ --surface: oklch(1 0 0);
+ --surface-2: oklch(0.97 0.004 250);
+ --border: oklch(0.90 0.005 250);
+ --border-strong: oklch(0.82 0.008 250);
+ --text: oklch(0.18 0.012 250);
+ --text-muted: oklch(0.42 0.012 250);
+ --text-subtle: oklch(0.60 0.010 250);
+ --shadow-1: 0 1px 0 oklch(1 0 0) inset, 0 1px 2px oklch(0.5 0 0 / 0.08);
+ --shadow-2: 0 1px 0 oklch(1 0 0) inset, 0 8px 24px oklch(0.4 0 0 / 0.08);
+ --shadow-pop: 0 1px 0 oklch(1 0 0) inset, 0 24px 60px oklch(0.3 0 0 / 0.15);
+ --dot: oklch(0.93 0.005 250);
+}
diff --git a/web/src/test/Logo.test.tsx b/web/src/test/Logo.test.tsx
new file mode 100644
index 0000000..d07e659
--- /dev/null
+++ b/web/src/test/Logo.test.tsx
@@ -0,0 +1,35 @@
+import { describe, it, expect } from "vitest";
+import { render } from "@testing-library/react";
+import { Logo, LogoWordmark } from "../components/Logo";
+
+describe("Logo", () => {
+ it("renders the brand mark with default size", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toBeInTheDocument();
+ expect(svg).toHaveAttribute("width", "28");
+ expect(svg).toHaveAttribute("aria-label", "MCP Hub");
+ });
+
+ it("uses CSS vars for fills by default (theme-aware)", () => {
+ const { container } = render();
+ const paths = container.querySelectorAll("path");
+ expect(paths).toHaveLength(2);
+ expect(paths[0].getAttribute("fill")).toContain("var(--brand-500)");
+ expect(paths[1].getAttribute("fill")).toContain("var(--accent-500)");
+ });
+
+ it("falls back to original colors when original=true", () => {
+ const { container } = render();
+ const paths = container.querySelectorAll("path");
+ expect(paths[0].getAttribute("fill")).toBe("#51b9f4");
+ expect(paths[1].getAttribute("fill")).toBe("#fec13d");
+ });
+
+ it("LogoWordmark renders text and SVG", () => {
+ const { getByText, container } = render();
+ expect(getByText(/MCP/)).toBeInTheDocument();
+ expect(getByText(/Hub/)).toBeInTheDocument();
+ expect(container.querySelector("svg")).toBeInTheDocument();
+ });
+});
diff --git a/web/src/test/primitives.test.tsx b/web/src/test/primitives.test.tsx
new file mode 100644
index 0000000..94995f0
--- /dev/null
+++ b/web/src/test/primitives.test.tsx
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { Badge, Btn, Switch, Seg, Toast } from "../components/primitives";
+
+describe("Badge", () => {
+ it("renders children with variant class", () => {
+ const { container } = render(healthy);
+ const span = container.firstChild as HTMLElement;
+ expect(span.className).toContain("badge");
+ expect(span.className).toContain("badge-success");
+ expect(span.textContent).toBe("healthy");
+ });
+
+ it("adds badge-dot class when dot=true", () => {
+ const { container } = render(x);
+ expect((container.firstChild as HTMLElement).className).toContain("badge-dot");
+ });
+});
+
+describe("Btn", () => {
+ it("invokes onClick", () => {
+ const handler = vi.fn();
+ render(click);
+ fireEvent.click(screen.getByText("click"));
+ expect(handler).toHaveBeenCalled();
+ });
+
+ it("respects disabled", () => {
+ const handler = vi.fn();
+ render(
+
+ x
+ ,
+ );
+ fireEvent.click(screen.getByText("x"));
+ expect(handler).not.toHaveBeenCalled();
+ });
+});
+
+describe("Switch", () => {
+ it("toggles via click", () => {
+ const handler = vi.fn();
+ const { container } = render();
+ fireEvent.click(container.firstChild as HTMLElement);
+ expect(handler).toHaveBeenCalledWith(true);
+ });
+
+ it("reflects on state via class", () => {
+ const { container } = render( {}} />);
+ expect((container.firstChild as HTMLElement).className).toContain("on");
+ });
+});
+
+describe("Seg", () => {
+ it("highlights active option and switches on click", () => {
+ const handler = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByText("A").className).toContain("is-active");
+ fireEvent.click(screen.getByText("B"));
+ expect(handler).toHaveBeenCalledWith("b");
+ });
+});
+
+describe("Toast", () => {
+ it("renders msg", () => {
+ render( {}} />);
+ expect(screen.getByText("saved")).toBeInTheDocument();
+ });
+
+ it("renders nothing without msg", () => {
+ const { container } = render( {}} />);
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/web/src/test/role-visibility.test.tsx b/web/src/test/role-visibility.test.tsx
new file mode 100644
index 0000000..88fe895
--- /dev/null
+++ b/web/src/test/role-visibility.test.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { Sidebar } from "../components/Sidebar";
+import { SettingsPage } from "../pages/Settings";
+import type { Session } from "../lib/types";
+
+const state = vi.hoisted(() => ({
+ session: {
+ authenticated: true,
+ user_id: "admin",
+ email: "admin@example.com",
+ name: "Admin",
+ role: "admin",
+ type: "master",
+ is_admin: true,
+ lang: "en",
+ } as Session,
+}));
+
+vi.mock("../lib/queries", () => ({
+ useAdminApiKeys: () => ({ data: [] }),
+ useManagedSettings: () => ({ data: [], isLoading: false }),
+ useOAuthClients: () => ({ data: [] }),
+ useResetSettings: () => ({ mutate: vi.fn(), isPending: false }),
+ useSaveSetting: () => ({ mutate: vi.fn(), isPending: false }),
+ useSession: () => ({ data: state.session }),
+ useSites: () => ({ data: [] }),
+ useTranslations: () => ({ data: undefined }),
+ useUserKeys: () => ({ data: [] }),
+}));
+
+function userSession(): Session {
+ return {
+ authenticated: true,
+ user_id: "user",
+ email: "user@example.com",
+ name: "User",
+ role: "user",
+ type: "oauth_user",
+ is_admin: false,
+ lang: "en",
+ };
+}
+
+function renderSidebar(session: Session) {
+ render(
+
+
+ ,
+ );
+}
+
+describe("role-based dashboard visibility", () => {
+ it("shows admin-only navigation for admins", () => {
+ renderSidebar(state.session);
+
+ expect(screen.queryByText("OAuth Clients")).not.toBeInTheDocument();
+ expect(screen.getByText("Health")).toBeInTheDocument();
+ expect(screen.getByText("Audit Logs")).toBeInTheDocument();
+ expect(screen.getByText("Support MCP Hub")).toBeInTheDocument();
+ });
+
+ it("hides admin-only navigation for normal users", () => {
+ renderSidebar(userSession());
+
+ expect(screen.queryByText("OAuth Clients")).not.toBeInTheDocument();
+ expect(screen.queryByText("Health")).not.toBeInTheDocument();
+ expect(screen.queryByText("Audit Logs")).not.toBeInTheDocument();
+ expect(screen.getByText("Support MCP Hub")).toBeInTheDocument();
+ });
+
+ it("shows settings limits, plugin visibility, and danger tabs only for admins", () => {
+ state.session = { ...state.session, is_admin: true, role: "admin" };
+ const admin = render();
+
+ expect(screen.getByText("Limits")).toBeInTheDocument();
+ expect(screen.getByText("Public plugin visibility")).toBeInTheDocument();
+ expect(screen.getByText("Danger zone")).toBeInTheDocument();
+
+ admin.unmount();
+ state.session = userSession();
+ render();
+
+ expect(screen.queryByText("Limits")).not.toBeInTheDocument();
+ expect(screen.queryByText("Public plugin visibility")).not.toBeInTheDocument();
+ expect(screen.queryByText("Danger zone")).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
new file mode 100644
index 0000000..d0de870
--- /dev/null
+++ b/web/src/test/setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom";
diff --git a/web/src/test/site-form-ai.test.tsx b/web/src/test/site-form-ai.test.tsx
new file mode 100644
index 0000000..ca1141c
--- /dev/null
+++ b/web/src/test/site-form-ai.test.tsx
@@ -0,0 +1,62 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { SiteFormDialog } from "../components/SiteFormDialog";
+import type { Site } from "../lib/types";
+
+vi.mock("../lib/queries", () => ({
+ useCreateSite: () => ({ mutateAsync: vi.fn() }),
+ usePluginCatalog: () => ({
+ isLoading: false,
+ data: [
+ {
+ type: "wordpress",
+ name: "WordPress",
+ fields: [
+ { name: "username", label: "Username", type: "text", required: true },
+ { name: "app_password", label: "Application Password", type: "password", required: true },
+ ],
+ },
+ {
+ type: "woocommerce",
+ name: "WooCommerce",
+ fields: [
+ { name: "consumer_key", label: "Consumer Key", type: "text", required: true },
+ { name: "consumer_secret", label: "Consumer Secret", type: "password", required: true },
+ ],
+ },
+ {
+ type: "coolify",
+ name: "Coolify",
+ fields: [],
+ },
+ ],
+ }),
+ useTranslations: () => ({ data: undefined }),
+ useUpdateSite: () => ({ mutateAsync: vi.fn() }),
+}));
+
+const noop = vi.fn();
+
+describe("SiteFormDialog AI image placement", () => {
+ it("shows AI Image Generation guidance while adding a WordPress site", () => {
+ render();
+
+ expect(screen.getByText("AI Image Generation")).toBeInTheDocument();
+ expect(screen.getByText(/After creating this service/)).toBeInTheDocument();
+ });
+
+ it("shows the tools-page link while editing a WooCommerce site", () => {
+ const site = {
+ id: "site-1",
+ alias: "shop",
+ url: "https://shop.example.com",
+ plugin_type: "woocommerce",
+ status: "active",
+ } as Site;
+
+ render();
+
+ const link = screen.getByRole("link", { name: "Open AI Image Generation settings" });
+ expect(link).toHaveAttribute("href", "/dashboard/sites/site-1/tools");
+ });
+});
diff --git a/web/src/test/site-tools-ai.test.tsx b/web/src/test/site-tools-ai.test.tsx
new file mode 100644
index 0000000..876de60
--- /dev/null
+++ b/web/src/test/site-tools-ai.test.tsx
@@ -0,0 +1,138 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { SiteToolsPage } from "../pages/SiteTools";
+
+const state = vi.hoisted(() => ({
+ pluginType: "woocommerce",
+ providers: [] as string[],
+}));
+
+vi.mock("../lib/queries", () => ({
+ useDeleteSiteProviderKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useOpenRouterImageModels: () => ({
+ data: [
+ {
+ id: "google/gemini-2.5-flash-image",
+ name: "Gemini 2.5 Flash Image",
+ price_per_image_usd: 0.039,
+ },
+ ],
+ isLoading: false,
+ isError: false,
+ }),
+ useSetSiteProviderDefaultModel: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useSetSiteProviderKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useSiteCapabilities: () => ({
+ isLoading: false,
+ data: {
+ ok: true,
+ plugin_type: state.pluginType,
+ probe_available: true,
+ granted: ["read_write"],
+ ai_providers_configured: state.providers,
+ tier: "admin",
+ fit: { status: "ok", required: ["write_products"], missing: [] },
+ },
+ }),
+ useSite: () => ({
+ data: {
+ id: "site-1",
+ alias: "shop",
+ plugin_type: state.pluginType,
+ tool_scope: "admin",
+ },
+ }),
+ useSiteProviderKeys: () => ({
+ data: { ok: true, providers: state.providers, default_models: { openrouter: "google/gemini-2.5-flash-image" } },
+ }),
+ useSiteTools: () => ({
+ isLoading: false,
+ data: {
+ site_id: "site-1",
+ plugin_type: state.pluginType,
+ tool_scope: "admin",
+ scope_presets: [],
+ configured_providers: state.providers,
+ tools: [
+ {
+ name: "woocommerce_generate_and_upload_image",
+ description: "Generate an image and upload it to WooCommerce media.",
+ plugin_type: "woocommerce",
+ category: "media",
+ sensitivity: null,
+ required_scope: "write",
+ enabled: true,
+ provider_key_required: true,
+ provider_key_configured: state.providers.length > 0,
+ available: state.providers.length > 0,
+ unavailable_reason: state.providers.length > 0 ? null : "provider_key",
+ },
+ ],
+ },
+ }),
+ useToggleSiteTool: () => ({ mutateAsync: vi.fn() }),
+ useTranslations: () => ({ data: undefined }),
+ useUpdateSiteToolScope: () => ({ mutateAsync: vi.fn(), isPending: false }),
+}));
+
+function renderPage() {
+ render(
+
+
+ } />
+
+ ,
+ );
+}
+
+describe("SiteTools AI image provider UI", () => {
+ it("shows provider-key management for WooCommerce image tools", () => {
+ state.pluginType = "woocommerce";
+ state.providers = [];
+
+ renderPage();
+
+ expect(screen.getByText("AI Image Generation")).toBeInTheDocument();
+ expect(screen.getByText("OpenAI")).toBeInTheDocument();
+ expect(screen.getByText("OpenRouter")).toBeInTheDocument();
+ expect(screen.getByText("Needs an AI provider key — configure one above.")).toBeInTheDocument();
+ expect(screen.getByText("needs AI provider key")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Configure key" })).toBeInTheDocument();
+ expect(screen.getByText("woocommerce_generate_and_upload_image")).toBeInTheDocument();
+ });
+
+ it("shows service readiness guidance for the current tool scope", () => {
+ state.pluginType = "woocommerce";
+ state.providers = [];
+
+ renderPage();
+
+ expect(screen.getByText("Service readiness")).toBeInTheDocument();
+ expect(screen.getByText("Credential requirement for Admin")).toBeInTheDocument();
+ expect(screen.getByText("credential fits selected tier")).toBeInTheDocument();
+ expect(screen.getByText(/Configured AI providers/)).toBeInTheDocument();
+ expect(screen.getByText(/Media and AI image upload tools additionally need/)).toBeInTheDocument();
+ expect(screen.getByText(/Admin grants the full destructive surface/)).toBeInTheDocument();
+ });
+
+ it("shows OpenRouter default image model selection when the key is set", () => {
+ state.pluginType = "woocommerce";
+ state.providers = ["openrouter"];
+
+ renderPage();
+
+ expect(screen.getByText("Default image model")).toBeInTheDocument();
+ expect(screen.getByText("Gemini 2.5 Flash Image")).toBeInTheDocument();
+ expect(screen.getByText(/Current default:/)).toBeInTheDocument();
+ });
+
+ it("hides provider-key management for non-WordPress services", () => {
+ state.pluginType = "coolify";
+ state.providers = [];
+
+ renderPage();
+
+ expect(screen.queryByText("AI Image Generation")).not.toBeInTheDocument();
+ });
+});
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..8cb5183
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"],
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src", "vite.config.ts", "vitest.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..0d2cd42
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,38 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import path from "node:path";
+
+// SPA build → core/templates/static/dist for Starlette to serve.
+// In dev, proxy /api, /auth, /dashboard, /static to the running Python server.
+export default defineConfig({
+ plugins: [react()],
+ base: "/static/dist/",
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "src"),
+ },
+ },
+ build: {
+ outDir: path.resolve(__dirname, "../core/templates/static/dist"),
+ emptyOutDir: true,
+ sourcemap: true,
+ assetsDir: "assets",
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ "/api": "http://localhost:8000",
+ "/auth": "http://localhost:8000",
+ "/dashboard": "http://localhost:8000",
+ "/static": "http://localhost:8000",
+ "/health": "http://localhost:8000",
+ "/oauth": "http://localhost:8000",
+ },
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./src/test/setup.ts"],
+ css: false,
+ },
+});
diff --git a/wordpress-plugin/SUBMISSION.md b/wordpress-plugin/SUBMISSION.md
new file mode 100644
index 0000000..a033427
--- /dev/null
+++ b/wordpress-plugin/SUBMISSION.md
@@ -0,0 +1,185 @@
+# Airano MCP Bridge — wp.org Submission Handoff
+
+This document covers the full transition from the legacy `airano-mcp-seo-bridge` slug to the new `airano-mcp-bridge` slug on WordPress.org.
+
+---
+
+## Phase 1 — Local prep (DONE)
+
+- [x] readme.txt header: `Stable tag: 2.10.1`, `Tested up to: 6.9` (major.minor only — Plugin Check rejects `6.9.4`)
+- [x] PHP header: `Plugin URI` (live site) and `Author URI` (GitHub org) are now distinct — Plugin Check rejected when both pointed to the same URL
+- [x] readme.txt: changelog entries for 2.10.0 and 2.10.1, matching upgrade notices
+- [x] readme.txt + main PHP: live-site link (`https://mcp.palebluedot.live`) added; GitHub source kept as secondary
+- [x] README.md version bumped to 2.10.1
+- [x] Plugin URI + Author URI in main PHP point to live site
+- [x] Submission zip rebuilt with POSIX paths: `wordpress-plugin/airano-mcp-bridge.zip` (33.9 KB, 3 files)
+- [x] SVN staging layout prepared at `wordpress-plugin/airano-mcp-bridge-svn/`:
+ - `trunk/` — final files
+ - `assets/icon.svg` — copied from `core/templates/static/logo.svg`
+ - `tags/` — empty (release tags created in Phase 3)
+
+---
+
+## Phase 2 — Submit to wp.org (USER ACTION, in browser)
+
+### Step 1 — Go to the submission form
+
+https://wordpress.org/plugins/developers/add/
+
+Sign in with the same wp.org account that owns `airano-mcp-seo-bridge` (`airano`).
+
+### Step 2 — Upload the ZIP
+
+The submission form has **one upload field** + **one "Additional Information" textbox**. The plugin name, slug, tags and full description all come from the `readme.txt` inside the ZIP — the form does NOT ask for them separately.
+
+- **ZIP**: `wordpress-plugin/airano-mcp-bridge.zip`
+- **Additional Information**: paste the short message in Step 3 below
+
+### Step 3 — "Additional Information" message (paste this)
+
+This field is read by reviewers, not end users. Keep it short and give context they can't infer from the readme.
+
+```
+Companion plugin for MCP Hub (https://mcp.palebluedot.live), a self-hosted
+hub that lets AI assistants manage WordPress, WooCommerce and other
+self-hosted services through the Model Context Protocol.
+
+The plugin extends the WordPress REST API with a few routes the stock
+endpoints can't cover — raw-binary media uploads that bypass
+upload_max_filesize, bulk meta writes, cache purge, transient cleanup,
+unified site-health snapshot, HMAC-signed audit webhook, and SEO meta
+for Rank Math / Yoast. All routes are gated by WordPress capability
+checks and require Application Password authentication.
+
+Submission notes for the review team:
+
+- Same author account ("airano") previously published
+ https://wordpress.org/plugins/airano-mcp-seo-bridge/ (1.3.0, 30+ active
+ installs). That earlier plugin was SEO-only. Over the past two months
+ it grew well beyond SEO, so we are submitting under a new slug
+ (airano-mcp-bridge) that better reflects the broader scope. The old
+ slug will get a final release with a migration notice and then be
+ retired — we won't rename or replace its main file.
+
+- All file I/O uses WP_Filesystem, not bare PHP file functions.
+- All output is escaped (esc_html, esc_url, wp_kses_post as appropriate).
+- Translations use the 'airano-mcp-bridge' text domain (36 strings).
+- No bundled binaries, no obfuscation, no remote calls except the
+ user-configured audit-hook webhook (which is opt-in and fully disabled
+ by default).
+```
+
+### Step 4 — After submitting
+
+You'll get an automated confirmation email. Reviewers may take **1-14 days** to respond. They might:
+
+- **Approve immediately** (clean readme + standards-compliant code) → SVN credentials emailed
+- **Ask for fixes** (e.g. extra escaping, license clarifications) → reply with patches
+- **Reject** → reasons given; address and resubmit
+
+**While you wait:** the old slug `airano-mcp-seo-bridge` stays live and untouched. If you need to push an urgent fix to the existing 30+ installs in the meantime, you can — see `Fallback` section below.
+
+---
+
+## Phase 3 — After approval (CLI commands, run from this repo)
+
+When wp.org emails the SVN URL (`https://plugins.svn.wordpress.org/airano-mcp-bridge/`):
+
+```bash
+# 1. Checkout the empty SVN repo
+svn co https://plugins.svn.wordpress.org/airano-mcp-bridge/ /tmp/airano-mcp-bridge-svn
+
+# 2. Copy our prepared layout into it
+cp -r wordpress-plugin/airano-mcp-bridge-svn/trunk/* /tmp/airano-mcp-bridge-svn/trunk/
+cp -r wordpress-plugin/airano-mcp-bridge-svn/assets/* /tmp/airano-mcp-bridge-svn/assets/
+
+# 3. Stage everything
+cd /tmp/airano-mcp-bridge-svn
+svn add trunk/* --force
+svn add assets/* --force
+
+# 4. First commit to trunk
+svn ci -m "Initial release 2.10.1 — companion plugin for MCP Hub"
+
+# 5. Tag the release (so wp.org indexes it as v2.10.1)
+svn cp trunk tags/2.10.1
+svn ci -m "Tag 2.10.1"
+```
+
+### Asset checklist (Phase 3 polish — can be done after first release)
+
+- [x] `assets/icon.svg` — staged
+- [ ] `assets/icon-256x256.png` — render the SVG at 256x256 (any online SVG-to-PNG tool, e.g. [cloudconvert](https://cloudconvert.com/svg-to-png))
+- [ ] `assets/banner-772x250.png` — needs design (logo + "Airano MCP Bridge" text + tagline on a #51b9f4 / #fec13d brand background)
+- [ ] `assets/banner-1544x500.png` — 2x retina version of the banner
+- [ ] `assets/screenshot-1.png` (optional) — could be a screenshot of MCP Hub dashboard showing the connected WP site, or skipped entirely
+
+---
+
+## Phase 4 — Deprecate old slug (LATER, weeks after Phase 3 is stable)
+
+### Strategy: soft handover, then closure
+
+1. **Wait** until the new plugin has its own organic traffic and a working install base (~2-4 weeks).
+2. **Push a final 1.x release** to the OLD slug (`airano-mcp-seo-bridge`) that does **two things**:
+ - Adds a persistent admin notice: *"Airano MCP SEO Meta Bridge has been renamed and rebuilt as **Airano MCP Bridge**. Install the new plugin and deactivate this one. → \[Install link]"*
+ - Keeps the old REST routes (`airano-mcp-seo-bridge/v1/*`) working so existing MCP Hub deployments don't break overnight.
+3. **Email closure** to `plugins@wordpress.org` from the author email **only after** the new plugin has been live for at least a month.
+
+### Closure email template (use later, NOT NOW)
+
+```
+To: plugins@wordpress.org
+Subject: Plugin closure request — airano-mcp-seo-bridge
+
+Hello,
+
+Please close the plugin with slug "airano-mcp-seo-bridge"
+(https://wordpress.org/plugins/airano-mcp-seo-bridge/).
+
+Reason: The plugin has been rebuilt and rebranded as "Airano MCP Bridge"
+(slug: airano-mcp-bridge), available at
+https://wordpress.org/plugins/airano-mcp-bridge/. The old slug's name
+and SEO-only scope no longer reflect what the plugin does.
+
+A migration notice has been published to the old slug since version 1.4.0
+directing users to the new plugin.
+
+Author: airano
+```
+
+---
+
+## Fallback — if wp.org rejects or you need to ship to old users first
+
+The old slug still works. To push an urgent fix (e.g. critical bug) without waiting for the new plugin to be approved:
+
+```bash
+# Checkout old slug SVN
+svn co https://plugins.svn.wordpress.org/airano-mcp-seo-bridge/ /tmp/old-svn
+
+# Copy current code in (need to rename main file back to airano-mcp-seo-bridge.php
+# and adjust Plugin Name in the header to match the old display name)
+# ... then commit + tag as you would for a normal release
+
+# Bump the version in trunk/readme.txt and trunk/airano-mcp-seo-bridge.php
+# svn ci -m "Bug fix release X.Y.Z"
+# svn cp trunk tags/X.Y.Z
+# svn ci -m "Tag X.Y.Z"
+```
+
+⚠️ Don't push the new file `airano-mcp-bridge.php` directly to the old slug — wp.org tracks the main plugin file by name and renaming it breaks WordPress's auto-update.
+
+---
+
+## Files touched in Phase 1
+
+- `wordpress-plugin/airano-mcp-bridge/readme.txt` — version, changelog, upgrade notice, live-site link
+- `wordpress-plugin/airano-mcp-bridge/README.md` — version, live-site link
+- `wordpress-plugin/airano-mcp-bridge/airano-mcp-bridge.php` — Plugin URI, Author URI
+- `wordpress-plugin/airano-mcp-bridge.zip` — rebuilt with POSIX paths
+
+## New files (staging only — not yet committed anywhere)
+
+- `wordpress-plugin/airano-mcp-bridge-svn/{trunk,assets,tags}/` — pre-built SVN layout
+- `wordpress-plugin/SUBMISSION.md` — this file
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/README.md b/wordpress-plugin/airano-mcp-bridge-wporg/README.md
new file mode 100644
index 0000000..e929f65
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge-wporg/README.md
@@ -0,0 +1,80 @@
+# airano-mcp-bridge — WordPress.org submission package
+
+This folder is the **wp.org-bound** version of the companion plugin. It is
+deliberately the small surface (≈15 routes — SEO, media uploads, capability
+probe, cache/transient/site-health, audit-hook), **not** the full v2.18.x
+internal build.
+
+The internal build at [../airano-mcp-bridge/](../airano-mcp-bridge) carries
+the admin namespace (plugins/themes/files/db/elementor/etc.) which is too
+broad for wp.org's review bar and stays out of the public release.
+
+## Layout
+
+```
+airano-mcp-bridge-wporg/
+├── airano-mcp-bridge/ ← what goes in the zip
+│ ├── airano-mcp-bridge.php
+│ ├── readme.txt
+│ └── README.md
+├── tests/
+│ ├── check.sh ← static checks (headers, escaping, routes, etc.)
+│ └── test_permission_callback.php ← unit test for the new gate
+└── airano-mcp-bridge.zip ← ready-to-upload bundle (built from the folder)
+```
+
+## Pre-submission checks
+
+```bash
+# 1. Static / heuristic checks (no WordPress runtime required)
+bash wordpress-plugin/airano-mcp-bridge-wporg/tests/check.sh
+
+# 2. Unit tests on the new permission_callback (PHP-only, no WP bootstrap)
+php wordpress-plugin/airano-mcp-bridge-wporg/tests/test_permission_callback.php
+```
+
+Both must exit 0 before zipping or uploading.
+
+## Building the zip
+
+```bash
+python - <<'PY'
+import zipfile, os
+src = "wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge"
+dst = "wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge.zip"
+if os.path.exists(dst): os.remove(dst)
+with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
+ for root, _, files in os.walk(src):
+ for f in files:
+ full = os.path.join(root, f)
+ arc = os.path.relpath(full, "wordpress-plugin/airano-mcp-bridge-wporg").replace(os.sep, "/")
+ z.write(full, arc)
+print("built:", dst, os.path.getsize(dst), "bytes")
+PY
+```
+
+## Reply to the wp.org review email
+
+Use the message in [SUBMISSION_REPLY.md](SUBMISSION_REPLY.md). Keep it
+short — the wp.org guidance is "be brief and direct".
+
+## What the v2.10.2 release fixes (vs the v2.10.1 they reviewed)
+
+1. **Direct core file include**
+ `wp-admin/includes/media.php` was loaded inside the `/upload-chunk` and
+ `/upload-and-attach` REST callbacks but never used (the code only calls
+ helpers from `file.php` and `image.php`). The redundant `require_once`
+ lines are removed.
+
+2. **`/upload-and-attach` permission gate**
+ The route now uses a dedicated `require_upload_and_attach_capability`
+ method that runs `current_user_can('edit_post', $attach_to_post)` at
+ the route gate when `attach_to_post` is supplied. The check is no
+ longer hidden inside the callback body, so static analysis can see it.
+ The method also rejects `set_featured` without a target post.
+
+3. **Ownership verification**
+ DNS TXT record `wordpressorg-airano-verification` is published at
+ `mcp.example.com`, which is also the `Plugin URI`. This pairs the
+ submission with the domain the plugin advertises and satisfies the
+ wp.org review request for a non-gmail.com proof of identity.
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/SUBMISSION_REPLY.md b/wordpress-plugin/airano-mcp-bridge-wporg/SUBMISSION_REPLY.md
new file mode 100644
index 0000000..db5cfe7
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge-wporg/SUBMISSION_REPLY.md
@@ -0,0 +1,95 @@
+# Reply to wp.org review email — paste into the email thread
+
+Keep this short and direct. wp.org explicitly asks reviewers to skim, not read essays.
+
+---
+
+## Subject line (current round — class-prefix fix)
+
+Re: It's time to move forward with the plugin review "airano" — airano-mcp-bridge
+
+(Reply in the existing thread; don't open a new one.)
+
+---
+
+## Body — paste this for the SECOND review (class prefix fix)
+
+```
+Hi,
+
+Thanks. Addressed in v2.10.3:
+
+- Main class renamed from generic `SEO_API_Bridge` to `Airano_MCP_Bridge`
+ to use the plugin-specific prefix (your "unique prefixes" guideline).
+ All other elements already used the `airano` prefix as you noted.
+ This is an internal refactor — the class is only instantiated from the
+ file's bootstrap line and is never referenced by callers, so there's no
+ public-API impact.
+
+Updated zip uploaded via "Add your plugin" while logged in as airano.
+
+Thanks,
+airano
+```
+
+### Reply for the FIRST review (already sent — kept here for reference)
+
+This was the response to the initial pre-review email about ownership
++ media.php/permission_callback. Already sent.
+
+```
+Hi,
+
+Thanks for the review. Two notes on the requested items:
+
+1) Ownership / domain verification
+
+The plugin's URI is https://mcp.example.com (the public site for
+MCP Hub, the project this companion plugin pairs with). I publish to
+both mcp.example.com and example.com; my account on
+wordpress.org is "airano".
+
+I've published the requested DNS TXT record at the plugin's domain:
+
+ Host: mcp.example.com
+ Type: TXT
+ Value: wordpressorg-airano-verification
+
+Please verify with `dig TXT mcp.example.com +short` (the value
+should appear within the TTL).
+
+I have also already published https://wordpress.org/plugins/airano-mcp-seo-bridge/
+under the same airano account. That plugin is the SEO-only ancestor of
+this one. Once airano-mcp-bridge is approved I will release a final
+update there pointing users to the new slug.
+
+2) Code review items
+
+Both code points have been addressed in v2.10.2:
+
+- The redundant `wp-admin/includes/media.php` requires inside
+ /upload-chunk and /upload-and-attach are removed. Those callbacks
+ only use helpers from file.php (wp_tempnam, wp_handle_sideload) and
+ image.php (wp_generate_attachment_metadata).
+
+- /upload-and-attach now has its own permission_callback,
+ `require_upload_and_attach_capability`, which enforces
+ `current_user_can('edit_post', $attach_to_post)` at the route gate
+ when attach_to_post is supplied (no longer hidden inside the
+ callback body), and rejects set_featured without a target post.
+
+I've uploaded the corrected v2.10.2 zip via "Add your plugin" while
+logged in as airano.
+
+Happy to make further changes if anything else turns up on a closer read.
+
+Thanks,
+airano
+```
+
+---
+
+## Before sending
+
+- [ ] You've uploaded `airano-mcp-bridge.zip` (v2.10.3) via the same "Add your plugin" page while logged in as `airano`.
+- [ ] You're replying *in the existing thread* with the body shown above.
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/SVN_RELEASE.md b/wordpress-plugin/airano-mcp-bridge-wporg/SVN_RELEASE.md
new file mode 100644
index 0000000..1d19f36
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge-wporg/SVN_RELEASE.md
@@ -0,0 +1,132 @@
+# Releasing v2.10.3 to wordpress.org via SVN — DONE 2026-05-10
+
+**Status (2026-05-10):**
+- ✅ trunk committed at `r3527909` (`svn ci`-ed by maintainer)
+- ✅ assets committed at `r3527917` (icon-128/256, banner-772x250 / 1544x500)
+- ✅ tag `2.10.3` committed at `r3527918`
+- ✅ legacy slug `airano-mcp-seo-bridge` updated to v1.3.1 at
+ `r3527927/r3527928` with migration notice pointing here
+
+Live page:
+
+The rest of this file is the original runbook, kept as reference for
+future releases.
+
+---
+
+---
+
+## What is already staged for you
+
+A working copy is checked out at
+`wordpress-plugin/airano-mcp-bridge-wporg-svn/` (gitignored). The three
+plugin files have been copied into `trunk/` and `svn add`-ed:
+
+```
+A trunk/README.md
+A trunk/airano-mcp-bridge.php
+A trunk/readme.txt
+```
+
+The files are byte-identical to
+`wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/`, which is
+the version that just passed review.
+
+---
+
+## Step 1 — confirm credentials
+
+If the approval email landed less than an hour ago, wait until the hour
+is up. Then verify your SVN password at:
+
+ https://profiles.wordpress.org/me/profile/edit/group/3/?screen=svn-password
+
+The SVN username is your wordpress.org login: `airano` (case-sensitive).
+
+---
+
+## Step 2 — commit the initial release to trunk
+
+```bash
+cd wordpress-plugin/airano-mcp-bridge-wporg-svn
+
+svn ci -m "Initial release v2.10.3"
+# → SVN will prompt for username (airano) and password the first time.
+# Tick "store password" if you trust the machine.
+```
+
+Wait for the response. A successful commit ends with `Committed revision XXXXXXX.`
+
+---
+
+## Step 3 — tag the release
+
+wp.org expects every released version to also exist as a tag under
+`tags//`. The Stable Tag in `readme.txt` already points to
+`2.10.3`, so this tag is what `wp.org` actually serves to users.
+
+```bash
+# Still inside wordpress-plugin/airano-mcp-bridge-wporg-svn/
+svn cp trunk tags/2.10.3
+svn ci -m "Tag 2.10.3"
+```
+
+After this, `https://wordpress.org/plugins/airano-mcp-bridge/` will start
+serving v2.10.3 within a few minutes.
+
+---
+
+## Step 4 — verify on wp.org
+
+- Plugin page:
+- Latest tag:
+- Trunk:
+
+The "Tested up to" badge, version number, and changelog should all show
+2.10.3.
+
+---
+
+## Optional but recommended — plugin assets
+
+Plugin assets (icon, banner, screenshots) live in the SVN `assets/`
+directory at the same level as `trunk/` — NOT inside `trunk/assets/`.
+
+Required filenames (any of):
+```
+assets/icon-128x128.png (or .jpg / icon.svg)
+assets/icon-256x256.png (high-DPI)
+assets/banner-772x250.png (header image, listing card)
+assets/banner-1544x500.png (high-DPI header)
+assets/screenshot-1.png (matched by readme.txt "== Screenshots ==" entries)
+assets/screenshot-2.png
+```
+
+To upload after creating them locally:
+```bash
+cd wordpress-plugin/airano-mcp-bridge-wporg-svn
+cp /path/to/icon-256x256.png assets/
+cp /path/to/banner-1544x500.png assets/
+svn add assets/icon-256x256.png assets/banner-1544x500.png
+svn ci -m "Add plugin assets — icon + banner"
+```
+
+Spec:
+
+---
+
+## Optional — close the old slug
+
+The old slug `airano-mcp-seo-bridge` (the SEO-only ancestor) is still
+live. After v2.10.3 of the new slug is on the directory, you have two
+options:
+
+**(a) Push a final 1.x release to the old slug** that simply prints an
+admin notice ("This plugin is now Airano MCP Bridge — please install
+that and deactivate this one"). Less disruptive for existing users.
+
+**(b) Email `plugins@wordpress.org`** asking to close the old slug as
+"author request — superseded by airano-mcp-bridge". Faster.
+
+Either is fine. Do this *after* the new slug is live and you've
+confirmed at least one install path works.
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/banner-1544x500.png b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/banner-1544x500.png
new file mode 100644
index 0000000..9b3cf21
Binary files /dev/null and b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/banner-1544x500.png differ
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/banner-772x250.png b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/banner-772x250.png
new file mode 100644
index 0000000..c8cb5e3
Binary files /dev/null and b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/banner-772x250.png differ
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/icon-128x128.png b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/icon-128x128.png
new file mode 100644
index 0000000..f35b51e
Binary files /dev/null and b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/icon-128x128.png differ
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/icon-256x256.png b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/icon-256x256.png
new file mode 100644
index 0000000..df8eab3
Binary files /dev/null and b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge-assets/icon-256x256.png differ
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge.zip b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge.zip
new file mode 100644
index 0000000..b7eb4d3
Binary files /dev/null and b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge.zip differ
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/README.md b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/README.md
new file mode 100644
index 0000000..2b430ee
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/README.md
@@ -0,0 +1,193 @@
+# Airano MCP Bridge
+
+**Version:** 2.10.3
+**Requires WordPress:** 5.0+
+**Tested up to:** 6.9
+**Requires PHP:** 7.4+
+**License:** GPLv2 or later
+**Plugin slug:** `airano-mcp-bridge` (permanent)
+
+> Companion plugin for [MCP Hub](https://mcp.example.com) — the AI-native management hub for WordPress, WooCommerce, and self-hosted services. Source: [github.com/airano-ir/mcphub](https://github.com/airano-ir/mcphub).
+
+## What this plugin does
+
+MCP Hub lets AI assistants (Claude, ChatGPT, Cursor, VS Code Copilot, …) manage your WordPress site through the Model Context Protocol. The core features work against the stock `/wp-json/wp/v2/` REST endpoints — **you do not need this plugin to use MCP Hub**.
+
+Installing **Airano MCP Bridge** unlocks a second tier of capabilities that WordPress's built-in REST API can't reach on its own:
+
+- **Large-file uploads** that bypass `upload_max_filesize`
+- **One-round-trip upload + metadata + attach + set-featured** (v2.9.0)
+- **Thumbnail regeneration** (no WP-CLI / SSH)
+- **Cache purge** for 6 major cache plugins (LiteSpeed, WP Rocket, W3TC, WP Super Cache, WP Fastest Cache, SG Optimizer)
+- **Transient cleanup** (expired / all / pattern)
+- **Bulk post-meta writes** in a single REST round-trip
+- **Unified site-health snapshot** (5+ stock calls → 1)
+- **Structured JSON export** (posts / pages / products + media + terms + meta)
+- **Capability probe** — tells MCP Hub what the calling application password can actually do
+- **Audit hook** — forwards WordPress action events to MCP Hub with HMAC-signed webhooks
+- **SEO meta routes** for Rank Math and Yoast
+
+All operations remain guarded by WordPress's own capability system — this plugin never bypasses auth.
+
+## Installation
+
+1. Download `airano-mcp-bridge.zip` from the MCP Hub release page.
+2. In WP Admin → **Plugins → Add New → Upload Plugin**, choose the zip and **Activate**.
+3. Generate an **Application Password** under Users → Profile (scroll to bottom).
+4. In MCP Hub dashboard → **My Sites**, add your WordPress site using that Application Password.
+
+The plugin works out of the box. There are no settings pages to configure; behavior is controlled entirely through the REST API.
+
+## REST API endpoints
+
+### SEO meta (namespace `airano-mcp-bridge/v1`)
+
+- `GET / POST /posts/{id}/seo` — Post SEO data (Rank Math or Yoast, auto-detected)
+- `GET / POST /pages/{id}/seo` — Page SEO data
+- `GET / POST /products/{id}/seo` — WooCommerce product SEO data
+- `GET /status` — Plugin status + active SEO plugins list
+
+### MCP Bridge helpers (namespace `airano-mcp/v1`)
+
+| Route | Method | Introduced | Purpose |
+|---|---|---|---|
+| `/upload-limits` | GET | 2.0.0 | Returns effective PHP + WP upload limits so MCP Hub can pick the right upload path |
+| `/upload-chunk` | POST | 2.0.0 | Raw-body upload that bypasses `upload_max_filesize` (still bounded by `post_max_size`) |
+| `/upload-and-attach` | POST | 2.9.0 | Same as `/upload-chunk` plus query-param metadata (`attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`) — collapses 3 REST round-trips into 1 |
+| `/capabilities` | GET | 2.1.0 | Effective capabilities of the calling Application Password + feature flags + available routes |
+| `/bulk-meta` | POST | 2.2.0 | Batch `post_meta` writes in one request (max 500 items, per-item permission check) |
+| `/export` | GET | 2.3.0 | Structured JSON export of posts / pages / products + media + terms + meta, with `post_type` / `status` / `since` / `limit` / `offset` paging |
+| `/cache-purge` | POST | 2.4.0 | Auto-detects LiteSpeed, WP Rocket, W3 Total Cache, WP Super Cache, WP Fastest Cache, SiteGround Optimizer and triggers each one's purge API; always flushes object cache |
+| `/transient-flush` | POST | 2.5.0 | Native transient cleanup with `expired` / `all` / `pattern` scopes; optional site-transient handling for multisite |
+| `/site-health` | GET | 2.6.0 | Single-envelope health snapshot: WP/PHP/MySQL versions, disk free, active plugins, theme, writability, SSL — replaces 5+ stock calls |
+| `/audit-hook` | GET / POST / DELETE | 2.7.0 | Configure a webhook that forwards WP action events to MCP Hub. HMAC-SHA256-signed, non-blocking |
+| `/regenerate-thumbnails` | POST | 2.8.0 | Rebuild attachment sub-sizes via `wp_generate_attachment_metadata()` for a list of IDs or in paged batch mode |
+
+### Authentication
+
+All routes require **HTTP Basic** authentication with a WordPress **Application Password** (Users → Profile → Application Passwords). Per-route capability requirements:
+
+| Route family | Required capability |
+|---|---|
+| SEO meta (read/write) | `edit_posts` |
+| Upload helpers | `upload_files` or `manage_options` |
+| Capabilities probe | `read` (any logged-in user) |
+| Bulk meta, Export | `edit_posts` (per-item `edit_post` check) |
+| Cache purge, Transient flush, Site health, Audit hook, Regenerate thumbnails | `manage_options` |
+
+## Privacy
+
+This plugin makes **no outbound network calls** on its own. It exposes authenticated REST API endpoints that your MCP Hub instance calls into. The audit-hook route **only** sends webhooks when you explicitly configure it (POST to `/audit-hook` with an endpoint URL + secret); without that configuration, zero telemetry leaves your WordPress.
+
+No analytics, no phone-home, no cloud relay.
+
+## Security notes
+
+- All write routes enforce capability checks.
+- Raw-body uploads go through `wp_handle_sideload()` which respects WordPress's allowed-MIME list.
+- Bulk meta writes check `current_user_can('edit_post', $post_id)` per item, not just per batch.
+- Audit-hook secrets are stored in `wp_options` and never echoed in responses (only the last 4 characters are returned on read).
+- Audit-hook webhooks are signed with HMAC-SHA256 so MCP Hub can verify the message came from your site and hasn't been tampered with.
+
+If you find a security issue, please report it privately to the maintainers at the [GitHub issues page](https://github.com/airano-ir/mcphub/issues) rather than filing a public issue.
+
+## Frequently asked questions
+
+### Do I need MCP Hub to use this plugin?
+
+No. Every route is a standard authenticated WordPress REST endpoint and can be called from any HTTP client (curl, Postman, a custom integration). MCP Hub is the reference consumer but not a requirement.
+
+### Which SEO plugins are supported?
+
+**Rank Math SEO** and **Yoast SEO**. The plugin auto-detects which is active and routes SEO-meta calls accordingly.
+
+### Does this plugin work on multisite?
+
+Yes. Route registration, capability checks, and transient flushing all respect multisite boundaries. The optional `include_site_transients` flag on `/transient-flush` handles site transients on multisite setups.
+
+### Why keep the folder name `airano-mcp-bridge` even though the plugin now does much more than SEO?
+
+Plugin slugs on WordPress.org are permanent once registered. Existing installs keep working; display name, description, and feature surface are what we can update.
+
+### What happens if I uninstall?
+
+Uninstall removes plugin options including the audit-hook secret and endpoint. Media uploaded via the plugin stays in your media library (it's regular WordPress attachments — the plugin just provides the upload pipeline).
+
+### How large a file can I upload?
+
+The `/upload-chunk` route bypasses PHP's `upload_max_filesize` (typically 2–64 MB on shared hosting), but is still bounded by `post_max_size` and `memory_limit`. For files larger than `post_max_size`, MCP Hub falls back to its own server-side chunked session pipeline that reassembles chunks before handing the full file to this route.
+
+## Changelog
+
+### 2.9.0 — 2026-04-16
+
+- New: `POST /airano-mcp/v1/upload-and-attach` — raw-body upload + metadata (title / alt / caption / description) + attach-to-post + set-featured in a single REST round-trip. Saves 2–3 calls for every hero-image or product-image workflow. Per-target permission enforced via `current_user_can('edit_post', attach_to_post)`.
+- Status + capabilities payloads now advertise `upload_and_attach: true` + `regenerate_thumbnails: true`.
+
+### 2.8.0 — 2026-04-15
+
+- New: `POST /airano-mcp/v1/regenerate-thumbnails` — rebuild attachment sub-sizes via `wp_generate_attachment_metadata()` after uploads, theme switches, or format conversions. Two modes: `{ "ids": [...] }` for targeted regeneration (max 50/call) or `{ "all": true, "offset": N, "limit": M }` for paged batches.
+
+### 2.7.0
+
+- New: `GET|POST|DELETE /airano-mcp/v1/audit-hook` — configure a webhook that forwards WP action events (`transition_post_status`, `deleted_post`, `user_register`, `profile_update`, `deleted_user`, `activated_plugin`, `deactivated_plugin`, `switch_theme`) to MCP Hub. HMAC-SHA256-signed, non-blocking `wp_remote_post`. Permission: `manage_options`.
+
+### 2.6.0
+
+- New: `GET /airano-mcp/v1/site-health` — single-envelope snapshot: WP version / multisite / locale, PHP version + extensions, server software + disk free, MySQL/MariaDB version + charset, active plugins + theme, writability checks (wp-content, uploads) + SSL.
+
+### 2.5.0
+
+- New: `POST /airano-mcp/v1/transient-flush` — native transient cleanup. Scopes: `expired` (default), `all`, or `pattern` (glob match, e.g. `rank_math_*`). Optional `include_site_transients` for multisite.
+
+### 2.4.0
+
+- New: `POST /airano-mcp/v1/cache-purge` — auto-detects active page-cache plugins (LiteSpeed, WP Rocket, W3 Total Cache, WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and invokes each one's purge API. Always calls `wp_cache_flush()` for object caches.
+
+### 2.3.0
+
+- New: `GET /airano-mcp/v1/export` — structured JSON export (not WXR). Query params: `post_type`, `status`, `since`, `limit` (max 500), `offset`, `include_media`, `include_terms`, `include_meta`.
+
+### 2.2.0
+
+- New: `POST /airano-mcp/v1/bulk-meta` — batch post-meta writes (max 500 items per call). Per-item permission check via `current_user_can('edit_post', $post_id)`. Null meta values delete the key.
+
+### 2.1.0
+
+- New: `GET /airano-mcp/v1/capabilities` — reports the effective capabilities of the calling Application Password plus feature flags (Rank Math / Yoast / WooCommerce / multisite) and the list of companion routes this version ships. Consumed by MCP Hub's F.7e capability probe.
+
+### 2.0.0
+
+- **Rebrand**: "Airano MCP SEO Meta Bridge" → "Airano MCP Bridge". No longer SEO-only.
+- New: `GET /airano-mcp/v1/upload-limits` — returns effective PHP + WP upload limits.
+- New: `POST /airano-mcp/v1/upload-chunk` — raw-body upload route that bypasses `upload_max_filesize`.
+
+### 1.3.0
+
+- Added REST API endpoints for posts, pages, and products (GET/POST operations).
+- Added authentication requirement for all endpoints (edit_posts).
+- Fixed `rank_math_title` meta key registration.
+
+### 1.2.0
+
+- Enhanced WooCommerce product support.
+- Improved MariaDB compatibility.
+
+### 1.1.0
+
+- Added status endpoint for plugin detection.
+- Improved error handling.
+
+### 1.0.0
+
+- Initial release with Rank Math and Yoast SEO support.
+
+## Upgrade notice
+
+### 2.9.0
+
+Adds `POST /airano-mcp/v1/upload-and-attach` — upload + metadata + attach + featured in one REST round-trip. No breaking changes to existing routes.
+
+## Credits
+
+Built by the [MCP Hub](https://github.com/airano-ir/mcphub) project. Contributions welcome — see the parent repository for contribution guidelines.
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/airano-mcp-bridge.php b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/airano-mcp-bridge.php
new file mode 100644
index 0000000..f048168
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/airano-mcp-bridge.php
@@ -0,0 +1,2918 @@
+ true,
+ 'upload_limits' => true,
+ 'upload_chunk' => true,
+ 'upload_and_attach' => true,
+ 'capabilities' => true,
+ 'bulk_meta' => true,
+ 'export' => true,
+ 'cache_purge' => true,
+ 'transient_flush' => true,
+ 'site_health' => true,
+ 'audit_hook' => true,
+ 'regenerate_thumbnails' => true,
+ ];
+ }
+
+ /**
+ * Option keys used by F.18.7 audit-hook persistence.
+ */
+ const AUDIT_OPT_ENABLED = 'airano_mcp_audit_enabled';
+ const AUDIT_OPT_ENDPOINT = 'airano_mcp_audit_endpoint';
+ const AUDIT_OPT_SECRET = 'airano_mcp_audit_secret';
+ const AUDIT_OPT_EVENTS = 'airano_mcp_audit_events';
+ const AUDIT_OPT_LAST_PUSH_GMT = 'airano_mcp_audit_last_push_gmt';
+ const AUDIT_OPT_FAILURE_COUNT = 'airano_mcp_audit_failure_count';
+ const AUDIT_OPT_LAST_ERROR = 'airano_mcp_audit_last_error';
+
+ /**
+ * Default event list if the caller doesn't specify.
+ */
+ const AUDIT_DEFAULT_EVENTS = [
+ 'transition_post_status',
+ 'deleted_post',
+ 'user_register',
+ 'profile_update',
+ 'deleted_user',
+ 'activated_plugin',
+ 'deactivated_plugin',
+ 'switch_theme',
+ ];
+
+ /**
+ * Supported post types
+ */
+ private $supported_post_types = ['post', 'page', 'product'];
+
+ /**
+ * Constructor
+ */
+ public function __construct() {
+ add_action('rest_api_init', [$this, 'register_meta_fields']);
+ add_action('rest_api_init', [$this, 'register_rest_routes']);
+ add_action('admin_notices', [$this, 'admin_notices']);
+
+ // Add product support message if WooCommerce is active
+ if ($this->is_woocommerce_active()) {
+ $this->supported_post_types[] = 'product_variation'; // Also support variations
+ }
+
+ // F.18.7: register audit hooks if the operator has configured a
+ // MCPHub webhook endpoint. Hooks are idempotent when disabled.
+ $this->audit_register_hooks();
+ }
+
+ /**
+ * Register REST API routes
+ */
+ public function register_rest_routes() {
+ // Status endpoint
+ register_rest_route('airano-mcp-bridge/v1', '/status', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_status'],
+ 'permission_callback' => function() {
+ return is_user_logged_in();
+ }
+ ]);
+
+ // Post SEO endpoints
+ register_rest_route('airano-mcp-bridge/v1', '/posts/(?P\d+)/seo', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_post_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'update_post_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ]
+ ]);
+
+ // Page SEO endpoints
+ register_rest_route('airano-mcp-bridge/v1', '/pages/(?P\d+)/seo', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_page_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'update_page_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_pages');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ]
+ ]);
+
+ // Product SEO endpoints (WooCommerce)
+ register_rest_route('airano-mcp-bridge/v1', '/products/(?P\d+)/seo', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_product_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'update_product_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ]
+ ]);
+
+ // --- F.5a.7: MCP Bridge upload helper routes -----------------------
+ // Namespace: airano-mcp/v1 (separate from airano-mcp-bridge/v1 so
+ // the SEO namespace stays focused and MCPHub probes a short URL).
+
+ // GET /wp-json/airano-mcp/v1/upload-limits
+ register_rest_route('airano-mcp/v1', '/upload-limits', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_upload_limits'],
+ 'permission_callback' => [$this, 'require_upload_capability'],
+ ]);
+
+ // POST /wp-json/airano-mcp/v1/upload-chunk
+ // Accepts raw body (php://input) + Content-Type + Content-Disposition.
+ // Bypasses PHP upload_max_filesize (still bounded by post_max_size).
+ register_rest_route('airano-mcp/v1', '/upload-chunk', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_upload_chunk'],
+ 'permission_callback' => [$this, 'require_upload_capability'],
+ ]);
+
+ // --- F.5a.8.5: single-call upload + metadata + attach + featured -
+ // POST /wp-json/airano-mcp/v1/upload-and-attach
+ // Same raw-body semantics as /upload-chunk, plus query params:
+ // ?attach_to_post=42
+ // ?set_featured=true|false
+ // ?title=... ?alt_text=... ?caption=...
+ // All metadata application happens in-process so the caller saves
+ // 2-3 round-trips compared to the upload → PATCH /media/{id} →
+ // PATCH /posts/{id} sequence.
+ register_rest_route('airano-mcp/v1', '/upload-and-attach', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_upload_and_attach'],
+ 'permission_callback' => [$this, 'require_upload_and_attach_capability'],
+ ]);
+
+ // --- F.18.1: Capability probe ------------------------------------
+ // GET /wp-json/airano-mcp/v1/capabilities
+ // Reports the exact capability set of the calling application password
+ // plus feature flags + available routes. Consumed by MCPHub's F.7e
+ // probe to decide which tools to expose to the user.
+ register_rest_route('airano-mcp/v1', '/capabilities', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_capabilities'],
+ 'permission_callback' => function () {
+ return is_user_logged_in();
+ },
+ ]);
+
+ // --- F.18.2: Bulk meta writes ------------------------------------
+ // POST /wp-json/airano-mcp/v1/bulk-meta
+ // Body: {"updates": [{"post_id": 123, "meta": {"k": "v", ...}}, ...]}
+ // Loops in PHP: one HTTP round-trip for many post_meta writes.
+ register_rest_route('airano-mcp/v1', '/bulk-meta', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_bulk_meta'],
+ 'permission_callback' => function () {
+ return current_user_can('edit_posts');
+ },
+ ]);
+
+ // --- F.18.3: Structured JSON export ------------------------------
+ // GET /wp-json/airano-mcp/v1/export
+ // Query: post_type, status, since, limit, offset,
+ // include_media, include_terms, include_meta
+ register_rest_route('airano-mcp/v1', '/export', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_export'],
+ 'permission_callback' => function () {
+ return current_user_can('edit_posts');
+ },
+ ]);
+
+ // --- F.18.4: Cache purge (auto-detects active cache plugin) ------
+ // POST /wp-json/airano-mcp/v1/cache-purge
+ // Body (optional): {} — currently always purges "all" + object cache.
+ register_rest_route('airano-mcp/v1', '/cache-purge', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_cache_purge'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ]);
+
+ // --- F.18.5: Transient flush ------------------------------------
+ // POST /wp-json/airano-mcp/v1/transient-flush
+ // Body (optional): {"scope": "expired|all|pattern", "pattern": "foo_*",
+ // "include_site_transients": true}
+ register_rest_route('airano-mcp/v1', '/transient-flush', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_transient_flush'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ]);
+
+ // --- F.18.6: Unified site-health snapshot -----------------------
+ // GET /wp-json/airano-mcp/v1/site-health
+ register_rest_route('airano-mcp/v1', '/site-health', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_site_health'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ]);
+
+ // --- F.18.7: Audit hook configuration / status ------------------
+ // GET → current config (secret masked)
+ // POST → set endpoint_url / secret / enabled / events
+ // DELETE → clear config (disables pushing)
+ register_rest_route('airano-mcp/v1', '/audit-hook', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_audit_hook_get'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_audit_hook_set'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ],
+ [
+ 'methods' => 'DELETE',
+ 'callback' => [$this, 'handle_audit_hook_clear'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ],
+ ]);
+
+ // --- F.5a.8.2: Regenerate attachment thumbnails -----------------
+ // POST /wp-json/airano-mcp/v1/regenerate-thumbnails
+ // Body: { "ids": [int, ...] } → regenerate sub-sizes for those
+ // attachments only.
+ // { "all": true, "offset": N, "limit": M }
+ // → batch mode; caps limit at 50 per request, returns
+ // { "processed": int, "errors": [...], "has_more": bool,
+ // "next_offset": int }.
+ register_rest_route('airano-mcp/v1', '/regenerate-thumbnails', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_regenerate_thumbnails'],
+ 'permission_callback' => function () {
+ return current_user_can('upload_files') || current_user_can('manage_options');
+ },
+ ]);
+ }
+
+ /**
+ * Permission callback for upload helper routes.
+ *
+ * Allows users with either `upload_files` or `manage_options` capability.
+ * `upload_files` is granted to Author+ roles by default.
+ */
+ public function require_upload_capability() {
+ return current_user_can('upload_files') || current_user_can('manage_options');
+ }
+
+ /**
+ * Permission callback for `/upload-and-attach`.
+ *
+ * Two-layer route gate, enforced BEFORE the callback runs. Per wp.org
+ * review guidance, a per-target capability check inside the callback
+ * is not visible to static analysis, so each gate is in this method:
+ * 1. Caller must have `upload_files` (or `manage_options`).
+ * 2. If `attach_to_post` is supplied, caller must also have
+ * `edit_post` on that specific target post.
+ * 3. `set_featured` without an `attach_to_post` is rejected — a
+ * featured-image needs a target post.
+ *
+ * @param WP_REST_Request $request
+ * @return true|WP_Error
+ */
+ public function require_upload_and_attach_capability( $request ) {
+ if ( ! current_user_can('upload_files') && ! current_user_can('manage_options') ) {
+ return new WP_Error(
+ 'rest_forbidden',
+ __( 'Sorry, you are not allowed to upload files.', 'airano-mcp-bridge' ),
+ ['status' => rest_authorization_required_code()]
+ );
+ }
+
+ $attach_to_post = (int) $request->get_param('attach_to_post');
+ $set_featured_raw = $request->get_param('set_featured');
+ $set_featured = in_array(
+ strtolower((string) $set_featured_raw),
+ ['1', 'true', 'yes', 'on'],
+ true
+ );
+
+ if ( $set_featured && $attach_to_post <= 0 ) {
+ return new WP_Error(
+ 'rest_invalid_param',
+ __( 'set_featured requires a valid attach_to_post target.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ if ( $attach_to_post > 0 && ! current_user_can('edit_post', $attach_to_post) ) {
+ return new WP_Error(
+ 'rest_cannot_edit',
+ __( 'Sorry, you are not allowed to attach media to this post.', 'airano-mcp-bridge' ),
+ ['status' => rest_authorization_required_code(), 'attach_to_post' => $attach_to_post]
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Get plugin status endpoint
+ */
+ public function get_status() {
+ $rank_math_active = $this->is_rank_math_active();
+ $yoast_active = $this->is_yoast_active();
+
+ $response = [
+ 'plugin' => 'Airano MCP Bridge',
+ 'version' => self::VERSION,
+ 'active' => true,
+ 'capabilities' => [
+ 'seo_meta' => true,
+ 'upload_limits' => true,
+ 'upload_chunk' => true,
+ 'capabilities' => true,
+ 'bulk_meta' => true,
+ 'export' => true,
+ 'cache_purge' => true,
+ 'transient_flush' => true,
+ 'site_health' => true,
+ 'audit_hook' => true,
+ 'regenerate_thumbnails' => true,
+ 'upload_and_attach' => true,
+ ],
+ 'seo_plugins' => [
+ 'rank_math' => [
+ 'active' => $rank_math_active,
+ 'version' => $rank_math_active && defined('RANK_MATH_VERSION') ? RANK_MATH_VERSION : null
+ ],
+ 'yoast' => [
+ 'active' => $yoast_active,
+ 'version' => $yoast_active && defined('WPSEO_VERSION') ? WPSEO_VERSION : null
+ ]
+ ],
+ 'supported_post_types' => $this->supported_post_types,
+ 'message' => $this->get_status_message($rank_math_active, $yoast_active)
+ ];
+
+ return rest_ensure_response($response);
+ }
+
+ /**
+ * Get status message based on active plugins
+ */
+ private function get_status_message($rank_math_active, $yoast_active) {
+ if (!$rank_math_active && !$yoast_active) {
+ return 'No SEO plugin detected. Please install and activate Rank Math SEO or Yoast SEO.';
+ }
+
+ $active_plugins = [];
+ if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
+ if ($yoast_active) $active_plugins[] = 'Yoast SEO';
+
+ return 'Airano MCP SEO Meta Bridge is active and working with ' . implode(' and ', $active_plugins) . '.';
+ }
+
+ /**
+ * Register all meta fields for REST API
+ */
+ public function register_meta_fields() {
+ // Register Rank Math fields if plugin is active
+ if ($this->is_rank_math_active()) {
+ $this->register_rank_math_fields();
+ }
+
+ // Register Yoast SEO fields if plugin is active
+ if ($this->is_yoast_active()) {
+ $this->register_yoast_fields();
+ }
+ }
+
+ /**
+ * Check if Rank Math is active
+ */
+ private function is_rank_math_active() {
+ return defined('RANK_MATH_VERSION') || class_exists('RankMath');
+ }
+
+ /**
+ * Check if Yoast SEO is active
+ */
+ private function is_yoast_active() {
+ return defined('WPSEO_VERSION') || class_exists('WPSEO_Options');
+ }
+
+ /**
+ * Check if WooCommerce is active
+ */
+ private function is_woocommerce_active() {
+ return class_exists('WooCommerce') || defined('WC_VERSION');
+ }
+
+ /**
+ * Register Rank Math meta fields
+ */
+ private function register_rank_math_fields() {
+ $rank_math_fields = [
+ // Core SEO Fields
+ 'rank_math_focus_keyword' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math focus keyword',
+ 'single' => true,
+ ],
+ 'rank_math_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math SEO title (meta title)',
+ 'single' => true,
+ ],
+ 'rank_math_description' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math meta description',
+ 'single' => true,
+ ],
+
+ // Additional Keywords
+ 'rank_math_additional_keywords' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math additional keywords (comma-separated)',
+ 'single' => true,
+ ],
+
+ // Advanced Fields
+ 'rank_math_canonical_url' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math canonical URL',
+ 'single' => true,
+ ],
+ 'rank_math_robots' => [
+ 'type' => 'array',
+ 'description' => 'Rank Math robots meta (noindex, nofollow, etc.)',
+ 'single' => true,
+ ],
+ 'rank_math_breadcrumb_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math breadcrumb title',
+ 'single' => true,
+ ],
+
+ // Open Graph Fields
+ 'rank_math_facebook_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Facebook Open Graph title',
+ 'single' => true,
+ ],
+ 'rank_math_facebook_description' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Facebook Open Graph description',
+ 'single' => true,
+ ],
+ 'rank_math_facebook_image' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Facebook Open Graph image URL',
+ 'single' => true,
+ ],
+ 'rank_math_facebook_image_id' => [
+ 'type' => 'integer',
+ 'description' => 'Rank Math Facebook Open Graph image ID',
+ 'single' => true,
+ ],
+
+ // Twitter Card Fields
+ 'rank_math_twitter_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card title',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_description' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card description',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_image' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card image URL',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_image_id' => [
+ 'type' => 'integer',
+ 'description' => 'Rank Math Twitter Card image ID',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_card_type' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card type (summary, summary_large_image)',
+ 'single' => true,
+ ],
+ ];
+
+ $this->register_fields($rank_math_fields);
+ }
+
+ /**
+ * Register Yoast SEO meta fields
+ */
+ private function register_yoast_fields() {
+ $yoast_fields = [
+ // Core SEO Fields
+ '_yoast_wpseo_focuskw' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO focus keyword',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_title' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO title (meta title)',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_metadesc' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO meta description',
+ 'single' => true,
+ ],
+
+ // Advanced Fields
+ '_yoast_wpseo_canonical' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO canonical URL',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_meta-robots-noindex' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO noindex setting (0 = index, 1 = noindex)',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_meta-robots-nofollow' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO nofollow setting (0 = follow, 1 = nofollow)',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_bctitle' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO breadcrumb title',
+ 'single' => true,
+ ],
+
+ // Open Graph Fields
+ '_yoast_wpseo_opengraph-title' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Open Graph title',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_opengraph-description' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Open Graph description',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_opengraph-image' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Open Graph image URL',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_opengraph-image-id' => [
+ 'type' => 'integer',
+ 'description' => 'Yoast SEO Open Graph image ID',
+ 'single' => true,
+ ],
+
+ // Twitter Card Fields
+ '_yoast_wpseo_twitter-title' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Twitter title',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_twitter-description' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Twitter description',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_twitter-image' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Twitter image URL',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_twitter-image-id' => [
+ 'type' => 'integer',
+ 'description' => 'Yoast SEO Twitter image ID',
+ 'single' => true,
+ ],
+ ];
+
+ $this->register_fields($yoast_fields);
+ }
+
+ /**
+ * Register fields for all supported post types
+ */
+ private function register_fields($fields) {
+ foreach ($this->supported_post_types as $post_type) {
+ foreach ($fields as $meta_key => $args) {
+ $show_in_rest = $args['type'] === 'array'
+ ? [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ] ]
+ : true;
+
+ register_post_meta($post_type, $meta_key, [
+ 'show_in_rest' => $show_in_rest,
+ 'single' => $args['single'],
+ 'type' => $args['type'],
+ 'description' => $args['description'],
+ 'auth_callback' => function() {
+ return current_user_can('edit_posts');
+ }
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Get SEO data for a post
+ */
+ public function get_post_seo($request) {
+ $post_id = $request['id'];
+ return $this->get_seo_data($post_id, 'post');
+ }
+
+ /**
+ * Update SEO data for a post
+ */
+ public function update_post_seo($request) {
+ $post_id = $request['id'];
+ return $this->update_seo_data($post_id, $request->get_json_params(), 'post');
+ }
+
+ /**
+ * Get SEO data for a page
+ */
+ public function get_page_seo($request) {
+ $post_id = $request['id'];
+ return $this->get_seo_data($post_id, 'page');
+ }
+
+ /**
+ * Update SEO data for a page
+ */
+ public function update_page_seo($request) {
+ $post_id = $request['id'];
+ return $this->update_seo_data($post_id, $request->get_json_params(), 'page');
+ }
+
+ /**
+ * Get SEO data for a product
+ */
+ public function get_product_seo($request) {
+ $post_id = $request['id'];
+ return $this->get_seo_data($post_id, 'product');
+ }
+
+ /**
+ * Update SEO data for a product
+ */
+ public function update_product_seo($request) {
+ $post_id = $request['id'];
+ return $this->update_seo_data($post_id, $request->get_json_params(), 'product');
+ }
+
+ /**
+ * Generic method to get SEO data
+ */
+ private function get_seo_data($post_id, $post_type) {
+ // Verify post exists and is correct type
+ $post = get_post($post_id);
+ if (!$post || $post->post_type !== $post_type) {
+ return new WP_Error(
+ 'invalid_post',
+ /* translators: %s: post type label (e.g. Post, Page, Product). */
+ sprintf( __( '%s not found', 'airano-mcp-bridge' ), ucfirst( $post_type ) ),
+ ['status' => 404]
+ );
+ }
+
+ $seo_data = [
+ 'post_id' => $post_id,
+ 'post_type' => $post_type,
+ 'post_title' => $post->post_title,
+ ];
+
+ // Detect which SEO plugin is active and get data
+ if ($this->is_rank_math_active()) {
+ $seo_data['plugin'] = 'rank_math';
+ $seo_data = array_merge($seo_data, $this->get_rank_math_data($post_id));
+ } elseif ($this->is_yoast_active()) {
+ $seo_data['plugin'] = 'yoast';
+ $seo_data = array_merge($seo_data, $this->get_yoast_data($post_id));
+ } else {
+ return new WP_Error(
+ 'no_seo_plugin',
+ __( 'No supported SEO plugin found (Rank Math or Yoast required).', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ return rest_ensure_response($seo_data);
+ }
+
+ /**
+ * Get Rank Math SEO data
+ */
+ private function get_rank_math_data($post_id) {
+ return [
+ 'focus_keyword' => get_post_meta($post_id, 'rank_math_focus_keyword', true),
+ 'seo_title' => get_post_meta($post_id, 'rank_math_title', true),
+ 'meta_description' => get_post_meta($post_id, 'rank_math_description', true),
+ 'canonical_url' => get_post_meta($post_id, 'rank_math_canonical_url', true),
+ 'robots' => get_post_meta($post_id, 'rank_math_robots', true),
+ 'og_title' => get_post_meta($post_id, 'rank_math_facebook_title', true),
+ 'og_description' => get_post_meta($post_id, 'rank_math_facebook_description', true),
+ 'og_image' => get_post_meta($post_id, 'rank_math_facebook_image', true),
+ 'twitter_title' => get_post_meta($post_id, 'rank_math_twitter_title', true),
+ 'twitter_description' => get_post_meta($post_id, 'rank_math_twitter_description', true),
+ 'twitter_image' => get_post_meta($post_id, 'rank_math_twitter_image', true),
+ ];
+ }
+
+ /**
+ * Get Yoast SEO data
+ */
+ private function get_yoast_data($post_id) {
+ return [
+ 'focus_keyword' => get_post_meta($post_id, '_yoast_wpseo_focuskw', true),
+ 'seo_title' => get_post_meta($post_id, '_yoast_wpseo_title', true),
+ 'meta_description' => get_post_meta($post_id, '_yoast_wpseo_metadesc', true),
+ 'canonical_url' => get_post_meta($post_id, '_yoast_wpseo_canonical', true),
+ 'noindex' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true),
+ 'nofollow' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', true),
+ 'og_title' => get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true),
+ 'og_description' => get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true),
+ 'og_image' => get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true),
+ 'twitter_title' => get_post_meta($post_id, '_yoast_wpseo_twitter-title', true),
+ 'twitter_description' => get_post_meta($post_id, '_yoast_wpseo_twitter-description', true),
+ 'twitter_image' => get_post_meta($post_id, '_yoast_wpseo_twitter-image', true),
+ ];
+ }
+
+ /**
+ * Generic method to update SEO data
+ */
+ private function update_seo_data($post_id, $params, $post_type) {
+ // Verify post exists and is correct type
+ $post = get_post($post_id);
+ if (!$post || $post->post_type !== $post_type) {
+ return new WP_Error(
+ 'invalid_post',
+ /* translators: %s: post type label (e.g. Post, Page, Product). */
+ sprintf( __( '%s not found', 'airano-mcp-bridge' ), ucfirst( $post_type ) ),
+ ['status' => 404]
+ );
+ }
+
+ $updated_fields = [];
+
+ // Update based on active SEO plugin
+ if ($this->is_rank_math_active()) {
+ $updated_fields = $this->update_rank_math_data($post_id, $params);
+ } elseif ($this->is_yoast_active()) {
+ $updated_fields = $this->update_yoast_data($post_id, $params);
+ } else {
+ return new WP_Error(
+ 'no_seo_plugin',
+ __( 'No supported SEO plugin found.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ return rest_ensure_response([
+ 'post_id' => $post_id,
+ 'post_type' => $post_type,
+ 'updated_fields' => $updated_fields,
+ 'message' => __( 'SEO metadata updated successfully.', 'airano-mcp-bridge' ),
+ ]);
+ }
+
+ /**
+ * Update Rank Math data
+ */
+ private function update_rank_math_data($post_id, $params) {
+ $updated = [];
+ $field_map = [
+ 'focus_keyword' => 'rank_math_focus_keyword',
+ 'seo_title' => 'rank_math_title',
+ 'meta_description' => 'rank_math_description',
+ 'canonical_url' => 'rank_math_canonical_url',
+ 'robots' => 'rank_math_robots',
+ 'og_title' => 'rank_math_facebook_title',
+ 'og_description' => 'rank_math_facebook_description',
+ 'og_image' => 'rank_math_facebook_image',
+ 'twitter_title' => 'rank_math_twitter_title',
+ 'twitter_description' => 'rank_math_twitter_description',
+ 'twitter_image' => 'rank_math_twitter_image',
+ ];
+
+ foreach ($field_map as $param_key => $meta_key) {
+ if (isset($params[$param_key])) {
+ update_post_meta($post_id, $meta_key, $params[$param_key]);
+ $updated[] = $meta_key;
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * Update Yoast data
+ */
+ private function update_yoast_data($post_id, $params) {
+ $updated = [];
+ $field_map = [
+ 'focus_keyword' => '_yoast_wpseo_focuskw',
+ 'seo_title' => '_yoast_wpseo_title',
+ 'meta_description' => '_yoast_wpseo_metadesc',
+ 'canonical_url' => '_yoast_wpseo_canonical',
+ 'og_title' => '_yoast_wpseo_opengraph-title',
+ 'og_description' => '_yoast_wpseo_opengraph-description',
+ 'og_image' => '_yoast_wpseo_opengraph-image',
+ 'twitter_title' => '_yoast_wpseo_twitter-title',
+ 'twitter_description' => '_yoast_wpseo_twitter-description',
+ 'twitter_image' => '_yoast_wpseo_twitter-image',
+ ];
+
+ foreach ($field_map as $param_key => $meta_key) {
+ if (isset($params[$param_key])) {
+ update_post_meta($post_id, $meta_key, $params[$param_key]);
+ $updated[] = $meta_key;
+ }
+ }
+
+ return $updated;
+ }
+
+ // ------------------------------------------------------------------
+ // F.5a.7 — Upload helper routes (airano-mcp/v1 namespace)
+ // ------------------------------------------------------------------
+
+ /**
+ * GET /airano-mcp/v1/upload-limits
+ *
+ * Returns the effective PHP + WordPress upload limits so MCPHub can
+ * decide which upload path to use (REST /wp/v2/media vs companion
+ * upload-chunk). Shape matches plugins/wordpress/handlers/media_probe.py.
+ */
+ public function get_upload_limits() {
+ $wp_max = function_exists('wp_max_upload_size') ? (int) wp_max_upload_size() : null;
+
+ $response = [
+ 'upload_max_filesize' => (string) ini_get('upload_max_filesize'),
+ 'post_max_size' => (string) ini_get('post_max_size'),
+ 'memory_limit' => (string) ini_get('memory_limit'),
+ 'max_input_time' => (string) ini_get('max_input_time'),
+ 'wp_max_upload_size' => $wp_max,
+ 'plugin_version' => self::VERSION,
+ 'companion' => true,
+ ];
+
+ return rest_ensure_response($response);
+ }
+
+ /**
+ * POST /airano-mcp/v1/upload-chunk
+ *
+ * Accepts a raw binary body (via php://input) plus Content-Type and
+ * Content-Disposition headers, writes it to the media library via
+ * wp_handle_sideload() + wp_insert_attachment() + wp_update_attachment_metadata().
+ *
+ * Returns a JSON attachment shape matching /wp/v2/media so MCPHub can
+ * reuse the same parser.
+ *
+ * Security:
+ * - Auth: application password + upload_files or manage_options capability
+ * - MIME: validated against WP's allowed list via wp_check_filetype()
+ * - Filename: sanitized via sanitize_file_name()
+ */
+ public function handle_upload_chunk($request) {
+ // Read raw body. Note: php://input is not affected by upload_max_filesize,
+ // but IS still bounded by post_max_size and memory_limit.
+ $body = $request->get_body();
+ if ($body === null || $body === '') {
+ return new WP_Error(
+ 'empty_body',
+ __( 'Request body is empty. Send raw binary file data.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ $content_type = $request->get_header('content_type');
+ if (empty($content_type)) {
+ $content_type = 'application/octet-stream';
+ }
+
+ // Parse Content-Disposition to extract filename.
+ $disposition = $request->get_header('content_disposition');
+ $filename = $this->parse_content_disposition_filename($disposition);
+ if (empty($filename)) {
+ // Fall back to a safe generic filename; wp_check_filetype will
+ // derive an extension from the MIME if one is mapped.
+ $ext = '';
+ $guess = wp_check_filetype('x.' . $this->mime_to_ext($content_type));
+ if (!empty($guess['ext'])) {
+ $ext = '.' . $guess['ext'];
+ }
+ $filename = 'upload-' . time() . $ext;
+ }
+ $filename = sanitize_file_name($filename);
+
+ // Validate MIME against WP's allowed types.
+ $check = wp_check_filetype($filename);
+ if (empty($check['type'])) {
+ return new WP_Error(
+ 'invalid_mime',
+ __( 'File type is not allowed by this WordPress installation.', 'airano-mcp-bridge' ),
+ ['status' => 400, 'filename' => $filename]
+ );
+ }
+
+ // Stage to tmp + hand off to wp_handle_sideload(). file.php has
+ // wp_tempnam() and wp_handle_sideload(); image.php has
+ // wp_generate_attachment_metadata(). media.php is intentionally
+ // NOT loaded — none of its helpers are used in this REST callback.
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+
+ $tmp = wp_tempnam($filename);
+ if (!$tmp) {
+ return new WP_Error(
+ 'tmp_failed',
+ __( 'Could not create temp file.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+ $written = $this->write_temp_body( $tmp, $body );
+ if ($written === false) {
+ wp_delete_file($tmp);
+ return new WP_Error(
+ 'write_failed',
+ __( 'Could not write payload to temp file.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ $file_array = [
+ 'name' => $filename,
+ 'type' => $content_type,
+ 'tmp_name' => $tmp,
+ 'error' => 0,
+ 'size' => filesize($tmp),
+ ];
+
+ $overrides = [
+ 'test_form' => false,
+ 'test_size' => true,
+ ];
+
+ $sideload = wp_handle_sideload($file_array, $overrides);
+ if (isset($sideload['error'])) {
+ wp_delete_file($tmp);
+ return new WP_Error(
+ 'sideload_failed',
+ (string) $sideload['error'],
+ ['status' => 400]
+ );
+ }
+
+ $attachment = [
+ 'post_mime_type' => $sideload['type'],
+ 'post_title' => sanitize_text_field(pathinfo($filename, PATHINFO_FILENAME)),
+ 'post_content' => '',
+ 'post_status' => 'inherit',
+ ];
+
+ $attach_id = wp_insert_attachment($attachment, $sideload['file']);
+ if (is_wp_error($attach_id) || !$attach_id) {
+ wp_delete_file($sideload['file']);
+ return new WP_Error(
+ 'insert_failed',
+ is_wp_error($attach_id)
+ ? $attach_id->get_error_message()
+ : __( 'wp_insert_attachment failed.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ $metadata = wp_generate_attachment_metadata($attach_id, $sideload['file']);
+ wp_update_attachment_metadata($attach_id, $metadata);
+
+ // Build response with same shape as /wp/v2/media so MCPHub's parser
+ // continues to work unchanged.
+ $response = $this->format_attachment_response($attach_id, $sideload, $metadata);
+ return rest_ensure_response($response);
+ }
+
+ /**
+ * POST /airano-mcp/v1/upload-and-attach
+ *
+ * F.5a.8.5: raw-body upload, then in one PHP call apply metadata and
+ * optionally attach to a post and mark as the post's featured image.
+ * Replaces 2-3 REST round-trips with one.
+ *
+ * Same raw-body semantics as /upload-chunk. Metadata comes from query
+ * params (URL-encoded):
+ * attach_to_post — positive int
+ * set_featured — "1" / "true" to mark as the post's featured image;
+ * requires attach_to_post to also be set.
+ * title — attachment title override (otherwise derived from filename)
+ * alt_text — stored as post_meta _wp_attachment_image_alt
+ * caption — stored as post_excerpt
+ * description — stored as post_content
+ *
+ * Returns the same /wp/v2/media-shaped response as upload-chunk, plus
+ * ``_upload_and_attach: { attach_to_post, set_featured, applied_meta }``
+ * so the caller can verify what actually happened.
+ */
+ public function handle_upload_and_attach($request) {
+ // --- F.X.fix #7: idempotent replay --------------------------------
+ // Clients generate a stable ``Idempotency-Key`` header per
+ // logical upload. If a client timed out mid-call and retried,
+ // we return the already-created attachment rather than
+ // producing an "-2.webp" orphan. Transient TTL 24h.
+ $idempotency_key = $request->get_header('idempotency_key');
+ if (is_string($idempotency_key) && $idempotency_key !== ''
+ && preg_match('/^[A-Za-z0-9_\-]{1,128}$/', $idempotency_key)) {
+ $cached_id = get_transient('airano_idemp_' . md5($idempotency_key));
+ if ($cached_id) {
+ $cached_post = get_post((int) $cached_id);
+ if ($cached_post && $cached_post->post_type === 'attachment') {
+ $replay = $this->format_idempotent_replay((int) $cached_id);
+ return rest_ensure_response($replay);
+ }
+ }
+ }
+
+ // --- Same raw-body upload as handle_upload_chunk -----------------
+ $body = $request->get_body();
+ if ($body === null || $body === '') {
+ return new WP_Error(
+ 'empty_body',
+ __( 'Request body is empty. Send raw binary file data.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ $content_type = $request->get_header('content_type');
+ if (empty($content_type)) {
+ $content_type = 'application/octet-stream';
+ }
+
+ $disposition = $request->get_header('content_disposition');
+ $filename = $this->parse_content_disposition_filename($disposition);
+ if (empty($filename)) {
+ $ext = '';
+ $guess = wp_check_filetype('x.' . $this->mime_to_ext($content_type));
+ if (!empty($guess['ext'])) {
+ $ext = '.' . $guess['ext'];
+ }
+ $filename = 'upload-' . time() . $ext;
+ }
+ $filename = sanitize_file_name($filename);
+
+ $check = wp_check_filetype($filename);
+ if (empty($check['type'])) {
+ return new WP_Error(
+ 'invalid_mime',
+ __( 'File type is not allowed by this WordPress installation.', 'airano-mcp-bridge' ),
+ ['status' => 400, 'filename' => $filename]
+ );
+ }
+
+ // file.php → wp_tempnam, wp_handle_sideload; image.php →
+ // wp_generate_attachment_metadata. media.php is NOT needed here —
+ // we don't call any media.php helpers in this callback.
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+
+ $tmp = wp_tempnam($filename);
+ if (!$tmp) {
+ return new WP_Error(
+ 'tmp_failed',
+ __( 'Could not create temp file.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+ $written = $this->write_temp_body( $tmp, $body );
+ if ($written === false) {
+ wp_delete_file($tmp);
+ return new WP_Error(
+ 'write_failed',
+ __( 'Could not write payload to temp file.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ $file_array = [
+ 'name' => $filename,
+ 'type' => $content_type,
+ 'tmp_name' => $tmp,
+ 'error' => 0,
+ 'size' => filesize($tmp),
+ ];
+ $sideload = wp_handle_sideload($file_array, ['test_form' => false, 'test_size' => true]);
+ if (isset($sideload['error'])) {
+ wp_delete_file($tmp);
+ return new WP_Error('sideload_failed', (string) $sideload['error'], ['status' => 400]);
+ }
+
+ // --- Gather query params -----------------------------------------
+ $attach_to_post = (int) $request->get_param('attach_to_post');
+ $set_featured = filter_var(
+ $request->get_param('set_featured'),
+ FILTER_VALIDATE_BOOLEAN,
+ FILTER_NULL_ON_FAILURE
+ );
+ $title_override = $request->get_param('title');
+ $alt_text = $request->get_param('alt_text');
+ $caption = $request->get_param('caption');
+ $description = $request->get_param('description');
+
+ // set_featured requires a target post; silently drop otherwise.
+ if ($attach_to_post <= 0) {
+ $attach_to_post = 0;
+ $set_featured = false;
+ }
+
+ // Per-target permission check when attaching.
+ if ($attach_to_post > 0 && ! current_user_can('edit_post', $attach_to_post)) {
+ wp_delete_file($sideload['file']);
+ return new WP_Error(
+ 'cannot_edit_target',
+ __( 'Current user lacks edit_post on attach_to_post target.', 'airano-mcp-bridge' ),
+ ['status' => 403, 'attach_to_post' => $attach_to_post]
+ );
+ }
+
+ // --- Insert attachment with desired parent + title ---------------
+ $attachment = [
+ 'post_mime_type' => $sideload['type'],
+ 'post_title' => is_string($title_override) && $title_override !== ''
+ ? sanitize_text_field($title_override)
+ : sanitize_text_field(pathinfo($filename, PATHINFO_FILENAME)),
+ 'post_content' => is_string($description) ? wp_kses_post($description) : '',
+ 'post_excerpt' => is_string($caption) ? sanitize_text_field($caption) : '',
+ 'post_status' => 'inherit',
+ 'post_parent' => $attach_to_post,
+ ];
+
+ $attach_id = wp_insert_attachment($attachment, $sideload['file'], $attach_to_post);
+ if (is_wp_error($attach_id) || !$attach_id) {
+ wp_delete_file($sideload['file']);
+ return new WP_Error(
+ 'insert_failed',
+ is_wp_error($attach_id)
+ ? $attach_id->get_error_message()
+ : __( 'wp_insert_attachment failed.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ // --- Alt text (stored in post_meta, not post_excerpt) ------------
+ $applied_alt = false;
+ if (is_string($alt_text) && $alt_text !== '') {
+ update_post_meta($attach_id, '_wp_attachment_image_alt', sanitize_text_field($alt_text));
+ $applied_alt = true;
+ }
+
+ // --- Metadata rebuild (thumbnails, EXIF, etc.) -------------------
+ $metadata = wp_generate_attachment_metadata($attach_id, $sideload['file']);
+ wp_update_attachment_metadata($attach_id, $metadata);
+
+ // --- Set as featured image ---------------------------------------
+ $applied_featured = false;
+ if ($set_featured && $attach_to_post > 0) {
+ $ok = update_post_meta($attach_to_post, '_thumbnail_id', $attach_id);
+ $applied_featured = (bool) $ok;
+ }
+
+ $response = $this->format_attachment_response($attach_id, $sideload, $metadata);
+ if (is_array($response)) {
+ $response['_upload_and_attach'] = [
+ 'attach_to_post' => $attach_to_post ?: null,
+ 'set_featured' => $applied_featured,
+ 'applied_meta' => [
+ 'title' => isset($attachment['post_title']) ? $attachment['post_title'] : null,
+ 'alt_text' => $applied_alt,
+ 'caption' => is_string($caption) && $caption !== '',
+ 'description' => is_string($description) && $description !== '',
+ ],
+ ];
+ }
+
+ // F.X.fix #7: record the (idempotency_key -> attach_id) mapping
+ // so a retry within 24h gets the same id back.
+ if (is_string($idempotency_key) && $idempotency_key !== ''
+ && preg_match('/^[A-Za-z0-9_\-]{1,128}$/', $idempotency_key)) {
+ set_transient(
+ 'airano_idemp_' . md5($idempotency_key),
+ (int) $attach_id,
+ 24 * HOUR_IN_SECONDS
+ );
+ }
+
+ return rest_ensure_response($response);
+ }
+
+ /**
+ * Build a replay response for an already-created attachment.
+ *
+ * Called when the client retries with a matching Idempotency-Key.
+ * We re-derive just enough of the ``/wp/v2/media`` shape from the
+ * stored attachment so the Python side sees the same ``id``,
+ * ``source_url``, ``mime_type`` as the original response, plus an
+ * ``_idempotent_replay: true`` marker for audit logs.
+ */
+ private function format_idempotent_replay($attach_id) {
+ $post = get_post($attach_id);
+ $mime = $post ? (string) $post->post_mime_type : '';
+ $source_url = wp_get_attachment_url($attach_id);
+ return [
+ 'id' => (int) $attach_id,
+ 'date' => $post ? mysql_to_rfc3339($post->post_date) : null,
+ 'slug' => $post ? $post->post_name : '',
+ 'type' => 'attachment',
+ 'link' => get_permalink($attach_id),
+ 'title' => ['rendered' => $post ? $post->post_title : ''],
+ 'author' => $post ? (int) $post->post_author : 0,
+ 'post' => $post ? (int) $post->post_parent : 0,
+ 'mime_type' => $mime,
+ 'media_type' => $this->mime_to_media_type($mime),
+ 'source_url' => $source_url,
+ 'media_details' => wp_get_attachment_metadata($attach_id) ?: [],
+ 'companion' => true,
+ '_idempotent_replay' => true,
+ ];
+ }
+
+ /**
+ * Write a raw upload body to a temp path using WP_Filesystem.
+ *
+ * Plugin Check flags direct file_put_contents(). WP_Filesystem's
+ * put_contents() provides the wp.org-approved wrapper and normalises
+ * permissions via FS_CHMOD_FILE. The temp path is expected to be the
+ * result of wp_tempnam(), which is already in WP's upload directory.
+ *
+ * Returns the byte count on success, false on failure.
+ *
+ * @param string $tmp Absolute path created by wp_tempnam().
+ * @param string $body Raw request body bytes.
+ * @return int|false
+ */
+ private function write_temp_body( $tmp, $body ) {
+ global $wp_filesystem;
+ if ( empty( $wp_filesystem ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ WP_Filesystem();
+ }
+ if ( empty( $wp_filesystem ) || ! is_object( $wp_filesystem ) ) {
+ return false;
+ }
+ $ok = $wp_filesystem->put_contents( $tmp, $body, FS_CHMOD_FILE );
+ if ( ! $ok ) {
+ return false;
+ }
+ return strlen( $body );
+ }
+
+ /**
+ * Extract filename from a Content-Disposition header.
+ *
+ * Supports:
+ * - filename="photo.jpg"
+ * - filename=photo.jpg
+ * - filename*=UTF-8''%D8%B9%DA%A9%D8%B3.jpg (RFC 5987)
+ *
+ * Returns null if no filename can be extracted.
+ */
+ private function parse_content_disposition_filename($disposition) {
+ if (empty($disposition)) {
+ return null;
+ }
+ // RFC 5987: filename*=charset''percent-encoded
+ if (preg_match("/filename\\*\\s*=\\s*(?:UTF-8'')?([^;]+)/i", $disposition, $m)) {
+ $val = trim($m[1], " \t\"'");
+ $decoded = rawurldecode($val);
+ if (!empty($decoded)) {
+ return $decoded;
+ }
+ }
+ // filename=... or filename="..."
+ if (preg_match('/filename\\s*=\\s*"([^"]+)"/i', $disposition, $m)) {
+ return $m[1];
+ }
+ if (preg_match('/filename\\s*=\\s*([^;]+)/i', $disposition, $m)) {
+ return trim($m[1], " \t\"'");
+ }
+ return null;
+ }
+
+ /**
+ * Best-effort MIME → extension mapping used only when Content-Disposition
+ * is missing so we can still pick a non-ambiguous filename.
+ */
+ private function mime_to_ext($mime) {
+ $map = [
+ 'image/png' => 'png',
+ 'image/jpeg' => 'jpg',
+ 'image/gif' => 'gif',
+ 'image/webp' => 'webp',
+ 'image/svg+xml' => 'svg',
+ 'application/pdf' => 'pdf',
+ 'video/mp4' => 'mp4',
+ 'audio/mpeg' => 'mp3',
+ ];
+ return isset($map[$mime]) ? $map[$mime] : 'bin';
+ }
+
+ /**
+ * Build a JSON shape matching /wp/v2/media so existing MCPHub parsers
+ * continue to work unchanged.
+ */
+ private function format_attachment_response($attach_id, $sideload, $metadata) {
+ $post = get_post($attach_id);
+ $source_url = wp_get_attachment_url($attach_id);
+ $title = $post ? $post->post_title : '';
+
+ return [
+ 'id' => (int) $attach_id,
+ 'date' => $post ? mysql_to_rfc3339($post->post_date) : null,
+ 'slug' => $post ? $post->post_name : '',
+ 'type' => 'attachment',
+ 'link' => get_permalink($attach_id),
+ 'title' => ['rendered' => $title],
+ 'author' => $post ? (int) $post->post_author : 0,
+ 'mime_type' => $sideload['type'],
+ 'media_type' => $this->mime_to_media_type($sideload['type']),
+ 'source_url' => $source_url,
+ 'media_details' => is_array($metadata) ? $metadata : [],
+ 'companion' => true,
+ ];
+ }
+
+ private function mime_to_media_type($mime) {
+ if (strpos($mime, 'image/') === 0) return 'image';
+ if (strpos($mime, 'video/') === 0) return 'video';
+ if (strpos($mime, 'audio/') === 0) return 'audio';
+ return 'file';
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.1 — Capability probe (airano-mcp/v1/capabilities)
+ // ------------------------------------------------------------------
+
+ /**
+ * GET /airano-mcp/v1/capabilities
+ *
+ * Reports the effective capability set of the currently-authenticated
+ * user (i.e. the owner of the application password MCPHub is using) so
+ * MCPHub's F.7e probe can decide which tool families to expose.
+ *
+ * Response shape:
+ * {
+ * "plugin_version": "2.1.0",
+ * "companion": true,
+ * "user": {
+ * "id": int,
+ * "login": "string",
+ * "roles": ["administrator"],
+ * "capabilities": { "upload_files": true, "edit_posts": true, ... }
+ * },
+ * "features": { "rank_math": bool, "yoast": bool, "woocommerce": bool, "multisite": bool },
+ * "routes": { "capabilities": true, "upload_limits": true, ... },
+ * "wordpress": { "version": "6.5.3", "php_version": "8.1.x", "rest_enabled": true }
+ * }
+ */
+ public function get_capabilities() {
+ $user = wp_get_current_user();
+ if (!$user || !$user->ID) {
+ return new WP_Error(
+ 'not_authenticated',
+ __( 'No authenticated user found for capability probe.', 'airano-mcp-bridge' ),
+ ['status' => 401]
+ );
+ }
+
+ // Report the explicit capability set MCPHub cares about. We do NOT
+ // return the entire $user->allcaps map: we curate a stable shape so
+ // the Python side can type-check it cleanly.
+ $caps = [
+ 'upload_files' => user_can($user, 'upload_files'),
+ 'edit_posts' => user_can($user, 'edit_posts'),
+ 'publish_posts' => user_can($user, 'publish_posts'),
+ 'edit_others_posts' => user_can($user, 'edit_others_posts'),
+ 'delete_posts' => user_can($user, 'delete_posts'),
+ 'edit_pages' => user_can($user, 'edit_pages'),
+ 'publish_pages' => user_can($user, 'publish_pages'),
+ 'manage_categories' => user_can($user, 'manage_categories'),
+ 'moderate_comments' => user_can($user, 'moderate_comments'),
+ 'manage_options' => user_can($user, 'manage_options'),
+ 'edit_users' => user_can($user, 'edit_users'),
+ 'list_users' => user_can($user, 'list_users'),
+ 'manage_woocommerce'=> user_can($user, 'manage_woocommerce'),
+ 'edit_shop_orders' => user_can($user, 'edit_shop_orders'),
+ 'edit_products' => user_can($user, 'edit_products'),
+ ];
+
+ $features = [
+ 'rank_math' => $this->is_rank_math_active(),
+ 'yoast' => $this->is_yoast_active(),
+ 'woocommerce' => $this->is_woocommerce_active(),
+ 'multisite' => is_multisite(),
+ ];
+
+ // F.X.fix #10: read from the single-source route map so
+ // capabilities and site_health can never drift again.
+ $routes = self::get_route_map();
+
+ $wp_env = [
+ 'version' => get_bloginfo('version'),
+ 'php_version' => PHP_VERSION,
+ 'rest_enabled' => true,
+ ];
+
+ $response = [
+ 'plugin_version' => self::VERSION,
+ 'companion' => true,
+ 'user' => [
+ 'id' => (int) $user->ID,
+ 'login' => $user->user_login,
+ 'roles' => array_values((array) $user->roles),
+ 'capabilities' => $caps,
+ ],
+ 'features' => $features,
+ 'routes' => $routes,
+ 'wordpress'=> $wp_env,
+ ];
+
+ return rest_ensure_response($response);
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.2 — Bulk meta writes (airano-mcp/v1/bulk-meta)
+ // ------------------------------------------------------------------
+
+ /**
+ * Maximum number of {post_id, meta} items accepted in a single request.
+ * Protects the site from accidental DoS through runaway batches.
+ */
+ const BULK_META_MAX_ITEMS = 500;
+
+ /**
+ * POST /airano-mcp/v1/bulk-meta
+ *
+ * Body shape:
+ * { "updates": [ { "post_id": 123, "meta": { "key": "value", ... } }, ... ] }
+ *
+ * Per item:
+ * - Skipped if post doesn't exist (status=not_found)
+ * - Skipped if current user cannot edit it (status=forbidden)
+ * - Otherwise each meta key is written via update_post_meta().
+ * - Values of null delete the meta key (delete_post_meta).
+ *
+ * Response:
+ * {
+ * "total": N, "updated": U, "failed": F, "skipped": S,
+ * "results": [ { "post_id": int, "status": "ok|forbidden|not_found|error",
+ * "updated_keys": [...], "error": "..." }, ... ]
+ * }
+ */
+ public function handle_bulk_meta($request) {
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ return new WP_Error(
+ 'invalid_body',
+ __( 'Expected JSON body with `updates` array.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ // Accept either {"updates": [...]} or a bare top-level array for
+ // convenience when calling from the CLI.
+ $updates = isset($params['updates']) ? $params['updates'] : $params;
+ if (!is_array($updates) || empty($updates)) {
+ return new WP_Error(
+ 'invalid_updates',
+ __( 'No updates supplied. Send `{"updates": [{"post_id": N, "meta": {...}}, ...]}`.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ if (count($updates) > self::BULK_META_MAX_ITEMS) {
+ return new WP_Error(
+ 'too_many_items',
+ /* translators: %d: maximum items per bulk-meta request. */
+ sprintf( __( 'bulk-meta supports at most %d items per request.', 'airano-mcp-bridge' ), self::BULK_META_MAX_ITEMS ),
+ ['status' => 413]
+ );
+ }
+
+ $results = [];
+ $updated_count = 0;
+ $failed_count = 0;
+ $skipped_count = 0;
+
+ foreach ($updates as $idx => $item) {
+ if (!is_array($item)) {
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => null,
+ 'status' => 'error',
+ 'error' => 'invalid_item',
+ 'message' => __( 'Each update must be an object with post_id + meta.', 'airano-mcp-bridge' ),
+ ];
+ $failed_count++;
+ continue;
+ }
+
+ $post_id = isset($item['post_id']) ? (int) $item['post_id'] : 0;
+ $meta = isset($item['meta']) && is_array($item['meta']) ? $item['meta'] : null;
+
+ if ($post_id <= 0 || $meta === null) {
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => $post_id ?: null,
+ 'status' => 'error',
+ 'error' => 'invalid_item',
+ 'message' => __( 'Each item needs a positive post_id and a meta object.', 'airano-mcp-bridge' ),
+ ];
+ $failed_count++;
+ continue;
+ }
+
+ $post = get_post($post_id);
+ if (!$post) {
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => $post_id,
+ 'status' => 'not_found',
+ 'error' => 'post_not_found',
+ ];
+ $skipped_count++;
+ continue;
+ }
+
+ if (!current_user_can('edit_post', $post_id)) {
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => $post_id,
+ 'status' => 'forbidden',
+ 'error' => 'cannot_edit_post',
+ ];
+ $skipped_count++;
+ continue;
+ }
+
+ $updated_keys = [];
+ $deleted_keys = [];
+ $item_error = null;
+
+ foreach ($meta as $meta_key => $meta_value) {
+ if (!is_string($meta_key) || $meta_key === '') {
+ $item_error = 'invalid_meta_key';
+ break;
+ }
+ // Block `_` (private) keys unless the user can edit others'
+ // posts — matches WP's own guardrail for register_post_meta
+ // with non-exposed keys. Application passwords typically
+ // belong to admins so this is rarely limiting in practice.
+ if (strpos($meta_key, '_') === 0 && !current_user_can('edit_others_posts')) {
+ $item_error = 'private_meta_key_denied';
+ break;
+ }
+
+ if ($meta_value === null) {
+ delete_post_meta($post_id, $meta_key);
+ $deleted_keys[] = $meta_key;
+ } else {
+ update_post_meta($post_id, $meta_key, $meta_value);
+ $updated_keys[] = $meta_key;
+ }
+ }
+
+ if ($item_error !== null) {
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => $post_id,
+ 'status' => 'error',
+ 'error' => $item_error,
+ 'updated_keys' => $updated_keys,
+ 'deleted_keys' => $deleted_keys,
+ ];
+ $failed_count++;
+ continue;
+ }
+
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => $post_id,
+ 'status' => 'ok',
+ 'updated_keys' => $updated_keys,
+ 'deleted_keys' => $deleted_keys,
+ ];
+ $updated_count++;
+ }
+
+ return rest_ensure_response([
+ 'total' => count($updates),
+ 'updated' => $updated_count,
+ 'failed' => $failed_count,
+ 'skipped' => $skipped_count,
+ 'results' => $results,
+ ]);
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.3 — Structured JSON export (airano-mcp/v1/export)
+ // ------------------------------------------------------------------
+
+ /**
+ * Maximum number of posts returned in a single export page. Larger
+ * datasets must be paginated via offset.
+ */
+ const EXPORT_MAX_LIMIT = 500;
+ const EXPORT_DEFAULT_LIMIT = 100;
+
+ /**
+ * GET /airano-mcp/v1/export
+ *
+ * Query params:
+ * post_type — comma-list, default "post" ("post,page,product,...")
+ * status — comma-list or "any", default "publish"
+ * since — ISO8601 date; only posts modified after this
+ * limit — 1..500, default 100
+ * offset — default 0
+ * include_media — bool, default true
+ * include_terms — bool, default true
+ * include_meta — bool, default true
+ *
+ * Response is a single JSON envelope (no chunking) so chunking/paging
+ * is the caller's responsibility via `offset` + `has_more` + `next_offset`.
+ */
+ public function handle_export($request) {
+ $post_types_raw = (string) ($request->get_param('post_type') ?: 'post');
+ $status_raw = (string) ($request->get_param('status') ?: 'publish');
+ $since = $request->get_param('since');
+ $limit = (int) ($request->get_param('limit') ?: self::EXPORT_DEFAULT_LIMIT);
+ $offset = (int) ($request->get_param('offset') ?: 0);
+
+ $include_media = $this->bool_param($request->get_param('include_media'), true);
+ $include_terms = $this->bool_param($request->get_param('include_terms'), true);
+ $include_meta = $this->bool_param($request->get_param('include_meta'), true);
+
+ // Normalise the input.
+ $post_types = array_values(array_filter(array_map('trim', explode(',', $post_types_raw))));
+ if (empty($post_types)) {
+ $post_types = ['post'];
+ }
+ $statuses = array_values(array_filter(array_map('trim', explode(',', $status_raw))));
+ if (empty($statuses)) {
+ $statuses = ['publish'];
+ }
+ if (in_array('any', $statuses, true)) {
+ $statuses = 'any';
+ }
+
+ if ($limit <= 0) {
+ $limit = self::EXPORT_DEFAULT_LIMIT;
+ }
+ if ($limit > self::EXPORT_MAX_LIMIT) {
+ $limit = self::EXPORT_MAX_LIMIT;
+ }
+ if ($offset < 0) {
+ $offset = 0;
+ }
+
+ // Validate each requested post_type is real + readable.
+ foreach ($post_types as $pt) {
+ if (!post_type_exists($pt)) {
+ return new WP_Error(
+ 'unknown_post_type',
+ /* translators: %s: post type slug supplied by caller. */
+ sprintf( __( 'Unknown post type: %s', 'airano-mcp-bridge' ), $pt ),
+ ['status' => 400]
+ );
+ }
+ }
+
+ $query_args = [
+ 'post_type' => $post_types,
+ 'post_status' => $statuses,
+ 'posts_per_page' => $limit,
+ 'offset' => $offset,
+ 'orderby' => 'modified',
+ 'order' => 'DESC',
+ 'no_found_rows' => false,
+ 'suppress_filters' => false,
+ ];
+
+ // Validate and translate `since` to a date_query.
+ if ($since) {
+ $ts = strtotime((string) $since);
+ if (!$ts) {
+ return new WP_Error(
+ 'invalid_since',
+ __( '`since` must be a parseable ISO8601 / strtotime date.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ $query_args['date_query'] = [
+ [
+ 'column' => 'post_modified_gmt',
+ 'after' => gmdate('Y-m-d H:i:s', $ts),
+ 'inclusive' => false,
+ ],
+ ];
+ }
+
+ $q = new WP_Query($query_args);
+ $posts = [];
+ $media_ids = [];
+
+ foreach ($q->posts as $post) {
+ $item = [
+ 'id' => (int) $post->ID,
+ 'post_type' => $post->post_type,
+ 'status' => $post->post_status,
+ 'title' => $post->post_title,
+ 'slug' => $post->post_name,
+ 'content' => $post->post_content,
+ 'excerpt' => $post->post_excerpt,
+ 'date_gmt' => mysql_to_rfc3339($post->post_date_gmt),
+ 'modified_gmt' => mysql_to_rfc3339($post->post_modified_gmt),
+ 'author_id' => (int) $post->post_author,
+ 'parent_id' => (int) $post->post_parent,
+ 'menu_order' => (int) $post->menu_order,
+ 'link' => get_permalink($post),
+ ];
+
+ $featured = (int) get_post_thumbnail_id($post->ID);
+ $item['featured_media_id'] = $featured ?: null;
+ if ($featured) {
+ $media_ids[$featured] = true;
+ }
+
+ if ($include_meta) {
+ // get_post_meta with no key returns all meta — keys are arrays
+ // because meta is multi-value; we flatten single-value keys for
+ // ergonomics but preserve arrays when > 1.
+ $raw_meta = get_post_meta($post->ID);
+ $flat = [];
+ if (is_array($raw_meta)) {
+ foreach ($raw_meta as $mk => $mv) {
+ if (is_array($mv) && count($mv) === 1) {
+ $flat[$mk] = maybe_unserialize($mv[0]);
+ } else {
+ $flat[$mk] = array_map('maybe_unserialize', (array) $mv);
+ }
+ }
+ }
+ $item['meta'] = $flat;
+ }
+
+ if ($include_terms) {
+ $taxonomies = get_object_taxonomies($post->post_type, 'objects');
+ $terms_out = [];
+ foreach ($taxonomies as $tx_name => $tx_obj) {
+ if (!$tx_obj->show_in_rest && !$tx_obj->public) {
+ continue;
+ }
+ $terms = wp_get_post_terms($post->ID, $tx_name, ['fields' => 'all']);
+ if (is_wp_error($terms) || empty($terms)) {
+ continue;
+ }
+ $terms_out[$tx_name] = array_map(function ($t) {
+ return [
+ 'id' => (int) $t->term_id,
+ 'name' => $t->name,
+ 'slug' => $t->slug,
+ ];
+ }, $terms);
+ }
+ $item['terms'] = $terms_out;
+ }
+
+ $posts[] = $item;
+ }
+
+ $media = [];
+ if ($include_media && !empty($media_ids)) {
+ $ids = array_keys($media_ids);
+ // Fetch media in a single WP_Query for efficiency.
+ $mq = new WP_Query([
+ 'post_type' => 'attachment',
+ 'post_status' => 'inherit',
+ 'post__in' => $ids,
+ 'posts_per_page' => count($ids),
+ 'no_found_rows' => true,
+ ]);
+ foreach ($mq->posts as $m) {
+ $media[] = [
+ 'id' => (int) $m->ID,
+ 'source_url' => wp_get_attachment_url($m->ID),
+ 'mime_type' => $m->post_mime_type,
+ 'alt_text' => (string) get_post_meta($m->ID, '_wp_attachment_image_alt', true),
+ 'title' => $m->post_title,
+ ];
+ }
+ }
+
+ $total = (int) $q->found_posts;
+ $returned = count($posts);
+ $has_more = ($offset + $returned) < $total;
+
+ return rest_ensure_response([
+ 'post_types' => $post_types,
+ 'status' => is_array($statuses) ? $statuses : ['any'],
+ 'since' => $since ?: null,
+ 'limit' => $limit,
+ 'offset' => $offset,
+ 'returned' => $returned,
+ 'total_matching' => $total,
+ 'has_more' => $has_more,
+ 'next_offset' => $has_more ? $offset + $returned : null,
+ 'include_media' => $include_media,
+ 'include_terms' => $include_terms,
+ 'include_meta' => $include_meta,
+ 'posts' => $posts,
+ 'media' => $media,
+ 'exported_at_gmt' => gmdate('Y-m-d\TH:i:s\Z'),
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ /**
+ * Coerce a REST query param into a bool.
+ * Accepts true/false/"true"/"false"/"1"/"0"/1/0; anything else → $default.
+ */
+ private function bool_param($v, $default) {
+ if ($v === null || $v === '') {
+ return (bool) $default;
+ }
+ if (is_bool($v)) {
+ return $v;
+ }
+ if (is_numeric($v)) {
+ return (int) $v !== 0;
+ }
+ $s = strtolower((string) $v);
+ if (in_array($s, ['true', 'yes', 'on'], true)) {
+ return true;
+ }
+ if (in_array($s, ['false', 'no', 'off'], true)) {
+ return false;
+ }
+ return (bool) $default;
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.4 — Cache purge (airano-mcp/v1/cache-purge)
+ // ------------------------------------------------------------------
+
+ /**
+ * POST /airano-mcp/v1/cache-purge
+ *
+ * Auto-detects active cache plugins and invokes their "purge all"
+ * API. Always flushes the WP object cache (safe no-op when no
+ * persistent object cache is configured). Makes the Docker-socket +
+ * WP-CLI fallback unnecessary for cache maintenance on platforms
+ * that don't allow the socket mount.
+ *
+ * Response shape:
+ * {
+ * "detected": ["wp_rocket", "litespeed"],
+ * "purged": ["wp_rocket_all", "litespeed_all", "object_cache"],
+ * "skipped": [],
+ * "errors": [],
+ * "plugin_version": "2.4.0"
+ * }
+ */
+ public function handle_cache_purge($request) {
+ $detected = [];
+ $purged = [];
+ $errors = [];
+
+ // --- WP Rocket ---
+ if (function_exists('rocket_clean_domain')) {
+ $detected[] = 'wp_rocket';
+ try {
+ rocket_clean_domain();
+ if (function_exists('rocket_clean_minify')) {
+ rocket_clean_minify();
+ }
+ $purged[] = 'wp_rocket_all';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'wp_rocket', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- W3 Total Cache ---
+ if (function_exists('w3tc_flush_all')) {
+ $detected[] = 'w3_total_cache';
+ try {
+ w3tc_flush_all();
+ $purged[] = 'w3_total_cache_all';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'w3_total_cache', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- WP Super Cache ---
+ if (function_exists('wp_cache_clear_cache')) {
+ $detected[] = 'wp_super_cache';
+ try {
+ wp_cache_clear_cache();
+ $purged[] = 'wp_super_cache_all';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'wp_super_cache', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- LiteSpeed Cache ---
+ if (defined('LSCWP_V') || class_exists('\\LiteSpeed\\Purge')) {
+ $detected[] = 'litespeed';
+ try {
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party hook
+ do_action('litespeed_purge_all');
+ $purged[] = 'litespeed_all';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'litespeed', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- WP Fastest Cache ---
+ if (class_exists('WpFastestCache')) {
+ $detected[] = 'wp_fastest_cache';
+ try {
+ $wpfc = new \WpFastestCache();
+ if (method_exists($wpfc, 'deleteCache')) {
+ $wpfc->deleteCache(true);
+ $purged[] = 'wp_fastest_cache_all';
+ } else {
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party hook
+ do_action('wpfc_clear_all_cache', true);
+ $purged[] = 'wp_fastest_cache_all';
+ }
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'wp_fastest_cache', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- SiteGround Optimizer ---
+ if (
+ function_exists('sg_cachepress_purge_cache')
+ || class_exists('SiteGround_Optimizer\\Supercacher\\Supercacher')
+ ) {
+ $detected[] = 'siteground_optimizer';
+ try {
+ if (function_exists('sg_cachepress_purge_cache')) {
+ sg_cachepress_purge_cache();
+ } elseif (method_exists('SiteGround_Optimizer\\Supercacher\\Supercacher', 'purge_cache')) {
+ \SiteGround_Optimizer\Supercacher\Supercacher::purge_cache();
+ }
+ $purged[] = 'siteground_optimizer_all';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'siteground_optimizer', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- Cloudflare (WP) ---
+ // CF has no canonical "purge all" helper exposed publicly; skip unless
+ // we detect the official plugin and even then we only advertise
+ // detection so the user can act.
+ if (class_exists('CF\\WordPress\\Hooks')) {
+ $detected[] = 'cloudflare';
+ // no-op — CF's purge requires the user's API token we don't have.
+ }
+
+ // --- Object cache (always) ---
+ // Safe no-op if no persistent object cache is configured.
+ if (function_exists('wp_cache_flush')) {
+ try {
+ wp_cache_flush();
+ $purged[] = 'object_cache';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'object_cache', 'message' => $e->getMessage()];
+ }
+ }
+
+ return rest_ensure_response([
+ 'detected' => array_values(array_unique($detected)),
+ 'purged' => array_values(array_unique($purged)),
+ 'skipped' => [],
+ 'errors' => $errors,
+ 'ok' => empty($errors),
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.5 — Transient flush (airano-mcp/v1/transient-flush)
+ // ------------------------------------------------------------------
+
+ /**
+ * Cap on the number of deleted-key samples returned so the response
+ * stays small even when thousands of transients are purged.
+ */
+ const TRANSIENT_SAMPLE_MAX = 100;
+
+ /**
+ * POST /airano-mcp/v1/transient-flush
+ *
+ * Body (optional):
+ * {
+ * "scope": "expired" | "all" | "pattern", // default "expired"
+ * "pattern": "foo_*", // required when scope=pattern
+ * "include_site_transients": true // default true
+ * }
+ *
+ * Response:
+ * {
+ * "ok": bool, "scope": ..., "pattern": ..., "include_site_transients": ...,
+ * "deleted_count": N,
+ * "deleted_sample": [...], // capped at TRANSIENT_SAMPLE_MAX
+ * "plugin_version": "2.5.0"
+ * }
+ */
+ public function handle_transient_flush($request) {
+ global $wpdb;
+
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ $params = [];
+ }
+
+ $scope = isset($params['scope']) ? strtolower((string) $params['scope']) : 'expired';
+ $pattern = isset($params['pattern']) ? (string) $params['pattern'] : '';
+ $include_site = array_key_exists('include_site_transients', $params)
+ ? $this->bool_param($params['include_site_transients'], true)
+ : true;
+
+ if (!in_array($scope, ['expired', 'all', 'pattern'], true)) {
+ return new WP_Error(
+ 'invalid_scope',
+ __( '`scope` must be one of: "expired", "all", "pattern".', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ if ($scope === 'pattern' && $pattern === '') {
+ return new WP_Error(
+ 'pattern_required',
+ __( '`pattern` is required when scope="pattern" (e.g. "rank_math_*").', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ $deleted_keys = [];
+
+ if ($scope === 'expired') {
+ // Count the timeout rows that will be considered expired by
+ // delete_expired_transients() (option_value < UNIX_TIMESTAMP())
+ // so we can report a stable count.
+ $now = time();
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- transient inventory has no WP API; cache flush follows below
+ $rows = $wpdb->get_col($wpdb->prepare(
+ "SELECT option_name FROM {$wpdb->options}
+ WHERE option_name LIKE %s AND option_value < %d",
+ $wpdb->esc_like('_transient_timeout_') . '%',
+ $now
+ ));
+ foreach ($rows as $row) {
+ $deleted_keys[] = preg_replace('/^_transient_timeout_/', '', $row);
+ }
+
+ if (function_exists('delete_expired_transients')) {
+ delete_expired_transients($include_site);
+ }
+ } elseif ($scope === 'all') {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- transient inventory has no WP API; cache flush follows below
+ $option_names = $wpdb->get_col($wpdb->prepare(
+ "SELECT option_name FROM {$wpdb->options}
+ WHERE option_name LIKE %s AND option_name NOT LIKE %s",
+ $wpdb->esc_like('_transient_') . '%',
+ $wpdb->esc_like('_transient_timeout_') . '%'
+ ));
+ foreach ($option_names as $name) {
+ $key = preg_replace('/^_transient_/', '', $name);
+ if (delete_transient($key)) {
+ $deleted_keys[] = $key;
+ }
+ }
+
+ if ($include_site && is_multisite()) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- site-transient inventory has no WP API
+ $site_names = $wpdb->get_col($wpdb->prepare(
+ "SELECT meta_key FROM {$wpdb->sitemeta}
+ WHERE meta_key LIKE %s AND meta_key NOT LIKE %s",
+ $wpdb->esc_like('_site_transient_') . '%',
+ $wpdb->esc_like('_site_transient_timeout_') . '%'
+ ));
+ foreach ($site_names as $name) {
+ $key = preg_replace('/^_site_transient_/', '', $name);
+ if (delete_site_transient($key)) {
+ $deleted_keys[] = 'site:' . $key;
+ }
+ }
+ }
+ } else {
+ // pattern: convert shell-style * glob to SQL LIKE %.
+ $like = $wpdb->esc_like('_transient_') . str_replace('*', '%', $pattern);
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- pattern-match transient inventory has no WP API
+ $option_names = $wpdb->get_col($wpdb->prepare(
+ "SELECT option_name FROM {$wpdb->options}
+ WHERE option_name LIKE %s AND option_name NOT LIKE %s",
+ $like,
+ $wpdb->esc_like('_transient_timeout_') . '%'
+ ));
+ foreach ($option_names as $name) {
+ $key = preg_replace('/^_transient_/', '', $name);
+ if (delete_transient($key)) {
+ $deleted_keys[] = $key;
+ }
+ }
+
+ if ($include_site && is_multisite()) {
+ $site_like = $wpdb->esc_like('_site_transient_') . str_replace('*', '%', $pattern);
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- site-transient pattern inventory has no WP API
+ $site_names = $wpdb->get_col($wpdb->prepare(
+ "SELECT meta_key FROM {$wpdb->sitemeta}
+ WHERE meta_key LIKE %s AND meta_key NOT LIKE %s",
+ $site_like,
+ $wpdb->esc_like('_site_transient_timeout_') . '%'
+ ));
+ foreach ($site_names as $name) {
+ $key = preg_replace('/^_site_transient_/', '', $name);
+ if (delete_site_transient($key)) {
+ $deleted_keys[] = 'site:' . $key;
+ }
+ }
+ }
+ }
+
+ wp_cache_flush();
+
+ return rest_ensure_response([
+ 'ok' => true,
+ 'scope' => $scope,
+ 'pattern' => $scope === 'pattern' ? $pattern : null,
+ 'include_site_transients' => $include_site,
+ 'deleted_count' => count($deleted_keys),
+ 'deleted_sample' => array_slice($deleted_keys, 0, self::TRANSIENT_SAMPLE_MAX),
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.6 — Unified site health (airano-mcp/v1/site-health)
+ // ------------------------------------------------------------------
+
+ /**
+ * GET /airano-mcp/v1/site-health
+ *
+ * Single JSON envelope with everything MCPHub needs to render a
+ * "is this site healthy" dashboard. Collects in one request what
+ * otherwise requires 5+ round-trips and/or WP-CLI.
+ */
+ public function handle_site_health() {
+ global $wp_version, $wpdb;
+
+ // --- WordPress environment ----------------------------------------
+ $wp_env = [
+ 'version' => $wp_version,
+ 'multisite' => is_multisite(),
+ 'home_url' => home_url(),
+ 'site_url' => site_url(),
+ 'language' => get_locale(),
+ 'timezone' => wp_timezone_string(),
+ 'rest_enabled' => true,
+ 'application_passwords_available'=> function_exists('wp_is_application_passwords_available')
+ ? (bool) wp_is_application_passwords_available()
+ : true,
+ 'debug_mode' => defined('WP_DEBUG') && WP_DEBUG,
+ 'script_debug' => defined('SCRIPT_DEBUG') && SCRIPT_DEBUG,
+ 'savequeries' => defined('SAVEQUERIES') && SAVEQUERIES,
+ 'https_home' => strpos(home_url(), 'https://') === 0,
+ ];
+
+ // --- PHP ---------------------------------------------------------
+ $extensions = get_loaded_extensions();
+ sort($extensions);
+ $php_env = [
+ 'version' => PHP_VERSION,
+ 'memory_limit' => (string) ini_get('memory_limit'),
+ 'max_execution_time' => (string) ini_get('max_execution_time'),
+ 'upload_max_filesize' => (string) ini_get('upload_max_filesize'),
+ 'post_max_size' => (string) ini_get('post_max_size'),
+ 'max_input_vars' => (int) ini_get('max_input_vars'),
+ 'extensions' => array_values($extensions),
+ 'has_mbstring' => extension_loaded('mbstring'),
+ 'has_curl' => extension_loaded('curl'),
+ 'has_gd' => extension_loaded('gd'),
+ 'has_imagick' => extension_loaded('imagick'),
+ 'has_intl' => extension_loaded('intl'),
+ ];
+
+ // --- Server ------------------------------------------------------
+ $server_software = '';
+ if ( isset( $_SERVER['SERVER_SOFTWARE'] ) ) {
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised on the next line
+ $raw_software = wp_unslash( $_SERVER['SERVER_SOFTWARE'] );
+ $server_software = sanitize_text_field( $raw_software );
+ }
+
+ $disk_free = @disk_free_space(ABSPATH);
+ $disk_total = @disk_total_space(ABSPATH);
+ $server_env = [
+ 'software' => $server_software,
+ 'disk_free_bytes' => $disk_free !== false ? (int) $disk_free : null,
+ 'disk_total_bytes' => $disk_total !== false ? (int) $disk_total : null,
+ ];
+
+ // --- MySQL / MariaDB --------------------------------------------
+ $db_version = '';
+ if (is_object($wpdb) && method_exists($wpdb, 'db_version')) {
+ $db_version = (string) $wpdb->db_version();
+ }
+ $db_env = [
+ 'version' => $db_version,
+ 'server_info' => is_object($wpdb) && isset($wpdb->dbh) && is_object($wpdb->dbh)
+ && method_exists($wpdb->dbh, 'get_server_info')
+ ? $wpdb->dbh->get_server_info()
+ : null,
+ 'charset' => (string) $wpdb->charset,
+ 'collate' => (string) $wpdb->collate,
+ 'prefix' => (string) $wpdb->prefix,
+ ];
+
+ // --- Plugins -----------------------------------------------------
+ if (!function_exists('get_plugins')) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ $all_plugins = get_plugins();
+ $active_paths = (array) get_option('active_plugins', []);
+ $active_list = [];
+ foreach ($active_paths as $path) {
+ if (isset($all_plugins[$path])) {
+ $meta = $all_plugins[$path];
+ $active_list[] = [
+ 'file' => $path,
+ 'name' => $meta['Name'] ?? '',
+ 'version' => $meta['Version'] ?? '',
+ ];
+ }
+ }
+ // MU plugins
+ $mu_plugins = function_exists('get_mu_plugins') ? get_mu_plugins() : [];
+
+ $plugins_env = [
+ 'total_count' => count($all_plugins),
+ 'active_count' => count($active_list),
+ 'active' => $active_list,
+ 'must_use_count' => count($mu_plugins),
+ ];
+
+ // --- Theme -------------------------------------------------------
+ $active_theme = wp_get_theme();
+ $parent_theme = $active_theme->parent();
+ $theme_env = [
+ 'active' => [
+ 'name' => $active_theme ? $active_theme->get('Name') : null,
+ 'version' => $active_theme ? $active_theme->get('Version') : null,
+ 'stylesheet' => $active_theme ? $active_theme->get_stylesheet() : null,
+ ],
+ 'parent' => $parent_theme
+ ? [
+ 'name' => $parent_theme->get('Name'),
+ 'version' => $parent_theme->get('Version'),
+ 'stylesheet' => $parent_theme->get_stylesheet(),
+ ]
+ : null,
+ 'total_count' => count(wp_get_themes()),
+ ];
+
+ // --- Write/permission checks ------------------------------------
+ global $wp_filesystem;
+ if ( empty( $wp_filesystem ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ WP_Filesystem();
+ }
+ $uploads = wp_get_upload_dir();
+ $checks = [
+ 'writable_wp_content' => ! empty( $wp_filesystem )
+ ? $wp_filesystem->is_writable( WP_CONTENT_DIR )
+ : null,
+ 'writable_uploads' => is_array($uploads) && !empty($uploads['basedir']) && ! empty( $wp_filesystem )
+ ? $wp_filesystem->is_writable( $uploads['basedir'] )
+ : null,
+ 'ssl_enabled' => function_exists('is_ssl') ? is_ssl() : null,
+ ];
+
+ // --- Companion self-description ---------------------------------
+ // F.X.fix #10: read from the shared route map — capabilities
+ // and site_health must agree on what's shipping.
+ $companion = [
+ 'plugin_version' => self::VERSION,
+ 'routes' => self::get_route_map(),
+ 'features' => [
+ 'rank_math' => $this->is_rank_math_active(),
+ 'yoast' => $this->is_yoast_active(),
+ 'woocommerce' => $this->is_woocommerce_active(),
+ ],
+ ];
+
+ return rest_ensure_response([
+ 'ok' => true,
+ 'wordpress' => $wp_env,
+ 'php' => $php_env,
+ 'server' => $server_env,
+ 'database' => $db_env,
+ 'plugins' => $plugins_env,
+ 'theme' => $theme_env,
+ 'checks' => $checks,
+ 'companion' => $companion,
+ 'generated_at_gmt'=> gmdate('Y-m-d\TH:i:s\Z'),
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.7 — Audit hook (airano-mcp/v1/audit-hook)
+ // ------------------------------------------------------------------
+
+ /**
+ * GET /airano-mcp/v1/audit-hook — current config + stats.
+ * Secret is returned masked; never echoed in full.
+ */
+ public function handle_audit_hook_get() {
+ $secret = (string) get_option(self::AUDIT_OPT_SECRET, '');
+ $secret_last4 = strlen($secret) >= 4 ? substr($secret, -4) : '';
+ $events = get_option(self::AUDIT_OPT_EVENTS, self::AUDIT_DEFAULT_EVENTS);
+ if (!is_array($events)) {
+ $events = self::AUDIT_DEFAULT_EVENTS;
+ }
+ return rest_ensure_response([
+ 'enabled' => (bool) get_option(self::AUDIT_OPT_ENABLED, false),
+ 'endpoint_url' => (string) get_option(self::AUDIT_OPT_ENDPOINT, ''),
+ 'secret_set' => $secret !== '',
+ 'secret_last4' => $secret_last4,
+ 'events' => array_values($events),
+ 'last_push_gmt' => (string) get_option(self::AUDIT_OPT_LAST_PUSH_GMT, ''),
+ 'failure_count' => (int) get_option(self::AUDIT_OPT_FAILURE_COUNT, 0),
+ 'last_error' => (string) get_option(self::AUDIT_OPT_LAST_ERROR, ''),
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/audit-hook — upsert config.
+ * Body: {endpoint_url, secret, enabled, events}
+ */
+ public function handle_audit_hook_set($request) {
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ return new WP_Error(
+ 'invalid_body',
+ __( 'JSON body required.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ if (isset($params['endpoint_url'])) {
+ $raw_url = (string) $params['endpoint_url'];
+ $url = esc_url_raw( $raw_url, [ 'http', 'https' ] );
+ if ($url === '' && $raw_url !== '') {
+ return new WP_Error(
+ 'invalid_url',
+ __( 'endpoint_url must be a valid http:// or https:// URL.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ // Defence-in-depth: also reject schemes esc_url_raw accepts but
+ // we never want here (e.g. mailto:, javascript:, data:).
+ if ( $url !== '' ) {
+ $scheme = wp_parse_url( $url, PHP_URL_SCHEME );
+ if ( ! in_array( $scheme, [ 'http', 'https' ], true ) ) {
+ return new WP_Error(
+ 'invalid_url_scheme',
+ __( 'endpoint_url scheme must be http or https.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ }
+ update_option(self::AUDIT_OPT_ENDPOINT, $url, false);
+ }
+
+ if (isset($params['secret'])) {
+ $secret = (string) $params['secret'];
+ if (strlen($secret) > 0 && strlen($secret) < 16) {
+ return new WP_Error(
+ 'secret_too_short',
+ __( 'Shared secret must be at least 16 characters (use a random token).', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ update_option(self::AUDIT_OPT_SECRET, $secret, false);
+ }
+
+ if (array_key_exists('enabled', $params)) {
+ update_option(
+ self::AUDIT_OPT_ENABLED,
+ $this->bool_param($params['enabled'], false),
+ false
+ );
+ }
+
+ if (isset($params['events']) && is_array($params['events'])) {
+ $clean = array_values(array_unique(array_filter(array_map(
+ function ($e) { return is_string($e) ? sanitize_key($e) : null; },
+ $params['events']
+ ))));
+ if (empty($clean)) {
+ $clean = self::AUDIT_DEFAULT_EVENTS;
+ }
+ update_option(self::AUDIT_OPT_EVENTS, $clean, false);
+ }
+
+ // Reset failure counters on successful config change so operators
+ // don't see stale errors after fixing the endpoint.
+ update_option(self::AUDIT_OPT_FAILURE_COUNT, 0, false);
+ update_option(self::AUDIT_OPT_LAST_ERROR, '', false);
+
+ // Re-register hooks immediately so the new event list takes effect
+ // without requiring a page reload on the WP side.
+ $this->audit_register_hooks();
+
+ return $this->handle_audit_hook_get();
+ }
+
+ /**
+ * DELETE /airano-mcp/v1/audit-hook — disable + clear config.
+ * Deliberately does NOT leave the secret in wp_options.
+ */
+ public function handle_audit_hook_clear() {
+ delete_option(self::AUDIT_OPT_ENABLED);
+ delete_option(self::AUDIT_OPT_ENDPOINT);
+ delete_option(self::AUDIT_OPT_SECRET);
+ delete_option(self::AUDIT_OPT_EVENTS);
+ delete_option(self::AUDIT_OPT_LAST_PUSH_GMT);
+ delete_option(self::AUDIT_OPT_FAILURE_COUNT);
+ delete_option(self::AUDIT_OPT_LAST_ERROR);
+
+ return rest_ensure_response([
+ 'enabled' => false,
+ 'endpoint_url' => '',
+ 'secret_set' => false,
+ 'cleared' => true,
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ // ------------------------------------------------------------------
+ // F.5a.8.2 — Regenerate attachment thumbnails
+ // ------------------------------------------------------------------
+
+ /**
+ * POST /airano-mcp/v1/regenerate-thumbnails
+ *
+ * Body shapes:
+ * { "ids": [12, 34, 56] } — regenerate specific attachments
+ * { "all": true } — batch mode starting at offset 0
+ * { "all": true, "offset": 100, "limit": 25 } — paged batch
+ *
+ * Per request cap: 50 attachments. Callers paginate via the returned
+ * `next_offset` when `has_more` is true. Only `image/*` attachments are
+ * regenerated; other types are silently skipped.
+ *
+ * Uses `wp_generate_attachment_metadata()` which rebuilds the size set
+ * registered by `add_image_size()` plus the standard WP sizes.
+ */
+ public function handle_regenerate_thumbnails( $request ) {
+ $body = $request->get_json_params();
+ if ( ! is_array( $body ) ) {
+ $body = [];
+ }
+
+ // The helper requires the core metadata functions, which are not
+ // loaded on REST requests by default.
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+
+ $per_call_cap = 50;
+ $ids = [];
+ $mode = 'ids';
+ $next_offset = null;
+ $has_more = false;
+ $total = null;
+
+ if ( ! empty( $body['ids'] ) && is_array( $body['ids'] ) ) {
+ foreach ( $body['ids'] as $raw_id ) {
+ $id = (int) $raw_id;
+ if ( $id > 0 ) {
+ $ids[] = $id;
+ }
+ }
+ $ids = array_slice( array_values( array_unique( $ids ) ), 0, $per_call_cap );
+ } elseif ( ! empty( $body['all'] ) ) {
+ $mode = 'all';
+ $offset = isset( $body['offset'] ) ? max( 0, (int) $body['offset'] ) : 0;
+ $limit = isset( $body['limit'] ) ? max( 1, (int) $body['limit'] ) : $per_call_cap;
+ $limit = min( $limit, $per_call_cap );
+
+ $query = new WP_Query( [
+ 'post_type' => 'attachment',
+ 'post_status' => 'inherit',
+ 'post_mime_type' => 'image',
+ 'fields' => 'ids',
+ 'posts_per_page' => $limit,
+ 'offset' => $offset,
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ 'no_found_rows' => false,
+ ] );
+ $ids = array_map( 'intval', $query->posts );
+ $total = (int) $query->found_posts;
+ $next_offset = $offset + count( $ids );
+ $has_more = $next_offset < $total;
+ } else {
+ return new WP_Error(
+ 'airano_mcp_invalid_request',
+ __( 'Provide either "ids" (array of attachment IDs) or "all": true.', 'airano-mcp-bridge' ),
+ [ 'status' => 400 ]
+ );
+ }
+
+ $processed = 0;
+ $skipped = [];
+ $errors = [];
+
+ foreach ( $ids as $attachment_id ) {
+ $mime = get_post_mime_type( $attachment_id );
+ if ( ! $mime || strpos( $mime, 'image/' ) !== 0 ) {
+ $skipped[] = [ 'id' => $attachment_id, 'reason' => 'not_image' ];
+ continue;
+ }
+ if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
+ $errors[] = [ 'id' => $attachment_id, 'error' => 'forbidden' ];
+ continue;
+ }
+
+ $file = get_attached_file( $attachment_id );
+ if ( ! $file || ! file_exists( $file ) ) {
+ $errors[] = [ 'id' => $attachment_id, 'error' => 'file_missing' ];
+ continue;
+ }
+
+ $meta = wp_generate_attachment_metadata( $attachment_id, $file );
+ if ( is_wp_error( $meta ) ) {
+ $errors[] = [
+ 'id' => $attachment_id,
+ 'error' => $meta->get_error_code(),
+ 'message' => $meta->get_error_message(),
+ ];
+ continue;
+ }
+ if ( ! is_array( $meta ) || empty( $meta ) ) {
+ $errors[] = [ 'id' => $attachment_id, 'error' => 'empty_metadata' ];
+ continue;
+ }
+
+ wp_update_attachment_metadata( $attachment_id, $meta );
+ $processed++;
+ }
+
+ $response = [
+ 'plugin_version' => self::VERSION,
+ 'mode' => $mode,
+ 'attempted' => count( $ids ),
+ 'processed' => $processed,
+ 'skipped' => $skipped,
+ 'errors' => $errors,
+ ];
+
+ if ( 'all' === $mode ) {
+ $response['offset'] = isset( $body['offset'] ) ? (int) $body['offset'] : 0;
+ $response['limit'] = isset( $body['limit'] ) ? (int) $body['limit'] : $per_call_cap;
+ $response['has_more'] = $has_more;
+ $response['next_offset'] = $next_offset;
+ $response['total'] = $total;
+ }
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Attach WordPress action hooks for every configured audit event.
+ * Called from __construct() and after handle_audit_hook_set().
+ */
+ private function audit_register_hooks() {
+ if (!get_option(self::AUDIT_OPT_ENABLED, false)) {
+ return;
+ }
+ $events = get_option(self::AUDIT_OPT_EVENTS, self::AUDIT_DEFAULT_EVENTS);
+ if (!is_array($events)) {
+ return;
+ }
+
+ foreach ($events as $event) {
+ switch ($event) {
+ case 'transition_post_status':
+ add_action('transition_post_status', [$this, 'audit_on_transition_post'], 10, 3);
+ break;
+ case 'deleted_post':
+ add_action('deleted_post', [$this, 'audit_on_deleted_post'], 10, 1);
+ break;
+ case 'user_register':
+ add_action('user_register', [$this, 'audit_on_user_register'], 10, 1);
+ break;
+ case 'profile_update':
+ add_action('profile_update', [$this, 'audit_on_profile_update'], 10, 1);
+ break;
+ case 'deleted_user':
+ add_action('deleted_user', [$this, 'audit_on_deleted_user'], 10, 1);
+ break;
+ case 'activated_plugin':
+ add_action('activated_plugin', [$this, 'audit_on_activated_plugin'], 10, 1);
+ break;
+ case 'deactivated_plugin':
+ add_action('deactivated_plugin', [$this, 'audit_on_deactivated_plugin'], 10, 1);
+ break;
+ case 'switch_theme':
+ add_action('switch_theme', [$this, 'audit_on_switch_theme'], 10, 1);
+ break;
+ }
+ }
+ }
+
+ public function audit_on_transition_post($new_status, $old_status, $post) {
+ if ($new_status === $old_status) {
+ return;
+ }
+ $this->audit_push('transition_post_status', [
+ 'post_id' => is_object($post) ? (int) $post->ID : null,
+ 'post_type' => is_object($post) ? $post->post_type : null,
+ 'title' => is_object($post) ? $post->post_title : null,
+ 'new_status' => $new_status,
+ 'old_status' => $old_status,
+ ]);
+ }
+
+ public function audit_on_deleted_post($post_id) {
+ $this->audit_push('deleted_post', ['post_id' => (int) $post_id]);
+ }
+
+ public function audit_on_user_register($user_id) {
+ $this->audit_push('user_register', ['user_id' => (int) $user_id]);
+ }
+
+ public function audit_on_profile_update($user_id) {
+ $this->audit_push('profile_update', ['user_id' => (int) $user_id]);
+ }
+
+ public function audit_on_deleted_user($user_id) {
+ $this->audit_push('deleted_user', ['user_id' => (int) $user_id]);
+ }
+
+ public function audit_on_activated_plugin($plugin) {
+ $this->audit_push('activated_plugin', ['plugin' => (string) $plugin]);
+ }
+
+ public function audit_on_deactivated_plugin($plugin) {
+ $this->audit_push('deactivated_plugin', ['plugin' => (string) $plugin]);
+ }
+
+ public function audit_on_switch_theme($new_theme) {
+ $this->audit_push('switch_theme', ['new_theme' => (string) $new_theme]);
+ }
+
+ /**
+ * Sign + POST an event to the configured MCPHub endpoint.
+ * Non-blocking (`blocking=false`) so WP admin requests stay snappy —
+ * at the cost of not being able to detect per-event delivery failures
+ * inline. Failures are instead discovered by MCPHub's own retry /
+ * missing-event analysis.
+ */
+ private function audit_push($event, $data) {
+ $endpoint = (string) get_option(self::AUDIT_OPT_ENDPOINT, '');
+ $secret = (string) get_option(self::AUDIT_OPT_SECRET, '');
+ if ($endpoint === '' || $secret === '') {
+ return;
+ }
+
+ $envelope = [
+ 'event' => (string) $event,
+ 'site_url' => site_url(),
+ 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
+ 'user_id' => (int) get_current_user_id(),
+ 'data' => $data,
+ 'plugin_version' => self::VERSION,
+ ];
+ $body = wp_json_encode($envelope);
+ if ($body === false) {
+ return;
+ }
+
+ $signature = hash_hmac('sha256', $body, $secret);
+
+ $args = [
+ 'method' => 'POST',
+ 'timeout' => 2,
+ 'redirection' => 0,
+ 'blocking' => false,
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'X-Airano-MCP-Signature' => 'sha256=' . $signature,
+ 'X-Airano-MCP-Site' => site_url(),
+ 'X-Airano-MCP-Version' => self::VERSION,
+ 'User-Agent' => 'Airano-MCP-Bridge/' . self::VERSION,
+ ],
+ 'body' => $body,
+ ];
+
+ $resp = wp_remote_post($endpoint, $args);
+ // With blocking=false we get a WP_HTTP_Requests_Response-shaped stub
+ // that doesn't carry a real status; record the attempt timestamp
+ // so operators know pushes are happening at all.
+ update_option(self::AUDIT_OPT_LAST_PUSH_GMT, gmdate('Y-m-d\TH:i:s\Z'), false);
+
+ if (is_wp_error($resp)) {
+ $failures = (int) get_option(self::AUDIT_OPT_FAILURE_COUNT, 0) + 1;
+ update_option(self::AUDIT_OPT_FAILURE_COUNT, $failures, false);
+ update_option(self::AUDIT_OPT_LAST_ERROR, $resp->get_error_message(), false);
+ }
+ }
+
+ /**
+ * Display admin notices
+ */
+ public function admin_notices() {
+ // Only show notices on the Plugins page to avoid clutter on every admin page
+ $screen = get_current_screen();
+ if ( ! $screen || $screen->id !== 'plugins' ) {
+ return;
+ }
+
+ $rank_math_active = $this->is_rank_math_active();
+ $yoast_active = $this->is_yoast_active();
+ $woocommerce_active = $this->is_woocommerce_active();
+
+ if (!$rank_math_active && !$yoast_active) {
+ echo '';
+ echo ' Airano MCP Bridge: ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. SEO meta routes are idle, but upload helper routes remain active.', 'airano-mcp-bridge' ) . ' ';
+ echo ' ';
+ } else {
+ $active_plugins = [];
+ if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
+ if ($yoast_active) $active_plugins[] = 'Yoast SEO';
+
+ $supported_types = implode(', ', $this->supported_post_types);
+
+ echo '';
+ echo ' Airano MCP Bridge v' . esc_html( self::VERSION ) . ': ' . esc_html(
+ sprintf(
+ /* translators: %s: comma-separated list of active SEO plugin names. */
+ __( 'Successfully registered meta fields for %s.', 'airano-mcp-bridge' ),
+ implode( ' and ', $active_plugins )
+ )
+ ) . ' ';
+ echo ' ' . esc_html__( 'Supported post types:', 'airano-mcp-bridge' ) . ' ' . esc_html( $supported_types ) . ' ';
+ echo ' ' . esc_html__( 'MCP Bridge upload helper:', 'airano-mcp-bridge' ) . ' ' . esc_html__( '/wp-json/airano-mcp/v1/upload-limits + /upload-chunk (bypasses upload_max_filesize).', 'airano-mcp-bridge' ) . ' ';
+
+ if ($woocommerce_active) {
+ echo ' WooCommerce: ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'airano-mcp-bridge' ) . ' ';
+ }
+ echo ' ';
+ }
+ }
+}
+
+// Initialize the plugin
+new Airano_MCP_Bridge();
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/readme.txt b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/readme.txt
new file mode 100644
index 0000000..54f33ba
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/readme.txt
@@ -0,0 +1,207 @@
+=== Airano MCP Bridge ===
+Contributors: airano
+Tags: mcp, ai, rest-api, seo, media
+Requires at least: 5.0
+Tested up to: 6.9
+Requires PHP: 7.4
+Stable tag: 2.10.3
+License: GPLv2 or later
+License URI: https://www.gnu.org/licenses/gpl-2.0.html
+
+Companion plugin for MCP Hub. REST API routes for SEO meta and raw-binary media uploads that bypass upload_max_filesize.
+
+== Description ==
+
+**Airano MCP Bridge** is the official companion plugin for [MCP Hub](https://mcp.palebluedot.live) ([source](https://github.com/airano-ir/mcphub)) — the AI-native management hub for WordPress, WooCommerce, and self-hosted services. It extends the WordPress REST API with dedicated routes that AI agents (Claude, ChatGPT, Cursor, VS Code Copilot) and the MCP Hub server rely on:
+
+* **SEO meta routes** — read and write Rank Math SEO and Yoast SEO metadata for posts, pages, and WooCommerce products.
+* **Media upload helper routes** — accept raw-binary uploads via `php://input`, which bypasses the `upload_max_filesize` PHP limit (still bounded by `post_max_size`). Unlocks reliable hero-image and large-asset uploads for AI-agent workflows.
+* **Capability probe** — reports the effective PHP + WordPress upload limits so MCP Hub can pick the right upload path automatically.
+
+**Features:**
+
+* **Rank Math SEO Support** — Full access to all Rank Math meta fields
+* **Yoast SEO Support** — Full access to all Yoast SEO meta fields
+* **WooCommerce Compatible** — Works with product post types
+* **MCP Hub media pipeline** — bypasses `upload_max_filesize` for AI-agent uploads
+* **Secure** — Requires WordPress Application Password + capability checks on every route
+* **Auto-Detection** — Automatically detects active SEO plugin and advertises capabilities
+* **Zero Configuration** — Works out of the box after activation
+
+**REST API Endpoints:**
+
+*SEO meta (namespace `airano-mcp-bridge/v1`)*
+
+* `GET/POST /wp-json/airano-mcp-bridge/v1/posts/{id}/seo` — Post SEO data
+* `GET/POST /wp-json/airano-mcp-bridge/v1/pages/{id}/seo` — Page SEO data
+* `GET/POST /wp-json/airano-mcp-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
+* `GET /wp-json/airano-mcp-bridge/v1/status` — Plugin status + active SEO plugins
+
+*MCP Bridge helpers (namespace `airano-mcp/v1`, added in 2.0.0)*
+
+* `GET /wp-json/airano-mcp/v1/upload-limits` — Effective PHP + WP upload limits
+* `POST /wp-json/airano-mcp/v1/upload-chunk` — Raw-body media upload (bypasses `upload_max_filesize`)
+* `GET /wp-json/airano-mcp/v1/capabilities` — Effective capabilities + feature flags + available routes (added in 2.1.0)
+* `POST /wp-json/airano-mcp/v1/bulk-meta` — Batch post/product meta writes in a single HTTP round-trip (added in 2.2.0)
+* `GET /wp-json/airano-mcp/v1/export` — Structured JSON export: posts, pages, products + media + terms + meta, with post_type/status/since/limit/offset paging (added in 2.3.0)
+* `POST /wp-json/airano-mcp/v1/cache-purge` — Auto-detects active cache plugins (LiteSpeed, WP Rocket, W3TC, Super Cache, Fastest Cache, SG Optimizer) and invokes their purge API; always flushes object cache (added in 2.4.0)
+* `POST /wp-json/airano-mcp/v1/transient-flush` — Native transient cleanup with scope=expired/all/pattern (glob); optional site-transient handling on multisite (added in 2.5.0)
+* `GET /wp-json/airano-mcp/v1/site-health` — Unified site-health snapshot: PHP/MySQL/WP versions, extensions, disk free, active plugins + theme, writability checks (added in 2.6.0)
+* `GET|POST|DELETE /wp-json/airano-mcp/v1/audit-hook` — Configure + query a webhook that forwards WordPress action events to MCP Hub (HMAC-SHA256 signed, non-blocking) (added in 2.7.0)
+* `POST /wp-json/airano-mcp/v1/regenerate-thumbnails` — Regenerate attachment sub-sizes via `wp_generate_attachment_metadata` for a list of IDs or in paged batch mode (added in 2.8.0)
+* `POST /wp-json/airano-mcp/v1/upload-and-attach` — Raw-body upload + metadata + attach-to-post + set-featured in a single REST call (added in 2.9.0). Query params: `attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`. Permission: `upload_files` + `edit_post` on the target post.
+
+**Designed for [MCP Hub](https://mcp.palebluedot.live)** — the AI-native management hub for WordPress and self-hosted services. [Try the live platform](https://mcp.palebluedot.live) · [Source on GitHub](https://github.com/airano-ir/mcphub).
+
+== Installation ==
+
+1. Upload the `airano-mcp-bridge` folder to `/wp-content/plugins/`
+2. Activate the plugin through the 'Plugins' menu in WordPress
+3. For SEO meta routes: ensure either Rank Math SEO or Yoast SEO is active
+4. The REST API endpoints are now available
+
+== Frequently Asked Questions ==
+
+= Which SEO plugins are supported? =
+
+Rank Math SEO and Yoast SEO. The plugin auto-detects which one is active.
+
+= Does it work with WooCommerce? =
+
+Yes. Product SEO endpoints are available when WooCommerce is active.
+
+= How is authentication handled? =
+
+All endpoints require WordPress Application Password authentication. SEO routes require `edit_posts`. Upload helper routes require `upload_files` or `manage_options`.
+
+= Do I need MCP Hub to use the upload helper routes? =
+
+No. The `/airano-mcp/v1/upload-chunk` route is a standard authenticated REST endpoint and can be used from any client that can send a raw-binary `POST` with `Content-Disposition` and `Content-Type` headers.
+
+= Is there a size cap on the upload helper? =
+
+The helper reads the request body via `php://input`, which is **not** subject to PHP's `upload_max_filesize` limit — but it is still bounded by `post_max_size` and `memory_limit`. For files larger than `post_max_size`, MCP Hub falls back to its own server-side chunked session pipeline.
+
+= Why keep the folder name "airano-mcp-bridge" even though the plugin now does more than SEO? =
+
+Plugin slugs on wordpress.org are permanent. Existing installs keep working; the display name and feature set are updated in 2.0.0.
+
+= Can I use this without MCP Hub? =
+
+Yes. All REST API endpoints work with any application that can make authenticated HTTP requests.
+
+== Changelog ==
+
+= 2.10.3 =
+* WordPress.org review fix: rename main class from the generic `SEO_API_Bridge` to `Airano_MCP_Bridge` per the wp.org "unique prefixes" guideline. The class is only instantiated from this file's bootstrap line and is never referenced by callers (the plugin's public surface is its REST routes, not its PHP class), so this is an internal refactor with no behaviour change.
+
+= 2.10.2 =
+* WordPress.org review fixes:
+ * Drop the unused `wp-admin/includes/media.php` includes from `/upload-chunk` and `/upload-and-attach`. The code only uses helpers from `file.php` (`wp_tempnam`, `wp_handle_sideload`) and `image.php` (`wp_generate_attachment_metadata`) — `media.php` was a needless direct core include.
+ * Split `/upload-and-attach` permission_callback into its own method (`require_upload_and_attach_capability`) that explicitly enforces `current_user_can('edit_post', $attach_to_post)` at the route gate when `attach_to_post` is supplied (no longer hidden inside the callback body), and rejects `set_featured` requests that don't include a target post.
+* No behaviour changes for clients sending well-formed requests.
+
+= 2.10.1 =
+* Fix: `capabilities` and `site-health` routes now share a single source of truth for the `routes` bitmap (previously duplicated with conflicting values for `audit_hook` and missing `upload_and_attach`). Both endpoints now read from the shared route-map constant.
+
+= 2.10.0 =
+* `POST /wp-json/airano-mcp/v1/upload-and-attach` now honours an `Idempotency-Key` request header. A retry within 24 hours with the same key returns the already-created attachment (`_idempotent_replay: true`) rather than creating a duplicate `-2.webp` orphan. Protects against client-timeout regressions where a successful upload was retried because the response was lost.
+
+= 2.9.0 =
+* Added `POST /wp-json/airano-mcp/v1/upload-and-attach` — same raw-body semantics as `/upload-chunk`, but accepts query params (`attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`) and applies them in one PHP round-trip. Saves 2-3 REST calls per upload. Per-target permission enforced via `current_user_can('edit_post', attach_to_post)`.
+* `status` + `capabilities` routes now advertise `upload_and_attach: true` alongside the existing capability flags.
+
+= 2.8.0 =
+* Added `POST /wp-json/airano-mcp/v1/regenerate-thumbnails` — rebuild attachment sub-sizes via `wp_generate_attachment_metadata()` after an upload or format conversion changes source pixels. Supports two modes: `{ "ids": [...] }` for targeted regeneration (up to 50 per call) and `{ "all": true, "offset": N, "limit": M }` for paged batches (max 50 per page, with `has_more` + `next_offset` for pagination). Non-image attachments are skipped; per-item permission check via `current_user_can('edit_post', $attachment_id)`. Permission: `upload_files` or `manage_options`.
+
+= 2.7.0 =
+* Added `GET|POST|DELETE /wp-json/airano-mcp/v1/audit-hook` — configure a webhook that forwards WordPress action events (post transitions, deletions, user events, plugin activations, theme switches) to MCP Hub for unified auditing. Each event is signed with HMAC-SHA256 using a shared secret and posted non-blocking via `wp_remote_post` so the admin UI stays snappy. Secret is stored in `wp_options` and never echoed back in GET responses (only the last 4 characters are returned). Permission: `manage_options`.
+
+= 2.6.0 =
+* Added `GET /wp-json/airano-mcp/v1/site-health` — single-envelope health snapshot: WordPress version/multisite/locale, PHP version + critical extensions, server software + disk free, MySQL/MariaDB version + charset, active plugins list (with versions), active + parent theme, writability checks (wp-content, uploads) and SSL status. Replaces 5+ separate REST calls with a single request. Permission: `manage_options`.
+
+= 2.5.0 =
+* Added `POST /wp-json/airano-mcp/v1/transient-flush` — native PHP transient cleanup. Scopes: `expired` (default, calls `delete_expired_transients()`), `all` (delete every `_transient_%` row), or `pattern` (glob match, e.g. `rank_math_*`). Optional `include_site_transients` for multisite. Response includes `deleted_count` + capped `deleted_sample` for observability. Permission: `manage_options`.
+
+= 2.4.0 =
+* Added `POST /wp-json/airano-mcp/v1/cache-purge` — auto-detects active page-cache plugins (LiteSpeed Cache, WP Rocket, W3 Total Cache, WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and triggers their purge API. Always calls `wp_cache_flush()` for object caches. Permission: `manage_options`. Removes the need for Docker-socket + WP-CLI to flush caches on managed hosts.
+
+= 2.3.0 =
+* Added `GET /wp-json/airano-mcp/v1/export` — structured JSON export of posts, pages, and WooCommerce products (not WXR). Query params: `post_type`, `status`, `since`, `limit` (max 500), `offset`, `include_media`, `include_terms`, `include_meta`. Response includes posts + referenced media + taxonomy terms + post meta in a single envelope, plus `has_more` / `next_offset` for pagination. Permission: `edit_posts`.
+
+= 2.2.0 =
+* Added `POST /wp-json/airano-mcp/v1/bulk-meta` — writes many `post_meta` / `product_meta` entries in a single REST request. Capped at 500 items per call; each item is permission-checked via `current_user_can('edit_post', $post_id)`. Null meta values delete the key. Status endpoint now advertises `capabilities.bulk_meta = true` and the capabilities probe reports `routes.bulk_meta = true`.
+
+= 2.1.0 =
+* Added `GET /wp-json/airano-mcp/v1/capabilities` — reports the effective capabilities of the calling application password, plus feature flags (Rank Math / Yoast / WooCommerce / multisite) and the list of companion routes this version actually ships. Consumed by MCP Hub's capability probe so per-user clients only see tools they're actually authorised to use.
+* Status endpoint now advertises `capabilities.capabilities = true`.
+
+= 2.0.0 =
+* **Rebrand**: "Airano MCP SEO Meta Bridge" → "Airano MCP Bridge". The companion plugin is no longer SEO-only.
+* Added `GET /wp-json/airano-mcp/v1/upload-limits` — returns effective PHP + WP upload limits for MCP Hub probes.
+* Added `POST /wp-json/airano-mcp/v1/upload-chunk` — raw-body upload route that bypasses `upload_max_filesize`.
+* Status endpoint now includes `capabilities: { seo_meta, upload_limits, upload_chunk }`.
+* Plugin folder / slug unchanged: `airano-mcp-bridge` (permanent on wordpress.org).
+
+= 1.3.0 =
+* Added REST API endpoints for posts, pages, and products (GET/POST operations)
+* Added authentication requirement for all endpoints
+* Fixed rank_math_title meta key registration
+
+= 1.2.0 =
+* Enhanced WooCommerce product support
+* Improved MariaDB compatibility
+
+= 1.1.0 =
+* Added status endpoint for plugin detection
+* Improved error handling
+
+= 1.0.0 =
+* Initial release with Rank Math and Yoast SEO support
+
+== Upgrade Notice ==
+
+= 2.10.3 =
+Internal refactor only — main class renamed from `SEO_API_Bridge` to `Airano_MCP_Bridge` to satisfy wp.org's prefix guideline. No behaviour or REST API changes.
+
+= 2.10.2 =
+WordPress.org review fixes only — no behaviour changes. Drops a redundant core file include and tightens the `/upload-and-attach` permission gate so the per-target `edit_post` check runs before the callback (visible to static analysis).
+
+= 2.10.1 =
+Internal fix: capabilities and site-health now share a single route map. No breaking changes.
+
+= 2.10.0 =
+`/upload-and-attach` now supports `Idempotency-Key` header to safely retry uploads after client timeouts without creating duplicates. No breaking changes.
+
+= 2.9.0 =
+Adds `POST /airano-mcp/v1/upload-and-attach` — upload + metadata + attach + featured in one REST round-trip. No breaking changes.
+
+= 2.8.0 =
+Adds `POST /airano-mcp/v1/regenerate-thumbnails` for rebuilding attachment sub-sizes after uploads or format changes. No breaking changes.
+
+= 2.7.0 =
+Adds `GET|POST|DELETE /airano-mcp/v1/audit-hook` to forward WordPress action events to MCP Hub. Disabled until configured. No breaking changes.
+
+= 2.6.0 =
+Adds `GET /airano-mcp/v1/site-health` for a unified site-health snapshot. No breaking changes.
+
+= 2.5.0 =
+Adds `POST /airano-mcp/v1/transient-flush` for native transient cleanup. No breaking changes.
+
+= 2.4.0 =
+Adds `POST /airano-mcp/v1/cache-purge` for native cache flushing. No breaking changes.
+
+= 2.3.0 =
+Adds `GET /airano-mcp/v1/export` for offline backups and migrations. No breaking changes.
+
+= 2.2.0 =
+Adds `POST /airano-mcp/v1/bulk-meta` so MCP Hub can write many post meta values in one request. No breaking changes.
+
+= 2.1.0 =
+Adds `GET /airano-mcp/v1/capabilities` so MCP Hub can probe which tools the current application password can actually use. No breaking changes; existing routes unchanged.
+
+= 2.0.0 =
+Rebranded to "Airano MCP Bridge". Adds new `airano-mcp/v1` namespace with upload-helper routes. All existing 1.x REST endpoints keep working unchanged.
+
+= 1.3.0 =
+GET endpoints now require authentication (edit_posts capability). Update your API clients if needed.
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/tests/check.sh b/wordpress-plugin/airano-mcp-bridge-wporg/tests/check.sh
new file mode 100644
index 0000000..ce44b14
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge-wporg/tests/check.sh
@@ -0,0 +1,170 @@
+#!/usr/bin/env bash
+# Pre-submission checks for airano-mcp-bridge (wp.org-bound version).
+#
+# Run from the repo root or from this directory:
+# bash wordpress-plugin/airano-mcp-bridge-wporg/tests/check.sh
+#
+# Exit code is non-zero on any FAIL. Prints a one-line summary at the end.
+set -u
+
+PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/airano-mcp-bridge"
+PHP_FILE="${PLUGIN_DIR}/airano-mcp-bridge.php"
+README_TXT="${PLUGIN_DIR}/readme.txt"
+
+fail=0
+pass() { printf ' \033[32mPASS\033[0m %s\n' "$1"; }
+fail() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); }
+section() { printf '\n\033[1m%s\033[0m\n' "$1"; }
+
+[ -f "$PHP_FILE" ] || { echo "main PHP file not found: $PHP_FILE"; exit 2; }
+[ -f "$README_TXT" ] || { echo "readme.txt not found: $README_TXT"; exit 2; }
+
+section "1. PHP syntax (php -l)"
+if php -l "$PHP_FILE" > /tmp/phplint.out 2>&1; then
+ pass "syntax clean"
+else
+ fail "php -l reported errors:"
+ cat /tmp/phplint.out
+fi
+
+section "2. Plugin headers"
+ver_php=$(grep -oE '^\s*\*\s*Version:\s*\S+' "$PHP_FILE" | head -1 | awk '{print $NF}')
+ver_const=$(grep -oE "const VERSION\s*=\s*'[^']+'" "$PHP_FILE" | head -1 | grep -oE "'[^']+'" | tr -d "'")
+stable=$(grep -oE '^Stable tag:\s*\S+' "$README_TXT" | awk '{print $NF}')
+tested=$(grep -oE '^Tested up to:\s*\S+' "$README_TXT" | awk '{print $NF}')
+plugin_uri=$(grep -oE '^\s*\*\s*Plugin URI:\s*\S+' "$PHP_FILE" | head -1 | awk '{print $NF}')
+author_uri=$(grep -oE '^\s*\*\s*Author URI:\s*\S+' "$PHP_FILE" | head -1 | awk '{print $NF}')
+
+[ "$ver_php" = "$ver_const" ] && pass "Version header == const VERSION ($ver_php)" || fail "Version mismatch: header=$ver_php const=$ver_const"
+[ "$ver_php" = "$stable" ] && pass "Version header == readme.txt Stable tag ($ver_php)" || fail "Stable tag mismatch: $ver_php vs $stable"
+case "$tested" in
+ *.*.*) fail "Tested up to has minor version ($tested) — wp.org rejects this. Use major.minor only." ;;
+ *.*) pass "Tested up to is major.minor ($tested)" ;;
+ *) fail "Tested up to has unexpected format: $tested" ;;
+esac
+[ -n "$plugin_uri" ] && [ -n "$author_uri" ] && [ "$plugin_uri" != "$author_uri" ] \
+ && pass "Plugin URI ($plugin_uri) and Author URI ($author_uri) are distinct" \
+ || fail "Plugin URI and Author URI are the same or missing"
+
+section "3. wp.org review issues from review email"
+# Class name must use the Airano_MCP_ prefix (no generic SEO_API_Bridge)
+if grep -q "^class SEO_API_Bridge" "$PHP_FILE"; then
+ fail "main class still named SEO_API_Bridge — wp.org rejects generic prefixes"
+else
+ pass "main class is not the generic SEO_API_Bridge"
+fi
+if grep -q "^class Airano_MCP_Bridge" "$PHP_FILE"; then
+ pass "main class uses the Airano_MCP_ prefix"
+else
+ fail "main class is not Airano_MCP_Bridge"
+fi
+# (a) media.php must NOT be loaded directly outside of the comment/changelog
+includes=$(grep -nE "^\s*require(_once)?\s*\(?\s*ABSPATH\s*\.\s*['\"]wp-admin/includes/media\.php" "$PHP_FILE" | wc -l)
+if [ "$includes" -eq 0 ]; then
+ pass "no direct require of wp-admin/includes/media.php"
+else
+ fail "found $includes direct require(_once) of wp-admin/includes/media.php"
+ grep -nE "^\s*require(_once)?\s*\(?\s*ABSPATH\s*\.\s*['\"]wp-admin/includes/media\.php" "$PHP_FILE"
+fi
+
+# (b) /upload-and-attach must use the new dedicated permission_callback
+if grep -q "'permission_callback' => \[\$this, 'require_upload_and_attach_capability'\]" "$PHP_FILE"; then
+ pass "/upload-and-attach is gated by require_upload_and_attach_capability"
+else
+ fail "/upload-and-attach does not use require_upload_and_attach_capability"
+fi
+if grep -q "function require_upload_and_attach_capability" "$PHP_FILE"; then
+ pass "require_upload_and_attach_capability method is defined"
+else
+ fail "require_upload_and_attach_capability method is missing"
+fi
+# It must call current_user_can('edit_post', $attach_to_post)
+if grep -q "current_user_can('edit_post', \$attach_to_post)" "$PHP_FILE"; then
+ pass "edit_post per-target check is enforced at the route gate"
+else
+ fail "edit_post per-target check is missing"
+fi
+
+section "4. REST routes — every register_rest_route has a permission_callback"
+# Walk the file and check each register_rest_route(...) block individually.
+# A block runs from its register_rest_route line up to the matching closing
+# `]);` at the same brace depth. We check each block contains
+# `permission_callback`.
+audit=$(php -r '
+$src = file_get_contents($argv[1]);
+$lines = preg_split("/\r?\n/", $src);
+$total = 0; $missing = 0; $missing_lines = [];
+$inBlock = false; $depth = 0; $hasPerm = false; $startLine = 0;
+foreach ($lines as $i => $line) {
+ $lineNo = $i + 1;
+ if (!$inBlock && preg_match("/register_rest_route\s*\(/", $line)) {
+ $inBlock = true; $depth = 0; $hasPerm = false; $startLine = $lineNo; $total++;
+ }
+ if ($inBlock) {
+ if (strpos($line, "permission_callback") !== false) $hasPerm = true;
+ $depth += substr_count($line, "[") + substr_count($line, "(");
+ $depth -= substr_count($line, "]") + substr_count($line, ")");
+ if ($depth <= 0 && $lineNo > $startLine) {
+ if (!$hasPerm) { $missing++; $missing_lines[] = $startLine; }
+ $inBlock = false;
+ }
+ }
+}
+echo "$total $missing " . implode(",", $missing_lines);
+' "$PHP_FILE")
+total=$(echo "$audit" | awk '{print $1}')
+missing=$(echo "$audit" | awk '{print $2}')
+missing_lines=$(echo "$audit" | awk '{print $3}')
+if [ "$missing" -eq 0 ]; then
+ pass "all $total REST routes have a permission_callback"
+else
+ fail "$missing of $total REST routes are missing permission_callback (lines: $missing_lines)"
+fi
+
+# No __return_true on routes that change state (POST/PUT/DELETE)
+public_writes=$(awk '/register_rest_route/,/\]\s*\)\s*;/' "$PHP_FILE" \
+ | grep -B2 "__return_true" \
+ | grep -E "'methods'\s*=>\s*'(POST|PUT|DELETE|PATCH)'" \
+ | wc -l)
+if [ "$public_writes" -eq 0 ]; then
+ pass "no public (__return_true) callbacks on POST/PUT/DELETE routes"
+else
+ fail "$public_writes write routes use __return_true (public) — review needed"
+fi
+
+section "5. Output escaping & sanitisation (heuristic — manual review still required)"
+# Find raw echo/print of REST request params
+raw_echo=$(grep -nE "echo\s+\\\$request->|print\s+\\\$request->" "$PHP_FILE" | wc -l)
+[ "$raw_echo" -eq 0 ] && pass "no raw echo of \$request-> values" || fail "$raw_echo raw echoes of \$request->"
+# Reject sql concatenation patterns
+sql_concat=$(grep -nE '\$wpdb->(query|get_results|get_var|get_row).*\.\s*\$' "$PHP_FILE" | wc -l)
+[ "$sql_concat" -eq 0 ] && pass "no obvious SQL concatenation (no \$wpdb-> ... . \$var)" || fail "$sql_concat possible SQL concatenations — manual review"
+# eval / system / exec must NOT appear
+dangerous=$(grep -nE "\b(eval|exec|system|passthru|popen|shell_exec|proc_open)\s*\(" "$PHP_FILE" | grep -v "^\s*\*" | wc -l)
+[ "$dangerous" -eq 0 ] && pass "no eval/exec/system/passthru/popen calls" || fail "$dangerous dangerous function calls"
+
+section "6. Text domain"
+td_count=$(grep -cE "(__|_e|_x|_n|esc_html__|esc_attr__|esc_html_e|esc_attr_e)\(\s*['\"]" "$PHP_FILE")
+non_plugin_td=$(grep -oE "(__|_e|_x|_n|esc_html__|esc_attr__|esc_html_e|esc_attr_e)\(\s*['\"][^'\"]*['\"]\s*,\s*['\"][^'\"]+['\"]" "$PHP_FILE" \
+ | grep -oE "['\"][^'\"]+['\"]\s*\)\s*$" | grep -vc "airano-mcp-bridge" || true)
+if [ "$non_plugin_td" -eq 0 ]; then
+ pass "$td_count translation calls all use 'airano-mcp-bridge' domain"
+else
+ fail "$non_plugin_td translation calls use a non-plugin text domain"
+fi
+
+section "7. Direct file access guard"
+if grep -q "if (!defined('ABSPATH'))" "$PHP_FILE" && grep -q "exit;" "$PHP_FILE"; then
+ pass "ABSPATH guard present"
+else
+ fail "missing 'if (!defined(ABSPATH)) exit;' guard"
+fi
+
+section "Summary"
+if [ "$fail" -eq 0 ]; then
+ printf '\033[32m✓ all checks passed\033[0m — version %s ready for wp.org review reply\n' "$ver_php"
+ exit 0
+else
+ printf '\033[31m✗ %d check(s) failed\033[0m\n' "$fail"
+ exit 1
+fi
diff --git a/wordpress-plugin/airano-mcp-bridge-wporg/tests/test_permission_callback.php b/wordpress-plugin/airano-mcp-bridge-wporg/tests/test_permission_callback.php
new file mode 100644
index 0000000..db054b9
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge-wporg/tests/test_permission_callback.php
@@ -0,0 +1,183 @@
+code = $code; $this->message = $message; $this->data = $data;
+ }
+ }
+}
+
+if (!function_exists('rest_authorization_required_code')) {
+ function rest_authorization_required_code() { return 401; }
+}
+
+// __() / esc_html__ / etc. just return the string unchanged.
+foreach (['__', '_e', '_x', 'esc_html__', 'esc_attr__', 'esc_html_e', 'esc_attr_e'] as $fn) {
+ if (!function_exists($fn)) {
+ eval("function $fn(\$s, \$d = '') { return \$s; }");
+ }
+}
+
+// Capability + post-edit lookup is driven by globals so each test can
+// configure the scenario.
+$GLOBALS['__caps'] = []; // ['upload_files' => true, 'manage_options' => false, ...]
+$GLOBALS['__edits'] = []; // [42 => true, 99 => false]
+
+if (!function_exists('current_user_can')) {
+ function current_user_can($cap, $object_id = null) {
+ if ($cap === 'edit_post' && $object_id !== null) {
+ return !empty($GLOBALS['__edits'][$object_id]);
+ }
+ return !empty($GLOBALS['__caps'][$cap]);
+ }
+}
+
+// Tiny WP_REST_Request stub — only ->get_param() is used by the gate.
+if (!class_exists('WP_REST_Request')) {
+ class WP_REST_Request {
+ private $params;
+ public function __construct(array $params = []) { $this->params = $params; }
+ public function get_param($k) { return $this->params[$k] ?? null; }
+ }
+}
+
+// --- Load just the Airano_MCP_Bridge class -------------------------------
+// We can't require the whole plugin (it calls add_action()/add_filter() in
+// the global scope on load), so we extract the class body and eval it.
+// Strip the two add_action() lines at the bottom.
+$src = file_get_contents(__DIR__ . '/../airano-mcp-bridge/airano-mcp-bridge.php');
+$src = preg_replace('/<\?php/', '', $src, 1);
+$src = preg_replace('/^.*?(if\s*\(!\s*defined.*?ABSPATH.*?\)\s*\{[^}]*\})/s', '', $src, 1);
+// Remove any add_action / add_filter calls outside the class.
+$src = preg_replace('/^\s*(add_action|add_filter)\s*\([^;]+;\s*$/m', '', $src);
+// Remove `new Airano_MCP_Bridge();` invocation.
+$src = preg_replace('/^\s*new\s+Airano_MCP_Bridge\s*\(\s*\)\s*;\s*$/m', '', $src);
+
+// WP function shims so the class file can be loaded without bootstrapping
+// WordPress. Each returns a benign default — the tests only exercise the
+// permission method, not the rest of the plugin.
+$_wp_voids = [
+ 'add_action', 'add_filter', 'do_action', 'register_activation_hook',
+ 'register_deactivation_hook', 'register_uninstall_hook',
+ 'load_plugin_textdomain', 'wp_schedule_event', 'wp_clear_scheduled_hook',
+];
+foreach ($_wp_voids as $fn) {
+ if (!function_exists($fn)) {
+ eval("function $fn() { return true; }");
+ }
+}
+$_wp_returns_first = ['apply_filters', 'sanitize_text_field', 'sanitize_key', 'wp_unslash'];
+foreach ($_wp_returns_first as $fn) {
+ if (!function_exists($fn)) {
+ eval("function $fn(\$a) { return \$a; }");
+ }
+}
+if (!function_exists('get_option')) { function get_option($k, $d = false) { return $d; } }
+if (!function_exists('update_option')) { function update_option() { return true; } }
+if (!function_exists('add_option')) { function add_option() { return true; } }
+if (!function_exists('delete_option')) { function delete_option() { return true; } }
+if (!function_exists('wp_next_scheduled')) { function wp_next_scheduled() { return false; } }
+if (!function_exists('plugin_dir_path')) { function plugin_dir_path($f) { return dirname($f); } }
+if (!function_exists('plugin_basename')) { function plugin_basename($f) { return basename($f); } }
+if (!function_exists('untrailingslashit')) { function untrailingslashit($s) { return rtrim($s, '/\\'); } }
+if (!function_exists('trailingslashit')) { function trailingslashit($s) { return rtrim($s, '/\\') . '/'; } }
+if (!function_exists('wp_parse_url')) { function wp_parse_url($u, $c = -1) { return $c === -1 ? parse_url($u) : parse_url($u, $c); } }
+
+eval($src);
+
+// --- Tests ---------------------------------------------------------------
+$pass = 0; $fail = 0;
+function check($label, $ok, $detail = '') {
+ global $pass, $fail;
+ if ($ok) { echo " \033[32mPASS\033[0m $label\n"; $pass++; }
+ else { echo " \033[31mFAIL\033[0m $label" . ($detail ? " — $detail" : '') . "\n"; $fail++; }
+}
+
+$bridge = new Airano_MCP_Bridge();
+
+echo "\n\033[1mrequire_upload_and_attach_capability — gate semantics\033[0m\n";
+
+// 1. No upload_files + no manage_options → forbidden
+$GLOBALS['__caps'] = ['upload_files' => false, 'manage_options' => false];
+$GLOBALS['__edits'] = [];
+$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request([]));
+check('caller without upload_files/manage_options is rejected',
+ $res instanceof WP_Error && $res->code === 'rest_forbidden');
+
+// 2. Has upload_files, no attach_to_post → allowed (no per-target check)
+$GLOBALS['__caps'] = ['upload_files' => true];
+$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request([]));
+check('upload_files alone, no attach_to_post → true', $res === true,
+ is_object($res) ? "got " . $res->code : "got " . var_export($res, true));
+
+// 3. Has upload_files, attach_to_post=42, no edit_post → forbidden
+$GLOBALS['__caps'] = ['upload_files' => true];
+$GLOBALS['__edits'] = [42 => false];
+$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['attach_to_post' => 42]));
+check('attach_to_post supplied, edit_post denied → rest_cannot_edit',
+ $res instanceof WP_Error && $res->code === 'rest_cannot_edit',
+ is_object($res) ? "got code: " . $res->code : "got: " . var_export($res, true));
+
+// 4. Has upload_files, attach_to_post=42, edit_post granted → allowed
+$GLOBALS['__caps'] = ['upload_files' => true];
+$GLOBALS['__edits'] = [42 => true];
+$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['attach_to_post' => 42]));
+check('attach_to_post + edit_post → true', $res === true,
+ is_object($res) ? "got " . $res->code : "got " . var_export($res, true));
+
+// 5. set_featured=true without attach_to_post → invalid_param
+$GLOBALS['__caps'] = ['upload_files' => true];
+$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['set_featured' => 'true']));
+check('set_featured without attach_to_post → rest_invalid_param',
+ $res instanceof WP_Error && $res->code === 'rest_invalid_param');
+
+// 6. set_featured=1 without attach_to_post → invalid_param (numeric string)
+$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['set_featured' => '1']));
+check("set_featured='1' without attach_to_post → rest_invalid_param",
+ $res instanceof WP_Error && $res->code === 'rest_invalid_param');
+
+// 7. set_featured=false → does NOT trigger the invalid_param branch
+$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['set_featured' => 'false']));
+check("set_featured='false' (no attach_to_post) → allowed",
+ $res === true,
+ is_object($res) ? "got " . $res->code : "got " . var_export($res, true));
+
+// 8. set_featured=true + attach_to_post=42 + edit_post=true → allowed
+$GLOBALS['__edits'] = [42 => true];
+$res = $bridge->require_upload_and_attach_capability(
+ new WP_REST_Request(['attach_to_post' => 42, 'set_featured' => 'true']));
+check('set_featured=true + attach_to_post + edit_post → true', $res === true);
+
+// 9. manage_options-only caller (no upload_files) is allowed (admins still pass)
+$GLOBALS['__caps'] = ['upload_files' => false, 'manage_options' => true];
+$GLOBALS['__edits'] = [];
+$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request([]));
+check('manage_options without upload_files → true', $res === true);
+
+// 10. attach_to_post=0 (defaulted/missing) is treated as "not supplied" — no
+// per-target check needed.
+$GLOBALS['__caps'] = ['upload_files' => true];
+$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['attach_to_post' => 0]));
+check('attach_to_post=0 → no per-target check, allowed', $res === true);
+
+echo "\n";
+if ($fail === 0) {
+ echo "\033[32m✓ $pass tests passed\033[0m\n";
+ exit(0);
+} else {
+ echo "\033[31m✗ $fail of " . ($pass + $fail) . " tests failed\033[0m\n";
+ exit(1);
+}
diff --git a/wordpress-plugin/airano-mcp-bridge.zip b/wordpress-plugin/airano-mcp-bridge.zip
index 972ad55..6bf4421 100644
Binary files a/wordpress-plugin/airano-mcp-bridge.zip and b/wordpress-plugin/airano-mcp-bridge.zip differ
diff --git a/wordpress-plugin/airano-mcp-bridge/README.md b/wordpress-plugin/airano-mcp-bridge/README.md
index ec9e9f9..2705c5e 100644
--- a/wordpress-plugin/airano-mcp-bridge/README.md
+++ b/wordpress-plugin/airano-mcp-bridge/README.md
@@ -1,6 +1,6 @@
# Airano MCP Bridge
-**Version:** 2.9.0
+**Version:** 2.18.0
**Requires WordPress:** 5.0+
**Tested up to:** 6.9
**Requires PHP:** 7.4+
@@ -26,6 +26,14 @@ Installing **Airano MCP Bridge** unlocks a second tier of capabilities that Word
- **Capability probe** — tells MCP Hub what the calling application password can actually do
- **Audit hook** — forwards WordPress action events to MCP Hub with HMAC-signed webhooks
- **SEO meta routes** for Rank Math and Yoast
+- **WordPress Specialist admin namespace** (v2.11.0+) — read-only inventory of plugins, themes, users, options, cron, maintenance status. Powers the `wordpress_specialist` MCP plugin so admins on shared hosting can use the same tools that previously required Docker socket + WP-CLI.
+- **System diagnostics** (v2.12.0) — `system-info`, `phpinfo` (curated, not the full HTML dump), `disk-usage` (bounded directory walks).
+- **Page editing** (v2.13.0) — Gutenberg block read/replace/insert/remove, Elementor detect/get/set/regen-css/template-list/template-apply, Classic-editor html replace. Writes are double-gated: `manage_options` route-level + per-post `edit_post` enforcement; raw HTML requires `unfiltered_html`; Elementor JSON capped at 5,000 nodes per call.
+- **Theme dev surface** (v2.14.0) — install (from URL or base64 zip) / activate / delete + theme file CRUD (list, read, write, delete). All theme paths canonicalise via `realpath()` and reject any escape from the slug directory. PHP file writes additionally require `!DISALLOW_FILE_EDIT`. Optimistic concurrency via `expected_sha256` on writes. Per-call caps 5 MB/file, 1000 files/list, 50 MB/install zip. Refuses to delete the active theme (or its parent) and `style.css` of the active theme.
+- **Plugin write management** (v2.15.0) — install (from wp.org slug, URL, or base64 zip) / activate / deactivate / update / delete. Per-route capability checks (`install_plugins`, `activate_plugins`, `delete_plugins`). Refuses to deactivate or delete the companion plugin itself (would brick the MCP connection) and refuses to delete active plugins. Update route returns `up_to_date:true` cleanly when no update is available.
+- **Site config** (v2.16.0) — Settings → General + Reading + Permalinks panels via typed REST. `GET/POST /admin/site/identity` (title / tagline / site_icon / custom_logo), `GET/POST /admin/site/reading` (show_on_front / page_on_front / page_for_posts / posts_per_page / blog_public), `GET/POST /admin/permalinks` (structure + category_base + tag_base, with `flush_rewrite_rules()` after each write).
+- **Site layout** (v2.17.0) — Settings → Menus + Appearance → Widgets + Customizer surfaces. Menus: `GET /admin/menus`, `GET /admin/menus/{id}`, `PUT /admin/menus/{id}` (full-replace items + optional rename, slug stays frozen, S-22 per-item cap dispatch). Widgets: `GET /admin/widgets/areas`, `GET /admin/widgets/{area_id}`, `PUT /admin/widgets/{area_id}` (block-kind areas accept any block raw HTML; legacy areas accept `text` widget settings only this round). Customizer: `POST /admin/customizer/changeset` with `{action: get|apply|discard}` — single-tool wrapper around the changeset queue; `apply` requires the `customize` cap (S-24).
+- **Database inspection** (v2.18.0) — read-only DB introspection via typed routes that NEVER expose raw SQL. `GET /admin/db/size` (single `information_schema.TABLES` aggregation: database_bytes + table_count + row_count_estimate), `GET /admin/db/tables` (per-table breakdown), `POST /admin/db/search` (`WP_Query` wrapper with `s=$query`; sanitised + length-capped at 200 chars; S-25 keeps non-readable posts out via `WP_Query`'s own `posts_clauses`). Bundled with the wordpress_advanced sunset on the MCP Hub side: the deprecated WP-CLI/Docker-socket plugin was removed in F.19.3.2-.3; `wordpress_specialist` is now the only WP-management surface and has no Docker socket dependency.
All operations remain guarded by WordPress's own capability system — this plugin never bypasses auth.
@@ -63,6 +71,66 @@ The plugin works out of the box. There are no settings pages to configure; behav
| `/audit-hook` | GET / POST / DELETE | 2.7.0 | Configure a webhook that forwards WP action events to MCP Hub. HMAC-SHA256-signed, non-blocking |
| `/regenerate-thumbnails` | POST | 2.8.0 | Rebuild attachment sub-sizes via `wp_generate_attachment_metadata()` for a list of IDs or in paged batch mode |
+### WordPress Specialist admin namespace (`airano-mcp/v1/admin/*`)
+
+All routes require `manage_options`. They power the `wordpress_specialist` MCP plugin (companion-backed advanced WordPress management — no Docker socket required).
+
+| Route | Method | Introduced | Purpose |
+|---|---|---|---|
+| `/admin/plugins` | GET | 2.11.0 | Every plugin known to WP with active/network-active/version/author/update flags |
+| `/admin/themes` | GET | 2.11.0 | Installed themes with stylesheet/template/parent/block-theme/active flags |
+| `/admin/users` | GET | 2.11.0 | Paginated user list (cap 200/call) with optional `role` and `search` filters |
+| `/admin/options/{name}` | GET | 2.11.0 | Single option fetch with deny-list of credential-shaped keys (`*_secret`, `*_password`, `*_api_key`, `*_token`, `auth_*_key`, `auth_*_salt`) |
+| `/admin/cron` | GET | 2.11.0 | Full cron table with epoch + ISO 8601 next_run, schedule slug, interval, args |
+| `/admin/maintenance` | GET | 2.11.0 | `.maintenance` sentinel inspection (`enabled` / `started_at` / `stale`, 10-min stale threshold matches WP) |
+| `/admin/system-info` | GET | 2.12.0 | WP/PHP/MySQL/server versions + memory + paths in one envelope |
+| `/admin/phpinfo` | GET | 2.12.0 | Sorted extension list + curated ini whitelist + disabled functions + opcache state (structured JSON, not the full HTML dump) |
+| `/admin/disk-usage` | GET | 2.12.0 | Bytes for uploads/plugins/themes + filesystem `disk_total/free/used` (bounded 200k files / 5s per tree, with `truncated:true` flag) |
+| `/admin/blocks/replace` | POST | 2.13.0 | Replace post block tree (companion runs `serialize_blocks()`; max 200 blocks; `wp_kses_post` unless caller has `unfiltered_html`) |
+| `/admin/blocks/insert` | POST | 2.13.0 | Insert one block at index N |
+| `/admin/blocks/remove` | POST | 2.13.0 | Remove block at index N (returns the removed block for rollback) |
+| `/admin/elementor/status` | GET | 2.13.0 | Elementor presence + version + Pro flag + supported post types; `installed:false` cleanly when absent |
+| `/admin/elementor/{post_id}` | GET | 2.13.0 | Parsed `_elementor_data` (slash-stripped, JSON-decoded) |
+| `/admin/elementor/{post_id}` | POST | 2.13.0 | Replace `_elementor_data`; validates id/elType/settings; capped at 5,000 nodes; fires `elementor/document/after_save` |
+| `/admin/elementor/{post_id}/regen-css` | POST | 2.13.0 | Trigger per-post Elementor CSS regeneration |
+| `/admin/elementor/templates` | GET | 2.13.0 | Saved Elementor templates (id/title/type/modified) |
+| `/admin/elementor/templates/apply` | POST | 2.13.0 | Copy a saved template's `_elementor_data` into a target post |
+| `/admin/classic/{post_id}/replace` | POST | 2.13.0 | Pure post_content swap for classic-editor sites (`wp_kses_post` unless caller has `unfiltered_html`) |
+| `/admin/themes/install` | POST | 2.14.0 | Install theme from `zip_url` or base64 `zip_base64`; supports `activate`, `overwrite`. Runs WP core's `Theme_Upgrader`. Requires `install_themes` (+ `switch_themes` if `activate`). 50 MB cap. |
+| `/admin/themes/{slug}/activate` | POST | 2.14.0 | Switch the active theme. Requires `switch_themes`. |
+| `/admin/themes/{slug}` | DELETE | 2.14.0 | Delete an installed theme. Refuses if active or active parent. Requires `delete_themes`. |
+| `/admin/themes/files/{slug}` | GET | 2.14.0 | Theme directory walk: each file's relative path, size, mime, sha256, modified_at. Optional `glob` + `max_files` (cap 1000). |
+| `/admin/themes/files/{slug}/{path}` | GET | 2.14.0 | Read a theme file as base64 + sha256 + mime + modified_at. 5 MB cap. |
+| `/admin/themes/files/{slug}/{path}` | PUT | 2.14.0 | Write a theme file (base64). Optional `expected_sha256` for optimistic concurrency. PHP writes require `!DISALLOW_FILE_EDIT`. Requires `edit_themes`. 5 MB cap. |
+| `/admin/themes/files/{slug}/{path}` | DELETE | 2.14.0 | Delete a theme file. Refuses to delete `style.css` of the active theme. Requires `edit_themes`. |
+| `/admin/plugins/install` | POST | 2.15.0 | Install via wp.org `slug`, OR via `zip_url`, OR via inline `zip_base64`. Supports `activate` + `overwrite`. Runs WP core's `Plugin_Upgrader`. Requires `install_plugins`. 50 MB zip cap. |
+| `/admin/plugins/{slug}/activate` | POST | 2.15.0 | Activate an installed plugin. Optional `network_wide` for multisite. Requires `activate_plugins`. |
+| `/admin/plugins/{slug}/deactivate` | POST | 2.15.0 | Deactivate. Refuses companion (S-20) and `Required` plugins (S-21). Requires `activate_plugins`. |
+| `/admin/plugins/{slug}/update` | POST | 2.15.0 | Update to latest wp.org version. Returns `up_to_date:true` cleanly when no update available. Requires `update_plugins`. |
+| `/admin/plugins/{slug}` | DELETE | 2.15.0 | Delete an installed plugin. Refuses active plugins, the companion, and `Required` plugins. Requires `delete_plugins`. |
+| `/admin/site/identity` | GET | 2.16.0 | Site title / tagline / site_icon / custom_logo / charset / WP version / admin_email / language / siteurl. |
+| `/admin/site/identity` | POST | 2.16.0 | Update title / tagline / site_icon_id / custom_logo_id (any subset). Attachment ids validated against the media library. |
+| `/admin/site/reading` | GET | 2.16.0 | show_on_front / page_on_front / page_for_posts / posts_per_page / posts_per_rss / blog_public. |
+| `/admin/site/reading` | POST | 2.16.0 | Update reading settings; page_on_front + page_for_posts must reference published Pages. |
+| `/admin/permalinks` | GET | 2.16.0 | permalink_structure + category_base + tag_base. |
+| `/admin/permalinks` | POST | 2.16.0 | Update permalink structure (empty string = plain). Calls `flush_rewrite_rules()` after the write. |
+| `/admin/menus` | GET | 2.17.0 | Every nav menu with id / name / slug / theme locations / item count. |
+| `/admin/menus/{menu_id}` | GET | 2.17.0 | One menu's items: `{id, title, type, object, object_id, parent, order, url, target, classes, xfn}`. |
+| `/admin/menus/{menu_id}` | PUT | 2.17.0 | Full-replace items + optional rename. Slug frozen. Two-pass: every item validated against S-22 before any mutation; refusals return `forbidden_object_id` 403/404 and leave the menu untouched. |
+| `/admin/widgets/areas` | GET | 2.17.0 | Every registered sidebar with id / name / theme_location / widget_count / kind (`block` or `legacy`). |
+| `/admin/widgets/{area_id}` | GET | 2.17.0 | One area's widgets. Block-kind: `{id, type:'block', blocks:[parsed], raw}`. Legacy-kind: `{id, type, settings}`. |
+| `/admin/widgets/{area_id}` | PUT | 2.17.0 | Full replace. Block areas accept any block raw HTML; legacy areas accept `text` widget settings only this round (other legacy types remain read-only). HTML sanitised via `wp_kses_post` unless caller has `unfiltered_html` (S-23). |
+| `/admin/customizer/changeset` | POST | 2.17.0 | `{action: get|apply|discard}`. `apply` requires the `customize` cap on top of `manage_options` (S-24). Empty changeset returns `{status:'empty'}` 200. |
+| `/admin/db/size` | GET | 2.18.0 | Aggregate database size from `information_schema.TABLES`: `{database_bytes, table_count, row_count_estimate, database_name, table_prefix}`. No SQL exposure. |
+| `/admin/db/tables` | GET | 2.18.0 | Per-table breakdown: `{name, engine, rows, data_bytes, index_bytes, total_bytes, collation}` ordered by total_bytes desc. |
+| `/admin/db/search` | POST | 2.18.0 | `WP_Query` wrapper. Body `{query, post_type?, status?, limit?}` (limit default 20, max 100). Sanitises + length-caps query at 200 chars (S-25). Non-readable posts filtered out via WP's own `posts_clauses`. Never raw SQL. |
+
+Writes added in 2.13.0 require **two** capabilities: route-level `manage_options` AND per-post `edit_post` on the target (S-12 in MCP Hub's security ruleset). Raw-HTML pass-through requires the calling user to also hold `unfiltered_html`. Elementor JSON node count is capped at 5,000 per call (S-14); oversized payloads return `elementor_too_large` — switch to `/admin/elementor/templates/apply` instead.
+
+Theme dev routes added in 2.14.0 layer per-route capability checks on top of `manage_options`: `install_themes`, `switch_themes`, `delete_themes`, `edit_themes`. PHP file writes additionally require `!DISALLOW_FILE_EDIT` (S-17). All file paths canonicalise via `realpath()` and must resolve under `wp-content/themes/{slug}` (S-16); attempts to escape return `path_invalid`. Optimistic concurrency via `expected_sha256` returns `sha_mismatch` (409) if the on-disk sha256 differs (S-19).
+
+Plugin write routes added in 2.15.0 layer per-route capability checks on `manage_options`: `install_plugins` (install / update), `activate_plugins` (activate / deactivate), `delete_plugins` (delete). New rules: S-20 refuses to deactivate / delete the Airano MCP Bridge companion itself (returns `companion_self` 409 — would brick the MCP connection); S-21 refuses to deactivate / delete plugins whose header sets `Required: yes`. The single `POST /admin/plugins/install` route accepts three mutually-exclusive body shapes — `{slug}` for wp.org install, `{zip_url}` for download, `{zip_base64}` for inline — with the same 50 MB cap (S-18) on zip payloads. Delete refuses any plugin currently active (`plugin_active` 409 — caller must deactivate first).
+
### Authentication
All routes require **HTTP Basic** authentication with a WordPress **Application Password** (Users → Profile → Application Passwords). Per-route capability requirements:
@@ -74,6 +142,7 @@ All routes require **HTTP Basic** authentication with a WordPress **Application
| Capabilities probe | `read` (any logged-in user) |
| Bulk meta, Export | `edit_posts` (per-item `edit_post` check) |
| Cache purge, Transient flush, Site health, Audit hook, Regenerate thumbnails | `manage_options` |
+| Admin namespace (`/admin/*`) | `manage_options` |
## Privacy
@@ -119,6 +188,38 @@ The `/upload-chunk` route bypasses PHP's `upload_max_filesize` (typically 2–64
## Changelog
+### 2.18.0 — 2026-05-04
+
+- **F.19.3.2-.3: Database inspection (read) + bulk fan-out (write) + wordpress_advanced sunset.** Three new admin routes — `GET /admin/db/size` (single `information_schema.TABLES` aggregation, returns `{database_bytes, table_count, row_count_estimate, database_name, table_prefix}` — no SQL exposure), `GET /admin/db/tables` (per-table breakdown with name / engine / rows / data_bytes / index_bytes / total_bytes / collation, ordered by total_bytes desc), `POST /admin/db/search` (body `{query, post_type?, status?, limit?}` — wraps `WP_Query` with `s=$query`, sanitises via `sanitize_text_field` and length-caps at 200 chars server-side, `limit` capped at 100; returns `{query, limit, count, hits}`). New security rule **S-25**: db/search wraps `WP_Query`, NEVER raw SQL; `WP_Query`'s own `posts_clauses` filter (the same gate the WP search page uses) keeps non-readable posts (private/draft from other authors) out of the result set. Bulk write tools on the MCP Hub side (`wp_bulk_post_update`, `wp_bulk_term_update`) ride stock REST `wp/v2/posts/{id}` and `wp/v2/{taxonomy}/{id}` — no companion routes needed for those, but the cap of 50 items per call is documented under **S-26** in the MCP Hub spec. All admin routes gated on `manage_options` route-level. **Bundled with the wordpress_advanced sunset on the MCP Hub side**: the deprecated WP-CLI/Docker-socket plugin was removed in F.19.3.2-.3 (2026-05-04); `wordpress_specialist` is now the only WP-management surface and has no Docker socket dependency.
+
+### 2.17.0 — 2026-05-03
+
+- **F.19.6.B: Site layout (menus + widgets + customizer).** Seven new admin routes — three for menus, three for widgets, one for the customizer changeset queue. Menus: `GET /admin/menus`, `GET /admin/menus/{id}`, `PUT /admin/menus/{id}` (body `{items:[...], name?}` — full replace, items not in the array are deleted, slug stays frozen so `theme_location` mapping survives). Widgets: `GET /admin/widgets/areas` (each area carries `kind: 'block'|'legacy'`), `GET /admin/widgets/{area_id}` (block-kind returns parsed block tree + roundtrip-safe `raw`; legacy-kind returns option-keyed settings), `PUT /admin/widgets/{area_id}` (block areas accept any block raw HTML; legacy areas accept `text` widget settings only this round — other legacy types remain read-only and return `unsupported_legacy_widget`; caller-side `kind` is ignored, area kind is determined by the area itself). Customizer: `POST /admin/customizer/changeset` with `{action: get|apply|discard}` — single-tool wrapper around the changeset queue, intentionally limited because most modern themes use FSE/site-editor instead of the customizer. New security rules: **S-22** dispatches per nav-menu item type — `post_type` checks `current_user_can('read_post', id)`; `taxonomy` allows public taxonomies and otherwise requires the taxonomy's `assign_terms` cap (deliberately NOT `manage_categories` which is a write cap and would refuse routine "add public Category X to footer" flows for editors who don't manage taxonomies); `custom` URL items skip the object validation. Refusals surface as `forbidden_object_id` 403/404. **S-23** sanitises widget HTML via `wp_kses_post` unless the caller has `unfiltered_html` (mirrors S-13 from F.19.5). **S-24** customizer apply requires the `customize` cap on top of `manage_options` — same bar as `/wp-admin/customize.php`. All routes gated on `manage_options` route-level. Pre-validates every menu item before mutating so a partial S-22 failure leaves the menu untouched. Customizer apply is racy with concurrent edits via the customizer UI — intentional, mirrors WP behaviour.
+
+### 2.16.0 — 2026-05-03
+
+- **F.19.6.A: Site config surface.** Six new admin routes covering Settings → General + Reading + Permalinks: `GET/POST /admin/site/identity` (title / tagline / site_icon / custom_logo), `GET/POST /admin/site/reading` (show_on_front / page_on_front / page_for_posts / posts_per_page / posts_per_rss / blog_public), `GET/POST /admin/permalinks` (permalink_structure + category_base + tag_base). All gated on `manage_options`. POST routes validate attachment ids against the media library, reject non-published page ids for page_on_front / page_for_posts, and route every option write through WP's own `sanitize_option_*` hooks. After a permalink_structure write `flush_rewrite_rules()` runs so the new structure takes effect immediately. First consumer of the `settings` tier introduced by F.19.2.0.
+
+### 2.15.0 — 2026-05-02
+
+- **F.19.2.1: Plugin write management.** Five new admin routes — `POST /admin/plugins/install` (accepts `{slug}` for wp.org install OR `{zip_url|zip_base64}` for arbitrary zip; supports `activate` + `overwrite`), `POST /admin/plugins/{slug}/activate`, `POST /admin/plugins/{slug}/deactivate`, `POST /admin/plugins/{slug}/update`, `DELETE /admin/plugins/{slug}`. Per-route capability checks: `install_plugins` (install / update), `activate_plugins` (activate / deactivate), `delete_plugins` (delete). New security rules: S-20 refuses to deactivate / delete the Airano MCP Bridge companion itself (returns `companion_self` 409 — operator must use WP-Admin → Plugins instead); S-21 refuses to deactivate / delete plugins marked `Required: yes` in their header. Reuses S-15 (slug whitelist via `get_plugins()`) + S-18 (50 MB cap on install zip). Refuses to delete an active plugin (caller must deactivate first). Update route returns `up_to_date:true` cleanly when no update is available. Multisite supports `network_wide` activation/deactivation (requires `manage_network_plugins` for activate).
+
+### 2.14.0 — 2026-05-02
+
+- **F.19.7: Theme dev surface (install + activate + delete + file CRUD).** Three theme management routes — `POST /admin/themes/install` (accepts either `zip_url` or inline `zip_base64`; runs WP core's `Theme_Upgrader`; supports `activate` + `overwrite`), `POST /admin/themes/{slug}/activate`, `DELETE /admin/themes/{slug}` — and four file CRUD routes — `GET /admin/themes/files/{slug}` (glob walk capped at 1000 files, returns `path/size/mime/sha256/modified_at` per entry), `GET /admin/themes/files/{slug}/{path}` (read as base64), `PUT /admin/themes/files/{slug}/{path}` (write with optional `expected_sha256` for optimistic concurrency and `create_dirs`), `DELETE /admin/themes/files/{slug}/{path}`. Per-route capability checks: `install_themes`, `switch_themes`, `delete_themes`, `edit_themes`. PHP file writes additionally require `!DISALLOW_FILE_EDIT` (S-17). All file paths canonicalise via `realpath()` and must stay under `wp-content/themes/{slug}` (S-16). Per-call caps: 5 MB/file, 1000 files/list, 50 MB/install zip (S-18). Optimistic concurrency on `expected_sha256` returns `sha_mismatch` (409) on drift (S-19). Refuses to delete the active theme, the active parent theme, or `style.css` of the active theme.
+
+### 2.13.0 — 2026-05-01
+
+- **F.19.5: Page editing surface (Gutenberg + Elementor + Classic).** Seven new write routes — `POST /admin/blocks/{replace,insert,remove}`, `POST /admin/elementor/{post_id}` (set), `POST /admin/elementor/{post_id}/regen-css`, `POST /admin/elementor/templates/apply`, `POST /admin/classic/{post_id}/replace` — and three new read routes — `GET /admin/elementor/status`, `GET /admin/elementor/{post_id}`, `GET /admin/elementor/templates`. Writes require `manage_options` AND per-post `edit_post` (S-12); block + classic content sanitised via `wp_kses_post` unless caller has `unfiltered_html` (S-13); Elementor JSON node count capped at 5,000 per call (S-14). All Elementor writes fire `elementor/document/after_save` so caches and CSS regenerate cleanly.
+
+### 2.12.0 — 2026-05-01
+
+- **F.19.3.1: System diagnostic ports from `wordpress_advanced`.** Three more read-only admin routes — `GET /admin/system-info`, `GET /admin/phpinfo`, `GET /admin/disk-usage` — replace the legacy WP-CLI / Docker-socket flow so admins on shared hosting can use those tools too. All gated on `manage_options`. `phpinfo` returns curated structured JSON (extension list + ini whitelist + opcache state), never the full `phpinfo()` HTML dump (which would leak server internals). `disk-usage` walks bounded at 200k files / 5s per tree.
+
+### 2.11.0 — 2026-05-01
+
+- **F.19.1: Read-only admin namespace.** Six new routes under `airano-mcp/v1/admin/*` — `plugins`, `themes`, `users`, `options/{name}`, `cron`, `maintenance` — gated on `manage_options`. `options` route uses a deny-list of credential-shaped suffixes on top of the cap check. `users` route paginates up to 200/call with optional `role` + `search` filters. No state changes; write operations land in F.19.2.
+
### 2.9.0 — 2026-04-16
- New: `POST /airano-mcp/v1/upload-and-attach` — raw-body upload + metadata (title / alt / caption / description) + attach-to-post + set-featured in a single REST round-trip. Saves 2–3 calls for every hero-image or product-image workflow. Per-target permission enforced via `current_user_can('edit_post', attach_to_post)`.
@@ -184,6 +285,22 @@ The `/upload-chunk` route bypasses PHP's `upload_max_filesize` (typically 2–64
## Upgrade notice
+### 2.16.0
+
+Adds the F.19.6.A site config surface (6 new admin routes — identity / reading / permalinks). All gated on `manage_options`. Permalink writes flush rewrite rules automatically. No breaking changes; existing routes unchanged.
+
+### 2.15.0
+
+Adds the F.19.2.1 plugin write management surface (5 new admin routes — install / activate / deactivate / update / delete). Per-route checks for `install_plugins` / `activate_plugins` / `delete_plugins`. Refuses to deactivate or delete the companion plugin itself. No breaking changes; existing routes unchanged.
+
+### 2.14.0
+
+Adds the F.19.7 theme dev surface (7 new admin routes — install / activate / delete + file CRUD). Per-route checks for `install_themes` / `switch_themes` / `delete_themes` / `edit_themes`; PHP file writes additionally require `!DISALLOW_FILE_EDIT`. No breaking changes; existing routes unchanged.
+
+### 2.13.0
+
+Adds the F.19.5 page editing surface (10 new admin routes — Gutenberg blocks, Elementor read/write/template, Classic html replace). Writes require `manage_options` AND per-post `edit_post`; raw-HTML pass-through is gated on `unfiltered_html`. No breaking changes; existing routes unchanged.
+
### 2.9.0
Adds `POST /airano-mcp/v1/upload-and-attach` — upload + metadata + attach + featured in one REST round-trip. No breaking changes to existing routes.
diff --git a/wordpress-plugin/airano-mcp-bridge/airano-mcp-bridge.php b/wordpress-plugin/airano-mcp-bridge/airano-mcp-bridge.php
index 6ca52a4..8e66802 100644
--- a/wordpress-plugin/airano-mcp-bridge/airano-mcp-bridge.php
+++ b/wordpress-plugin/airano-mcp-bridge/airano-mcp-bridge.php
@@ -1,9 +1,9 @@
true,
'audit_hook' => true,
'regenerate_thumbnails' => true,
+ // F.19.1 read-only admin namespace
+ 'admin_plugins' => true,
+ 'admin_themes' => true,
+ 'admin_users' => true,
+ 'admin_options' => true,
+ 'admin_cron' => true,
+ 'admin_maintenance' => true,
+ // F.19.3.1 ports from wordpress_advanced (read-only)
+ 'admin_system_info' => true,
+ 'admin_phpinfo' => true,
+ 'admin_disk_usage' => true,
+ // F.19.5 page editing (Gutenberg + Elementor + Classic)
+ 'admin_blocks_replace' => true,
+ 'admin_blocks_insert' => true,
+ 'admin_blocks_remove' => true,
+ 'admin_elementor_status' => true,
+ 'admin_elementor_get' => true,
+ 'admin_elementor_set' => true,
+ 'admin_elementor_render_css' => true,
+ 'admin_elementor_template_list'=> true,
+ 'admin_elementor_template_apply'=> true,
+ 'admin_classic_html_replace' => true,
+ // F.19.7 theme dev surface
+ 'admin_theme_install' => true,
+ 'admin_theme_activate' => true,
+ 'admin_theme_delete' => true,
+ 'admin_theme_file_list' => true,
+ 'admin_theme_file_read' => true,
+ 'admin_theme_file_write' => true,
+ 'admin_theme_file_delete' => true,
+ // F.19.2.1 plugin write management
+ 'admin_plugin_install' => true,
+ 'admin_plugin_activate' => true,
+ 'admin_plugin_deactivate' => true,
+ 'admin_plugin_update' => true,
+ 'admin_plugin_delete' => true,
+ // F.19.6.A site config (identity + reading + permalinks)
+ 'admin_site_identity_get' => true,
+ 'admin_site_identity_set' => true,
+ 'admin_site_reading_get' => true,
+ 'admin_site_reading_set' => true,
+ 'admin_permalinks_get' => true,
+ 'admin_permalinks_set' => true,
+ // F.19.6.B site layout (menus + widgets + customizer)
+ 'admin_menus_list' => true,
+ 'admin_menu_get' => true,
+ 'admin_menu_set' => true,
+ 'admin_widget_areas_list' => true,
+ 'admin_widget_get' => true,
+ 'admin_widget_set' => true,
+ 'admin_customizer_changeset' => true,
+ // F.19.3.2-.3 database inspection (read-only)
+ 'admin_db_size' => true,
+ 'admin_db_tables' => true,
+ 'admin_db_search' => true,
];
}
+ /**
+ * Option keys that admin_option_get refuses to return — they
+ * commonly hold credentials or per-install secrets. The whitelist
+ * approach used by the rest of the plugin is wrong for this route
+ * (the universe of safe option keys is too large), so we use a
+ * deny-list of *suffix patterns* instead. F.19.1.
+ */
+ const ADMIN_OPTION_BLOCKED_PATTERNS = [
+ '/(_|^)(secret|password|passwd|api[_-]?key|token|nonce_salt|auth_salt|secret_key)$/i',
+ '/^(auth|secure_auth|logged_in|nonce)_(key|salt)$/i',
+ ];
+
/**
* Option keys used by F.18.7 audit-hook persistence.
*/
@@ -264,7 +340,7 @@ class SEO_API_Bridge {
register_rest_route('airano-mcp/v1', '/upload-and-attach', [
'methods' => 'POST',
'callback' => [$this, 'handle_upload_and_attach'],
- 'permission_callback' => [$this, 'require_upload_capability'],
+ 'permission_callback' => [$this, 'require_upload_and_attach_capability'],
]);
// --- F.18.1: Capability probe ------------------------------------
@@ -380,6 +456,307 @@ class SEO_API_Bridge {
return current_user_can('upload_files') || current_user_can('manage_options');
},
]);
+
+ // ============================================================
+ // F.19.1 — Read-only admin namespace (manage_options gated)
+ // ============================================================
+ register_rest_route('airano-mcp/v1', '/admin/plugins', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_plugins'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/themes', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_themes'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/users', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_users'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/options/(?P[A-Za-z0-9_\-]+)', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_option_get'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/cron', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_cron'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/maintenance', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_maintenance'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+
+ // ============================================================
+ // F.19.3.1 — Ports from wordpress_advanced (read-only)
+ // ============================================================
+ register_rest_route('airano-mcp/v1', '/admin/system-info', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_system_info'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/phpinfo', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_phpinfo'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/disk-usage', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_disk_usage'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+
+ // ============================================================
+ // F.19.5 — Page editing (Gutenberg blocks + Elementor + Classic)
+ // ============================================================
+ // Writes use a shared permission callback that enforces
+ // `manage_options` AND `edit_post` on the target post (S-12).
+ // The per-post check happens inside the handler because the
+ // post id arrives in the JSON body for blocks, but in the URL
+ // for elementor/classic. The base `manage_options` check is
+ // still done at the route layer so non-admins are rejected
+ // before any payload parsing.
+
+ // Gutenberg block writes (S-12 + S-13).
+ register_rest_route('airano-mcp/v1', '/admin/blocks/replace', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_blocks_replace'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/blocks/insert', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_blocks_insert'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/blocks/remove', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_blocks_remove'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+
+ // Elementor read.
+ register_rest_route('airano-mcp/v1', '/admin/elementor/status', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_elementor_status'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/elementor/templates', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_elementor_template_list'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/elementor/(?P\d+)', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_elementor_get'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+
+ // Elementor writes (S-12 + S-14).
+ register_rest_route('airano-mcp/v1', '/admin/elementor/(?P\d+)', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_elementor_set'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/elementor/(?P\d+)/regen-css', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_elementor_render_css'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/elementor/templates/apply', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_elementor_template_apply'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+
+ // Classic editor fallback (S-12 + S-13).
+ register_rest_route('airano-mcp/v1', '/admin/classic/(?P\d+)/replace', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_classic_html_replace'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+
+ // ============================================================
+ // F.19.7 — Theme dev surface (install + activate + delete +
+ // file CRUD). All gated on `manage_options` route-side;
+ // per-handler checks for install_themes / switch_themes /
+ // delete_themes / edit_themes (+ DISALLOW_FILE_EDIT for
+ // PHP writes — S-17).
+ // ============================================================
+ register_rest_route('airano-mcp/v1', '/admin/themes/install', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_theme_install'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/themes/(?P[A-Za-z0-9][A-Za-z0-9_\-]{0,63})/activate', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_theme_activate'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/themes/(?P[A-Za-z0-9][A-Za-z0-9_\-]{0,63})', [
+ 'methods' => 'DELETE',
+ 'callback' => [$this, 'handle_admin_theme_delete'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/themes/files/(?P[A-Za-z0-9][A-Za-z0-9_\-]{0,63})', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_theme_file_list'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/themes/files/(?P[A-Za-z0-9][A-Za-z0-9_\-]{0,63})/(?P.+)', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_theme_file_read'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/themes/files/(?P[A-Za-z0-9][A-Za-z0-9_\-]{0,63})/(?P.+)', [
+ 'methods' => 'PUT',
+ 'callback' => [$this, 'handle_admin_theme_file_write'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/themes/files/(?P[A-Za-z0-9][A-Za-z0-9_\-]{0,63})/(?P.+)', [
+ 'methods' => 'DELETE',
+ 'callback' => [$this, 'handle_admin_theme_file_delete'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+
+ // ============================================================
+ // F.19.2.1 — Plugin write management. Per-handler caps:
+ // install/update → install_plugins
+ // activate/deactivate → activate_plugins
+ // delete → delete_plugins
+ // S-20: refuses to deactivate/delete the airano-mcp-bridge
+ // companion itself (would brick the MCP connection).
+ // S-21: refuses to deactivate/delete plugins marked Required.
+ // ============================================================
+ register_rest_route('airano-mcp/v1', '/admin/plugins/install', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_plugin_install'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/plugins/(?P[A-Za-z0-9][A-Za-z0-9_\-]{0,63})/activate', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_plugin_activate'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/plugins/(?P[A-Za-z0-9][A-Za-z0-9_\-]{0,63})/deactivate', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_plugin_deactivate'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/plugins/(?P[A-Za-z0-9][A-Za-z0-9_\-]{0,63})/update', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_plugin_update'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/plugins/(?P[A-Za-z0-9][A-Za-z0-9_\-]{0,63})', [
+ 'methods' => 'DELETE',
+ 'callback' => [$this, 'handle_admin_plugin_delete'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+
+ // ============================================================
+ // F.19.6.A — Site config (identity + reading + permalinks).
+ // All routes gated on manage_options. Writes route every
+ // option through WP's own sanitize_option_* hooks; permalink
+ // writes additionally call flush_rewrite_rules().
+ // ============================================================
+ register_rest_route('airano-mcp/v1', '/admin/site/identity', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_site_identity_get'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/site/identity', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_site_identity_set'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/site/reading', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_site_reading_get'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/site/reading', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_site_reading_set'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/permalinks', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_permalinks_get'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/permalinks', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_permalinks_set'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+
+ // ============================================================
+ // F.19.6.B — Site layout (menus + widgets + customizer).
+ // All routes gated on manage_options at the route level.
+ // Per-request capability checks (S-22 / S-24) and content
+ // sanitisation (S-23) live inside the handlers.
+ // ============================================================
+ register_rest_route('airano-mcp/v1', '/admin/menus', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_menus_list'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/menus/(?P\d+)', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_menu_get'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/menus/(?P\d+)', [
+ 'methods' => 'PUT',
+ 'callback' => [$this, 'handle_admin_menu_set'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/widgets/areas', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_widget_areas_list'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/widgets/(?P[A-Za-z0-9_\-]+)', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_widget_get'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/widgets/(?P[A-Za-z0-9_\-]+)', [
+ 'methods' => 'PUT',
+ 'callback' => [$this, 'handle_admin_widget_set'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/customizer/changeset', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_customizer_changeset'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+
+ // ============================================================
+ // F.19.3.2-.3 — Database inspection (read-only).
+ // No SQL exposure: db/size + db/tables aggregate
+ // information_schema.TABLES server-side; db/search wraps
+ // WP_Query with s=$query (S-25). All gated on manage_options.
+ // ============================================================
+ register_rest_route('airano-mcp/v1', '/admin/db/size', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_db_size'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/db/tables', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_admin_db_tables'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
+ register_rest_route('airano-mcp/v1', '/admin/db/search', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_admin_db_search'],
+ 'permission_callback' => [$this, 'require_admin_capability'],
+ ]);
}
/**
@@ -392,6 +769,64 @@ class SEO_API_Bridge {
return current_user_can('upload_files') || current_user_can('manage_options');
}
+ /**
+ * Permission callback for `/upload-and-attach`.
+ *
+ * Two-layer gate, enforced BEFORE the callback runs (per wp.org review
+ * guidance — a capability check inside the callback is not visible to
+ * static analysis):
+ * 1. Caller must have `upload_files` (or `manage_options`).
+ * 2. If `attach_to_post` is supplied, caller must also have
+ * `edit_post` on that specific target.
+ * 3. If `set_featured` is supplied without an `attach_to_post`,
+ * reject — featured-image needs a target post.
+ */
+ public function require_upload_and_attach_capability( $request ) {
+ if ( ! current_user_can('upload_files') && ! current_user_can('manage_options') ) {
+ return new WP_Error(
+ 'rest_forbidden',
+ __( 'Sorry, you are not allowed to upload files.', 'airano-mcp-bridge' ),
+ ['status' => rest_authorization_required_code()]
+ );
+ }
+
+ $attach_to_post = (int) $request->get_param('attach_to_post');
+ $set_featured_raw = $request->get_param('set_featured');
+ $set_featured = in_array(
+ strtolower((string) $set_featured_raw),
+ ['1', 'true', 'yes', 'on'],
+ true
+ );
+
+ if ( $set_featured && $attach_to_post <= 0 ) {
+ return new WP_Error(
+ 'rest_invalid_param',
+ __( 'set_featured requires a valid attach_to_post target.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ if ( $attach_to_post > 0 && ! current_user_can('edit_post', $attach_to_post) ) {
+ return new WP_Error(
+ 'rest_cannot_edit',
+ __( 'Sorry, you are not allowed to attach media to this post.', 'airano-mcp-bridge' ),
+ ['status' => rest_authorization_required_code(), 'attach_to_post' => $attach_to_post]
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Permission callback for the F.19.1 admin namespace.
+ *
+ * Strict ``manage_options`` check — same bar as the WP Settings page.
+ * Subscriber/Author/Editor roles are intentionally rejected here.
+ */
+ public function require_admin_capability() {
+ return current_user_can('manage_options');
+ }
+
/**
* Get plugin status endpoint
*/
@@ -1004,9 +1439,10 @@ class SEO_API_Bridge {
);
}
- // Stage to tmp + hand off to wp_handle_sideload().
+ // wp_tempnam() + wp_handle_sideload() live in file.php;
+ // wp_generate_attachment_metadata() lives in image.php. media.php
+ // is intentionally NOT loaded — none of its helpers are called here.
require_once ABSPATH . 'wp-admin/includes/file.php';
- require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$tmp = wp_tempnam($filename);
@@ -1154,8 +1590,10 @@ class SEO_API_Bridge {
);
}
+ // file.php → wp_tempnam, wp_handle_sideload; image.php →
+ // wp_generate_attachment_metadata. media.php is NOT needed — none
+ // of its helpers are used in this REST callback.
require_once ABSPATH . 'wp-admin/includes/file.php';
- require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$tmp = wp_tempnam($filename);
@@ -2855,6 +3293,3361 @@ class SEO_API_Bridge {
echo '';
}
}
+
+ // ============================================================
+ // F.19.1 — Admin namespace handlers (read-only)
+ // ============================================================
+
+ /**
+ * GET /airano-mcp/v1/admin/plugins
+ *
+ * Returns every plugin known to WordPress with active/network-active
+ * status, version, author, update availability. No state changes;
+ * activation lands in F.19.2.
+ */
+ public function handle_admin_plugins(WP_REST_Request $request) {
+ if (!function_exists('get_plugins')) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ $plugins = get_plugins();
+ $active = (array) get_option('active_plugins', []);
+ $network_active = is_multisite() ? array_keys((array) get_site_option('active_sitewide_plugins', [])) : [];
+
+ // Update detection — use the cached transient set by WP's update
+ // checker. We never trigger a fresh check here (it's slow + can
+ // hit wp.org). MCPHub can force a refresh by calling the WP-CLI
+ // path during F.19.3 if needed.
+ $update_data = get_site_transient('update_plugins');
+ $updates_available = isset($update_data->response) ? (array) $update_data->response : [];
+
+ $items = [];
+ foreach ($plugins as $file => $meta) {
+ $items[] = [
+ 'file' => $file,
+ 'slug' => dirname($file) === '.' ? basename($file, '.php') : dirname($file),
+ 'name' => isset($meta['Name']) ? (string) $meta['Name'] : '',
+ 'version' => isset($meta['Version']) ? (string) $meta['Version'] : '',
+ 'author' => isset($meta['Author']) ? wp_strip_all_tags((string) $meta['Author']) : '',
+ 'plugin_uri' => isset($meta['PluginURI']) ? (string) $meta['PluginURI'] : '',
+ 'description' => isset($meta['Description']) ? wp_strip_all_tags((string) $meta['Description']) : '',
+ 'active' => in_array($file, $active, true),
+ 'network_active' => in_array($file, $network_active, true),
+ 'update_available' => isset($updates_available[$file]),
+ 'new_version' => isset($updates_available[$file]->new_version)
+ ? (string) $updates_available[$file]->new_version : null,
+ ];
+ }
+
+ return rest_ensure_response([
+ 'plugins' => $items,
+ 'total' => count($items),
+ 'active_count' => count($active),
+ 'multisite' => is_multisite(),
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/themes
+ *
+ * Lists every installed theme + which is active. Parent/child
+ * relationship surfaced via ``parent`` field.
+ */
+ public function handle_admin_themes(WP_REST_Request $request) {
+ $themes = wp_get_themes();
+ $active_stylesheet = get_option('stylesheet');
+ $active_template = get_option('template');
+
+ $update_data = get_site_transient('update_themes');
+ $updates_available = isset($update_data->response) ? (array) $update_data->response : [];
+
+ $items = [];
+ foreach ($themes as $stylesheet => $theme) {
+ /** @var WP_Theme $theme */
+ $parent = $theme->parent();
+ $items[] = [
+ 'stylesheet' => (string) $stylesheet,
+ 'name' => (string) $theme->get('Name'),
+ 'version' => (string) $theme->get('Version'),
+ 'author' => wp_strip_all_tags((string) $theme->get('Author')),
+ 'description' => wp_strip_all_tags((string) $theme->get('Description')),
+ 'theme_uri' => (string) $theme->get('ThemeURI'),
+ 'template' => (string) $theme->get_template(),
+ 'parent' => $parent ? (string) $parent->get_stylesheet() : null,
+ 'is_block_theme' => method_exists($theme, 'is_block_theme') ? (bool) $theme->is_block_theme() : false,
+ 'active' => $stylesheet === $active_stylesheet,
+ 'is_template' => $stylesheet === $active_template,
+ 'update_available' => isset($updates_available[$stylesheet]),
+ 'new_version' => isset($updates_available[$stylesheet]['new_version'])
+ ? (string) $updates_available[$stylesheet]['new_version'] : null,
+ ];
+ }
+
+ return rest_ensure_response([
+ 'themes' => $items,
+ 'total' => count($items),
+ 'active_stylesheet' => (string) $active_stylesheet,
+ 'active_template' => (string) $active_template,
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/users
+ *
+ * Lists users (id, username, email, roles, registration timestamp).
+ * Honours the ``role`` and ``search`` query params; default cap of
+ * 200 per call to keep payload sane on large installs.
+ */
+ public function handle_admin_users(WP_REST_Request $request) {
+ $role = $request->get_param('role');
+ $search = $request->get_param('search');
+ $per_page = (int) $request->get_param('per_page');
+ if ($per_page <= 0 || $per_page > 200) {
+ $per_page = 50;
+ }
+ $page = (int) $request->get_param('page');
+ if ($page <= 0) {
+ $page = 1;
+ }
+
+ $args = [
+ 'number' => $per_page,
+ 'paged' => $page,
+ 'fields' => ['ID', 'user_login', 'user_email', 'user_registered', 'display_name'],
+ ];
+ if (is_string($role) && $role !== '') {
+ $args['role'] = sanitize_key($role);
+ }
+ if (is_string($search) && $search !== '') {
+ $args['search'] = '*' . esc_attr($search) . '*';
+ $args['search_columns'] = ['user_login', 'user_email', 'display_name'];
+ }
+
+ $query = new WP_User_Query($args);
+ $items = [];
+ foreach ((array) $query->get_results() as $user) {
+ $u = get_userdata($user->ID);
+ if (!$u) {
+ continue;
+ }
+ $items[] = [
+ 'id' => (int) $u->ID,
+ 'username' => (string) $u->user_login,
+ 'email' => (string) $u->user_email,
+ 'display_name' => (string) $u->display_name,
+ 'roles' => array_values((array) $u->roles),
+ 'user_registered' => (string) $u->user_registered,
+ ];
+ }
+
+ return rest_ensure_response([
+ 'users' => $items,
+ 'total' => (int) $query->get_total(),
+ 'page' => $page,
+ 'per_page' => $per_page,
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/options/{name}
+ *
+ * Returns a single named option. Refuses keys that match the
+ * deny-list of credential-shaped suffixes. The caller already has
+ * ``manage_options``, so this is a defence-in-depth check (operators
+ * who store extra secrets in custom options should still be safe).
+ */
+ public function handle_admin_option_get(WP_REST_Request $request) {
+ $name = (string) $request->get_param('name');
+ $name = sanitize_key($name);
+ if ($name === '') {
+ return new WP_Error(
+ 'invalid_option_name',
+ __( 'Option name is required.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ foreach (self::ADMIN_OPTION_BLOCKED_PATTERNS as $pattern) {
+ if (preg_match($pattern, $name)) {
+ return new WP_Error(
+ 'option_blocked',
+ __( 'This option key is blocked from remote read for safety. Inspect it directly via wp-admin if needed.', 'airano-mcp-bridge' ),
+ ['status' => 403, 'option' => $name]
+ );
+ }
+ }
+
+ $sentinel = '__airano_mcp_missing__';
+ $value = get_option($name, $sentinel);
+ $exists = ($value !== $sentinel);
+ if (!$exists) {
+ $value = null;
+ }
+
+ return rest_ensure_response([
+ 'option' => $name,
+ 'exists' => $exists,
+ 'value' => $value,
+ 'autoload' => $exists ? null : null, // reserved for F.19.2
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/cron
+ *
+ * Dumps the current cron array (next run, hook name, schedule, args).
+ * Times reported as both unix epoch and ISO 8601 UTC.
+ */
+ public function handle_admin_cron(WP_REST_Request $request) {
+ $cron = _get_cron_array();
+ $events = [];
+ if (is_array($cron)) {
+ foreach ($cron as $timestamp => $hooks) {
+ if (!is_array($hooks)) {
+ continue;
+ }
+ foreach ($hooks as $hook => $instances) {
+ if (!is_array($instances)) {
+ continue;
+ }
+ foreach ($instances as $key => $event) {
+ $schedule = isset($event['schedule']) ? (string) $event['schedule'] : '';
+ $interval = isset($event['interval']) ? (int) $event['interval'] : null;
+ $events[] = [
+ 'hook' => (string) $hook,
+ 'next_run_at' => (int) $timestamp,
+ 'next_run_iso' => gmdate('c', (int) $timestamp),
+ 'schedule' => $schedule,
+ 'interval_sec' => $interval,
+ 'args' => isset($event['args']) ? $event['args'] : [],
+ 'key' => (string) $key,
+ ];
+ }
+ }
+ }
+ }
+
+ return rest_ensure_response([
+ 'events' => $events,
+ 'total' => count($events),
+ 'now' => time(),
+ 'now_iso' => gmdate('c'),
+ 'timezone' => (string) wp_timezone_string(),
+ 'doing_cron' => (bool) defined('DOING_CRON') && DOING_CRON,
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/maintenance
+ *
+ * Reports maintenance-mode status by inspecting WP's
+ * ``.maintenance`` sentinel file in ABSPATH (set during core/plugin
+ * updates, removed when finished). Toggling lands in F.19.2.
+ */
+ public function handle_admin_maintenance(WP_REST_Request $request) {
+ $sentinel = ABSPATH . '.maintenance';
+ $enabled = false;
+ $started_at = null;
+ $stale = false;
+
+ if (file_exists($sentinel)) {
+ $enabled = true;
+ $upgrading = 0;
+ // The file defines $upgrading = time() at the moment WP
+ // entered maintenance. Read it without including (the file
+ // only does an integer assignment, but include is risky).
+ $contents = file_get_contents($sentinel);
+ if (is_string($contents) && preg_match('/\$upgrading\s*=\s*(\d+)/', $contents, $m)) {
+ $upgrading = (int) $m[1];
+ }
+ $started_at = $upgrading > 0 ? $upgrading : null;
+ // WP itself treats >10 min as stale (wp_is_maintenance_mode).
+ $stale = $upgrading > 0 && (time() - $upgrading) > 600;
+ }
+
+ return rest_ensure_response([
+ 'enabled' => $enabled,
+ 'started_at' => $started_at,
+ 'started_iso' => $started_at ? gmdate('c', $started_at) : null,
+ 'stale' => $stale,
+ ]);
+ }
+
+ // ============================================================
+ // F.19.3.1 — Ports from wordpress_advanced (read-only)
+ // ============================================================
+
+ /**
+ * GET /airano-mcp/v1/admin/system-info
+ *
+ * Replaces wordpress_advanced.system_info — returns the same shape
+ * the WP-CLI version produced (PHP/MySQL/WordPress versions, server
+ * software, memory limits, time settings, multisite flag) but via
+ * native PHP so no Docker socket is required.
+ */
+ public function handle_admin_system_info(WP_REST_Request $request) {
+ global $wpdb;
+
+ $mysql_version = '';
+ try {
+ $row = $wpdb->get_var('SELECT VERSION()');
+ if (is_string($row)) {
+ $mysql_version = $row;
+ }
+ } catch (Exception $e) { // pragma: defensive
+ $mysql_version = '';
+ }
+
+ $upload_dir = wp_upload_dir();
+
+ return rest_ensure_response([
+ 'wordpress' => [
+ 'version' => get_bloginfo('version'),
+ 'site_url' => home_url(),
+ 'admin_url' => admin_url(),
+ 'language' => get_locale(),
+ 'timezone' => (string) wp_timezone_string(),
+ 'multisite' => is_multisite(),
+ 'debug' => defined('WP_DEBUG') && WP_DEBUG,
+ 'debug_log' => defined('WP_DEBUG_LOG') && WP_DEBUG_LOG,
+ 'memory_limit' => defined('WP_MEMORY_LIMIT') ? WP_MEMORY_LIMIT : null,
+ ],
+ 'php' => [
+ 'version' => PHP_VERSION,
+ 'sapi' => PHP_SAPI,
+ 'memory_limit' => ini_get('memory_limit'),
+ 'max_execution_time' => (int) ini_get('max_execution_time'),
+ 'post_max_size' => ini_get('post_max_size'),
+ 'upload_max_filesize' => ini_get('upload_max_filesize'),
+ 'max_input_vars' => (int) ini_get('max_input_vars'),
+ ],
+ 'database' => [
+ 'engine' => 'MySQL/MariaDB',
+ 'version' => $mysql_version,
+ 'charset' => defined('DB_CHARSET') ? DB_CHARSET : null,
+ 'collate' => defined('DB_COLLATE') ? DB_COLLATE : null,
+ 'table_prefix' => $wpdb->prefix,
+ ],
+ 'server' => [
+ 'software' => isset($_SERVER['SERVER_SOFTWARE']) ? sanitize_text_field(wp_unslash($_SERVER['SERVER_SOFTWARE'])) : '',
+ 'os' => function_exists('php_uname') ? php_uname('s') . ' ' . php_uname('r') : '',
+ 'protocol' => isset($_SERVER['SERVER_PROTOCOL']) ? sanitize_text_field(wp_unslash($_SERVER['SERVER_PROTOCOL'])) : '',
+ ],
+ 'paths' => [
+ 'abspath' => ABSPATH,
+ 'wp_content' => defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : null,
+ 'plugins' => defined('WP_PLUGIN_DIR') ? WP_PLUGIN_DIR : null,
+ 'uploads' => $upload_dir['basedir'] ?? null,
+ ],
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/phpinfo
+ *
+ * Curated PHP configuration snapshot — extension list, common ini
+ * settings, disabled functions, opcache state. Unlike PHP's
+ * built-in ``phpinfo()`` this returns structured JSON; we never
+ * dump full ``phpinfo(INFO_ALL)`` output because that leaks
+ * server-internal paths and credentials.
+ */
+ public function handle_admin_phpinfo(WP_REST_Request $request) {
+ $extensions = get_loaded_extensions();
+ sort($extensions);
+
+ // Curated subset of ini settings — covers the common
+ // diagnostic surface (memory, file uploads, sessions) without
+ // exposing the full ini map.
+ $ini_keys = [
+ 'memory_limit',
+ 'max_execution_time',
+ 'max_input_time',
+ 'max_input_vars',
+ 'post_max_size',
+ 'upload_max_filesize',
+ 'file_uploads',
+ 'allow_url_fopen',
+ 'allow_url_include',
+ 'display_errors',
+ 'log_errors',
+ 'error_log',
+ 'session.gc_maxlifetime',
+ 'session.save_handler',
+ 'date.timezone',
+ 'default_socket_timeout',
+ ];
+ $ini = [];
+ foreach ($ini_keys as $key) {
+ $ini[$key] = ini_get($key);
+ }
+
+ $opcache_enabled = function_exists('opcache_get_status') && (bool) ini_get('opcache.enable');
+ $opcache = null;
+ if ($opcache_enabled) {
+ $status = @opcache_get_status(false);
+ if (is_array($status)) {
+ $opcache = [
+ 'enabled' => (bool) ($status['opcache_enabled'] ?? false),
+ 'cache_full' => (bool) ($status['cache_full'] ?? false),
+ 'memory_used' => (int) ($status['memory_usage']['used_memory'] ?? 0),
+ 'memory_free' => (int) ($status['memory_usage']['free_memory'] ?? 0),
+ 'cached_scripts' => (int) ($status['opcache_statistics']['num_cached_scripts'] ?? 0),
+ ];
+ }
+ }
+
+ $disabled_functions_raw = (string) ini_get('disable_functions');
+ $disabled_functions = $disabled_functions_raw === ''
+ ? []
+ : array_values(array_filter(array_map('trim', explode(',', $disabled_functions_raw))));
+
+ return rest_ensure_response([
+ 'php_version' => PHP_VERSION,
+ 'sapi' => PHP_SAPI,
+ 'extensions' => $extensions,
+ 'ini' => $ini,
+ 'disabled_functions' => $disabled_functions,
+ 'opcache' => $opcache,
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/disk-usage
+ *
+ * Replaces wordpress_advanced.system_disk_usage. Returns bytes (not
+ * MB strings) so the dashboard can format them client-side. Walks
+ * uploads, plugins, themes — caps at 200k files / 5s wall clock per
+ * tree to keep the route bounded on huge installs.
+ */
+ public function handle_admin_disk_usage(WP_REST_Request $request) {
+ $upload_dir = wp_upload_dir();
+ $uploads_path = $upload_dir['basedir'] ?? '';
+ $plugins_path = defined('WP_PLUGIN_DIR') ? WP_PLUGIN_DIR : '';
+ $themes_path = defined('WP_CONTENT_DIR') ? trailingslashit(WP_CONTENT_DIR) . 'themes' : '';
+ $abspath = ABSPATH;
+
+ $uploads = $this->_safe_dir_size($uploads_path);
+ $plugins = $this->_safe_dir_size($plugins_path);
+ $themes = $this->_safe_dir_size($themes_path);
+
+ $disk_total = function_exists('disk_total_space') ? @disk_total_space($abspath) : null;
+ $disk_free = function_exists('disk_free_space') ? @disk_free_space($abspath) : null;
+ $disk_used = ($disk_total !== false && $disk_free !== false && $disk_total !== null && $disk_free !== null)
+ ? ($disk_total - $disk_free) : null;
+
+ return rest_ensure_response([
+ 'uploads' => $uploads,
+ 'plugins' => $plugins,
+ 'themes' => $themes,
+ 'disk' => [
+ 'total_bytes' => $disk_total === false ? null : $disk_total,
+ 'free_bytes' => $disk_free === false ? null : $disk_free,
+ 'used_bytes' => $disk_used,
+ ],
+ 'paths' => [
+ 'uploads' => $uploads_path,
+ 'plugins' => $plugins_path,
+ 'themes' => $themes_path,
+ 'abspath' => $abspath,
+ ],
+ ]);
+ }
+
+ /**
+ * Safely walk a directory tree and return total bytes + file count.
+ * Bounded at 200,000 files OR 5 seconds wall clock per tree so a
+ * pathological directory cannot hang the request. Truncated walks
+ * surface ``truncated: true`` in the response so the caller can
+ * tell the number is a lower bound.
+ */
+ private function _safe_dir_size(string $path): array {
+ if ($path === '' || !is_dir($path) || !is_readable($path)) {
+ return ['size_bytes' => 0, 'file_count' => 0, 'truncated' => false, 'available' => false];
+ }
+ $deadline = microtime(true) + 5.0;
+ $max_files = 200000;
+ $size = 0;
+ $count = 0;
+ $truncated = false;
+ try {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS)
+ );
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $size += (int) $file->getSize();
+ $count++;
+ if ($count >= $max_files || microtime(true) > $deadline) {
+ $truncated = true;
+ break;
+ }
+ }
+ }
+ } catch (Exception $e) {
+ // Permission denied somewhere mid-walk — return what we have.
+ $truncated = true;
+ }
+ return [
+ 'size_bytes' => $size,
+ 'file_count' => $count,
+ 'truncated' => $truncated,
+ 'available' => true,
+ ];
+ }
+
+ // ============================================================
+ // F.19.5 — Page editing (Gutenberg + Elementor + Classic)
+ // ============================================================
+ //
+ // Security rules enforced here, on top of the F.19.2 S-1…S-11 set
+ // documented in `docs/ROADMAP.md`:
+ // * S-12 — every block / Elementor write requires `edit_post`
+ // on the target post (per-item, not just the global tier).
+ // * S-13 — block content is sanitised via `wp_kses_post` by
+ // default; `raw_html=true` only with `unfiltered_html`
+ // (which WP grants only to administrators on
+ // single-site, and only to network admins on
+ // multisite).
+ // * S-14 — Elementor JSON node count capped at 5,000 per call
+ // to bound parse cost; oversized payloads return
+ // `elementor_too_large`.
+
+ /**
+ * Maximum number of blocks accepted in a single F.19.5 write call.
+ * Bounds parse + serialize cost on huge `wp_kses_post` runs.
+ */
+ const BLOCKS_MAX_PER_CALL = 200;
+
+ /**
+ * Maximum number of Elementor nodes accepted in a single F.19.5
+ * write call (S-14). Counted recursively across the whole tree.
+ */
+ const ELEMENTOR_MAX_NODES = 5000;
+
+ /**
+ * Resolve a post id from request input.
+ *
+ * @param mixed $raw The raw input (from URL param or JSON body).
+ * @return int|WP_Error Positive int post id on success, WP_Error otherwise.
+ */
+ private function _resolve_post_id($raw) {
+ if (!is_numeric($raw)) {
+ return new WP_Error(
+ 'invalid_post_id',
+ __( 'post_id must be an integer.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ $post_id = (int) $raw;
+ if ($post_id <= 0) {
+ return new WP_Error(
+ 'invalid_post_id',
+ __( 'post_id must be a positive integer.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ $post = get_post($post_id);
+ if (!$post) {
+ return new WP_Error(
+ 'post_not_found',
+ sprintf(
+ /* translators: %d is the post id. */
+ __( 'No post with id %d exists.', 'airano-mcp-bridge' ),
+ $post_id
+ ),
+ ['status' => 404]
+ );
+ }
+ // S-12 — per-item edit_post check on top of the route-level
+ // manage_options gate.
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error(
+ 'rest_forbidden',
+ __( 'Current user cannot edit this post.', 'airano-mcp-bridge' ),
+ ['status' => 403]
+ );
+ }
+ return $post_id;
+ }
+
+ /**
+ * Sanitize a single block's content per S-13.
+ *
+ * Recursively walks the block tree applying `wp_kses_post` to
+ * `innerHTML` / `innerContent` strings. When `$raw_html` is true
+ * and the current user has `unfiltered_html`, the strings are
+ * passed through unchanged. Anything else falls back to
+ * `wp_kses_post` regardless of the flag (defence in depth — a
+ * caller can never bypass sanitisation by lying about caps).
+ *
+ * @param array $blocks Parsed block tree from `parse_blocks()`.
+ * @param bool $raw_html Whether to skip sanitisation.
+ * @return array Sanitised block tree (same shape).
+ */
+ private function _sanitize_blocks(array $blocks, bool $raw_html): array {
+ $allow_raw = $raw_html && current_user_can('unfiltered_html');
+ return array_values(array_map(
+ function ($block) use ($allow_raw) {
+ if (!is_array($block)) {
+ return $block;
+ }
+ if (isset($block['innerHTML']) && is_string($block['innerHTML']) && !$allow_raw) {
+ $block['innerHTML'] = wp_kses_post($block['innerHTML']);
+ }
+ if (isset($block['innerContent']) && is_array($block['innerContent']) && !$allow_raw) {
+ $block['innerContent'] = array_map(
+ function ($chunk) {
+ return is_string($chunk) ? wp_kses_post($chunk) : $chunk;
+ },
+ $block['innerContent']
+ );
+ }
+ if (isset($block['innerBlocks']) && is_array($block['innerBlocks'])) {
+ $block['innerBlocks'] = $this->_sanitize_blocks($block['innerBlocks'], $allow_raw);
+ }
+ return $block;
+ },
+ $blocks
+ ));
+ }
+
+ /**
+ * Validate Elementor data shape per S-14 — every node must carry
+ * `id`, `elType`, `settings`, and the recursive node count must
+ * stay under {@see ELEMENTOR_MAX_NODES}.
+ *
+ * @param array $tree Top-level Elementor data array.
+ * @return true|WP_Error true on success, WP_Error on validation failure.
+ */
+ private function _validate_elementor_tree(array $tree) {
+ $count = 0;
+ $walker = function (array $nodes) use (&$walker, &$count) {
+ foreach ($nodes as $node) {
+ if (!is_array($node)) {
+ return new WP_Error(
+ 'elementor_invalid',
+ __( 'Elementor node must be an object.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ foreach (['id', 'elType', 'settings'] as $required) {
+ if (!array_key_exists($required, $node)) {
+ return new WP_Error(
+ 'elementor_invalid',
+ sprintf(
+ /* translators: %s is the missing field name. */
+ __( 'Elementor node missing required field: %s', 'airano-mcp-bridge' ),
+ $required
+ ),
+ ['status' => 400]
+ );
+ }
+ }
+ $count++;
+ if ($count > self::ELEMENTOR_MAX_NODES) {
+ return new WP_Error(
+ 'elementor_too_large',
+ sprintf(
+ /* translators: %d is the configured node cap. */
+ __( 'Elementor payload exceeds %d nodes — use template apply instead.', 'airano-mcp-bridge' ),
+ self::ELEMENTOR_MAX_NODES
+ ),
+ ['status' => 413]
+ );
+ }
+ if (!empty($node['elements']) && is_array($node['elements'])) {
+ $err = $walker($node['elements']);
+ if (is_wp_error($err)) {
+ return $err;
+ }
+ }
+ }
+ return null;
+ };
+ $err = $walker($tree);
+ if (is_wp_error($err)) {
+ return $err;
+ }
+ return true;
+ }
+
+ /**
+ * Read-back helper: parse a post's content into blocks and return
+ * the ready-to-send envelope used by /admin/blocks/* responses.
+ */
+ private function _blocks_envelope(int $post_id): array {
+ $post = get_post($post_id);
+ $blocks = parse_blocks((string) ($post ? $post->post_content : ''));
+ return [
+ 'post_id' => $post_id,
+ 'count' => count($blocks),
+ 'blocks' => $blocks,
+ ];
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/blocks/replace
+ *
+ * Body: { "post_id": int, "blocks": array, "raw_html"?: bool }
+ */
+ public function handle_admin_blocks_replace(WP_REST_Request $request) {
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ return new WP_Error('invalid_body', __( 'Expected JSON body.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $post_id = $this->_resolve_post_id($params['post_id'] ?? null);
+ if (is_wp_error($post_id)) {
+ return $post_id;
+ }
+ $blocks = $params['blocks'] ?? null;
+ if (!is_array($blocks)) {
+ return new WP_Error('invalid_blocks', __( '`blocks` must be an array.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ if (count($blocks) > self::BLOCKS_MAX_PER_CALL) {
+ return new WP_Error(
+ 'blocks_too_large',
+ sprintf(
+ /* translators: %d is the per-call cap. */
+ __( 'Block payload exceeds %d items per call.', 'airano-mcp-bridge' ),
+ self::BLOCKS_MAX_PER_CALL
+ ),
+ ['status' => 413]
+ );
+ }
+ $sanitised = $this->_sanitize_blocks($blocks, !empty($params['raw_html']));
+ $serialised = serialize_blocks($sanitised);
+ $update = wp_update_post([
+ 'ID' => $post_id,
+ 'post_content' => $serialised,
+ ], true);
+ if (is_wp_error($update)) {
+ return $update;
+ }
+ return rest_ensure_response($this->_blocks_envelope($post_id));
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/blocks/insert
+ *
+ * Body: { "post_id": int, "index": int, "block": array, "raw_html"?: bool }
+ */
+ public function handle_admin_blocks_insert(WP_REST_Request $request) {
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ return new WP_Error('invalid_body', __( 'Expected JSON body.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $post_id = $this->_resolve_post_id($params['post_id'] ?? null);
+ if (is_wp_error($post_id)) {
+ return $post_id;
+ }
+ if (!isset($params['block']) || !is_array($params['block'])) {
+ return new WP_Error('invalid_block', __( '`block` must be an object.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $existing = parse_blocks((string) get_post_field('post_content', $post_id));
+ $index = isset($params['index']) ? (int) $params['index'] : count($existing);
+ if ($index < 0 || $index > count($existing)) {
+ return new WP_Error(
+ 'index_out_of_range',
+ sprintf(
+ /* translators: 1: requested index, 2: current block count. */
+ __( 'index %1$d out of range for post with %2$d blocks.', 'airano-mcp-bridge' ),
+ $index,
+ count($existing)
+ ),
+ ['status' => 400]
+ );
+ }
+ if (count($existing) + 1 > self::BLOCKS_MAX_PER_CALL) {
+ return new WP_Error(
+ 'blocks_too_large',
+ sprintf(
+ /* translators: %d is the per-call cap. */
+ __( 'Inserting would push the post beyond %d blocks.', 'airano-mcp-bridge' ),
+ self::BLOCKS_MAX_PER_CALL
+ ),
+ ['status' => 413]
+ );
+ }
+ $sanitised_one = $this->_sanitize_blocks([$params['block']], !empty($params['raw_html']));
+ array_splice($existing, $index, 0, $sanitised_one);
+ $update = wp_update_post([
+ 'ID' => $post_id,
+ 'post_content' => serialize_blocks($existing),
+ ], true);
+ if (is_wp_error($update)) {
+ return $update;
+ }
+ return rest_ensure_response($this->_blocks_envelope($post_id));
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/blocks/remove
+ *
+ * Body: { "post_id": int, "index": int }
+ * Returns the removed block under `removed` so the caller can rollback.
+ */
+ public function handle_admin_blocks_remove(WP_REST_Request $request) {
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ return new WP_Error('invalid_body', __( 'Expected JSON body.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $post_id = $this->_resolve_post_id($params['post_id'] ?? null);
+ if (is_wp_error($post_id)) {
+ return $post_id;
+ }
+ $existing = parse_blocks((string) get_post_field('post_content', $post_id));
+ if (!isset($params['index']) || !is_numeric($params['index'])) {
+ return new WP_Error('invalid_index', __( '`index` is required.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $index = (int) $params['index'];
+ if ($index < 0 || $index >= count($existing)) {
+ return new WP_Error(
+ 'index_out_of_range',
+ sprintf(
+ /* translators: 1: requested index, 2: current block count. */
+ __( 'index %1$d out of range for post with %2$d blocks.', 'airano-mcp-bridge' ),
+ $index,
+ count($existing)
+ ),
+ ['status' => 400]
+ );
+ }
+ $removed = $existing[$index];
+ array_splice($existing, $index, 1);
+ $update = wp_update_post([
+ 'ID' => $post_id,
+ 'post_content' => serialize_blocks($existing),
+ ], true);
+ if (is_wp_error($update)) {
+ return $update;
+ }
+ $envelope = $this->_blocks_envelope($post_id);
+ $envelope['removed'] = $removed;
+ return rest_ensure_response($envelope);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/elementor/status
+ *
+ * Reports Elementor presence + version + Pro flag + supported post
+ * types. Returns `{installed: false}` cleanly when Elementor is not
+ * active, so the caller can branch without a 404.
+ */
+ public function handle_admin_elementor_status(WP_REST_Request $request) {
+ $installed = defined('ELEMENTOR_VERSION') || class_exists('\\Elementor\\Plugin');
+ if (!$installed) {
+ return rest_ensure_response([
+ 'installed' => false,
+ 'version' => null,
+ 'pro' => false,
+ 'post_types' => [],
+ ]);
+ }
+ $version = defined('ELEMENTOR_VERSION') ? ELEMENTOR_VERSION : null;
+ $pro = defined('ELEMENTOR_PRO_VERSION') || class_exists('\\ElementorPro\\Plugin');
+
+ // Elementor's own getter for editable post types — fall back
+ // to the conventional defaults if the API isn't reachable.
+ $post_types = ['page', 'post'];
+ if (class_exists('\\Elementor\\Plugin') && method_exists('\\Elementor\\Plugin', 'instance')) {
+ try {
+ $plugin = \Elementor\Plugin::instance();
+ if (isset($plugin->documents) && method_exists($plugin->documents, 'get_cpt_support')) {
+ $cpt = $plugin->documents->get_cpt_support();
+ if (is_array($cpt) && !empty($cpt)) {
+ $post_types = array_values(array_unique(array_map('strval', $cpt)));
+ }
+ }
+ } catch (Exception $e) { // pragma: defensive
+ // Fall through to defaults.
+ }
+ }
+
+ return rest_ensure_response([
+ 'installed' => true,
+ 'version' => $version,
+ 'pro' => $pro,
+ 'post_types' => $post_types,
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/elementor/{post_id}
+ *
+ * Returns the parsed _elementor_data JSON. Elementor stores the
+ * data as escaped JSON in post meta; we slash-strip and decode
+ * server-side so the client always sees a plain array.
+ */
+ public function handle_admin_elementor_get(WP_REST_Request $request) {
+ $post_id = $this->_resolve_post_id($request['post_id']);
+ if (is_wp_error($post_id)) {
+ return $post_id;
+ }
+ $raw = get_post_meta($post_id, '_elementor_data', true);
+ if ($raw === '' || $raw === null) {
+ return rest_ensure_response([
+ 'post_id' => $post_id,
+ 'edited_with_elementor' => false,
+ 'data' => [],
+ ]);
+ }
+ $json = is_string($raw) ? wp_unslash($raw) : $raw;
+ $data = is_string($json) ? json_decode($json, true) : $json;
+ if (!is_array($data)) {
+ return new WP_Error(
+ 'elementor_invalid',
+ __( 'Stored Elementor data is not a JSON array.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+ return rest_ensure_response([
+ 'post_id' => $post_id,
+ 'edited_with_elementor' => true,
+ 'data' => $data,
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/elementor/{post_id}
+ *
+ * Body: { "data": array } ← top-level Elementor sections array.
+ * Validates shape (every node has id/elType/settings), enforces
+ * the 5,000-node cap (S-14), writes via update_post_meta, and
+ * fires `elementor/document/after_save` so caches and CSS clear.
+ */
+ public function handle_admin_elementor_set(WP_REST_Request $request) {
+ $post_id = $this->_resolve_post_id($request['post_id']);
+ if (is_wp_error($post_id)) {
+ return $post_id;
+ }
+ $params = $request->get_json_params();
+ if (!is_array($params) || !isset($params['data']) || !is_array($params['data'])) {
+ return new WP_Error('invalid_body', __( '`data` array is required.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $tree = $params['data'];
+ $check = $this->_validate_elementor_tree($tree);
+ if (is_wp_error($check)) {
+ return $check;
+ }
+ $encoded = wp_slash(wp_json_encode($tree));
+ if ($encoded === false) {
+ return new WP_Error(
+ 'elementor_encode_failed',
+ __( 'Could not encode Elementor data as JSON.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+ update_post_meta($post_id, '_elementor_data', $encoded);
+ update_post_meta($post_id, '_elementor_edit_mode', 'builder');
+
+ // Fire the Elementor save hook so cache/CSS clears match the
+ // editor's own write path.
+ if (class_exists('\\Elementor\\Plugin') && method_exists('\\Elementor\\Plugin', 'instance')) {
+ try {
+ $plugin = \Elementor\Plugin::instance();
+ if (isset($plugin->documents) && method_exists($plugin->documents, 'get')) {
+ $document = $plugin->documents->get($post_id);
+ if ($document && method_exists($document, 'save')) {
+ do_action('elementor/document/after_save', $document, $tree);
+ }
+ }
+ } catch (Exception $e) { // pragma: defensive
+ // Swallow — write succeeded; cache regen is best-effort.
+ }
+ }
+ return rest_ensure_response([
+ 'post_id' => $post_id,
+ 'node_count' => $this->_count_elementor_nodes($tree),
+ 'saved' => true,
+ ]);
+ }
+
+ /**
+ * Recursive node counter (mirrors _validate_elementor_tree's walker).
+ */
+ private function _count_elementor_nodes(array $tree): int {
+ $count = 0;
+ foreach ($tree as $node) {
+ if (is_array($node)) {
+ $count++;
+ if (!empty($node['elements']) && is_array($node['elements'])) {
+ $count += $this->_count_elementor_nodes($node['elements']);
+ }
+ }
+ }
+ return $count;
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/elementor/{post_id}/regen-css
+ *
+ * Triggers Elementor's per-post CSS regeneration. Equivalent to
+ * "Regenerate CSS" on the Elementor → Tools → Regenerate Files
+ * page, scoped to one post.
+ */
+ public function handle_admin_elementor_render_css(WP_REST_Request $request) {
+ $post_id = $this->_resolve_post_id($request['post_id']);
+ if (is_wp_error($post_id)) {
+ return $post_id;
+ }
+ if (!class_exists('\\Elementor\\Plugin')) {
+ return new WP_Error(
+ 'elementor_missing',
+ __( 'Elementor is not active on this site.', 'airano-mcp-bridge' ),
+ ['status' => 409]
+ );
+ }
+ try {
+ $plugin = \Elementor\Plugin::instance();
+ // Elementor exposes Files_Manager::clear_cache() on $plugin->files_manager.
+ // Per-post regeneration is best handled by deleting the
+ // Post_CSS file and letting the next render rebuild it.
+ if (isset($plugin->files_manager) && method_exists($plugin->files_manager, 'clear_cache')) {
+ $plugin->files_manager->clear_cache();
+ }
+ if (class_exists('\\Elementor\\Core\\Files\\CSS\\Post')) {
+ $css_file = new \Elementor\Core\Files\CSS\Post($post_id);
+ if (method_exists($css_file, 'update')) {
+ $css_file->update();
+ }
+ }
+ } catch (Exception $e) {
+ return new WP_Error(
+ 'elementor_render_failed',
+ $e->getMessage(),
+ ['status' => 500]
+ );
+ }
+ return rest_ensure_response([
+ 'post_id' => $post_id,
+ 'regenerated' => true,
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/elementor/templates
+ *
+ * Lists saved Elementor templates (the `elementor_library` CPT).
+ */
+ public function handle_admin_elementor_template_list(WP_REST_Request $request) {
+ if (!post_type_exists('elementor_library')) {
+ return rest_ensure_response([
+ 'installed' => false,
+ 'templates' => [],
+ ]);
+ }
+ $posts = get_posts([
+ 'post_type' => 'elementor_library',
+ 'post_status' => ['publish', 'private'],
+ 'posts_per_page' => 200,
+ 'orderby' => 'title',
+ 'order' => 'ASC',
+ ]);
+ $templates = [];
+ foreach ($posts as $post) {
+ $templates[] = [
+ 'id' => (int) $post->ID,
+ 'title' => get_the_title($post),
+ 'type' => get_post_meta($post->ID, '_elementor_template_type', true),
+ 'modified_gmt' => $post->post_modified_gmt,
+ ];
+ }
+ return rest_ensure_response([
+ 'installed' => true,
+ 'templates' => $templates,
+ 'total' => count($templates),
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/elementor/templates/apply
+ *
+ * Body: { "template_id": int, "post_id": int }
+ * Copies the template's _elementor_data into the target post,
+ * subject to the same S-12 (edit_post on target) and S-14 caps as
+ * elementor/set.
+ */
+ public function handle_admin_elementor_template_apply(WP_REST_Request $request) {
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ return new WP_Error('invalid_body', __( 'Expected JSON body.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ if (!isset($params['template_id']) || !is_numeric($params['template_id'])) {
+ return new WP_Error('invalid_template_id', __( '`template_id` is required.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $template_id = (int) $params['template_id'];
+ $template_post = get_post($template_id);
+ if (!$template_post || $template_post->post_type !== 'elementor_library') {
+ return new WP_Error(
+ 'template_not_found',
+ sprintf(
+ /* translators: %d is the template id. */
+ __( 'No Elementor template with id %d.', 'airano-mcp-bridge' ),
+ $template_id
+ ),
+ ['status' => 404]
+ );
+ }
+ $target_id = $this->_resolve_post_id($params['post_id'] ?? null);
+ if (is_wp_error($target_id)) {
+ return $target_id;
+ }
+ $raw = get_post_meta($template_id, '_elementor_data', true);
+ $json = is_string($raw) ? wp_unslash($raw) : $raw;
+ $data = is_string($json) ? json_decode($json, true) : $json;
+ if (!is_array($data)) {
+ return new WP_Error(
+ 'template_invalid',
+ __( 'Source template has no Elementor data.', 'airano-mcp-bridge' ),
+ ['status' => 409]
+ );
+ }
+ $check = $this->_validate_elementor_tree($data);
+ if (is_wp_error($check)) {
+ return $check;
+ }
+ $encoded = wp_slash(wp_json_encode($data));
+ update_post_meta($target_id, '_elementor_data', $encoded);
+ update_post_meta($target_id, '_elementor_edit_mode', 'builder');
+ return rest_ensure_response([
+ 'post_id' => $target_id,
+ 'template_id' => $template_id,
+ 'applied' => true,
+ 'node_count' => $this->_count_elementor_nodes($data),
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/classic/{post_id}/replace
+ *
+ * Body: { "html": string, "raw_html"?: bool }
+ * Pure post_content swap for sites still on the Classic editor
+ * (S-13: wp_kses_post by default).
+ */
+ public function handle_admin_classic_html_replace(WP_REST_Request $request) {
+ $post_id = $this->_resolve_post_id($request['post_id']);
+ if (is_wp_error($post_id)) {
+ return $post_id;
+ }
+ $params = $request->get_json_params();
+ if (!is_array($params) || !isset($params['html']) || !is_string($params['html'])) {
+ return new WP_Error('invalid_body', __( '`html` string is required.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $allow_raw = !empty($params['raw_html']) && current_user_can('unfiltered_html');
+ $content = $allow_raw ? $params['html'] : wp_kses_post($params['html']);
+ $update = wp_update_post([
+ 'ID' => $post_id,
+ 'post_content' => $content,
+ ], true);
+ if (is_wp_error($update)) {
+ return $update;
+ }
+ return rest_ensure_response([
+ 'post_id' => $post_id,
+ 'length' => strlen($content),
+ 'sanitised' => !$allow_raw,
+ ]);
+ }
+
+ // ================================================================
+ // F.19.7 — Theme dev surface (install + activate + delete + file CRUD)
+ // ================================================================
+ //
+ // Security ruleset (extends F.19.5 S-12…S-14):
+ //
+ // * S-15 — `theme_slug` must match a key in `wp_get_themes()`.
+ // Companion rejects anything else with `theme_not_found`
+ // (404). Structural pre-check via the route regex
+ // ([A-Za-z0-9][A-Za-z0-9_\-]{0,63}); the wp_get_themes()
+ // membership check is the binding gate.
+ // * S-16 — Path canonicalisation. File routes resolve
+ // `wp-content/themes/{slug}/{path}` via realpath() and
+ // reject any result outside the slug directory. Blocks
+ // `..`, symlinks, absolute paths, null bytes.
+ // * S-17 — PHP file writes require `current_user_can('edit_themes')`
+ // AND `!defined('DISALLOW_FILE_EDIT') || !DISALLOW_FILE_EDIT`.
+ // Non-PHP files (CSS/JSON/MO/PO/JS/images/fonts) skip the
+ // DISALLOW_FILE_EDIT check but still require edit_themes.
+ // * S-18 — Per-call caps: 5 MB per file, 1000 files per list,
+ // 50 MB per theme install zip.
+ // * S-19 — Optimistic concurrency. When `expected_sha256` is
+ // supplied on write, compare against the current file's
+ // sha256 and return `sha_mismatch` (409) on drift.
+
+ /** Hard cap for theme file payloads (S-18). */
+ const THEME_FILE_MAX_BYTES = 5242880; // 5 MB
+
+ /** Hard cap for theme install zip payloads (S-18). */
+ const THEME_ZIP_MAX_BYTES = 52428800; // 50 MB
+
+ /** Hard cap for files per `theme_file_list` call (S-18). */
+ const THEME_LIST_MAX_FILES = 1000;
+
+ /**
+ * S-15 — Validate a theme slug against wp_get_themes().
+ *
+ * @param mixed $slug Raw input (URL param).
+ * @return string|WP_Error The slug on success, WP_Error on failure.
+ */
+ private function _validate_theme_slug($slug) {
+ if (!is_string($slug) || $slug === '') {
+ return new WP_Error(
+ 'invalid_theme_slug',
+ __( 'theme_slug must be a non-empty string.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$/', $slug)) {
+ return new WP_Error(
+ 'invalid_theme_slug',
+ __( 'theme_slug must be alphanumerics + dashes + underscores (<=64 chars).', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ $themes = wp_get_themes();
+ if (!isset($themes[$slug])) {
+ return new WP_Error(
+ 'theme_not_found',
+ sprintf(
+ /* translators: %s is the theme slug. */
+ __( 'No theme with slug %s installed.', 'airano-mcp-bridge' ),
+ $slug
+ ),
+ ['status' => 404]
+ );
+ }
+ return $slug;
+ }
+
+ /**
+ * S-16 — Resolve a theme-relative path under wp-content/themes/{slug}.
+ *
+ * Returns the absolute path (real-resolved when the file exists,
+ * structurally validated when the file may not yet exist for
+ * write+create_dirs). Rejects any traversal that escapes the slug
+ * directory.
+ *
+ * @param string $slug Theme slug (already validated by S-15).
+ * @param mixed $path Caller-supplied relative path.
+ * @param bool $must_exist If true (default) the resolved path must
+ * exist; otherwise the parent directory
+ * tree is canonicalised and the candidate
+ * is returned for create-then-write.
+ * @return array|WP_Error On success: [
+ * 'absolute' => absolute path,
+ * 'relative' => normalised relative path,
+ * 'base' => absolute slug directory,
+ * 'exists' => bool,
+ * ].
+ */
+ private function _resolve_theme_file_path($slug, $path, $must_exist = true) {
+ if (!is_string($path) || $path === '') {
+ return new WP_Error(
+ 'path_invalid',
+ __( 'path must be a non-empty string.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ if (strpos($path, "\0") !== false) {
+ return new WP_Error(
+ 'path_invalid',
+ __( 'path must not contain null bytes.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ // Reject backslashes (Windows-style escapes) and absolute paths.
+ if (strpos($path, '\\') !== false) {
+ return new WP_Error(
+ 'path_invalid',
+ __( 'path must use forward slashes only.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ if ($path[0] === '/') {
+ return new WP_Error(
+ 'path_invalid',
+ __( 'path must be theme-relative.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ $segments = array_values(array_filter(explode('/', $path), function ($p) {
+ return $p !== '';
+ }));
+ foreach ($segments as $seg) {
+ if ($seg === '..') {
+ return new WP_Error(
+ 'path_invalid',
+ __( 'path must not contain `..` segments.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ }
+ if (empty($segments)) {
+ return new WP_Error(
+ 'path_invalid',
+ __( 'path must reference a file, not the theme root.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ $relative = implode('/', $segments);
+ $themes_root = WP_CONTENT_DIR . '/themes';
+ $base = realpath($themes_root . '/' . $slug);
+ if ($base === false) {
+ return new WP_Error(
+ 'theme_not_found',
+ __( 'Theme directory does not exist on disk.', 'airano-mcp-bridge' ),
+ ['status' => 404]
+ );
+ }
+ $candidate = $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
+ $real = realpath($candidate);
+ if ($real !== false) {
+ // Path exists — confirm it stays inside $base.
+ $base_with_sep = rtrim($base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+ if (strpos($real, $base_with_sep) !== 0 && $real !== $base) {
+ return new WP_Error(
+ 'path_invalid',
+ __( 'path resolves outside the theme directory.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ return [
+ 'absolute' => $real,
+ 'relative' => $relative,
+ 'base' => $base,
+ 'exists' => !is_dir($real), // we want files; mark dirs as non-existent so callers reject them
+ 'is_dir' => is_dir($real),
+ ];
+ }
+ if ($must_exist) {
+ return new WP_Error(
+ 'file_not_found',
+ __( 'No file at that path.', 'airano-mcp-bridge' ),
+ ['status' => 404]
+ );
+ }
+ // For writes the candidate may not exist yet — we must validate
+ // the deepest existing ancestor and confirm it is inside $base.
+ $ancestor = dirname($candidate);
+ $real_ancestor = realpath($ancestor);
+ while ($real_ancestor === false && $ancestor !== '.' && $ancestor !== DIRECTORY_SEPARATOR) {
+ $parent = dirname($ancestor);
+ if ($parent === $ancestor) {
+ break;
+ }
+ $ancestor = $parent;
+ $real_ancestor = realpath($ancestor);
+ }
+ if ($real_ancestor === false) {
+ return new WP_Error(
+ 'path_invalid',
+ __( 'path does not resolve to a real ancestor.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ $base_with_sep = rtrim($base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+ if ($real_ancestor !== $base && strpos($real_ancestor, $base_with_sep) !== 0) {
+ return new WP_Error(
+ 'path_invalid',
+ __( 'path resolves outside the theme directory.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ return [
+ 'absolute' => $candidate,
+ 'relative' => $relative,
+ 'base' => $base,
+ 'exists' => false,
+ 'is_dir' => false,
+ ];
+ }
+
+ /**
+ * Best-effort mime detection — uses fileinfo when available, falls
+ * back to the WP-supplied list which keys off the extension.
+ */
+ private function _theme_file_mime($absolute) {
+ if (function_exists('mime_content_type')) {
+ $detected = @mime_content_type($absolute);
+ if (is_string($detected) && $detected !== '') {
+ return $detected;
+ }
+ }
+ $info = wp_check_filetype(basename($absolute));
+ if (!empty($info['type'])) {
+ return $info['type'];
+ }
+ return 'application/octet-stream';
+ }
+
+ /**
+ * Translate an fnmatch glob (with leading `**` support) into a
+ * suffix-aware predicate compatible with PHP's fnmatch().
+ *
+ * `**\/*.php` → match every .php file at any depth
+ * `**\/*` → match every file at any depth
+ * `*.css` → match .css at any depth (interpreted permissively)
+ * literal → exact relative-path match
+ */
+ private function _theme_glob_match($pattern, $relative) {
+ if ($pattern === '' || $pattern === '**/*') {
+ return true;
+ }
+ // fnmatch only handles a single `*` segment — strip a leading
+ // `**/` so the remaining pattern matches against any tail.
+ $tail = $pattern;
+ if (strpos($tail, '**/') === 0) {
+ $tail = substr($tail, 3);
+ } elseif ($tail === '**') {
+ return true;
+ }
+ // If `tail` has no slashes, match the basename anywhere; else
+ // match the full relative path.
+ if (strpos($tail, '/') === false) {
+ return fnmatch($tail, basename($relative));
+ }
+ return fnmatch($pattern, $relative) || fnmatch($tail, $relative);
+ }
+
+ /**
+ * Recursively walk a directory and append matching entries to
+ * &$entries until the cap is reached. Returns the (possibly
+ * truncated) entries array via reference; the boolean return value
+ * indicates whether the walk was truncated.
+ */
+ private function _theme_walk(
+ $dir,
+ $base,
+ $glob,
+ $max_files,
+ &$entries
+ ) {
+ $iter = @scandir($dir);
+ if ($iter === false) {
+ return false;
+ }
+ sort($iter);
+ foreach ($iter as $name) {
+ if ($name === '.' || $name === '..') {
+ continue;
+ }
+ if (count($entries) >= $max_files) {
+ return true;
+ }
+ $abs = $dir . DIRECTORY_SEPARATOR . $name;
+ if (is_link($abs)) {
+ // S-16 — never follow a symlink that escapes the base
+ // (defensive; theme dirs rarely contain symlinks).
+ $target = realpath($abs);
+ if ($target === false) {
+ continue;
+ }
+ $base_with_sep = rtrim($base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+ if (strpos($target, $base_with_sep) !== 0 && $target !== $base) {
+ continue;
+ }
+ }
+ if (is_dir($abs)) {
+ $truncated = $this->_theme_walk($abs, $base, $glob, $max_files, $entries);
+ if ($truncated) {
+ return true;
+ }
+ continue;
+ }
+ if (!is_file($abs)) {
+ continue;
+ }
+ $relative = ltrim(
+ str_replace(DIRECTORY_SEPARATOR, '/', substr($abs, strlen($base))),
+ '/'
+ );
+ if (!$this->_theme_glob_match($glob, $relative)) {
+ continue;
+ }
+ $size = @filesize($abs);
+ $modified = @filemtime($abs);
+ $sha = @hash_file('sha256', $abs);
+ $entries[] = [
+ 'path' => $relative,
+ 'size' => $size === false ? null : (int) $size,
+ 'mime' => $this->_theme_file_mime($abs),
+ 'sha256' => $sha === false ? null : $sha,
+ 'modified_at' => $modified === false ? null : (int) $modified,
+ ];
+ }
+ return count($entries) >= $max_files;
+ }
+
+ /**
+ * S-17 — Decide whether a write to $relative is permitted given
+ * `edit_themes` (already checked by caller) plus the
+ * `DISALLOW_FILE_EDIT` constant for PHP files.
+ */
+ private function _theme_write_allowed($relative) {
+ $is_php = preg_match('/\.php$/i', $relative) === 1;
+ if ($is_php && defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT) {
+ return new WP_Error(
+ 'file_edit_disabled',
+ __( 'PHP file edits are disabled (DISALLOW_FILE_EDIT) on this site.', 'airano-mcp-bridge' ),
+ ['status' => 403]
+ );
+ }
+ return true;
+ }
+
+ /**
+ * Lazy-load WP_Filesystem (Direct transport). Returns the global on
+ * success or a WP_Error if it could not be initialised.
+ */
+ private function _theme_filesystem() {
+ global $wp_filesystem;
+ if (!empty($wp_filesystem)) {
+ return $wp_filesystem;
+ }
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ WP_Filesystem();
+ if (empty($wp_filesystem) || !is_object($wp_filesystem)) {
+ return new WP_Error(
+ 'filesystem_unavailable',
+ __( 'WP_Filesystem could not be initialised.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+ return $wp_filesystem;
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/themes/install
+ *
+ * Body: { zip_url? | zip_base64?, activate?, overwrite? }
+ */
+ public function handle_admin_theme_install(WP_REST_Request $request) {
+ if (!current_user_can('install_themes')) {
+ return new WP_Error('rest_forbidden', __( 'install_themes capability required.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ return new WP_Error('invalid_body', __( 'Expected JSON body.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $zip_url = isset($params['zip_url']) && is_string($params['zip_url']) ? $params['zip_url'] : '';
+ $zip_b64 = isset($params['zip_base64']) && is_string($params['zip_base64']) ? $params['zip_base64'] : '';
+ if ($zip_url === '' && $zip_b64 === '') {
+ return new WP_Error('invalid_body', __( 'zip_url or zip_base64 is required.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ if ($zip_url !== '' && $zip_b64 !== '') {
+ return new WP_Error('invalid_body', __( 'pass exactly one of zip_url or zip_base64.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $activate = !empty($params['activate']);
+ $overwrite = !empty($params['overwrite']);
+
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/misc.php';
+ require_once ABSPATH . 'wp-admin/includes/theme.php';
+ require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
+ if (!class_exists('WP_Ajax_Upgrader_Skin')) {
+ require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
+ }
+
+ $tmp_path = '';
+ if ($zip_url !== '') {
+ $download = download_url($zip_url);
+ if (is_wp_error($download)) {
+ return $download;
+ }
+ $tmp_path = $download;
+ } else {
+ // S-18 — base64 decode size cap. Strict mode rejects invalid
+ // input rather than silently dropping bad characters.
+ $decoded = base64_decode($zip_b64, true);
+ if ($decoded === false) {
+ return new WP_Error('invalid_zip', __( 'zip_base64 is not valid base64.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ if (strlen($decoded) > self::THEME_ZIP_MAX_BYTES) {
+ return new WP_Error(
+ 'zip_too_large',
+ sprintf(
+ /* translators: %d is the byte cap. */
+ __( 'Zip exceeds %d byte cap.', 'airano-mcp-bridge' ),
+ self::THEME_ZIP_MAX_BYTES
+ ),
+ ['status' => 413]
+ );
+ }
+ $tmp_path = wp_tempnam('theme-install-');
+ if (!$tmp_path) {
+ return new WP_Error('tmpnam_failed', __( 'Could not allocate a temp file.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ $fs = $this->_theme_filesystem();
+ if (is_wp_error($fs)) {
+ @unlink($tmp_path);
+ return $fs;
+ }
+ if (!$fs->put_contents($tmp_path, $decoded, FS_CHMOD_FILE)) {
+ @unlink($tmp_path);
+ return new WP_Error('write_failed', __( 'Could not write temp zip.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ }
+
+ $skin = new WP_Ajax_Upgrader_Skin();
+ $upgrader = new Theme_Upgrader($skin);
+ $result = $upgrader->install($tmp_path, [
+ 'overwrite_package' => (bool) $overwrite,
+ ]);
+ @unlink($tmp_path);
+
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ if ($result === false) {
+ $errors = $skin->get_errors();
+ $msg = is_object($errors) && method_exists($errors, 'get_error_message')
+ ? $errors->get_error_message()
+ : __( 'Theme install failed.', 'airano-mcp-bridge' );
+ return new WP_Error('install_failed', $msg ?: __( 'Theme install failed.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+
+ $info = $upgrader->theme_info();
+ $slug = '';
+ if ($info && is_object($info) && method_exists($info, 'get_stylesheet')) {
+ $slug = (string) $info->get_stylesheet();
+ }
+
+ $activated = false;
+ if ($activate && $slug !== '') {
+ if (!current_user_can('switch_themes')) {
+ return new WP_Error('rest_forbidden', __( 'switch_themes capability required to activate.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ switch_theme($slug);
+ $activated = (string) get_option('stylesheet') === $slug;
+ }
+
+ return rest_ensure_response([
+ 'installed' => true,
+ 'slug' => $slug,
+ 'activated' => $activated,
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/themes/{slug}/activate
+ */
+ public function handle_admin_theme_activate(WP_REST_Request $request) {
+ if (!current_user_can('switch_themes')) {
+ return new WP_Error('rest_forbidden', __( 'switch_themes capability required.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $slug = $this->_validate_theme_slug($request['slug']);
+ if (is_wp_error($slug)) {
+ return $slug;
+ }
+ switch_theme($slug);
+ $stylesheet = (string) get_option('stylesheet');
+ $template = (string) get_option('template');
+ if ($stylesheet !== $slug) {
+ return new WP_Error(
+ 'activation_failed',
+ __( 'Activation did not stick — check the theme has a valid style.css header.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+ return rest_ensure_response([
+ 'activated' => true,
+ 'stylesheet' => $stylesheet,
+ 'template' => $template,
+ ]);
+ }
+
+ /**
+ * DELETE /airano-mcp/v1/admin/themes/{slug}
+ */
+ public function handle_admin_theme_delete(WP_REST_Request $request) {
+ if (!current_user_can('delete_themes')) {
+ return new WP_Error('rest_forbidden', __( 'delete_themes capability required.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $slug = $this->_validate_theme_slug($request['slug']);
+ if (is_wp_error($slug)) {
+ return $slug;
+ }
+ if ($slug === (string) get_option('stylesheet')) {
+ return new WP_Error('theme_active', __( 'Refusing to delete the active theme.', 'airano-mcp-bridge' ), ['status' => 409]);
+ }
+ if ($slug === (string) get_option('template')) {
+ return new WP_Error('theme_active', __( 'Refusing to delete the active parent theme.', 'airano-mcp-bridge' ), ['status' => 409]);
+ }
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/theme.php';
+ $deleted = delete_theme($slug);
+ if (is_wp_error($deleted)) {
+ return $deleted;
+ }
+ if ($deleted === false) {
+ return new WP_Error('delete_failed', __( 'Theme delete failed.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ return rest_ensure_response([
+ 'deleted' => true,
+ 'slug' => $slug,
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/themes/files/{slug}
+ */
+ public function handle_admin_theme_file_list(WP_REST_Request $request) {
+ $slug = $this->_validate_theme_slug($request['slug']);
+ if (is_wp_error($slug)) {
+ return $slug;
+ }
+ $glob = $request->get_param('glob');
+ if (!is_string($glob) || $glob === '') {
+ $glob = '**/*';
+ }
+ $max_files = (int) $request->get_param('max_files');
+ if ($max_files <= 0) {
+ $max_files = self::THEME_LIST_MAX_FILES;
+ }
+ if ($max_files > self::THEME_LIST_MAX_FILES) {
+ $max_files = self::THEME_LIST_MAX_FILES;
+ }
+ $base = realpath(WP_CONTENT_DIR . '/themes/' . $slug);
+ if ($base === false) {
+ return new WP_Error('theme_not_found', __( 'Theme directory missing.', 'airano-mcp-bridge' ), ['status' => 404]);
+ }
+ $entries = [];
+ $truncated = $this->_theme_walk($base, $base, $glob, $max_files, $entries);
+ return rest_ensure_response([
+ 'theme_slug' => $slug,
+ 'count' => count($entries),
+ 'truncated' => (bool) $truncated,
+ 'glob' => $glob,
+ 'files' => $entries,
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/themes/files/{slug}/{path}
+ */
+ public function handle_admin_theme_file_read(WP_REST_Request $request) {
+ $slug = $this->_validate_theme_slug($request['slug']);
+ if (is_wp_error($slug)) {
+ return $slug;
+ }
+ $resolved = $this->_resolve_theme_file_path($slug, $request['path'], true);
+ if (is_wp_error($resolved)) {
+ return $resolved;
+ }
+ if (!empty($resolved['is_dir'])) {
+ return new WP_Error('path_invalid', __( 'path refers to a directory.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $absolute = $resolved['absolute'];
+ $size = filesize($absolute);
+ if ($size === false) {
+ return new WP_Error('read_failed', __( 'Could not stat file.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ if ($size > self::THEME_FILE_MAX_BYTES) {
+ return new WP_Error(
+ 'file_too_large',
+ sprintf(
+ /* translators: %d is the byte cap. */
+ __( 'File exceeds %d byte cap.', 'airano-mcp-bridge' ),
+ self::THEME_FILE_MAX_BYTES
+ ),
+ ['status' => 413]
+ );
+ }
+ $body = file_get_contents($absolute);
+ if ($body === false) {
+ return new WP_Error('read_failed', __( 'Could not read file.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ $sha = hash('sha256', $body);
+ $modified = filemtime($absolute);
+ return rest_ensure_response([
+ 'theme_slug' => $slug,
+ 'path' => $resolved['relative'],
+ 'size' => (int) $size,
+ 'mime' => $this->_theme_file_mime($absolute),
+ 'sha256' => $sha,
+ 'modified_at' => $modified === false ? null : (int) $modified,
+ 'content_base64' => base64_encode($body),
+ ]);
+ }
+
+ /**
+ * PUT /airano-mcp/v1/admin/themes/files/{slug}/{path}
+ */
+ public function handle_admin_theme_file_write(WP_REST_Request $request) {
+ if (!current_user_can('edit_themes')) {
+ return new WP_Error('rest_forbidden', __( 'edit_themes capability required.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $slug = $this->_validate_theme_slug($request['slug']);
+ if (is_wp_error($slug)) {
+ return $slug;
+ }
+ $params = $request->get_json_params();
+ if (!is_array($params) || !isset($params['content_base64']) || !is_string($params['content_base64'])) {
+ return new WP_Error('invalid_body', __( 'content_base64 string is required.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $create_dirs = !array_key_exists('create_dirs', $params) || !empty($params['create_dirs']);
+ $resolved = $this->_resolve_theme_file_path($slug, $request['path'], false);
+ if (is_wp_error($resolved)) {
+ return $resolved;
+ }
+ if (!empty($resolved['is_dir'])) {
+ return new WP_Error('path_invalid', __( 'path refers to a directory.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $allowed = $this->_theme_write_allowed($resolved['relative']);
+ if (is_wp_error($allowed)) {
+ return $allowed;
+ }
+ $decoded = base64_decode($params['content_base64'], true);
+ if ($decoded === false) {
+ return new WP_Error('invalid_body', __( 'content_base64 is not valid base64.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ if (strlen($decoded) > self::THEME_FILE_MAX_BYTES) {
+ return new WP_Error(
+ 'file_too_large',
+ sprintf(
+ /* translators: %d is the byte cap. */
+ __( 'Decoded body exceeds %d byte cap.', 'airano-mcp-bridge' ),
+ self::THEME_FILE_MAX_BYTES
+ ),
+ ['status' => 413]
+ );
+ }
+ // S-19 — optimistic concurrency.
+ if (isset($params['expected_sha256']) && is_string($params['expected_sha256']) && $params['expected_sha256'] !== '') {
+ $expected = strtolower($params['expected_sha256']);
+ if (!preg_match('/^[0-9a-f]{64}$/', $expected)) {
+ return new WP_Error('invalid_body', __( 'expected_sha256 must be 64 hex chars.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ if ($resolved['exists']) {
+ $current = @hash_file('sha256', $resolved['absolute']);
+ if ($current === false || $current !== $expected) {
+ return new WP_Error(
+ 'sha_mismatch',
+ __( 'On-disk sha256 does not match expected_sha256.', 'airano-mcp-bridge' ),
+ ['status' => 409]
+ );
+ }
+ } else {
+ // Caller expected an existing file but none exists.
+ return new WP_Error(
+ 'sha_mismatch',
+ __( 'expected_sha256 supplied but file does not exist.', 'airano-mcp-bridge' ),
+ ['status' => 409]
+ );
+ }
+ }
+ // Create parent directories if requested.
+ $absolute = $resolved['absolute'];
+ $parent = dirname($absolute);
+ if (!is_dir($parent)) {
+ if (!$create_dirs) {
+ return new WP_Error('parent_missing', __( 'Parent directory does not exist; pass create_dirs=true to auto-create.', 'airano-mcp-bridge' ), ['status' => 409]);
+ }
+ if (!wp_mkdir_p($parent)) {
+ return new WP_Error('mkdir_failed', __( 'Could not create parent directories.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ }
+ $fs = $this->_theme_filesystem();
+ if (is_wp_error($fs)) {
+ return $fs;
+ }
+ if (!$fs->put_contents($absolute, $decoded, FS_CHMOD_FILE)) {
+ return new WP_Error('write_failed', __( 'Theme file write failed.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ $new_sha = hash('sha256', $decoded);
+ $modified = @filemtime($absolute);
+ return rest_ensure_response([
+ 'theme_slug' => $slug,
+ 'path' => $resolved['relative'],
+ 'size' => strlen($decoded),
+ 'sha256' => $new_sha,
+ 'modified_at' => $modified === false ? null : (int) $modified,
+ 'created' => !$resolved['exists'],
+ ]);
+ }
+
+ /**
+ * DELETE /airano-mcp/v1/admin/themes/files/{slug}/{path}
+ */
+ public function handle_admin_theme_file_delete(WP_REST_Request $request) {
+ if (!current_user_can('edit_themes')) {
+ return new WP_Error('rest_forbidden', __( 'edit_themes capability required.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $slug = $this->_validate_theme_slug($request['slug']);
+ if (is_wp_error($slug)) {
+ return $slug;
+ }
+ $resolved = $this->_resolve_theme_file_path($slug, $request['path'], true);
+ if (is_wp_error($resolved)) {
+ return $resolved;
+ }
+ if (!empty($resolved['is_dir'])) {
+ return new WP_Error('path_invalid', __( 'path refers to a directory; refusing to recurse.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ // Refuse to delete style.css of the active theme — it would
+ // break the front-end immediately.
+ $is_active = $slug === (string) get_option('stylesheet');
+ if ($is_active && strtolower($resolved['relative']) === 'style.css') {
+ return new WP_Error('refused', __( 'Refusing to delete style.css of the active theme.', 'airano-mcp-bridge' ), ['status' => 409]);
+ }
+ $allowed = $this->_theme_write_allowed($resolved['relative']);
+ if (is_wp_error($allowed)) {
+ return $allowed;
+ }
+ if (!@unlink($resolved['absolute'])) {
+ return new WP_Error('delete_failed', __( 'Theme file delete failed.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ return rest_ensure_response([
+ 'deleted' => true,
+ 'theme_slug' => $slug,
+ 'path' => $resolved['relative'],
+ ]);
+ }
+
+ // ================================================================
+ // F.19.2.1 — Plugin write management
+ // ================================================================
+ //
+ // Security ruleset (extends F.19.7's S-15..S-19):
+ //
+ // * S-15 (reused) — slug must match a key in get_plugins() for
+ // activate/deactivate/update/delete.
+ // * S-18 (reused) — 50 MB cap on install zip payloads.
+ // * S-20 — refuses to deactivate/delete the airano-mcp-bridge
+ // companion itself. Doing so would brick the MCP
+ // connection; operators must use the WP-Admin Plugins
+ // page instead.
+ // * S-21 — refuses to deactivate/delete plugins whose `Required`
+ // header is `yes` (must-use plugins shipped by some
+ // managed hosts).
+
+ /** Hard cap for plugin install zip payloads (S-18, mirrors theme zip). */
+ const PLUGIN_ZIP_MAX_BYTES = 52428800; // 50 MB
+
+ /** Slug of the companion itself — never deactivate/delete via this surface (S-20). */
+ const COMPANION_SLUG = 'airano-mcp-bridge';
+
+ /**
+ * Resolve a plugin slug to its plugin_file (e.g. "akismet/akismet.php").
+ *
+ * S-15 — also acts as the wp_get_themes() equivalent: a slug that
+ * doesn't appear in get_plugins() is rejected with `plugin_not_found`.
+ *
+ * @param mixed $slug Raw slug from URL/body.
+ * @return string|WP_Error plugin_file on success, WP_Error otherwise.
+ */
+ private function _resolve_plugin_file($slug) {
+ if (!is_string($slug) || $slug === '' || !preg_match('/^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$/', $slug)) {
+ return new WP_Error(
+ 'invalid_plugin_slug',
+ __( 'slug must be alphanumerics + dashes + underscores (<=64 chars).', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ if (!function_exists('get_plugins')) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ $plugins = get_plugins();
+ // Two valid forms: dir/main.php (most common) or main.php (rare,
+ // for single-file plugins like Hello Dolly).
+ foreach ($plugins as $file => $_meta) {
+ $file_slug = (dirname($file) === '.') ? basename($file, '.php') : dirname($file);
+ if ($file_slug === $slug) {
+ return $file;
+ }
+ }
+ return new WP_Error(
+ 'plugin_not_found',
+ sprintf(
+ /* translators: %s is the plugin slug. */
+ __( 'No installed plugin with slug %s.', 'airano-mcp-bridge' ),
+ $slug
+ ),
+ ['status' => 404]
+ );
+ }
+
+ /**
+ * S-21 — fetch plugin meta and refuse if `Required: yes`.
+ * Returns true on allow, WP_Error on refusal.
+ */
+ private function _plugin_not_required($plugin_file) {
+ $path = WP_PLUGIN_DIR . '/' . $plugin_file;
+ if (!is_file($path)) {
+ return true; // can't read; let downstream handlers fail with the real reason
+ }
+ $data = get_plugin_data($path, false, false);
+ if (!empty($data['Required']) && strtolower((string) $data['Required']) === 'yes') {
+ return new WP_Error(
+ 'plugin_required',
+ __( 'Plugin marked Required by this site; refusing to deactivate or delete.', 'airano-mcp-bridge' ),
+ ['status' => 409]
+ );
+ }
+ return true;
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/plugins/install
+ *
+ * Body shape A (slug install — install_plugins, install tier on MCPHub):
+ * { "slug": "akismet", "activate"?: bool }
+ *
+ * Body shape B (zip install — install_plugins, admin tier on MCPHub):
+ * { "zip_url"?: str | "zip_base64"?: str, "activate"?: bool, "overwrite"?: bool }
+ */
+ public function handle_admin_plugin_install(WP_REST_Request $request) {
+ if (!current_user_can('install_plugins')) {
+ return new WP_Error('rest_forbidden', __( 'install_plugins capability required.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ return new WP_Error('invalid_body', __( 'Expected JSON body.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+
+ $slug = isset($params['slug']) && is_string($params['slug']) ? $params['slug'] : '';
+ $zip_url = isset($params['zip_url']) && is_string($params['zip_url']) ? $params['zip_url'] : '';
+ $zip_b64 = isset($params['zip_base64']) && is_string($params['zip_base64']) ? $params['zip_base64'] : '';
+ $modes = (int) ($slug !== '') + (int) ($zip_url !== '') + (int) ($zip_b64 !== '');
+ if ($modes !== 1) {
+ return new WP_Error(
+ 'invalid_body',
+ __( 'pass exactly one of slug, zip_url, or zip_base64.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ $activate = !empty($params['activate']);
+ $overwrite = !empty($params['overwrite']);
+
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/misc.php';
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
+ require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
+ if (!class_exists('WP_Ajax_Upgrader_Skin')) {
+ require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
+ }
+
+ $tmp_path = '';
+
+ if ($slug !== '') {
+ // Slug install — resolve the wp.org package URL and download.
+ if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$/', $slug)) {
+ return new WP_Error('invalid_plugin_slug', __( 'invalid slug.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $info = plugins_api('plugin_information', [
+ 'slug' => $slug,
+ 'fields' => ['short_description' => false, 'sections' => false, 'icons' => false],
+ ]);
+ if (is_wp_error($info)) {
+ return $info;
+ }
+ if (empty($info->download_link)) {
+ return new WP_Error(
+ 'wporg_no_download',
+ __( 'wp.org returned no download link for that slug.', 'airano-mcp-bridge' ),
+ ['status' => 502]
+ );
+ }
+ $download = download_url($info->download_link);
+ if (is_wp_error($download)) {
+ return $download;
+ }
+ $tmp_path = $download;
+ } elseif ($zip_url !== '') {
+ $download = download_url($zip_url);
+ if (is_wp_error($download)) {
+ return $download;
+ }
+ $tmp_path = $download;
+ } else {
+ // base64 install
+ $decoded = base64_decode($zip_b64, true);
+ if ($decoded === false) {
+ return new WP_Error('invalid_zip', __( 'zip_base64 is not valid base64.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ if (strlen($decoded) > self::PLUGIN_ZIP_MAX_BYTES) {
+ return new WP_Error(
+ 'zip_too_large',
+ sprintf(
+ /* translators: %d is the byte cap. */
+ __( 'Zip exceeds %d byte cap.', 'airano-mcp-bridge' ),
+ self::PLUGIN_ZIP_MAX_BYTES
+ ),
+ ['status' => 413]
+ );
+ }
+ $tmp_path = wp_tempnam('plugin-install-');
+ if (!$tmp_path) {
+ return new WP_Error('tmpnam_failed', __( 'Could not allocate a temp file.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ $fs = $this->_theme_filesystem(); // reuse helper from F.19.7
+ if (is_wp_error($fs)) {
+ @unlink($tmp_path);
+ return $fs;
+ }
+ if (!$fs->put_contents($tmp_path, $decoded, FS_CHMOD_FILE)) {
+ @unlink($tmp_path);
+ return new WP_Error('write_failed', __( 'Could not write temp zip.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ }
+
+ $skin = new WP_Ajax_Upgrader_Skin();
+ $upgrader = new Plugin_Upgrader($skin);
+ $result = $upgrader->install($tmp_path, [
+ 'overwrite_package' => (bool) $overwrite,
+ ]);
+ @unlink($tmp_path);
+
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ if ($result === false) {
+ $errors = $skin->get_errors();
+ $msg = is_object($errors) && method_exists($errors, 'get_error_message')
+ ? $errors->get_error_message()
+ : __( 'Plugin install failed.', 'airano-mcp-bridge' );
+ return new WP_Error('install_failed', $msg ?: __( 'Plugin install failed.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+
+ // Resolve the installed plugin file so callers can chain.
+ $installed_file = (string) $upgrader->plugin_info();
+ $installed_slug = $installed_file !== ''
+ ? ((dirname($installed_file) === '.') ? basename($installed_file, '.php') : dirname($installed_file))
+ : '';
+
+ $activated = false;
+ if ($activate && $installed_file !== '') {
+ if (!current_user_can('activate_plugins')) {
+ return new WP_Error('rest_forbidden', __( 'activate_plugins capability required to activate.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $err = activate_plugin($installed_file);
+ $activated = !is_wp_error($err);
+ }
+
+ return rest_ensure_response([
+ 'installed' => true,
+ 'slug' => $installed_slug,
+ 'plugin_file' => $installed_file,
+ 'activated' => $activated,
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/plugins/{slug}/activate
+ */
+ public function handle_admin_plugin_activate(WP_REST_Request $request) {
+ if (!current_user_can('activate_plugins')) {
+ return new WP_Error('rest_forbidden', __( 'activate_plugins capability required.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $plugin_file = $this->_resolve_plugin_file($request['slug']);
+ if (is_wp_error($plugin_file)) {
+ return $plugin_file;
+ }
+ $params = $request->get_json_params();
+ $network_wide = is_array($params) && !empty($params['network_wide']);
+ if ($network_wide && is_multisite() && !current_user_can('manage_network_plugins')) {
+ return new WP_Error('rest_forbidden', __( 'manage_network_plugins required for network-wide activation.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ if (!function_exists('activate_plugin')) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ $err = activate_plugin($plugin_file, '', $network_wide);
+ if (is_wp_error($err)) {
+ return $err;
+ }
+ return rest_ensure_response([
+ 'activated' => is_plugin_active($plugin_file) || ($network_wide && is_plugin_active_for_network($plugin_file)),
+ 'slug' => (string) $request['slug'],
+ 'plugin_file' => $plugin_file,
+ 'network_wide' => (bool) $network_wide,
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/plugins/{slug}/deactivate
+ */
+ public function handle_admin_plugin_deactivate(WP_REST_Request $request) {
+ if (!current_user_can('activate_plugins')) {
+ return new WP_Error('rest_forbidden', __( 'activate_plugins capability required.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $slug = (string) $request['slug'];
+ // S-20: guard against deactivating the companion itself.
+ if ($slug === self::COMPANION_SLUG) {
+ return new WP_Error(
+ 'companion_self',
+ __( 'Refusing to deactivate the Airano MCP Bridge companion via its own route — would brick the MCP connection. Use WP-Admin → Plugins instead.', 'airano-mcp-bridge' ),
+ ['status' => 409]
+ );
+ }
+ $plugin_file = $this->_resolve_plugin_file($slug);
+ if (is_wp_error($plugin_file)) {
+ return $plugin_file;
+ }
+ // S-21: refuse if header marks this plugin Required.
+ $req_check = $this->_plugin_not_required($plugin_file);
+ if (is_wp_error($req_check)) {
+ return $req_check;
+ }
+ $params = $request->get_json_params();
+ $network_wide = is_array($params) && !empty($params['network_wide']);
+ if (!function_exists('deactivate_plugins')) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ deactivate_plugins([$plugin_file], false, $network_wide);
+ return rest_ensure_response([
+ 'deactivated' => !is_plugin_active($plugin_file),
+ 'slug' => $slug,
+ 'plugin_file' => $plugin_file,
+ 'network_wide' => (bool) $network_wide,
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/plugins/{slug}/update
+ */
+ public function handle_admin_plugin_update(WP_REST_Request $request) {
+ if (!current_user_can('update_plugins')) {
+ return new WP_Error('rest_forbidden', __( 'update_plugins capability required.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $plugin_file = $this->_resolve_plugin_file($request['slug']);
+ if (is_wp_error($plugin_file)) {
+ return $plugin_file;
+ }
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
+ if (!class_exists('WP_Ajax_Upgrader_Skin')) {
+ require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
+ }
+
+ // Refresh the cached update_plugins transient so the upgrader
+ // sees the latest available version.
+ wp_update_plugins();
+ $update_data = get_site_transient('update_plugins');
+ $has_update = isset($update_data->response[$plugin_file]);
+
+ if (!$has_update) {
+ return rest_ensure_response([
+ 'up_to_date' => true,
+ 'updated' => false,
+ 'slug' => (string) $request['slug'],
+ 'plugin_file' => $plugin_file,
+ ]);
+ }
+
+ $skin = new WP_Ajax_Upgrader_Skin();
+ $upgrader = new Plugin_Upgrader($skin);
+ $result = $upgrader->upgrade($plugin_file);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ if ($result === false) {
+ $errors = $skin->get_errors();
+ $msg = is_object($errors) && method_exists($errors, 'get_error_message')
+ ? $errors->get_error_message()
+ : __( 'Plugin update failed.', 'airano-mcp-bridge' );
+ return new WP_Error('update_failed', $msg ?: __( 'Plugin update failed.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+
+ // After upgrade, re-read the plugin meta for the new version.
+ $new_data = get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin_file, false, false);
+ return rest_ensure_response([
+ 'up_to_date' => false,
+ 'updated' => true,
+ 'slug' => (string) $request['slug'],
+ 'plugin_file' => $plugin_file,
+ 'new_version' => isset($new_data['Version']) ? (string) $new_data['Version'] : null,
+ ]);
+ }
+
+ /**
+ * DELETE /airano-mcp/v1/admin/plugins/{slug}
+ */
+ // ================================================================
+ // F.19.6.A — Site config (identity + reading + permalinks)
+ // ================================================================
+
+ /**
+ * Validate that an attachment id refers to an existing media item.
+ * 0 is valid (means "clear"). Returns true on allow, WP_Error otherwise.
+ */
+ private function _validate_attachment_id($id, $field) {
+ if (!is_int($id) || $id < 0) {
+ return new WP_Error(
+ 'invalid_attachment',
+ sprintf(
+ /* translators: %s is the field name. */
+ __( '%s must be a non-negative integer.', 'airano-mcp-bridge' ),
+ $field
+ ),
+ ['status' => 400]
+ );
+ }
+ if ($id === 0) {
+ return true;
+ }
+ $post = get_post($id);
+ if (!$post || $post->post_type !== 'attachment') {
+ return new WP_Error(
+ 'invalid_attachment',
+ sprintf(
+ /* translators: %d is the attachment id. */
+ __( 'No attachment with id %d.', 'airano-mcp-bridge' ),
+ $id
+ ),
+ ['status' => 404]
+ );
+ }
+ return true;
+ }
+
+ /**
+ * Validate a page id refers to a published Page (or 0 to clear).
+ */
+ private function _validate_page_id($id, $field) {
+ if (!is_int($id) || $id < 0) {
+ return new WP_Error(
+ 'invalid_page',
+ sprintf(
+ __( '%s must be a non-negative integer.', 'airano-mcp-bridge' ),
+ $field
+ ),
+ ['status' => 400]
+ );
+ }
+ if ($id === 0) {
+ return true;
+ }
+ $post = get_post($id);
+ if (!$post || $post->post_type !== 'page' || $post->post_status !== 'publish') {
+ return new WP_Error(
+ 'invalid_page',
+ sprintf(
+ __( '%s must reference a published Page (got id %d).', 'airano-mcp-bridge' ),
+ $field,
+ $id
+ ),
+ ['status' => 400]
+ );
+ }
+ return true;
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/site/identity
+ */
+ public function handle_admin_site_identity_get(WP_REST_Request $request) {
+ return rest_ensure_response([
+ 'title' => (string) get_option('blogname', ''),
+ 'tagline' => (string) get_option('blogdescription', ''),
+ 'site_icon' => (int) get_option('site_icon', 0),
+ 'custom_logo' => (int) get_theme_mod('custom_logo', 0),
+ 'admin_email' => (string) get_option('admin_email', ''),
+ 'blog_charset' => (string) get_option('blog_charset', 'UTF-8'),
+ 'wp_version' => function_exists('get_bloginfo') ? (string) get_bloginfo('version') : null,
+ 'language' => (string) get_option('WPLANG', get_locale()),
+ 'timezone' => (string) get_option('timezone_string', ''),
+ 'siteurl' => (string) get_option('siteurl', ''),
+ 'home' => (string) get_option('home', ''),
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/site/identity
+ * Body: { title?, tagline?, site_icon_id?, custom_logo_id? }
+ */
+ public function handle_admin_site_identity_set(WP_REST_Request $request) {
+ $params = $request->get_json_params();
+ if (!is_array($params) || empty($params)) {
+ return new WP_Error('invalid_body', __( 'Expected a non-empty JSON body.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $changed = [];
+ if (array_key_exists('title', $params)) {
+ if (!is_string($params['title'])) {
+ return new WP_Error('invalid_title', __( 'title must be a string.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ update_option('blogname', sanitize_text_field($params['title']));
+ $changed[] = 'title';
+ }
+ if (array_key_exists('tagline', $params)) {
+ if (!is_string($params['tagline'])) {
+ return new WP_Error('invalid_tagline', __( 'tagline must be a string.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ update_option('blogdescription', sanitize_text_field($params['tagline']));
+ $changed[] = 'tagline';
+ }
+ if (array_key_exists('site_icon_id', $params)) {
+ $check = $this->_validate_attachment_id($params['site_icon_id'], 'site_icon_id');
+ if (is_wp_error($check)) {
+ return $check;
+ }
+ update_option('site_icon', (int) $params['site_icon_id']);
+ $changed[] = 'site_icon';
+ }
+ if (array_key_exists('custom_logo_id', $params)) {
+ $check = $this->_validate_attachment_id($params['custom_logo_id'], 'custom_logo_id');
+ if (is_wp_error($check)) {
+ return $check;
+ }
+ $logo_id = (int) $params['custom_logo_id'];
+ if ($logo_id === 0) {
+ remove_theme_mod('custom_logo');
+ } else {
+ set_theme_mod('custom_logo', $logo_id);
+ }
+ $changed[] = 'custom_logo';
+ }
+ if (empty($changed)) {
+ return new WP_Error('invalid_body', __( 'No supported fields supplied.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ return rest_ensure_response([
+ 'updated' => $changed,
+ 'identity' => [
+ 'title' => (string) get_option('blogname', ''),
+ 'tagline' => (string) get_option('blogdescription', ''),
+ 'site_icon' => (int) get_option('site_icon', 0),
+ 'custom_logo' => (int) get_theme_mod('custom_logo', 0),
+ ],
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/site/reading
+ */
+ public function handle_admin_site_reading_get(WP_REST_Request $request) {
+ return rest_ensure_response([
+ 'show_on_front' => (string) get_option('show_on_front', 'posts'),
+ 'page_on_front' => (int) get_option('page_on_front', 0),
+ 'page_for_posts' => (int) get_option('page_for_posts', 0),
+ 'posts_per_page' => (int) get_option('posts_per_page', 10),
+ 'posts_per_rss' => (int) get_option('posts_per_rss', 10),
+ 'blog_public' => ((int) get_option('blog_public', 1)) === 1,
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/site/reading
+ */
+ public function handle_admin_site_reading_set(WP_REST_Request $request) {
+ $params = $request->get_json_params();
+ if (!is_array($params) || empty($params)) {
+ return new WP_Error('invalid_body', __( 'Expected a non-empty JSON body.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $changed = [];
+ if (array_key_exists('show_on_front', $params)) {
+ if (!in_array($params['show_on_front'], ['posts', 'page'], true)) {
+ return new WP_Error(
+ 'invalid_show_on_front',
+ __( "show_on_front must be 'posts' or 'page'.", 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ update_option('show_on_front', $params['show_on_front']);
+ $changed[] = 'show_on_front';
+ }
+ if (array_key_exists('page_on_front', $params)) {
+ $check = $this->_validate_page_id($params['page_on_front'], 'page_on_front');
+ if (is_wp_error($check)) {
+ return $check;
+ }
+ update_option('page_on_front', (int) $params['page_on_front']);
+ $changed[] = 'page_on_front';
+ }
+ if (array_key_exists('page_for_posts', $params)) {
+ $check = $this->_validate_page_id($params['page_for_posts'], 'page_for_posts');
+ if (is_wp_error($check)) {
+ return $check;
+ }
+ update_option('page_for_posts', (int) $params['page_for_posts']);
+ $changed[] = 'page_for_posts';
+ }
+ if (array_key_exists('posts_per_page', $params)) {
+ if (!is_int($params['posts_per_page']) || $params['posts_per_page'] < 1 || $params['posts_per_page'] > 100) {
+ return new WP_Error(
+ 'invalid_posts_per_page',
+ __( 'posts_per_page must be an integer between 1 and 100.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ update_option('posts_per_page', (int) $params['posts_per_page']);
+ $changed[] = 'posts_per_page';
+ }
+ if (array_key_exists('posts_per_rss', $params)) {
+ if (!is_int($params['posts_per_rss']) || $params['posts_per_rss'] < 1 || $params['posts_per_rss'] > 100) {
+ return new WP_Error(
+ 'invalid_posts_per_rss',
+ __( 'posts_per_rss must be an integer between 1 and 100.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ update_option('posts_per_rss', (int) $params['posts_per_rss']);
+ $changed[] = 'posts_per_rss';
+ }
+ if (array_key_exists('blog_public', $params)) {
+ if (!is_bool($params['blog_public'])) {
+ return new WP_Error(
+ 'invalid_blog_public',
+ __( 'blog_public must be a boolean.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ update_option('blog_public', $params['blog_public'] ? 1 : 0);
+ $changed[] = 'blog_public';
+ }
+ if (empty($changed)) {
+ return new WP_Error('invalid_body', __( 'No supported fields supplied.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ // Re-emit the freshly-stored values so the caller can verify.
+ return rest_ensure_response([
+ 'updated' => $changed,
+ 'reading' => [
+ 'show_on_front' => (string) get_option('show_on_front', 'posts'),
+ 'page_on_front' => (int) get_option('page_on_front', 0),
+ 'page_for_posts' => (int) get_option('page_for_posts', 0),
+ 'posts_per_page' => (int) get_option('posts_per_page', 10),
+ 'posts_per_rss' => (int) get_option('posts_per_rss', 10),
+ 'blog_public' => ((int) get_option('blog_public', 1)) === 1,
+ ],
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/permalinks
+ */
+ public function handle_admin_permalinks_get(WP_REST_Request $request) {
+ return rest_ensure_response([
+ 'structure' => (string) get_option('permalink_structure', ''),
+ 'category_base' => (string) get_option('category_base', ''),
+ 'tag_base' => (string) get_option('tag_base', ''),
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/permalinks
+ * Body: { structure, category_base?, tag_base? }
+ * Calls flush_rewrite_rules() after option writes.
+ */
+ public function handle_admin_permalinks_set(WP_REST_Request $request) {
+ $params = $request->get_json_params();
+ if (!is_array($params) || !array_key_exists('structure', $params)) {
+ return new WP_Error('invalid_body', __( 'structure is required (use "" for plain).', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ if (!is_string($params['structure'])) {
+ return new WP_Error('invalid_structure', __( 'structure must be a string.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+
+ // Ensure the rewrite-rule flush helpers are loaded.
+ require_once ABSPATH . 'wp-admin/includes/misc.php';
+
+ global $wp_rewrite;
+
+ $wp_rewrite->set_permalink_structure($params['structure']);
+
+ if (array_key_exists('category_base', $params)) {
+ if (!is_string($params['category_base']) || strlen($params['category_base']) > 64) {
+ return new WP_Error('invalid_category_base', __( 'category_base must be a string up to 64 chars.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $wp_rewrite->set_category_base($params['category_base']);
+ }
+ if (array_key_exists('tag_base', $params)) {
+ if (!is_string($params['tag_base']) || strlen($params['tag_base']) > 64) {
+ return new WP_Error('invalid_tag_base', __( 'tag_base must be a string up to 64 chars.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $wp_rewrite->set_tag_base($params['tag_base']);
+ }
+
+ // Hard flush so the rules table is rebuilt from scratch — same
+ // as clicking "Save Changes" on Settings → Permalinks.
+ flush_rewrite_rules(true);
+
+ return rest_ensure_response([
+ 'flushed' => true,
+ 'structure' => (string) get_option('permalink_structure', ''),
+ 'category_base' => (string) get_option('category_base', ''),
+ 'tag_base' => (string) get_option('tag_base', ''),
+ ]);
+ }
+
+ public function handle_admin_plugin_delete(WP_REST_Request $request) {
+ if (!current_user_can('delete_plugins')) {
+ return new WP_Error('rest_forbidden', __( 'delete_plugins capability required.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $slug = (string) $request['slug'];
+ // S-20: never delete the companion via its own route.
+ if ($slug === self::COMPANION_SLUG) {
+ return new WP_Error(
+ 'companion_self',
+ __( 'Refusing to delete the Airano MCP Bridge companion via its own route. Use WP-Admin → Plugins instead.', 'airano-mcp-bridge' ),
+ ['status' => 409]
+ );
+ }
+ $plugin_file = $this->_resolve_plugin_file($slug);
+ if (is_wp_error($plugin_file)) {
+ return $plugin_file;
+ }
+ if (!function_exists('is_plugin_active')) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ if (is_plugin_active($plugin_file) || is_plugin_active_for_network($plugin_file)) {
+ return new WP_Error(
+ 'plugin_active',
+ __( 'Refusing to delete an active plugin. Deactivate it first.', 'airano-mcp-bridge' ),
+ ['status' => 409]
+ );
+ }
+ // S-21: header-marked Required plugins are off-limits for delete.
+ $req_check = $this->_plugin_not_required($plugin_file);
+ if (is_wp_error($req_check)) {
+ return $req_check;
+ }
+ $deleted = delete_plugins([$plugin_file]);
+ if (is_wp_error($deleted)) {
+ return $deleted;
+ }
+ if ($deleted === false || $deleted === null) {
+ return new WP_Error('delete_failed', __( 'Plugin delete failed.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ return rest_ensure_response([
+ 'deleted' => true,
+ 'slug' => $slug,
+ 'plugin_file' => $plugin_file,
+ ]);
+ }
+
+ // ================================================================
+ // F.19.6.B — Site layout (menus + widgets + customizer)
+ // ================================================================
+
+ /**
+ * S-22: validate a nav-menu item's object reference.
+ *
+ * Dispatch by item ``type``:
+ * - ``post_type`` → ``current_user_can('read_post', $object_id)``.
+ * Refuses items pointing at posts the caller can't read.
+ * - ``taxonomy`` → resolve term + taxonomy. Public taxonomies
+ * are readable by everyone; non-public requires the taxonomy's
+ * ``assign_terms`` cap. Deliberately NOT ``manage_categories``
+ * (that's a write cap and would refuse routine editor flows).
+ * - ``custom`` → no object_id; URL is sanitised separately.
+ */
+ private function _validate_menu_object_ref($type, $object, $object_id) {
+ if ($type === 'custom') {
+ return true;
+ }
+ $object_id = (int) $object_id;
+ if ($object_id <= 0) {
+ return new WP_Error(
+ 'forbidden_object_id',
+ sprintf(__( 'object_id is required for %s items.', 'airano-mcp-bridge' ), $type),
+ ['status' => 400]
+ );
+ }
+ if ($type === 'post_type') {
+ $post = get_post($object_id);
+ if (!$post) {
+ return new WP_Error(
+ 'forbidden_object_id',
+ sprintf(__( 'Post %d not found.', 'airano-mcp-bridge' ), $object_id),
+ ['status' => 404]
+ );
+ }
+ if (!current_user_can('read_post', $object_id)) {
+ return new WP_Error(
+ 'forbidden_object_id',
+ sprintf(__( 'Cannot read post %d.', 'airano-mcp-bridge' ), $object_id),
+ ['status' => 403]
+ );
+ }
+ return true;
+ }
+ if ($type === 'taxonomy') {
+ $taxonomy = is_string($object) && $object !== '' ? $object : 'category';
+ $tax = get_taxonomy($taxonomy);
+ if (!$tax) {
+ return new WP_Error(
+ 'forbidden_object_id',
+ sprintf(__( 'Unknown taxonomy %s.', 'airano-mcp-bridge' ), $taxonomy),
+ ['status' => 400]
+ );
+ }
+ $term = get_term($object_id, $taxonomy);
+ if (is_wp_error($term) || !$term) {
+ return new WP_Error(
+ 'forbidden_object_id',
+ sprintf(__( 'Term %d not found in taxonomy %s.', 'airano-mcp-bridge' ), $object_id, $taxonomy),
+ ['status' => 404]
+ );
+ }
+ if (!empty($tax->public)) {
+ return true;
+ }
+ $cap = isset($tax->cap->assign_terms) ? $tax->cap->assign_terms : 'edit_posts';
+ if (!current_user_can($cap)) {
+ return new WP_Error(
+ 'forbidden_object_id',
+ sprintf(__( 'Cannot reference term %d in non-public taxonomy %s.', 'airano-mcp-bridge' ), $object_id, $taxonomy),
+ ['status' => 403]
+ );
+ }
+ return true;
+ }
+ return new WP_Error(
+ 'forbidden_object_id',
+ sprintf(__( 'Unsupported menu item type %s.', 'airano-mcp-bridge' ), $type),
+ ['status' => 400]
+ );
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/menus
+ */
+ public function handle_admin_menus_list(WP_REST_Request $request) {
+ $menus = wp_get_nav_menus();
+ if (is_wp_error($menus)) {
+ return $menus;
+ }
+ $locations = get_nav_menu_locations();
+ $menu_to_location = [];
+ foreach ($locations as $loc => $mid) {
+ if (!isset($menu_to_location[$mid])) {
+ $menu_to_location[$mid] = [];
+ }
+ $menu_to_location[$mid][] = $loc;
+ }
+ $out = [];
+ foreach ($menus as $menu) {
+ $items = wp_get_nav_menu_items($menu->term_id);
+ $out[] = [
+ 'id' => (int) $menu->term_id,
+ 'name' => (string) $menu->name,
+ 'slug' => (string) $menu->slug,
+ 'locations' => isset($menu_to_location[$menu->term_id]) ? $menu_to_location[$menu->term_id] : [],
+ 'item_count' => is_array($items) ? count($items) : 0,
+ ];
+ }
+ return rest_ensure_response(['menus' => $out]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/menus/{menu_id}
+ */
+ public function handle_admin_menu_get(WP_REST_Request $request) {
+ $menu_id = (int) $request['menu_id'];
+ $menu = wp_get_nav_menu_object($menu_id);
+ if (!$menu) {
+ return new WP_Error('menu_not_found', __( 'Menu not found.', 'airano-mcp-bridge' ), ['status' => 404]);
+ }
+ $items_raw = wp_get_nav_menu_items($menu_id);
+ if ($items_raw === false || $items_raw === null) {
+ $items_raw = [];
+ }
+ $items = [];
+ foreach ($items_raw as $it) {
+ $items[] = [
+ 'id' => (int) $it->ID,
+ 'title' => (string) $it->title,
+ 'type' => (string) $it->type,
+ 'object' => (string) $it->object,
+ 'object_id' => (int) $it->object_id,
+ 'parent' => (int) $it->menu_item_parent,
+ 'order' => (int) $it->menu_order,
+ 'url' => (string) $it->url,
+ 'target' => (string) $it->target,
+ 'classes' => is_array($it->classes) ? array_values($it->classes) : [],
+ 'xfn' => (string) $it->xfn,
+ ];
+ }
+ return rest_ensure_response([
+ 'id' => (int) $menu->term_id,
+ 'name' => (string) $menu->name,
+ 'slug' => (string) $menu->slug,
+ 'items' => $items,
+ ]);
+ }
+
+ /**
+ * PUT /airano-mcp/v1/admin/menus/{menu_id}
+ * Body: { items: [...], name? } — full replace, slug frozen.
+ * Two-pass: validate every item against S-22 first, then mutate.
+ */
+ public function handle_admin_menu_set(WP_REST_Request $request) {
+ $menu_id = (int) $request['menu_id'];
+ $menu = wp_get_nav_menu_object($menu_id);
+ if (!$menu) {
+ return new WP_Error('menu_not_found', __( 'Menu not found.', 'airano-mcp-bridge' ), ['status' => 404]);
+ }
+ $params = $request->get_json_params();
+ if (!is_array($params) || !isset($params['items']) || !is_array($params['items'])) {
+ return new WP_Error('invalid_body', __( 'items array is required.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $items = $params['items'];
+ // ── Pass 1: validate every item up-front (S-22).
+ foreach ($items as $idx => $it) {
+ if (!is_array($it)) {
+ return new WP_Error('invalid_item', sprintf(__( 'items[%d] must be an object.', 'airano-mcp-bridge' ), $idx), ['status' => 400]);
+ }
+ $type = isset($it['type']) ? (string) $it['type'] : 'custom';
+ $object = isset($it['object']) ? (string) $it['object'] : '';
+ $object_id = isset($it['object_id']) ? (int) $it['object_id'] : 0;
+ $check = $this->_validate_menu_object_ref($type, $object, $object_id);
+ if (is_wp_error($check)) {
+ return $check;
+ }
+ }
+ // ── Optional rename (slug stays frozen — theme_location maps via slug).
+ if (array_key_exists('name', $params)) {
+ if (!is_string($params['name']) || trim($params['name']) === '') {
+ return new WP_Error('invalid_name', __( 'name must be a non-empty string.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $rename = wp_update_nav_menu_object($menu_id, ['menu-name' => sanitize_text_field($params['name'])]);
+ if (is_wp_error($rename)) {
+ return $rename;
+ }
+ }
+ // ── Pass 2: write items.
+ require_once ABSPATH . 'wp-admin/includes/nav-menu.php';
+ $existing = wp_get_nav_menu_items($menu_id);
+ if ($existing === false || $existing === null) {
+ $existing = [];
+ }
+ $existing_ids = [];
+ foreach ($existing as $e) {
+ $existing_ids[(int) $e->ID] = true;
+ }
+ $kept_ids = [];
+ $created = 0;
+ $updated = 0;
+ foreach ($items as $idx => $it) {
+ $type = isset($it['type']) ? (string) $it['type'] : 'custom';
+ $object = isset($it['object']) ? (string) $it['object'] : '';
+ $object_id = isset($it['object_id']) ? (int) $it['object_id'] : 0;
+ $title = isset($it['title']) ? (string) $it['title'] : '';
+ $url = isset($it['url']) ? esc_url_raw($it['url']) : '';
+ $parent = isset($it['parent']) ? (int) $it['parent'] : 0;
+ $order = isset($it['order']) ? (int) $it['order'] : ($idx + 1);
+ $target = isset($it['target']) ? (string) $it['target'] : '';
+ $existing_item_id = isset($it['id']) ? (int) $it['id'] : 0;
+ $menu_item_data = [
+ 'menu-item-title' => sanitize_text_field($title),
+ 'menu-item-type' => $type,
+ 'menu-item-object' => $object,
+ 'menu-item-object-id' => $object_id,
+ 'menu-item-parent-id' => $parent,
+ 'menu-item-position' => $order,
+ 'menu-item-url' => $url,
+ 'menu-item-target' => $target,
+ 'menu-item-status' => 'publish',
+ ];
+ if ($existing_item_id > 0 && isset($existing_ids[$existing_item_id])) {
+ $rid = wp_update_nav_menu_item($menu_id, $existing_item_id, $menu_item_data);
+ if (is_wp_error($rid)) {
+ return $rid;
+ }
+ $kept_ids[(int) $rid] = true;
+ $updated++;
+ } else {
+ $rid = wp_update_nav_menu_item($menu_id, 0, $menu_item_data);
+ if (is_wp_error($rid)) {
+ return $rid;
+ }
+ $kept_ids[(int) $rid] = true;
+ $created++;
+ }
+ }
+ // ── Delete items no longer in the array.
+ $deleted = 0;
+ foreach (array_keys($existing_ids) as $eid) {
+ if (!isset($kept_ids[$eid])) {
+ $r = wp_delete_post($eid, true);
+ if ($r) {
+ $deleted++;
+ }
+ }
+ }
+ return rest_ensure_response([
+ 'id' => $menu_id,
+ 'created' => $created,
+ 'updated' => $updated,
+ 'deleted' => $deleted,
+ 'total' => count($items),
+ ]);
+ }
+
+ /**
+ * Detect whether a sidebar's existing widgets are all `block-N` (block-kind)
+ * or contain at least one legacy widget. Empty area falls back to the
+ * global ``wp_use_widgets_block_editor()`` signal.
+ */
+ private function _detect_widget_area_kind($widget_ids) {
+ if (is_array($widget_ids) && !empty($widget_ids)) {
+ foreach ($widget_ids as $wid) {
+ if (strpos((string) $wid, 'block-') !== 0) {
+ return 'legacy';
+ }
+ }
+ return 'block';
+ }
+ return (function_exists('wp_use_widgets_block_editor') && wp_use_widgets_block_editor()) ? 'block' : 'legacy';
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/widgets/areas
+ */
+ public function handle_admin_widget_areas_list(WP_REST_Request $request) {
+ global $wp_registered_sidebars;
+ $sidebars_widgets = wp_get_sidebars_widgets();
+ $out = [];
+ if (is_array($wp_registered_sidebars)) {
+ foreach ($wp_registered_sidebars as $sidebar_id => $sidebar) {
+ $widget_ids = isset($sidebars_widgets[$sidebar_id]) ? $sidebars_widgets[$sidebar_id] : [];
+ $out[] = [
+ 'id' => (string) $sidebar_id,
+ 'name' => (string) (isset($sidebar['name']) ? $sidebar['name'] : $sidebar_id),
+ 'description' => (string) (isset($sidebar['description']) ? $sidebar['description'] : ''),
+ 'theme_location' => (string) $sidebar_id,
+ 'widget_count' => is_array($widget_ids) ? count($widget_ids) : 0,
+ 'kind' => $this->_detect_widget_area_kind($widget_ids),
+ ];
+ }
+ }
+ return rest_ensure_response(['areas' => $out]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/widgets/{area_id}
+ */
+ public function handle_admin_widget_get(WP_REST_Request $request) {
+ $area_id = (string) $request['area_id'];
+ global $wp_registered_sidebars;
+ if (!is_array($wp_registered_sidebars) || !isset($wp_registered_sidebars[$area_id])) {
+ return new WP_Error('area_not_found', __( 'Widget area not found.', 'airano-mcp-bridge' ), ['status' => 404]);
+ }
+ $sidebars_widgets = wp_get_sidebars_widgets();
+ $widget_ids = isset($sidebars_widgets[$area_id]) ? $sidebars_widgets[$area_id] : [];
+ $kind = $this->_detect_widget_area_kind($widget_ids);
+ $widgets = [];
+ if (is_array($widget_ids)) {
+ foreach ($widget_ids as $wid) {
+ $wid_str = (string) $wid;
+ $parts = preg_split('/-(?=\d+$)/', $wid_str);
+ $base = isset($parts[0]) ? $parts[0] : $wid_str;
+ $num = isset($parts[1]) ? (int) $parts[1] : 0;
+ $opt = get_option('widget_' . $base, []);
+ $instance = is_array($opt) && isset($opt[$num]) ? $opt[$num] : [];
+ if ($base === 'block') {
+ $raw = isset($instance['content']) ? (string) $instance['content'] : '';
+ $widgets[] = [
+ 'id' => $wid_str,
+ 'type' => 'block',
+ 'blocks' => function_exists('parse_blocks') ? parse_blocks($raw) : [],
+ 'raw' => $raw,
+ ];
+ } else {
+ $widgets[] = [
+ 'id' => $wid_str,
+ 'type' => $base,
+ 'settings' => is_array($instance) ? $instance : [],
+ ];
+ }
+ }
+ }
+ return rest_ensure_response([
+ 'area_id' => $area_id,
+ 'kind' => $kind,
+ 'widgets' => $widgets,
+ ]);
+ }
+
+ /**
+ * Walk every other sidebar and collect the widget instances (keyed
+ * by integer index) currently in use. We need this to avoid orphaning
+ * shared widget instances when we rewrite a single area.
+ */
+ private function _other_sidebars_used_instances($sidebars_widgets, $skip_area_id, $base_prefix, $existing_option) {
+ $out = [];
+ $prefix_len = strlen($base_prefix);
+ foreach ($sidebars_widgets as $sid => $wids) {
+ if ($sid === $skip_area_id) continue;
+ if (!is_array($wids)) continue;
+ foreach ($wids as $wid) {
+ if (strpos((string) $wid, $base_prefix) === 0) {
+ $n = (int) substr((string) $wid, $prefix_len);
+ if (isset($existing_option[$n])) {
+ $out[$n] = $existing_option[$n];
+ }
+ }
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * PUT /airano-mcp/v1/admin/widgets/{area_id}
+ * Body: { widgets: [...] } — full replace. Block-kind areas accept
+ * any widget with ``raw`` (block HTML, sanitised via wp_kses_post per
+ * S-23 unless caller has unfiltered_html). Legacy-kind areas accept
+ * ``text`` widget settings only this round; other legacy types are
+ * read-only and return ``unsupported_legacy_widget``. Any caller-side
+ * ``kind`` field in the body is ignored — area kind is determined by
+ * the area itself, not the request.
+ */
+ public function handle_admin_widget_set(WP_REST_Request $request) {
+ $area_id = (string) $request['area_id'];
+ global $wp_registered_sidebars;
+ if (!is_array($wp_registered_sidebars) || !isset($wp_registered_sidebars[$area_id])) {
+ return new WP_Error('area_not_found', __( 'Widget area not found.', 'airano-mcp-bridge' ), ['status' => 404]);
+ }
+ $params = $request->get_json_params();
+ if (!is_array($params) || !isset($params['widgets']) || !is_array($params['widgets'])) {
+ return new WP_Error('invalid_body', __( 'widgets array is required.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ $widgets_in = $params['widgets'];
+ $sidebars_widgets = wp_get_sidebars_widgets();
+ $existing_ids = isset($sidebars_widgets[$area_id]) ? $sidebars_widgets[$area_id] : [];
+ $area_kind = $this->_detect_widget_area_kind($existing_ids);
+ $can_raw_html = current_user_can('unfiltered_html');
+ // ── Pass 1: validate every widget shape up-front.
+ foreach ($widgets_in as $idx => $w) {
+ if (!is_array($w)) {
+ return new WP_Error('invalid_widget', sprintf(__( 'widgets[%d] must be an object.', 'airano-mcp-bridge' ), $idx), ['status' => 400]);
+ }
+ $type = isset($w['type']) ? (string) $w['type'] : '';
+ if ($area_kind === 'block') {
+ if ($type !== '' && $type !== 'block') {
+ return new WP_Error('invalid_widget', sprintf(__( 'Block-kind area accepts only `block` widgets (widgets[%d] has type=%s).', 'airano-mcp-bridge' ), $idx, $type), ['status' => 400]);
+ }
+ } else {
+ if ($type !== 'text') {
+ return new WP_Error('unsupported_legacy_widget', sprintf(__( 'F.19.6.B writes legacy widgets of type `text` only (widgets[%d] has type=%s). Other legacy types are read-only.', 'airano-mcp-bridge' ), $idx, $type), ['status' => 400]);
+ }
+ }
+ }
+ // ── Pass 2: write.
+ if ($area_kind === 'block') {
+ $existing_block = get_option('widget_block', []);
+ if (!is_array($existing_block)) {
+ $existing_block = [];
+ }
+ $next_num = 2;
+ foreach (array_keys($existing_block) as $k) {
+ if (is_int($k) && $k >= $next_num) {
+ $next_num = $k + 1;
+ }
+ }
+ $new_widget_ids = [];
+ $new_block_option = [];
+ foreach ($widgets_in as $w) {
+ $raw = isset($w['raw']) ? (string) $w['raw'] : '';
+ if ($raw === '' && isset($w['blocks']) && is_array($w['blocks']) && function_exists('serialize_blocks')) {
+ $raw = serialize_blocks($w['blocks']);
+ }
+ $sanitised = $can_raw_html ? $raw : wp_kses_post($raw);
+ $new_block_option[$next_num] = ['content' => $sanitised];
+ $new_widget_ids[] = 'block-' . $next_num;
+ $next_num++;
+ }
+ $other_used = $this->_other_sidebars_used_instances($sidebars_widgets, $area_id, 'block-', $existing_block);
+ $final_block = $other_used + $new_block_option;
+ $final_block['_multiwidget'] = 1;
+ update_option('widget_block', $final_block);
+ $sidebars_widgets[$area_id] = $new_widget_ids;
+ wp_set_sidebars_widgets($sidebars_widgets);
+ return rest_ensure_response([
+ 'area_id' => $area_id,
+ 'kind' => 'block',
+ 'count' => count($new_widget_ids),
+ ]);
+ }
+ // Legacy text-only path.
+ $existing_text = get_option('widget_text', []);
+ if (!is_array($existing_text)) {
+ $existing_text = [];
+ }
+ $next_num = 2;
+ foreach (array_keys($existing_text) as $k) {
+ if (is_int($k) && $k >= $next_num) {
+ $next_num = $k + 1;
+ }
+ }
+ $new_widget_ids = [];
+ $new_text_option = [];
+ foreach ($widgets_in as $w) {
+ $settings = isset($w['settings']) && is_array($w['settings']) ? $w['settings'] : [];
+ $title = isset($settings['title']) ? sanitize_text_field((string) $settings['title']) : '';
+ $text = isset($settings['text']) ? (string) $settings['text'] : '';
+ $sanitised_text = $can_raw_html ? $text : wp_kses_post($text);
+ $filter = isset($settings['filter']) ? (bool) $settings['filter'] : false;
+ $visual = isset($settings['visual']) ? (bool) $settings['visual'] : true;
+ $new_text_option[$next_num] = [
+ 'title' => $title,
+ 'text' => $sanitised_text,
+ 'filter' => $filter,
+ 'visual' => $visual,
+ ];
+ $new_widget_ids[] = 'text-' . $next_num;
+ $next_num++;
+ }
+ $other_used = $this->_other_sidebars_used_instances($sidebars_widgets, $area_id, 'text-', $existing_text);
+ $final_text = $other_used + $new_text_option;
+ $final_text['_multiwidget'] = 1;
+ update_option('widget_text', $final_text);
+ $sidebars_widgets[$area_id] = $new_widget_ids;
+ wp_set_sidebars_widgets($sidebars_widgets);
+ return rest_ensure_response([
+ 'area_id' => $area_id,
+ 'kind' => 'legacy',
+ 'count' => count($new_widget_ids),
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/customizer/changeset
+ * Body: { action: get|apply|discard }
+ *
+ * S-24: ``apply`` requires the ``customize`` cap (not just
+ * ``manage_options`` — same bar as /wp-admin/customize.php).
+ * Empty changeset returns ``status: empty`` 200, not 404 — easier
+ * for callers that poll without first probing existence.
+ */
+ public function handle_admin_customizer_changeset(WP_REST_Request $request) {
+ $params = $request->get_json_params();
+ $action = isset($params['action']) ? (string) $params['action'] : '';
+ if (!in_array($action, ['get', 'apply', 'discard'], true)) {
+ return new WP_Error('invalid_action', __( 'action must be one of get|apply|discard.', 'airano-mcp-bridge' ), ['status' => 400]);
+ }
+ if ($action === 'apply' && !current_user_can('customize')) {
+ return new WP_Error('rest_forbidden', __( 'customize capability required to apply a customizer changeset.', 'airano-mcp-bridge' ), ['status' => 403]);
+ }
+ $posts = get_posts([
+ 'post_type' => 'customize_changeset',
+ 'post_status' => ['draft', 'auto-draft', 'pending', 'future'],
+ 'numberposts' => 1,
+ 'orderby' => 'modified',
+ 'order' => 'DESC',
+ ]);
+ if (empty($posts)) {
+ return rest_ensure_response(['status' => 'empty', 'changeset' => null]);
+ }
+ $changeset = $posts[0];
+ if ($action === 'get') {
+ $data = json_decode($changeset->post_content, true);
+ if (!is_array($data)) {
+ $data = [];
+ }
+ return rest_ensure_response([
+ 'status' => 'pending',
+ 'id' => (int) $changeset->ID,
+ 'uuid' => (string) $changeset->post_name,
+ 'modified' => (string) $changeset->post_modified_gmt,
+ 'data' => $data,
+ ]);
+ }
+ if ($action === 'discard') {
+ $deleted = wp_delete_post($changeset->ID, true);
+ if (!$deleted) {
+ return new WP_Error('discard_failed', __( 'Failed to delete changeset.', 'airano-mcp-bridge' ), ['status' => 500]);
+ }
+ return rest_ensure_response([
+ 'status' => 'discarded',
+ 'id' => (int) $changeset->ID,
+ ]);
+ }
+ // action === 'apply'
+ require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
+ $manager = new WP_Customize_Manager(['changeset_uuid' => $changeset->post_name]);
+ $result = $manager->save_changeset_post(['status' => 'publish']);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ return rest_ensure_response([
+ 'status' => 'applied',
+ 'id' => (int) $changeset->ID,
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/db/size
+ *
+ * Single ``information_schema.TABLES`` aggregation scoped to the
+ * current WP database + the WP table prefix. Returns
+ * ``{database_bytes, table_count, row_count_estimate}`` — caller
+ * never picks the SQL, so there is no injection surface (S-25).
+ * ``row_count_estimate`` mirrors MySQL's own caveat: InnoDB row
+ * counts are estimates, not exact. F.19.3.2.
+ */
+ public function handle_admin_db_size(WP_REST_Request $request) {
+ global $wpdb;
+ $like = $wpdb->esc_like($wpdb->prefix) . '%';
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT
+ COALESCE(SUM(data_length + index_length), 0) AS total_bytes,
+ COUNT(*) AS table_count,
+ COALESCE(SUM(table_rows), 0) AS row_count
+ FROM information_schema.TABLES
+ WHERE table_schema = %s
+ AND table_name LIKE %s",
+ DB_NAME,
+ $like
+ ),
+ ARRAY_A
+ );
+ if (!is_array($row)) {
+ return new WP_Error(
+ 'db_size_query_failed',
+ __( 'Failed to read information_schema.TABLES.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+ return rest_ensure_response([
+ 'database_bytes' => (int) $row['total_bytes'],
+ 'table_count' => (int) $row['table_count'],
+ 'row_count_estimate' => (int) $row['row_count'],
+ 'database_name' => DB_NAME,
+ 'table_prefix' => $wpdb->prefix,
+ ]);
+ }
+
+ /**
+ * GET /airano-mcp/v1/admin/db/tables
+ *
+ * Per-WP-table breakdown from ``information_schema.TABLES``. Same
+ * source as ``/admin/db/size`` but one row per table. Useful for
+ * "which table is the bloat?" debugging. ``rows`` is an estimate
+ * for InnoDB. F.19.3.2.
+ */
+ public function handle_admin_db_tables(WP_REST_Request $request) {
+ global $wpdb;
+ $like = $wpdb->esc_like($wpdb->prefix) . '%';
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT
+ table_name,
+ engine,
+ table_rows,
+ data_length,
+ index_length,
+ (data_length + index_length) AS total_bytes,
+ table_collation
+ FROM information_schema.TABLES
+ WHERE table_schema = %s
+ AND table_name LIKE %s
+ ORDER BY (data_length + index_length) DESC",
+ DB_NAME,
+ $like
+ ),
+ ARRAY_A
+ );
+ if (!is_array($rows)) {
+ return new WP_Error(
+ 'db_tables_query_failed',
+ __( 'Failed to read information_schema.TABLES.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+ $out = [];
+ foreach ($rows as $r) {
+ $out[] = [
+ 'name' => (string) $r['table_name'],
+ 'engine' => $r['engine'] !== null ? (string) $r['engine'] : null,
+ 'rows' => (int) $r['table_rows'],
+ 'data_bytes' => (int) $r['data_length'],
+ 'index_bytes' => (int) $r['index_length'],
+ 'total_bytes' => (int) $r['total_bytes'],
+ 'collation' => $r['table_collation'] !== null ? (string) $r['table_collation'] : null,
+ ];
+ }
+ return rest_ensure_response([
+ 'database_name' => DB_NAME,
+ 'table_prefix' => $wpdb->prefix,
+ 'tables' => $out,
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/admin/db/search
+ * Body: { query, post_type?, status?, limit? }
+ *
+ * Wraps ``WP_Query`` with ``s=$query`` — never raw SQL (S-25).
+ * ``query`` is sanitised via ``sanitize_text_field`` and capped at
+ * 200 chars; ``limit`` is capped at 100. ``WP_Query``'s own
+ * ``posts_clauses`` filter (the same one the WP search page uses)
+ * keeps non-readable posts (private/draft from other authors) out
+ * of the result set. F.19.3.3.
+ */
+ public function handle_admin_db_search(WP_REST_Request $request) {
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ $params = [];
+ }
+ $query_raw = isset($params['query']) ? (string) $params['query'] : '';
+ $query = sanitize_text_field(wp_unslash($query_raw));
+ if (strlen($query) > 200) {
+ $query = substr($query, 0, 200);
+ }
+ if ($query === '') {
+ return new WP_Error(
+ 'invalid_query',
+ __( 'query is required and must be a non-empty string after sanitisation.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ $limit = isset($params['limit']) ? (int) $params['limit'] : 20;
+ if ($limit < 1) {
+ $limit = 1;
+ }
+ if ($limit > 100) {
+ $limit = 100;
+ }
+
+ $args = [
+ 's' => $query,
+ 'posts_per_page' => $limit,
+ 'post_status' => 'any',
+ 'no_found_rows' => true,
+ ];
+ if (isset($params['post_type']) && $params['post_type'] !== '') {
+ $pt_in = $params['post_type'];
+ if (is_array($pt_in)) {
+ $pt = array_values(array_filter(array_map('sanitize_key', $pt_in)));
+ } else {
+ $pt = sanitize_key((string) $pt_in);
+ }
+ $args['post_type'] = $pt;
+ } else {
+ $args['post_type'] = 'any';
+ }
+ if (isset($params['status']) && $params['status'] !== '') {
+ $st_in = $params['status'];
+ if (is_array($st_in)) {
+ $args['post_status'] = array_values(array_filter(array_map('sanitize_key', $st_in)));
+ } else {
+ $args['post_status'] = sanitize_key((string) $st_in);
+ }
+ }
+
+ $q = new WP_Query($args);
+ $hits = [];
+ foreach ($q->posts as $p) {
+ $excerpt = has_excerpt($p) ? $p->post_excerpt : wp_trim_words(wp_strip_all_tags($p->post_content), 30, '…');
+ $hits[] = [
+ 'id' => (int) $p->ID,
+ 'post_type' => (string) $p->post_type,
+ 'status' => (string) $p->post_status,
+ 'title' => (string) $p->post_title,
+ 'snippet' => (string) $excerpt,
+ 'url' => (string) get_permalink($p),
+ 'modified' => (string) $p->post_modified_gmt,
+ ];
+ }
+ return rest_ensure_response([
+ 'query' => $query,
+ 'limit' => $limit,
+ 'count' => count($hits),
+ 'hits' => $hits,
+ ]);
+ }
}
// Initialize the plugin
diff --git a/wordpress-plugin/airano-mcp-bridge/readme.txt b/wordpress-plugin/airano-mcp-bridge/readme.txt
index a764b89..118c742 100644
--- a/wordpress-plugin/airano-mcp-bridge/readme.txt
+++ b/wordpress-plugin/airano-mcp-bridge/readme.txt
@@ -4,11 +4,11 @@ Tags: mcp, ai, rest-api, seo, media
Requires at least: 5.0
Tested up to: 6.9
Requires PHP: 7.4
-Stable tag: 2.9.0
+Stable tag: 2.18.1
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
-Companion plugin for MCP Hub. REST API routes for SEO meta and raw-binary media uploads that bypass upload_max_filesize.
+Companion plugin for MCP Hub. REST API routes for SEO meta, raw-binary media uploads that bypass upload_max_filesize, and the WordPress Specialist admin namespace.
== Description ==
@@ -51,6 +51,78 @@ Companion plugin for MCP Hub. REST API routes for SEO meta and raw-binary media
* `POST /wp-json/airano-mcp/v1/regenerate-thumbnails` — Regenerate attachment sub-sizes via `wp_generate_attachment_metadata` for a list of IDs or in paged batch mode (added in 2.8.0)
* `POST /wp-json/airano-mcp/v1/upload-and-attach` — Raw-body upload + metadata + attach-to-post + set-featured in a single REST call (added in 2.9.0). Query params: `attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`. Permission: `upload_files` + `edit_post` on the target post.
+*WordPress Specialist admin namespace (`airano-mcp/v1/admin/*`, added in 2.11.0–2.18.0)*
+
+All admin routes require `manage_options`. They power the `wordpress_specialist` MCP plugin (companion-backed advanced WordPress management — no Docker socket required).
+
+Read routes (2.11.0–2.14.0):
+
+* `GET /wp-json/airano-mcp/v1/admin/plugins` — Every plugin known to WP with active/network-active/version/author/update flags (2.11.0)
+* `GET /wp-json/airano-mcp/v1/admin/themes` — Installed themes with stylesheet/template/parent/block-theme/active flags (2.11.0)
+* `GET /wp-json/airano-mcp/v1/admin/users?role=&search=&page=&per_page=` — Paginated user list (cap 200/call) (2.11.0)
+* `GET /wp-json/airano-mcp/v1/admin/options/{name}` — Single option fetch with deny-list of credential-shaped keys (`*_secret`, `*_password`, `*_api_key`, `auth_*_key`, etc.) (2.11.0)
+* `GET /wp-json/airano-mcp/v1/admin/cron` — Full cron table with epoch + ISO 8601 next_run, schedule slug, interval, args (2.11.0)
+* `GET /wp-json/airano-mcp/v1/admin/maintenance` — `.maintenance` sentinel inspection (`enabled` / `started_at` / `stale`) (2.11.0)
+* `GET /wp-json/airano-mcp/v1/admin/system-info` — WP/PHP/MySQL/server versions + memory + paths in one envelope (2.12.0)
+* `GET /wp-json/airano-mcp/v1/admin/phpinfo` — Sorted extension list + curated ini whitelist + disabled functions + opcache state (2.12.0)
+* `GET /wp-json/airano-mcp/v1/admin/disk-usage` — Bytes for uploads/plugins/themes + filesystem `disk_total/free/used` (bounded 200k files/5s per tree) (2.12.0)
+* `GET /wp-json/airano-mcp/v1/admin/elementor/status` — Elementor presence + version + Pro flag + supported post types; `installed:false` cleanly when absent (2.13.0)
+* `GET /wp-json/airano-mcp/v1/admin/elementor/{post_id}` — Parsed `_elementor_data` (slash-stripped, JSON-decoded) (2.13.0)
+* `GET /wp-json/airano-mcp/v1/admin/elementor/templates` — Saved Elementor templates (id/title/type/modified) (2.13.0)
+* `GET /wp-json/airano-mcp/v1/admin/themes/files/{slug}?glob=&max_files=` — Theme directory walk (path/size/mime/sha256/modified per file; capped at 1000 files/call) (2.14.0)
+* `GET /wp-json/airano-mcp/v1/admin/themes/files/{slug}/{path}` — Single theme file as base64 + sha256 + mime (5 MB cap) (2.14.0)
+
+Page-editing write routes (2.13.0). All require `manage_options` AND `edit_post` on the target post (S-12); block + classic content is sanitised via `wp_kses_post` unless the caller has `unfiltered_html` (S-13); Elementor JSON node count capped at 5,000 per call (S-14).
+
+* `POST /wp-json/airano-mcp/v1/admin/blocks/replace` — Replace post block tree; companion runs `serialize_blocks()` server-side (max 200 blocks)
+* `POST /wp-json/airano-mcp/v1/admin/blocks/insert` — Insert one block at index N
+* `POST /wp-json/airano-mcp/v1/admin/blocks/remove` — Remove block at index N (returns the removed block for rollback)
+* `POST /wp-json/airano-mcp/v1/admin/elementor/{post_id}` — Replace `_elementor_data`; fires `elementor/document/after_save`
+* `POST /wp-json/airano-mcp/v1/admin/elementor/{post_id}/regen-css` — Trigger per-post Elementor CSS regeneration
+* `POST /wp-json/airano-mcp/v1/admin/elementor/templates/apply` — Copy a saved template into a target post
+* `POST /wp-json/airano-mcp/v1/admin/classic/{post_id}/replace` — Pure post_content swap for classic-editor sites
+
+Theme dev write routes (2.14.0). Per-route capability checks layer on top of `manage_options`: `install_themes` for install, `switch_themes` for activate, `delete_themes` for theme delete, `edit_themes` for file CRUD. PHP file writes additionally require `!DISALLOW_FILE_EDIT` (S-17). theme_slug must match `wp_get_themes()` (S-15). File paths canonicalise via `realpath()` and must stay under `wp-content/themes/{slug}` (S-16). Caps: 5 MB/file, 50 MB/install zip (S-18). Optimistic concurrency on `expected_sha256` returns `sha_mismatch` (409) on drift (S-19).
+
+* `POST /wp-json/airano-mcp/v1/admin/themes/install` — Install theme from `zip_url` or inline `zip_base64`; supports `activate` + `overwrite`. Companion runs WP core's `Theme_Upgrader`.
+* `POST /wp-json/airano-mcp/v1/admin/themes/{slug}/activate` — Switch the active theme.
+* `DELETE /wp-json/airano-mcp/v1/admin/themes/{slug}` — Delete an installed theme (refuses if active or active parent).
+* `PUT /wp-json/airano-mcp/v1/admin/themes/files/{slug}/{path}` — Write a theme file (base64 body, optional `expected_sha256`, optional `create_dirs`).
+* `DELETE /wp-json/airano-mcp/v1/admin/themes/files/{slug}/{path}` — Delete a theme file (refuses to delete `style.css` of the active theme).
+
+Plugin write management routes (2.15.0). Per-route capability checks layer on `manage_options`: `install_plugins` for install / update, `activate_plugins` for activate / deactivate, `delete_plugins` for delete. New security rules: S-20 refuses to deactivate / delete the Airano MCP Bridge companion itself (would brick the MCP connection); S-21 refuses to deactivate / delete plugins marked `Required: yes` in their header. Reuses S-15 (slug whitelist via `get_plugins()`) + S-18 (50 MB cap on install zip).
+
+* `POST /wp-json/airano-mcp/v1/admin/plugins/install` — Install via wp.org `slug`, OR via `zip_url`, OR via inline `zip_base64` (mutually exclusive). Supports `activate` + `overwrite`. Companion runs WP core's `Plugin_Upgrader`.
+* `POST /wp-json/airano-mcp/v1/admin/plugins/{slug}/activate` — Activate an installed plugin. Optional `network_wide` for multisite.
+* `POST /wp-json/airano-mcp/v1/admin/plugins/{slug}/deactivate` — Deactivate. Refuses companion (S-20) and `Required` plugins (S-21).
+* `POST /wp-json/airano-mcp/v1/admin/plugins/{slug}/update` — Update to latest wp.org version. Returns `up_to_date:true` cleanly when no update available.
+* `DELETE /wp-json/airano-mcp/v1/admin/plugins/{slug}` — Delete. Refuses active plugins, the companion, and `Required` plugins.
+
+Site config routes (2.16.0). All gated on `manage_options`. Writes route every option through WP's own `sanitize_option_*` hooks; permalink writes additionally call `flush_rewrite_rules()`.
+
+* `GET /wp-json/airano-mcp/v1/admin/site/identity` — title / tagline / site_icon / custom_logo / charset / WP version / admin_email / language / siteurl
+* `POST /wp-json/airano-mcp/v1/admin/site/identity` — Update title / tagline / site_icon_id / custom_logo_id (any subset). Attachment ids are validated against the media library.
+* `GET /wp-json/airano-mcp/v1/admin/site/reading` — show_on_front / page_on_front / page_for_posts / posts_per_page / posts_per_rss / blog_public
+* `POST /wp-json/airano-mcp/v1/admin/site/reading` — Update reading settings. page_on_front / page_for_posts must reference published Pages.
+* `GET /wp-json/airano-mcp/v1/admin/permalinks` — permalink_structure / category_base / tag_base
+* `POST /wp-json/airano-mcp/v1/admin/permalinks` — Update permalink structure (empty string = plain). Calls `flush_rewrite_rules()` after the write so the new structure takes effect immediately.
+
+Site layout routes (2.17.0). All gated on `manage_options`. Per-request capability checks layer on top per the new security rules: S-22 dispatches per nav-menu item type (`post_type` checks `read_post`; `taxonomy` allows public taxonomies and otherwise requires `assign_terms`; `custom` URL items skip the object check); S-23 sanitises widget HTML via `wp_kses_post` unless caller has `unfiltered_html`; S-24 customizer apply requires the `customize` cap (not just `manage_options`).
+
+* `GET /wp-json/airano-mcp/v1/admin/menus` — Every nav menu with id / name / slug / theme locations / item count.
+* `GET /wp-json/airano-mcp/v1/admin/menus/{menu_id}` — One menu's items: `{id, title, type, object, object_id, parent, order, url, target, classes, xfn}`.
+* `PUT /wp-json/airano-mcp/v1/admin/menus/{menu_id}` — Full-replace items + optional rename. Slug stays frozen so theme_location mapping survives. Two-pass: every item validated against S-22 before any mutation.
+* `GET /wp-json/airano-mcp/v1/admin/widgets/areas` — Every registered sidebar with id / name / theme_location / widget_count / kind (`block` or `legacy`).
+* `GET /wp-json/airano-mcp/v1/admin/widgets/{area_id}` — One area's widgets. Block-kind returns `{id, type:'block', blocks: [...parsed], raw: ''}`; legacy-kind returns `{id, type, settings: {...}}` keyed by the widget's option store.
+* `PUT /wp-json/airano-mcp/v1/admin/widgets/{area_id}` — Full replace. Block areas accept `{raw}` (or `{blocks}` server-serialised); legacy areas accept `text` widget settings only this round (other legacy types remain read-only). Caller-side `kind` is ignored — area kind is determined by the area itself.
+* `POST /wp-json/airano-mcp/v1/admin/customizer/changeset` — `{action: get|apply|discard}`. `get` returns the pending changeset; `apply` publishes it; `discard` trashes it. Empty changeset returns `{status: 'empty'}` 200.
+
+Database inspection routes (2.18.0). All gated on `manage_options`. New security rule S-25: db/search wraps `WP_Query` with `s=$query` (NEVER raw SQL); query is sanitised via `sanitize_text_field` and length-capped at 200 chars; `WP_Query`'s own `posts_clauses` filter (the same gate the WP search page uses) keeps non-readable posts out of the result set.
+
+* `GET /wp-json/airano-mcp/v1/admin/db/size` — Aggregate database size. Single `information_schema.TABLES` aggregation scoped to the WP table prefix; returns `{database_bytes, table_count, row_count_estimate, database_name, table_prefix}`. No SQL exposure: caller doesn't pick the query.
+* `GET /wp-json/airano-mcp/v1/admin/db/tables` — Per-table breakdown. Same source as `db/size`, one row per WP table; returns `{database_name, table_prefix, tables: [{name, engine, rows, data_bytes, index_bytes, total_bytes, collation}]}` ordered by total_bytes descending.
+* `POST /wp-json/airano-mcp/v1/admin/db/search` — Full-text search wrapper around `WP_Query`. Body `{query, post_type?, status?, limit?}` (limit default 20, max 100). Returns `{query, limit, count, hits: [{id, post_type, status, title, snippet, url, modified}]}`.
+
**Designed for [MCP Hub](https://github.com/airano-ir/mcphub)** — the AI-native management hub for WordPress and self-hosted services.
== Installation ==
@@ -92,6 +164,33 @@ Yes. All REST API endpoints work with any application that can make authenticate
== Changelog ==
+= 2.18.1 =
+* WordPress.org review fixes: drop the unused `wp-admin/includes/media.php` include from `/upload-chunk` and `/upload-and-attach` (only helpers from `file.php` and `image.php` are actually used). Split the `/upload-and-attach` permission_callback into its own method (`require_upload_and_attach_capability`) that explicitly checks `current_user_can('edit_post', $attach_to_post)` at the route gate when `attach_to_post` is supplied, and rejects `set_featured` without a target. The per-target check is no longer hidden inside the callback body.
+
+= 2.18.0 =
+* F.19.3.2-.3 — Database inspection (read) + bulk fan-out (write). Three new admin routes: `GET /admin/db/size` (single `information_schema.TABLES` aggregation: database_bytes + table_count + row_count_estimate, no SQL exposure), `GET /admin/db/tables` (per-table breakdown: name, engine, rows, data_bytes, index_bytes, total_bytes, collation), `POST /admin/db/search` body `{query, post_type?, status?, limit?}` wraps `WP_Query` with `s=$query` (NEVER raw SQL), `query` sanitised via `sanitize_text_field` and capped at 200 chars server-side, `limit` capped at 100. New security rule S-25 — db/search wraps `WP_Query`, never raw SQL; `posts_clauses` filter (same as the WP search page) keeps non-readable posts (private/draft from other authors) out of the result set. The bulk write tools (`wp_bulk_post_update`, `wp_bulk_term_update` on MCP Hub side) ride stock REST `wp/v2/posts/{id}` and `wp/v2/{taxonomy}/{id}` — no companion routes needed for those, but the cap of 50 items per call is documented under S-26 in the MCP Hub spec. All admin routes gated on `manage_options`. This release is bundled with the wordpress_advanced sunset on the MCP Hub side: the deprecated WP-CLI/Docker-socket plugin is removed in F.19.3.2-.3 (2026-05-04); `wordpress_specialist` is now the only WP-management surface.
+
+= 2.17.0 =
+* F.19.6.B — Site layout (menus + widgets + customizer). Adds seven new admin routes: `GET /admin/menus`, `GET /admin/menus/{id}`, `PUT /admin/menus/{id}` (full-replace items + optional rename, slug stays frozen), `GET /admin/widgets/areas`, `GET /admin/widgets/{area_id}`, `PUT /admin/widgets/{area_id}` (block areas accept any block raw HTML; legacy areas accept `text` widget settings only — other legacy types remain read-only this round), and `POST /admin/customizer/changeset` (single-tool wrapper around the changeset queue with `{action: get|apply|discard}`). New security rules: S-22 dispatches per nav-menu item type — `post_type` checks `current_user_can('read_post', id)`, `taxonomy` allows public taxonomies and otherwise requires the taxonomy's `assign_terms` cap (deliberately NOT `manage_categories` which is a write cap), `custom` URL items skip the object validation; refusals surface as `forbidden_object_id` 403/404. S-23 sanitises widget HTML via `wp_kses_post` unless the caller has `unfiltered_html` (mirrors S-13 from F.19.5). S-24 customizer apply requires the `customize` cap (same bar as `/wp-admin/customize.php`); `manage_options` alone is not enough. All routes gated on `manage_options`. Pre-validates every menu item before mutating so a partial S-22 failure leaves the menu untouched. Customizer apply is racy with concurrent edits via the customizer UI — intentional, mirrors WP behaviour.
+
+= 2.16.0 =
+* F.19.6.A — Site config surface. Six new admin routes covering Settings → General + Reading + Permalinks: `GET/POST /admin/site/identity` (title / tagline / site_icon / custom_logo), `GET/POST /admin/site/reading` (show_on_front / page_on_front / page_for_posts / posts_per_page / posts_per_rss / blog_public), `GET/POST /admin/permalinks` (permalink_structure / category_base / tag_base). All gated on `manage_options`. POST routes validate attachment ids against the media library, reject non-published page ids for page_on_front / page_for_posts, and route every option write through WP's own `sanitize_option_*` hooks. After a permalink_structure write `flush_rewrite_rules()` runs so the new structure takes effect immediately. First consumer of the `settings` tier introduced by F.19.2.0.
+
+= 2.15.0 =
+* F.19.2.1 — Plugin write management. Adds five new admin routes — `POST /admin/plugins/install` (accepts `{slug}` for wp.org install OR `{zip_url|zip_base64}` for arbitrary zip), `POST /admin/plugins/{slug}/activate`, `POST /admin/plugins/{slug}/deactivate`, `POST /admin/plugins/{slug}/update`, `DELETE /admin/plugins/{slug}`. Per-route capability checks: `install_plugins` (install / update), `activate_plugins` (activate / deactivate), `delete_plugins` (delete). New security rules: S-20 refuses to deactivate or delete the Airano MCP Bridge companion itself (would brick the MCP connection — operators must use WP-Admin → Plugins instead); S-21 refuses to deactivate / delete plugins marked `Required: yes` in their header (must-use plugins shipped by some managed hosts). Reuses S-15 (slug whitelist via `get_plugins()`) + S-18 (50 MB cap on install zip). Refuses to delete an active plugin (caller must deactivate first). Update route returns `up_to_date:true` cleanly when no update is available.
+
+= 2.14.0 =
+* F.19.7 — Theme dev surface (install + activate + delete + file CRUD). Adds three theme management routes — `POST /admin/themes/install` (accepts either `zip_url` or `zip_base64`; runs WP core's `Theme_Upgrader`), `POST /admin/themes/{slug}/activate`, `DELETE /admin/themes/{slug}` — and four file CRUD routes — `GET /admin/themes/files/{slug}` (glob walk capped at 1000 files), `GET /admin/themes/files/{slug}/{path}` (file read as base64), `PUT /admin/themes/files/{slug}/{path}` (file write with optional `expected_sha256` for optimistic concurrency), `DELETE /admin/themes/files/{slug}/{path}`. New security rules: `theme_slug` must match `wp_get_themes()` (S-15); paths canonicalise via `realpath()` and must stay under `wp-content/themes/{slug}`, blocking `..`, symlinks, absolute paths, null bytes (S-16); PHP file writes additionally require `!DISALLOW_FILE_EDIT` (S-17); per-call caps 5 MB/file, 1000 files/list, 50 MB/zip (S-18); optimistic concurrency via `expected_sha256` returns `sha_mismatch` (409) on drift (S-19). All routes gated on `manage_options` route-side; per-handler capability checks add `install_themes` / `switch_themes` / `delete_themes` / `edit_themes`. Refuses to delete the active theme, the active parent theme, or `style.css` of the active theme.
+
+= 2.13.0 =
+* F.19.5 — Page editing surface (Gutenberg + Elementor + Classic). Adds seven write routes — `POST /admin/blocks/{replace,insert,remove}`, `POST /admin/elementor/{post_id}` (set), `POST /admin/elementor/{post_id}/regen-css`, `POST /admin/elementor/templates/apply`, `POST /admin/classic/{post_id}/replace` — and three new read routes — `GET /admin/elementor/status`, `GET /admin/elementor/{post_id}`, `GET /admin/elementor/templates`. Writes require `manage_options` AND `edit_post` on the target post (S-12). Block + classic content is sanitised via `wp_kses_post` unless the caller has `unfiltered_html` (S-13). Elementor JSON node count capped at 5,000 per call; oversized payloads return `elementor_too_large` (S-14). All Elementor writes fire `elementor/document/after_save` so caches and CSS regenerate cleanly.
+
+= 2.12.0 =
+* F.19.3.1 — Three more read-only admin routes ported from the legacy `wordpress_advanced` WP-CLI tools so they can run via the companion instead of `docker exec`. `GET /admin/system-info` (PHP/MySQL/WordPress versions + server software + memory limits), `GET /admin/phpinfo` (extension list + curated ini snapshot — never the full `phpinfo()` HTML which would leak server internals), `GET /admin/disk-usage` (uploads/plugins/themes/total bytes + `disk_free_space`, bounded at 200k files / 5s wall clock per tree). All gated on `manage_options`.
+
+= 2.11.0 =
+* F.19.1 — New read-only admin namespace `airano-mcp/v1/admin/*` for the WordPress Specialist tool surface. Six new routes — `plugins`, `themes`, `users`, `options/{name}`, `cron`, `maintenance` — all gated on `manage_options`. `options` route uses a deny-list of credential-shaped suffixes (`*_secret`, `*_password`, `*_api_key`, `*_token`, `auth_*_key`, `auth_*_salt`) on top of the cap check. `users` route paginates up to 200/call with optional `role` + `search` filters. No state changes in this version; write operations land in F.19.2.
+
= 2.9.0 =
* Added `POST /wp-json/airano-mcp/v1/upload-and-attach` — same raw-body semantics as `/upload-chunk`, but accepts query params (`attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`) and applies them in one PHP round-trip. Saves 2-3 REST calls per upload. Per-target permission enforced via `current_user_can('edit_post', attach_to_post)`.
* `status` + `capabilities` routes now advertise `upload_and_attach: true` alongside the existing capability flags.
@@ -146,6 +245,33 @@ Yes. All REST API endpoints work with any application that can make authenticate
== Upgrade Notice ==
+= 2.18.1 =
+WordPress.org review fixes only — no behaviour changes. Drops a redundant core file include and tightens the `/upload-and-attach` permission gate so the per-target `edit_post` check runs before the callback (visible to static analysis).
+
+= 2.18.0 =
+Adds the F.19.3.2-.3 database inspection surface (3 new admin read routes — db/size / db/tables / db/search) wrapping `information_schema.TABLES` + `WP_Query`. NEVER exposes raw SQL (S-25 — query is sanitised and length-capped). All gated on `manage_options`. No breaking changes; existing routes unchanged.
+
+= 2.17.0 =
+Adds the F.19.6.B site layout surface (7 new admin routes — menus / widgets / customizer changeset). New security rules S-22 (per-item caps for nav-menu object refs), S-23 (`wp_kses_post` on widget HTML unless `unfiltered_html`), S-24 (customizer apply requires `customize` cap). No breaking changes; existing routes unchanged.
+
+= 2.16.0 =
+Adds the F.19.6.A site config surface (6 new admin routes — identity / reading / permalinks). All gated on `manage_options`. Permalink writes flush rewrite rules automatically. No breaking changes; existing routes unchanged.
+
+= 2.15.0 =
+Adds the F.19.2.1 plugin write management surface (5 new admin routes — install / activate / deactivate / update / delete). Per-route checks for `install_plugins` / `activate_plugins` / `delete_plugins`. Refuses to deactivate or delete the companion plugin itself. No breaking changes; existing routes unchanged.
+
+= 2.14.0 =
+Adds the F.19.7 theme dev surface (7 new admin routes — install / activate / delete + file CRUD). Per-route checks for `install_themes` / `switch_themes` / `delete_themes` / `edit_themes`; PHP file writes additionally require `!DISALLOW_FILE_EDIT`. No breaking changes; existing routes unchanged.
+
+= 2.13.0 =
+Adds the F.19.5 page editing surface (10 new admin routes — Gutenberg blocks, Elementor read/write/template, Classic html replace). Writes require `manage_options` AND `edit_post` on the target post; raw HTML is gated on the caller also holding `unfiltered_html`. No breaking changes; existing routes unchanged.
+
+= 2.12.0 =
+Adds three more read-only admin routes (`/admin/system-info`, `/admin/phpinfo`, `/admin/disk-usage`). They replace the legacy WP-CLI / Docker-socket flow used by the `wordpress_advanced` MCP plugin so admins on shared hosting can use those tools too. No breaking changes.
+
+= 2.11.0 =
+Adds the `airano-mcp/v1/admin/*` namespace (six read routes) for the new WordPress Specialist MCP tool surface. No breaking changes; existing routes unchanged. Activate the plugin and any admin Application Password gets the new routes.
+
= 2.9.0 =
Adds `POST /airano-mcp/v1/upload-and-attach` — upload + metadata + attach + featured in one REST round-trip. No breaking changes.
diff --git a/wordpress-plugin/airano-mcp-seo-bridge-deprecated/README.md b/wordpress-plugin/airano-mcp-seo-bridge-deprecated/README.md
new file mode 100644
index 0000000..72b1753
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-seo-bridge-deprecated/README.md
@@ -0,0 +1,63 @@
+# airano-mcp-seo-bridge — deprecated (migration release)
+
+This folder is the source of the **final** v1.3.1 release of the legacy
+slug `airano-mcp-seo-bridge`, kept here so it stays under git review.
+
+The slug itself is still alive at:
+
+
+But it has been superseded by the broader `airano-mcp-bridge` plugin
+(approved 2026-05-10, published from
+[`wordpress-plugin/airano-mcp-bridge-wporg/`](../airano-mcp-bridge-wporg/)).
+
+## What v1.3.1 changes vs v1.3.0
+
+Only one thing: a dismissible admin notice that points users to
+`airano-mcp-bridge`.
+
+- **No** REST routes changed.
+- **No** behaviour change for existing API consumers.
+- New methods: `handle_migration_notice_dismiss()` (consumes a
+ nonce-protected `?airano_seo_bridge_dismiss_migration=1` GET),
+ `render_migration_notice()` (renders the notice on every admin page
+ for users with `install_plugins`, hidden once dismissed via
+ `update_user_meta`).
+- New constant: `SEO_API_Bridge::MIGRATION_DISMISS_META`.
+
+## Files
+
+- `airano-mcp-seo-bridge.php` — main plugin file, v1.3.1
+- `readme.txt` — wp.org-format readme with the migration banner in
+ the description and the 1.3.1 changelog/upgrade-notice entries
+
+## SVN release flow (already done as r3527927/r3527928)
+
+```bash
+# Working copy (gitignored):
+svn co https://plugins.svn.wordpress.org/airano-mcp-seo-bridge \
+ wordpress-plugin/airano-mcp-seo-bridge-svn
+
+# Copy these two files into trunk:
+cp airano-mcp-seo-bridge.php readme.txt \
+ wordpress-plugin/airano-mcp-seo-bridge-svn/trunk/
+
+# Then:
+svn ci wordpress-plugin/airano-mcp-seo-bridge-svn/trunk/ \
+ -m "v1.3.1 — Add migration notice pointing to airano-mcp-bridge"
+svn cp wordpress-plugin/airano-mcp-seo-bridge-svn/trunk \
+ wordpress-plugin/airano-mcp-seo-bridge-svn/tags/1.3.1
+svn ci wordpress-plugin/airano-mcp-seo-bridge-svn/tags/1.3.1 \
+ -m "Tag 1.3.1"
+```
+
+## What happens next
+
+The migration notice will appear for every admin (with `install_plugins`)
+on every admin page until they either install the replacement plugin or
+click the **Dismiss** link in the notice.
+
+When confident that most active installs have migrated, an email to
+`plugins@wordpress.org` with subject "Author request — close
+airano-mcp-seo-bridge (superseded by airano-mcp-bridge)" closes the slug
+permanently. Don't rush this — leave it open for several months so
+late migrators still get the notice.
diff --git a/wordpress-plugin/airano-mcp-seo-bridge-deprecated/airano-mcp-seo-bridge.php b/wordpress-plugin/airano-mcp-seo-bridge-deprecated/airano-mcp-seo-bridge.php
new file mode 100644
index 0000000..f0d264d
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-seo-bridge-deprecated/airano-mcp-seo-bridge.php
@@ -0,0 +1,794 @@
+is_woocommerce_active()) {
+ $this->supported_post_types[] = 'product_variation'; // Also support variations
+ }
+ }
+
+ /**
+ * Register REST API routes
+ */
+ public function register_rest_routes() {
+ // Status endpoint
+ register_rest_route('airano-mcp-seo-bridge/v1', '/status', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_status'],
+ 'permission_callback' => function() {
+ return is_user_logged_in();
+ }
+ ]);
+
+ // Post SEO endpoints
+ register_rest_route('airano-mcp-seo-bridge/v1', '/posts/(?P\d+)/seo', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_post_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'update_post_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ]
+ ]);
+
+ // Page SEO endpoints
+ register_rest_route('airano-mcp-seo-bridge/v1', '/pages/(?P\d+)/seo', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_page_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'update_page_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_pages');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ]
+ ]);
+
+ // Product SEO endpoints (WooCommerce)
+ register_rest_route('airano-mcp-seo-bridge/v1', '/products/(?P\d+)/seo', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_product_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'update_product_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ]
+ ]);
+ }
+
+ /**
+ * Get plugin status endpoint
+ */
+ public function get_status() {
+ $rank_math_active = $this->is_rank_math_active();
+ $yoast_active = $this->is_yoast_active();
+
+ $response = [
+ 'plugin' => 'Airano MCP SEO Meta Bridge',
+ 'version' => self::VERSION,
+ 'active' => true,
+ 'seo_plugins' => [
+ 'rank_math' => [
+ 'active' => $rank_math_active,
+ 'version' => $rank_math_active && defined('RANK_MATH_VERSION') ? RANK_MATH_VERSION : null
+ ],
+ 'yoast' => [
+ 'active' => $yoast_active,
+ 'version' => $yoast_active && defined('WPSEO_VERSION') ? WPSEO_VERSION : null
+ ]
+ ],
+ 'supported_post_types' => $this->supported_post_types,
+ 'message' => $this->get_status_message($rank_math_active, $yoast_active)
+ ];
+
+ return rest_ensure_response($response);
+ }
+
+ /**
+ * Get status message based on active plugins
+ */
+ private function get_status_message($rank_math_active, $yoast_active) {
+ if (!$rank_math_active && !$yoast_active) {
+ return 'No SEO plugin detected. Please install and activate Rank Math SEO or Yoast SEO.';
+ }
+
+ $active_plugins = [];
+ if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
+ if ($yoast_active) $active_plugins[] = 'Yoast SEO';
+
+ return 'Airano MCP SEO Meta Bridge is active and working with ' . implode(' and ', $active_plugins) . '.';
+ }
+
+ /**
+ * Register all meta fields for REST API
+ */
+ public function register_meta_fields() {
+ // Register Rank Math fields if plugin is active
+ if ($this->is_rank_math_active()) {
+ $this->register_rank_math_fields();
+ }
+
+ // Register Yoast SEO fields if plugin is active
+ if ($this->is_yoast_active()) {
+ $this->register_yoast_fields();
+ }
+ }
+
+ /**
+ * Check if Rank Math is active
+ */
+ private function is_rank_math_active() {
+ return defined('RANK_MATH_VERSION') || class_exists('RankMath');
+ }
+
+ /**
+ * Check if Yoast SEO is active
+ */
+ private function is_yoast_active() {
+ return defined('WPSEO_VERSION') || class_exists('WPSEO_Options');
+ }
+
+ /**
+ * Check if WooCommerce is active
+ */
+ private function is_woocommerce_active() {
+ return class_exists('WooCommerce') || defined('WC_VERSION');
+ }
+
+ /**
+ * Register Rank Math meta fields
+ */
+ private function register_rank_math_fields() {
+ $rank_math_fields = [
+ // Core SEO Fields
+ 'rank_math_focus_keyword' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math focus keyword',
+ 'single' => true,
+ ],
+ 'rank_math_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math SEO title (meta title)',
+ 'single' => true,
+ ],
+ 'rank_math_description' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math meta description',
+ 'single' => true,
+ ],
+
+ // Additional Keywords
+ 'rank_math_additional_keywords' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math additional keywords (comma-separated)',
+ 'single' => true,
+ ],
+
+ // Advanced Fields
+ 'rank_math_canonical_url' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math canonical URL',
+ 'single' => true,
+ ],
+ 'rank_math_robots' => [
+ 'type' => 'array',
+ 'description' => 'Rank Math robots meta (noindex, nofollow, etc.)',
+ 'single' => true,
+ ],
+ 'rank_math_breadcrumb_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math breadcrumb title',
+ 'single' => true,
+ ],
+
+ // Open Graph Fields
+ 'rank_math_facebook_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Facebook Open Graph title',
+ 'single' => true,
+ ],
+ 'rank_math_facebook_description' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Facebook Open Graph description',
+ 'single' => true,
+ ],
+ 'rank_math_facebook_image' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Facebook Open Graph image URL',
+ 'single' => true,
+ ],
+ 'rank_math_facebook_image_id' => [
+ 'type' => 'integer',
+ 'description' => 'Rank Math Facebook Open Graph image ID',
+ 'single' => true,
+ ],
+
+ // Twitter Card Fields
+ 'rank_math_twitter_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card title',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_description' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card description',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_image' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card image URL',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_image_id' => [
+ 'type' => 'integer',
+ 'description' => 'Rank Math Twitter Card image ID',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_card_type' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card type (summary, summary_large_image)',
+ 'single' => true,
+ ],
+ ];
+
+ $this->register_fields($rank_math_fields);
+ }
+
+ /**
+ * Register Yoast SEO meta fields
+ */
+ private function register_yoast_fields() {
+ $yoast_fields = [
+ // Core SEO Fields
+ '_yoast_wpseo_focuskw' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO focus keyword',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_title' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO title (meta title)',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_metadesc' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO meta description',
+ 'single' => true,
+ ],
+
+ // Advanced Fields
+ '_yoast_wpseo_canonical' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO canonical URL',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_meta-robots-noindex' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO noindex setting (0 = index, 1 = noindex)',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_meta-robots-nofollow' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO nofollow setting (0 = follow, 1 = nofollow)',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_bctitle' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO breadcrumb title',
+ 'single' => true,
+ ],
+
+ // Open Graph Fields
+ '_yoast_wpseo_opengraph-title' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Open Graph title',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_opengraph-description' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Open Graph description',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_opengraph-image' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Open Graph image URL',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_opengraph-image-id' => [
+ 'type' => 'integer',
+ 'description' => 'Yoast SEO Open Graph image ID',
+ 'single' => true,
+ ],
+
+ // Twitter Card Fields
+ '_yoast_wpseo_twitter-title' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Twitter title',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_twitter-description' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Twitter description',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_twitter-image' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Twitter image URL',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_twitter-image-id' => [
+ 'type' => 'integer',
+ 'description' => 'Yoast SEO Twitter image ID',
+ 'single' => true,
+ ],
+ ];
+
+ $this->register_fields($yoast_fields);
+ }
+
+ /**
+ * Register fields for all supported post types
+ */
+ private function register_fields($fields) {
+ foreach ($this->supported_post_types as $post_type) {
+ foreach ($fields as $meta_key => $args) {
+ $show_in_rest = $args['type'] === 'array'
+ ? [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ] ]
+ : true;
+
+ register_post_meta($post_type, $meta_key, [
+ 'show_in_rest' => $show_in_rest,
+ 'single' => $args['single'],
+ 'type' => $args['type'],
+ 'description' => $args['description'],
+ 'auth_callback' => function() {
+ return current_user_can('edit_posts');
+ }
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Get SEO data for a post
+ */
+ public function get_post_seo($request) {
+ $post_id = $request['id'];
+ return $this->get_seo_data($post_id, 'post');
+ }
+
+ /**
+ * Update SEO data for a post
+ */
+ public function update_post_seo($request) {
+ $post_id = $request['id'];
+ return $this->update_seo_data($post_id, $request->get_json_params(), 'post');
+ }
+
+ /**
+ * Get SEO data for a page
+ */
+ public function get_page_seo($request) {
+ $post_id = $request['id'];
+ return $this->get_seo_data($post_id, 'page');
+ }
+
+ /**
+ * Update SEO data for a page
+ */
+ public function update_page_seo($request) {
+ $post_id = $request['id'];
+ return $this->update_seo_data($post_id, $request->get_json_params(), 'page');
+ }
+
+ /**
+ * Get SEO data for a product
+ */
+ public function get_product_seo($request) {
+ $post_id = $request['id'];
+ return $this->get_seo_data($post_id, 'product');
+ }
+
+ /**
+ * Update SEO data for a product
+ */
+ public function update_product_seo($request) {
+ $post_id = $request['id'];
+ return $this->update_seo_data($post_id, $request->get_json_params(), 'product');
+ }
+
+ /**
+ * Generic method to get SEO data
+ */
+ private function get_seo_data($post_id, $post_type) {
+ // Verify post exists and is correct type
+ $post = get_post($post_id);
+ if (!$post || $post->post_type !== $post_type) {
+ return new WP_Error(
+ 'invalid_post',
+ sprintf('%s not found', ucfirst($post_type)),
+ ['status' => 404]
+ );
+ }
+
+ $seo_data = [
+ 'post_id' => $post_id,
+ 'post_type' => $post_type,
+ 'post_title' => $post->post_title,
+ ];
+
+ // Detect which SEO plugin is active and get data
+ if ($this->is_rank_math_active()) {
+ $seo_data['plugin'] = 'rank_math';
+ $seo_data = array_merge($seo_data, $this->get_rank_math_data($post_id));
+ } elseif ($this->is_yoast_active()) {
+ $seo_data['plugin'] = 'yoast';
+ $seo_data = array_merge($seo_data, $this->get_yoast_data($post_id));
+ } else {
+ return new WP_Error(
+ 'no_seo_plugin',
+ 'No supported SEO plugin found (Rank Math or Yoast required)',
+ ['status' => 500]
+ );
+ }
+
+ return rest_ensure_response($seo_data);
+ }
+
+ /**
+ * Get Rank Math SEO data
+ */
+ private function get_rank_math_data($post_id) {
+ return [
+ 'focus_keyword' => get_post_meta($post_id, 'rank_math_focus_keyword', true),
+ 'seo_title' => get_post_meta($post_id, 'rank_math_title', true),
+ 'meta_description' => get_post_meta($post_id, 'rank_math_description', true),
+ 'canonical_url' => get_post_meta($post_id, 'rank_math_canonical_url', true),
+ 'robots' => get_post_meta($post_id, 'rank_math_robots', true),
+ 'og_title' => get_post_meta($post_id, 'rank_math_facebook_title', true),
+ 'og_description' => get_post_meta($post_id, 'rank_math_facebook_description', true),
+ 'og_image' => get_post_meta($post_id, 'rank_math_facebook_image', true),
+ 'twitter_title' => get_post_meta($post_id, 'rank_math_twitter_title', true),
+ 'twitter_description' => get_post_meta($post_id, 'rank_math_twitter_description', true),
+ 'twitter_image' => get_post_meta($post_id, 'rank_math_twitter_image', true),
+ ];
+ }
+
+ /**
+ * Get Yoast SEO data
+ */
+ private function get_yoast_data($post_id) {
+ return [
+ 'focus_keyword' => get_post_meta($post_id, '_yoast_wpseo_focuskw', true),
+ 'seo_title' => get_post_meta($post_id, '_yoast_wpseo_title', true),
+ 'meta_description' => get_post_meta($post_id, '_yoast_wpseo_metadesc', true),
+ 'canonical_url' => get_post_meta($post_id, '_yoast_wpseo_canonical', true),
+ 'noindex' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true),
+ 'nofollow' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', true),
+ 'og_title' => get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true),
+ 'og_description' => get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true),
+ 'og_image' => get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true),
+ 'twitter_title' => get_post_meta($post_id, '_yoast_wpseo_twitter-title', true),
+ 'twitter_description' => get_post_meta($post_id, '_yoast_wpseo_twitter-description', true),
+ 'twitter_image' => get_post_meta($post_id, '_yoast_wpseo_twitter-image', true),
+ ];
+ }
+
+ /**
+ * Generic method to update SEO data
+ */
+ private function update_seo_data($post_id, $params, $post_type) {
+ // Verify post exists and is correct type
+ $post = get_post($post_id);
+ if (!$post || $post->post_type !== $post_type) {
+ return new WP_Error(
+ 'invalid_post',
+ sprintf('%s not found', ucfirst($post_type)),
+ ['status' => 404]
+ );
+ }
+
+ $updated_fields = [];
+
+ // Update based on active SEO plugin
+ if ($this->is_rank_math_active()) {
+ $updated_fields = $this->update_rank_math_data($post_id, $params);
+ } elseif ($this->is_yoast_active()) {
+ $updated_fields = $this->update_yoast_data($post_id, $params);
+ } else {
+ return new WP_Error(
+ 'no_seo_plugin',
+ 'No supported SEO plugin found',
+ ['status' => 500]
+ );
+ }
+
+ return rest_ensure_response([
+ 'post_id' => $post_id,
+ 'post_type' => $post_type,
+ 'updated_fields' => $updated_fields,
+ 'message' => 'SEO metadata updated successfully'
+ ]);
+ }
+
+ /**
+ * Update Rank Math data
+ */
+ private function update_rank_math_data($post_id, $params) {
+ $updated = [];
+ $field_map = [
+ 'focus_keyword' => 'rank_math_focus_keyword',
+ 'seo_title' => 'rank_math_title',
+ 'meta_description' => 'rank_math_description',
+ 'canonical_url' => 'rank_math_canonical_url',
+ 'robots' => 'rank_math_robots',
+ 'og_title' => 'rank_math_facebook_title',
+ 'og_description' => 'rank_math_facebook_description',
+ 'og_image' => 'rank_math_facebook_image',
+ 'twitter_title' => 'rank_math_twitter_title',
+ 'twitter_description' => 'rank_math_twitter_description',
+ 'twitter_image' => 'rank_math_twitter_image',
+ ];
+
+ foreach ($field_map as $param_key => $meta_key) {
+ if (isset($params[$param_key])) {
+ update_post_meta($post_id, $meta_key, $params[$param_key]);
+ $updated[] = $meta_key;
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * Update Yoast data
+ */
+ private function update_yoast_data($post_id, $params) {
+ $updated = [];
+ $field_map = [
+ 'focus_keyword' => '_yoast_wpseo_focuskw',
+ 'seo_title' => '_yoast_wpseo_title',
+ 'meta_description' => '_yoast_wpseo_metadesc',
+ 'canonical_url' => '_yoast_wpseo_canonical',
+ 'og_title' => '_yoast_wpseo_opengraph-title',
+ 'og_description' => '_yoast_wpseo_opengraph-description',
+ 'og_image' => '_yoast_wpseo_opengraph-image',
+ 'twitter_title' => '_yoast_wpseo_twitter-title',
+ 'twitter_description' => '_yoast_wpseo_twitter-description',
+ 'twitter_image' => '_yoast_wpseo_twitter-image',
+ ];
+
+ foreach ($field_map as $param_key => $meta_key) {
+ if (isset($params[$param_key])) {
+ update_post_meta($post_id, $meta_key, $params[$param_key]);
+ $updated[] = $meta_key;
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * Display admin notices
+ */
+ public function admin_notices() {
+ // Only show notices on the Plugins page to avoid clutter on every admin page
+ $screen = get_current_screen();
+ if ( ! $screen || $screen->id !== 'plugins' ) {
+ return;
+ }
+
+ $rank_math_active = $this->is_rank_math_active();
+ $yoast_active = $this->is_yoast_active();
+ $woocommerce_active = $this->is_woocommerce_active();
+
+ if (!$rank_math_active && !$yoast_active) {
+ echo '';
+ echo ' Airano MCP SEO Meta Bridge: ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. Please install and activate one of these plugins to enable SEO meta field access via REST API.', 'airano-mcp-seo-bridge' ) . ' ';
+ echo ' ';
+ } else {
+ $active_plugins = [];
+ if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
+ if ($yoast_active) $active_plugins[] = 'Yoast SEO';
+
+ $supported_types = implode(', ', $this->supported_post_types);
+
+ echo '';
+ echo ' Airano MCP SEO Meta Bridge v' . esc_html( self::VERSION ) . ': ' . esc_html( sprintf( 'Successfully registered meta fields for %s.', implode( ' and ', $active_plugins ) ) ) . ' ';
+ echo ' ' . esc_html__( 'Supported post types:', 'airano-mcp-seo-bridge' ) . ' ' . esc_html( $supported_types ) . ' ';
+
+ if ($woocommerce_active) {
+ echo ' WooCommerce: ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'airano-mcp-seo-bridge' ) . ' ';
+ }
+ echo ' ';
+ }
+ }
+
+ /**
+ * 1.3.1 — Handle the per-user dismissal click for the migration
+ * notice. Triggered before notices render so the dismissed state
+ * is reflected on the same page load (no double-render after
+ * redirect).
+ */
+ public function handle_migration_notice_dismiss() {
+ if ( ! isset( $_GET['airano_seo_bridge_dismiss_migration'] ) ) {
+ return;
+ }
+ if ( ! is_user_logged_in() ) {
+ return;
+ }
+ $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
+ if ( ! wp_verify_nonce( $nonce, 'airano_seo_bridge_dismiss_migration' ) ) {
+ return;
+ }
+ update_user_meta( get_current_user_id(), self::MIGRATION_DISMISS_META, 1 );
+ // Redirect back to a clean URL so the query parameter doesn't
+ // linger in the address bar or history.
+ $redirect = remove_query_arg( [ 'airano_seo_bridge_dismiss_migration', '_wpnonce' ] );
+ if ( $redirect ) {
+ wp_safe_redirect( $redirect );
+ exit;
+ }
+ }
+
+ /**
+ * 1.3.1 — Persistent migration notice that points users to the
+ * replacement plugin "Airano MCP Bridge" (slug `airano-mcp-bridge`).
+ * Shown on every admin page so users don't miss it; permanently
+ * dismissible per-user via a nonce-protected query param.
+ */
+ public function render_migration_notice() {
+ if ( ! current_user_can( 'install_plugins' ) ) {
+ return;
+ }
+ if ( get_user_meta( get_current_user_id(), self::MIGRATION_DISMISS_META, true ) ) {
+ return;
+ }
+ $install_url = wp_nonce_url(
+ self_admin_url( 'plugin-install.php?s=airano-mcp-bridge&tab=search&type=term' ),
+ 'airano_seo_bridge_open_install'
+ );
+ $dismiss_url = wp_nonce_url(
+ add_query_arg( 'airano_seo_bridge_dismiss_migration', '1' ),
+ 'airano_seo_bridge_dismiss_migration'
+ );
+ $msg = sprintf(
+ /* translators: 1: replacement plugin name, 2: installed plugin name. */
+ esc_html__( '%1$s supersedes %2$s — same SEO routes, plus media uploads, bulk meta, cache purge, transient flush, site health, audit hook, and thumbnail regeneration. Existing SEO functionality keeps working until you migrate.', 'airano-mcp-seo-bridge' ),
+ 'Airano MCP Bridge',
+ 'Airano MCP SEO Meta Bridge'
+ );
+ ?>
+
+ **⚠️ This plugin has been superseded by [Airano MCP Bridge](https://wordpress.org/plugins/airano-mcp-bridge/) (slug `airano-mcp-bridge`).** The replacement carries the same SEO routes plus media uploads (bypass `upload_max_filesize`), bulk meta writes, cache purge, transient flush, site health, audit hook, and thumbnail regeneration. Existing installs of *Airano MCP SEO Meta Bridge* keep working — no breaking changes — but new installs should use *Airano MCP Bridge*. An admin notice in v1.3.1+ links to the replacement.
+
+Airano MCP SEO Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO meta fields via dedicated REST API endpoints. This enables MCP servers, AI agents, and other applications to read and write SEO metadata programmatically for posts, pages, and WooCommerce products.
+
+**Features:**
+
+* **Rank Math SEO Support** — Full access to all Rank Math meta fields
+* **Yoast SEO Support** — Full access to all Yoast SEO meta fields
+* **WooCommerce Compatible** — Works with product post types
+* **Secure** — Requires WordPress Application Password and edit_posts capability
+* **Auto-Detection** — Automatically detects which SEO plugin is active
+* **Status Endpoint** — Dedicated API endpoint for plugin and SEO detection
+* **Zero Configuration** — Works out of the box after activation
+
+**REST API Endpoints:**
+
+* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/posts/{id}/seo` — Post SEO data
+* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/pages/{id}/seo` — Page SEO data
+* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
+* `GET /wp-json/airano-mcp-seo-bridge/v1/status` — Plugin status and SEO detection
+
+**Designed for [MCP Hub](https://github.com/airano-ir/mcphub)** — the AI-native management hub for WordPress and self-hosted services.
+
+== Installation ==
+
+1. Upload the `airano-mcp-seo-bridge` folder to `/wp-content/plugins/`
+2. Activate the plugin through the 'Plugins' menu in WordPress
+3. Ensure either Rank Math SEO or Yoast SEO is active
+4. The REST API endpoints are now available
+
+== Frequently Asked Questions ==
+
+= Which SEO plugins are supported? =
+
+Rank Math SEO and Yoast SEO. The plugin auto-detects which one is active.
+
+= Does it work with WooCommerce? =
+
+Yes. Product SEO endpoints are available when WooCommerce is active.
+
+= How is authentication handled? =
+
+All endpoints require WordPress Application Password authentication and the `edit_posts` capability. POST requests require the same capability.
+
+= Can I use this without MCP Hub? =
+
+Yes. The REST API endpoints work with any application that can make HTTP requests with proper WordPress authentication.
+
+== Changelog ==
+
+= 1.3.1 =
+* Migration notice — this plugin is now superseded by [Airano MCP Bridge](https://wordpress.org/plugins/airano-mcp-bridge/). A dismissible admin notice links to the replacement; SEO routes and behaviour are otherwise unchanged so existing installs continue to work without modification. Per-user dismissal is honoured via a nonce-protected query parameter and persisted in `user_meta`.
+
+= 1.3.0 =
+* Added REST API endpoints for posts, pages, and products (GET/POST operations)
+* Added authentication requirement for all endpoints
+* Fixed rank_math_title meta key registration
+
+= 1.2.0 =
+* Enhanced WooCommerce product support
+* Improved MariaDB compatibility
+
+= 1.1.0 =
+* Added status endpoint for plugin detection
+* Improved error handling
+
+= 1.0.0 =
+* Initial release with Rank Math and Yoast SEO support
+
+== Upgrade Notice ==
+
+= 1.3.1 =
+This plugin has been superseded by Airano MCP Bridge. v1.3.1 only adds a migration notice that links to the replacement; existing SEO routes are unchanged. Recommended: install Airano MCP Bridge from the directory and deactivate this plugin once migrated.
+
+= 1.3.0 =
+GET endpoints now require authentication (edit_posts capability). Update your API clients if needed.
|