bricks-mcp-server 0.14.0 → 0.15.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,89 +1,101 @@
1
- # bricks-mcp-server
2
-
3
- Provider-agnostic **MCP server** that exposes [Bricks Builder](https://bricksbuilder.io/) data — pages, templates, global classes and theme styles — as tools for any MCP-compatible AI client (Claude Code, Codex CLI, etc.).
4
-
5
- It talks to a companion WordPress plugin (**Bricks MCP Bridge**) over the REST API, authenticated with a WordPress Application Password. No AI API key ever lives in WordPress; all model calls happen in your client.
6
-
7
- ## Quick start
8
-
9
- No clone or build needed — `npx` runs it on demand. The fastest path is the guided wizard:
10
-
11
- ```bash
12
- npx -y bricks-mcp-server setup
13
- ```
14
-
15
- It asks for your site URL, opens the Application Password authorization flow, tests the connection, and writes your client config.
16
-
17
- ### Manual registration (Claude Code)
18
-
19
- ```bash
20
- claude mcp add bricks -s user \
21
- -e WP_URL=https://yoursite.tld \
22
- -e WP_USER=your-wp-username \
23
- -e WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx" \
24
- -- npx -y bricks-mcp-server
25
- ```
26
-
27
- Or in `~/.claude.json` under `mcpServers`:
28
-
29
- ```json
30
- {
31
- "mcpServers": {
32
- "bricks": {
33
- "command": "npx",
34
- "args": ["-y", "bricks-mcp-server"],
35
- "env": {
36
- "WP_URL": "https://yoursite.tld",
37
- "WP_USER": "your-wp-username",
38
- "WP_APP_PASSWORD": "xxxx xxxx xxxx xxxx xxxx xxxx"
39
- }
40
- }
41
- }
42
- }
43
- ```
44
-
45
- ## Requirements
46
-
47
- - Node.js ≥ 18
48
- - A WordPress site running **Bricks Builder** with the **Bricks MCP Bridge** plugin installed and active.
49
- - A WordPress Application Password for a user with the `edit_pages` capability.
50
-
51
- ## Commands
52
-
53
- | Command | Purpose |
54
- |---|---|
55
- | `npx -y bricks-mcp-server` | Run the MCP stdio server (what your client invokes). |
56
- | `npx -y bricks-mcp-server setup` | Interactive setup wizard. |
57
- | `npx -y bricks-mcp-server doctor` | Diagnose connection problems with specific fixes. |
58
-
59
- ## Diagnostics
60
-
61
- If the connection fails, `doctor` pinpoints the exact cause unreachable site, stripped `Authorization` header, bad credentials, missing capability, or plugin not installed:
62
-
63
- ```bash
64
- WP_URL=https://yoursite.tld WP_USER=you WP_APP_PASSWORD="…" npx -y bricks-mcp-server doctor
65
- ```
66
-
67
- ## Tools
68
-
69
- `bricks_start_here`, `bricks_ping`, page/element/media tools, `bricks_get_design_context`, `bricks_manage_global_data`, `bricks_apply_global_class_operations`, `bricks_manage_template`, `bricks_get_preview_url`, and Query Filters reindexing.
70
-
71
- Highlights (server ≥0.5.0, plugin ≥0.7.0):
72
-
73
- - **Optimistic locking** `bricks_get_page` returns `modified`; pass it as `expected_modified` on `bricks_update_page` to get a 409 instead of overwriting edits made in the builder since you read the page.
74
- - **`bricks_create_page`** — create pages/posts/CPT entries (draft by default), optionally with Bricks content.
75
- - **`bricks_ping`** echoes the effective `WP_URL`/user and warns when the configured host doesn't match the site that answered (stale config, redirect, proxy).
76
-
77
- ## License
78
-
79
- GPL-2.0-or-later
1
+ # bricks-mcp-server
2
+
3
+ > Local beta 0.15.0-beta.2 requires Bridge 0.18.0-beta.2 (`guarded-v2`) for writes.
4
+ > New in beta.2: `bricks_update_media`, `bricks_get_page` with `fields`/`subtree`/`compact`,
5
+ > `op: duplicate`, `verbose` on `bricks_start_here`/`bricks_ping`, and `server_upgrade_required`
6
+ > when the bridge announces a newer `min_server_version`.
7
+ > This version has not been published. Run the compiled `dist/index.js` to test it.
8
+ > `BRICKS_PROFILES_FILE` supports explicit per-call `site_id` profiles with
9
+ > `{url,user,password_env}` entries. Credentials stay in environment variables.
10
+ > Use `bricks_list_sites` to discover profiles; no shared active-site state exists.
11
+ > Element property patches need `expected_exists`/`expected` and a stable
12
+ > `operation_id`. Structural edits additionally require `expected_modified` and
13
+ > `expected_hash`. Read back every change. Full replacements require explicit permission.
14
+
15
+ Provider-agnostic **MCP server** that exposes [Bricks Builder](https://bricksbuilder.io/) data — pages, templates, global classes and theme styles as tools for any MCP-compatible AI client (Claude Code, Codex CLI, etc.).
16
+
17
+ It talks to a companion WordPress plugin (**Bricks MCP Bridge**) over the REST API, authenticated with a WordPress Application Password. No AI API key ever lives in WordPress; all model calls happen in your client.
18
+
19
+ ## Quick start
20
+
21
+ No clone or build needed — `npx` runs it on demand. The fastest path is the guided wizard:
22
+
23
+ ```bash
24
+ npx -y bricks-mcp-server setup
25
+ ```
26
+
27
+ It asks for your site URL, opens the Application Password authorization flow, tests the connection, and writes your client config.
28
+
29
+ ### Manual registration (Claude Code)
30
+
31
+ ```bash
32
+ claude mcp add bricks -s user \
33
+ -e WP_URL=https://yoursite.tld \
34
+ -e WP_USER=your-wp-username \
35
+ -e WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx" \
36
+ -- npx -y bricks-mcp-server
37
+ ```
38
+
39
+ Or in `~/.claude.json` under `mcpServers`:
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "bricks": {
45
+ "command": "npx",
46
+ "args": ["-y", "bricks-mcp-server"],
47
+ "env": {
48
+ "WP_URL": "https://yoursite.tld",
49
+ "WP_USER": "your-wp-username",
50
+ "WP_APP_PASSWORD": "xxxx xxxx xxxx xxxx xxxx xxxx"
51
+ }
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ ## Requirements
58
+
59
+ - Node.js ≥ 18
60
+ - A WordPress site running **Bricks Builder** with the **Bricks MCP Bridge** plugin installed and active.
61
+ - A WordPress Application Password for a user with the `edit_pages` capability.
62
+
63
+ ## Commands
64
+
65
+ | Command | Purpose |
66
+ |---|---|
67
+ | `npx -y bricks-mcp-server` | Run the MCP stdio server (what your client invokes). |
68
+ | `npx -y bricks-mcp-server setup` | Interactive setup wizard. |
69
+ | `npx -y bricks-mcp-server doctor` | Diagnose connection problems with specific fixes. |
70
+
71
+ ## Diagnostics
72
+
73
+ If the connection fails, `doctor` pinpoints the exact cause unreachable site, stripped `Authorization` header, bad credentials, missing capability, or plugin not installed:
74
+
75
+ ```bash
76
+ WP_URL=https://yoursite.tld WP_USER=you WP_APP_PASSWORD="…" npx -y bricks-mcp-server doctor
77
+ ```
78
+
79
+ ## Tools
80
+
81
+ `bricks_start_here`, `bricks_ping`, page/element/media tools, `bricks_get_design_context`, `bricks_manage_global_data`, `bricks_apply_global_class_operations`, `bricks_manage_template`, `bricks_get_preview_url`, and Query Filters reindexing.
82
+
83
+ Highlights (server ≥0.5.0, plugin ≥0.7.0):
84
+
85
+ - **Optimistic locking** — `bricks_get_page` returns `modified`; pass it as `expected_modified` on `bricks_update_page` to get a 409 instead of overwriting edits made in the builder since you read the page.
86
+ - **`bricks_create_page`** — create pages/posts/CPT entries (draft by default), optionally with Bricks content.
87
+ - **`bricks_ping`** echoes the effective `WP_URL`/user and warns when the configured host doesn't match the site that answered (stale config, redirect, proxy).
88
+
89
+ ## License
90
+
91
+ GPL-2.0-or-later
80
92
 
81
93
  ### Media safety
82
94
 
83
- Use bricks_upload_media for URL/base64 uploads, bricks_find_media_usage before deletion, and bricks_delete_media only after references are cleared. Private URLs, SVG, unsafe MIME types, and files over 10 MB are rejected.
84
- ### Lote 6 safety
85
-
86
- - Call `bricks_manage_global_data` with `action: "get"`, then reuse its `hash` in `action: "replace"`.
87
- - Prefer `bricks_apply_global_class_operations` over full-array replacement.
88
- - `bricks_manage_template` supports get/update/clone/delete; updates require `expected_modified`.
95
+ Use bricks_upload_media for URL/base64 uploads, bricks_find_media_usage before deletion, and bricks_delete_media only after references are cleared. Private URLs, SVG, unsafe MIME types, and files over 10 MB are rejected.
96
+ ### Lote 6 safety
97
+
98
+ - Call `bricks_manage_global_data` with `action: "get"`, then reuse its `hash` in `action: "replace"`.
99
+ - Prefer `bricks_apply_global_class_operations` over full-array replacement.
100
+ - `bricks_manage_template` supports get/update/clone/delete; updates require `expected_modified`.
89
101
  - Signed preview URLs expire after 60–900 seconds and must be treated as bearer secrets.
package/dist/contracts.js CHANGED
@@ -1,8 +1,19 @@
1
1
  import { z } from "zod";
2
- export const updatePageSchema = z.object({ id: z.number().int().positive(), expected_modified: z.string().optional(), title: z.string().optional(), status: z.enum(["draft", "publish", "private", "pending"]).optional(), post_content: z.string().optional(), excerpt: z.string().optional(), date: z.string().optional(), featured_media: z.number().int().nonnegative().optional(), terms: z.record(z.any()).optional(), acf: z.record(z.any()).optional(), meta: z.record(z.any()).optional(), content: z.array(z.any()).optional(), header: z.array(z.any()).optional(), footer: z.array(z.any()).optional(), settings: z.record(z.any()).optional() });
2
+ export const SERVER_VERSION = "0.15.0-beta.2";
3
+ export const updatePageSchema = z.object({ id: z.number().int().positive(), expected_modified: z.string().min(1), expected_version: z.string().length(64), replace_document: z.boolean().optional(), title: z.string().optional(), status: z.enum(["draft", "publish", "private", "pending"]).optional(), post_content: z.string().optional(), excerpt: z.string().optional(), date: z.string().optional(), featured_media: z.number().int().nonnegative().optional(), terms: z.record(z.any()).optional(), acf: z.record(z.any()).optional(), meta: z.record(z.any()).optional(), content: z.array(z.any()).optional(), header: z.array(z.any()).optional(), footer: z.array(z.any()).optional(), settings: z.record(z.any()).optional() });
3
4
  export const uploadMediaSchema = z.object({ url: z.string().url().optional(), base64: z.string().min(1).optional(), filename: z.string().min(1).optional(), title: z.string().optional(), alt: z.string().optional() }).refine(v => Boolean(v.url) !== Boolean(v.base64), "Provide exactly one source: url or base64.");
4
- export const elementOperationSchema = z.discriminatedUnion("op", [z.object({ op: z.literal("insert"), element: z.record(z.any()), parent: z.union([z.string(), z.number()]).optional(), position: z.number().int().nonnegative().optional() }), z.object({ op: z.literal("update"), id: z.string().min(1), changes: z.record(z.any()) }), z.object({ op: z.literal("move"), id: z.string().min(1), parent: z.union([z.string(), z.number()]), position: z.number().int().nonnegative().optional() }), z.object({ op: z.literal("delete"), id: z.string().min(1) })]);
5
- export const applyElementOperationsSchema = z.object({ id: z.number().int().positive(), expected_modified: z.string().min(1), field: z.enum(["content", "header", "footer"]).optional(), operations: z.array(elementOperationSchema).min(1) });
5
+ export const getPageSchema = z.object({ id: z.number().int().positive(), fields: z.array(z.enum(["content", "header", "footer", "settings"])).min(1).optional(), subtree: z.string().min(1).optional(), compact: z.boolean().optional() });
6
+ export const updateMediaSchema = z.object({ id: z.number().int().positive(), alt: z.string().max(1000).optional(), title: z.string().max(1000).optional(), caption: z.string().max(5000).optional(), description: z.string().max(20000).optional() }).refine(v => [v.alt, v.title, v.caption, v.description].some(x => x !== undefined), "Provide at least one of: alt, title, caption, description.");
7
+ export const verboseSchema = z.object({ verbose: z.boolean().optional() });
8
+ export const elementPositionSchema = z.union([z.number().int().nonnegative(), z.enum(["first", "start", "top", "prepend", "last", "end", "bottom", "append", "before", "after"])]);
9
+ export const propertyPatchSchema = z.object({ path: z.string().regex(/^\/(settings\/[^/].*|label)$/), expected_exists: z.boolean(), expected: z.unknown().optional(), value: z.unknown().optional(), remove: z.boolean().optional() }).strict().superRefine((v, ctx) => { if (v.expected_exists && !Object.hasOwn(v, "expected"))
10
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "expected is required" }); if (!v.remove && !Object.hasOwn(v, "value"))
11
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "value or remove:true is required" }); });
12
+ export const elementOperationSchema = z.discriminatedUnion("op", [z.object({ op: z.literal("patch"), id: z.string().min(1), patches: z.array(propertyPatchSchema).min(1).max(100) }), z.object({ op: z.literal("insert"), element: z.record(z.any()), parent: z.union([z.string(), z.number()]).optional(), position: elementPositionSchema.optional(), target: z.string().min(1).optional() }), z.object({ op: z.literal("update"), id: z.string().min(1), changes: z.record(z.any()), mode: z.enum(["merge", "deep", "replace"]).optional() }), z.object({ op: z.literal("move"), id: z.string().min(1), parent: z.union([z.string(), z.number()]).optional(), position: elementPositionSchema.optional(), target: z.string().min(1).optional() }), z.object({ op: z.literal("delete"), id: z.string().min(1) }), z.object({ op: z.literal("duplicate"), id: z.string().min(1), parent: z.union([z.string(), z.number()]).optional(), position: elementPositionSchema.optional(), target: z.string().min(1).optional() })]).superRefine((v, ctx) => { if ((v.op === "insert" || v.op === "move" || v.op === "duplicate") && (v.position === "before" || v.position === "after") && !v.target)
13
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `position "${v.position}" requires a sibling id in "target"` }); });
14
+ export const applyElementOperationsSchema = z.object({ id: z.number().int().positive(), expected_modified: z.string().min(1).optional(), expected_hash: z.string().length(64).optional(), operation_id: z.string().regex(/^[a-zA-Z0-9_-]{16,80}$/).optional(), dry_run: z.boolean().optional(), field: z.enum(["content", "header", "footer"]).optional(), operations: z.array(elementOperationSchema).min(1).max(100) }).superRefine((v, ctx) => { if (!v.dry_run && !v.operation_id)
15
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "operation_id is required" }); if (v.operations.some(o => o.op !== "patch") && (!v.expected_modified || !v.expected_hash))
16
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "expected_modified and expected_hash are required for structural/merge operations" }); });
6
17
  export const designContextSchema = z.object({ include: z.array(z.enum(["classes", "variables", "palettes", "theme_styles", "breakpoints", "components", "global_settings"])).optional(), compact: z.boolean().optional() });
7
18
  export const globalScopeSchema = z.enum(["global-variables", "color-palettes", "theme-styles", "components", "breakpoints"]);
8
19
  export const globalDataUpdateSchema = z.object({ scope: globalScopeSchema, action: z.enum(["get", "replace"]), expected_hash: z.string().length(64).optional(), value: z.array(z.any()).or(z.record(z.any())).optional() }).superRefine((v, ctx) => { if (v.action === "replace" && (!v.expected_hash || v.value === undefined))
@@ -68,6 +79,20 @@ export function buildToolRequest(name, args) {
68
79
  const query = { scope, ...(post_id ? { post_id: String(post_id) } : {}) };
69
80
  return action === "get" ? { path: "/custom-css", method: "GET", query } : { path: "/custom-css", method: "PUT", query, body };
70
81
  }
82
+ if (name === "bricks_get_page") {
83
+ const parsed = getPageSchema.parse(args);
84
+ const query = { ...(parsed.fields ? { fields: parsed.fields.join(",") } : {}), ...(parsed.subtree ? { subtree: parsed.subtree } : {}), ...(parsed.compact ? { compact: "true" } : {}) };
85
+ return { path: `/pages/${parsed.id}`, method: "GET", query: Object.keys(query).length ? query : undefined };
86
+ }
87
+ if (name === "bricks_update_media") {
88
+ const parsed = updateMediaSchema.parse(args);
89
+ const { id, ...body } = parsed;
90
+ return { path: `/media/${id}`, method: "PATCH", body };
91
+ }
92
+ if (name === "bricks_start_here" || name === "bricks_ping") {
93
+ const parsed = verboseSchema.parse(args);
94
+ return { path: name === "bricks_start_here" ? "/start-here" : "/ping", method: "GET", query: parsed.verbose ? { verbose: "true" } : undefined };
95
+ }
71
96
  if (name === "bricks_upload_media")
72
97
  return { path: "/media", method: "POST", body: uploadMediaSchema.parse(args) };
73
98
  if (name === "bricks_find_media_usage")
@@ -76,4 +101,13 @@ export function buildToolRequest(name, args) {
76
101
  return { path: `/media/${z.number().int().positive().parse(args.id)}`, method: "DELETE", query: args.force ? { force: "true" } : undefined };
77
102
  throw new Error(`Unsupported tool request: ${name}`);
78
103
  }
79
- export function negotiateCapabilities(startHere) { const c = startHere.capabilities ?? {}; return { canWrite: !c.dry_run && !c.allowlist?.read_only, canUploadMedia: c.media_upload !== false, canForceDelete: c.force_delete_allowed === true, acf: c.acf_active === true, queryFilters: c.query_filters === true }; }
104
+ /** Compares dotted versions with an optional prerelease: 0.15.0-beta.2 < 0.15.0 < 0.15.1. */
105
+ export function compareVersions(a, b) { const split = (v) => { const [core, pre] = v.split("-", 2); return { core: core.split(".").map(n => parseInt(n, 10) || 0), pre }; }; const x = split(a), y = split(b); for (let i = 0; i < Math.max(x.core.length, y.core.length); i++) {
106
+ const d = (x.core[i] ?? 0) - (y.core[i] ?? 0);
107
+ if (d)
108
+ return Math.sign(d);
109
+ } if (x.pre === y.pre)
110
+ return 0; if (x.pre === undefined)
111
+ return 1; if (y.pre === undefined)
112
+ return -1; return x.pre.localeCompare(y.pre, undefined, { numeric: true }) < 0 ? -1 : x.pre.localeCompare(y.pre, undefined, { numeric: true }) > 0 ? 1 : 0; }
113
+ export function negotiateCapabilities(startHere) { const c = startHere.capabilities ?? {}; return { canWrite: !c.dry_run && !c.allowlist?.read_only, canUpdateMedia: Array.isArray(c.allowlist?.enabled) ? c.allowlist.enabled.includes("media_update") : c.allowlist?.items?.media_update?.enabled === true, canUploadMedia: c.media_upload !== false, canForceDelete: c.force_delete_allowed === true, acf: c.acf_active === true, queryFilters: c.query_filters === true }; }
package/dist/index.js CHANGED
@@ -3,7 +3,10 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { z } from "zod";
6
- import { wpRequest, loadConfig, diagnose, formatDiagnosis } from "./wp-client.js";
6
+ import { wpRequest, loadConfig, diagnose, formatDiagnosis, WPError } from "./wp-client.js";
7
+ import { SERVER_VERSION, getPageSchema, updateMediaSchema, verboseSchema, elementOperationSchema, applyElementOperationsSchema, updatePageSchema, uploadMediaSchema, manageTemplateSchema, revisionsSchema, customCssSchema, globalDataUpdateSchema, globalClassOperationsSchema, dynamicDataSchema, previewUrlSchema, designContextSchema } from "./contracts.js";
8
+ import { zodToJsonSchema } from 'zod-to-json-schema';
9
+ import { listSites, selectProfile, requestContext } from './profiles.js';
7
10
  /** Extract the hostname from a URL, or null if it can't be parsed. */
8
11
  function hostOf(url) {
9
12
  try {
@@ -33,26 +36,26 @@ if (subcommand === "doctor" || subcommand === "diagnose") {
33
36
  if (process.env.WP_URL) {
34
37
  console.error(`[bricks-mcp] target WP_URL=${process.env.WP_URL} WP_USER=${process.env.WP_USER ?? "?"}`);
35
38
  }
36
- const server = new Server({ name: "bricks-mcp-server", version: "0.14.0" }, { capabilities: { tools: {} } });
39
+ const server = new Server({ name: "bricks-mcp-server", version: SERVER_VERSION }, { capabilities: { tools: {} } });
37
40
  const tools = [
38
41
  {
39
42
  name: "bricks_start_here",
40
43
  description: "CALL THIS FIRST, once per session, before any other bricks_* tool. Returns everything needed to work on this site safely: site identity, capabilities (dry-run state, ACF, query filters), the content model (post types + taxonomies + ACF fields), a design-system summary, existing templates, and the working rules that prevent the classic Bricks failure modes (write conflicts, wrong template fields, un-indexed filters, clobbered global classes).",
41
44
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
42
- schema: z.object({}),
43
- handler: async () => wpRequest("/start-here"),
45
+ schema: verboseSchema,
46
+ handler: async (args) => wpRequest("/start-here", { query: args.verbose ? { verbose: "true" } : undefined }),
44
47
  },
45
48
  {
46
49
  name: "bricks_ping",
47
- description: "Verify the WordPress connection and report which site you're connected to (wp_url + site that answered), Bricks + WP version.",
50
+ description: "Verify the WordPress connection and report which site you're connected to (wp_url + site that answered), Bricks + WP + plugin version. verbose:true includes the full capability registry.",
48
51
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
49
- schema: z.object({}),
50
- handler: async () => {
52
+ schema: verboseSchema,
53
+ handler: async (args) => {
51
54
  // Echo the config this *process* was launched with, up front. If Claude
52
55
  // Code is still holding a stale config (old process not restarted after a
53
56
  // site switch), wp_url makes it obvious without guessing from permalinks.
54
57
  const { baseUrl, user } = loadConfig();
55
- const res = (await wpRequest("/ping"));
58
+ const res = (await wpRequest("/ping", { query: args.verbose ? { verbose: "true" } : undefined }));
56
59
  const configHost = hostOf(baseUrl);
57
60
  const siteHost = typeof res.site_url === "string" ? hostOf(res.site_url) : null;
58
61
  const mismatch = configHost !== null && siteHost !== null && configHost !== siteHost;
@@ -110,15 +113,15 @@ const tools = [
110
113
  },
111
114
  {
112
115
  name: "bricks_get_page",
113
- description: "Get a page/post/template by ID, including its Bricks content/header/footer/settings JSON. The response includes `modified` — pass it as expected_modified on bricks_update_page to avoid clobbering edits made in the builder since you read it.",
116
+ description: "Get a page/post/template by ID, including its Bricks content/header/footer/settings JSON. On large pages narrow the read: fields (e.g. [\"content\"]), subtree (an element id: only it and its descendants) and compact:true (structure without settings). Hashes always describe the full tree. The response includes `modified` — pass it as expected_modified on bricks_update_page to avoid clobbering edits made in the builder since you read it.",
114
117
  inputSchema: {
115
118
  type: "object",
116
119
  properties: { id: { type: "number" } },
117
120
  required: ["id"],
118
121
  additionalProperties: false,
119
122
  },
120
- schema: z.object({ id: z.number().int().positive() }),
121
- handler: async (args) => wpRequest(`/pages/${args.id}`),
123
+ schema: getPageSchema,
124
+ handler: async (args) => wpRequest(`/pages/${args.id}`, { query: { fields: args.fields?.join(","), subtree: args.subtree, compact: args.compact ? "true" : undefined } }),
122
125
  },
123
126
  {
124
127
  name: "bricks_update_page",
@@ -302,6 +305,13 @@ const tools = [
302
305
  schema: z.object({ url: z.string().url().optional(), base64: z.string().min(1).optional(), filename: z.string().min(1).optional(), title: z.string().optional(), alt: z.string().optional() }).refine(v => Boolean(v.url) !== Boolean(v.base64), "Provide exactly one source."),
303
306
  handler: async (args) => wpRequest("/media", { method: "POST", body: args }),
304
307
  },
308
+ {
309
+ name: "bricks_update_media",
310
+ description: "Edit alt text, title, caption or description of an existing attachment. Pass expected_version from bricks_list_media; a 409 write_conflict means list again and retry.",
311
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
312
+ schema: updateMediaSchema,
313
+ handler: async (args) => { const { id, ...body } = args; return wpRequest(`/media/${id}`, { method: "PATCH", body }); },
314
+ },
305
315
  {
306
316
  name: "bricks_find_media_usage",
307
317
  description: "Find posts and Bricks data that reference an attachment. Call before deletion.",
@@ -324,9 +334,9 @@ const tools = [
324
334
  },
325
335
  {
326
336
  name: "bricks_apply_element_operations",
327
- description: "Atomically apply insert/update/move/delete operations to one Bricks element tree. Requires expected_modified. The whole batch validates before one snapshot and one write; dry-run returns the diff without persistence.",
328
- inputSchema: { type: "object", properties: { id: { type: "number" }, expected_modified: { type: "string" }, field: { type: "string" }, operations: { type: "array", items: { type: "object" } } }, required: ["id", "expected_modified", "operations"], additionalProperties: false },
329
- schema: z.object({ id: z.number().int().positive(), expected_modified: z.string().min(1), field: z.enum(["content", "header", "footer"]).optional(), operations: z.array(z.object({ op: z.enum(["insert", "update", "move", "delete"]) }).passthrough()).min(1) }),
337
+ description: "Atomically apply insert/update/move/delete operations to one Bricks element tree. Requires expected_modified. The whole batch validates before one snapshot and one write; dry-run returns the diff without persistence.\n\nOrdering: `position` accepts an index, or first/last, or before/after combined with `target` (a sibling id). Omitting it appends last. Root sections (parent 0) are ordered too — they render in tree order, so use position/target there as well.\n\ninsert: {op, element, parent?, position?, target?} — parent defaults to element.parent (0 = root).\nupdate: {op, id, changes, mode?} — changes may carry settings/label and `children` ONLY to reorder the existing ids; changing parent/id needs move or delete. mode \"merge\" (default) replaces each top-level key inside a group, so passing settings.link swaps the whole link object and drops stale flags; \"deep\" merges recursively; \"replace\" swaps whole groups. null removes a key at any depth.\nmove: {op, id, parent?, position?, target?} — parent defaults to the element's CURRENT parent; pass 0 explicitly to promote to root.\ndelete: {op, id} — removes the element and all its descendants.",
338
+ inputSchema: { type: "object", properties: { id: { type: "number" }, expected_modified: { type: "string" }, field: { type: "string", description: "content (default), header, or footer" }, operations: { type: "array", items: { type: "object", properties: { op: { type: "string", enum: ["insert", "update", "move", "delete"] }, id: { type: "string", description: "target element id (update/move/delete)" }, element: { type: "object", description: "element object to insert" }, parent: { description: "parent id, or 0 for root" }, position: { description: "index, or first/last, or before/after (needs target)" }, target: { type: "string", description: "sibling id for before/after" }, changes: { type: "object", description: "fields to apply (update)" }, mode: { type: "string", enum: ["merge", "deep", "replace"], description: "how changes are applied; default merge" } }, required: ["op"] } } }, required: ["id", "expected_modified", "operations"], additionalProperties: false },
339
+ schema: z.object({ id: z.number().int().positive(), expected_modified: z.string().min(1), field: z.enum(["content", "header", "footer"]).optional(), operations: z.array(elementOperationSchema).min(1) }),
330
340
  handler: async (args) => { const { id, ...body } = args; return wpRequest(`/pages/${id}/element-operations`, { method: "POST", body }); },
331
341
  }, {
332
342
  name: "bricks_get_design_context",
@@ -456,6 +466,8 @@ const tools = [
456
466
  schema: z.object({
457
467
  classes: z.array(z.any()).optional(),
458
468
  categories: z.array(z.any()).optional(),
469
+ expected_hash: z.string().length(64),
470
+ expected_categories_hash: z.string().length(64).optional(),
459
471
  }),
460
472
  handler: async (args) => wpRequest("/global-classes", { method: "PUT", body: args }),
461
473
  },
@@ -494,6 +506,7 @@ const TOOL_ANNOTATIONS = {
494
506
  bricks_list_media: { readOnlyHint: true },
495
507
  bricks_find_media_usage: { readOnlyHint: true },
496
508
  bricks_upload_media: { readOnlyHint: false, destructiveHint: false },
509
+ bricks_update_media: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
497
510
  bricks_delete_media: { readOnlyHint: false, destructiveHint: true },
498
511
  bricks_create_page: { readOnlyHint: false, destructiveHint: false },
499
512
  bricks_create_template: { readOnlyHint: false, destructiveHint: false },
@@ -502,31 +515,52 @@ const TOOL_ANNOTATIONS = {
502
515
  bricks_update_global_classes: { readOnlyHint: false, destructiveHint: true },
503
516
  bricks_delete_page: { readOnlyHint: false, destructiveHint: true },
504
517
  };
518
+ const changeSchema = z.object({ id: z.number().int().positive(), action: z.enum(['list', 'get', 'propose', 'apply', 'undo']), change_id: z.number().int().positive().optional(), field: z.enum(['content', 'header', 'footer']).optional(), expected_modified: z.string().optional(), expected_hash: z.string().length(64).optional(), operations: z.array(elementOperationSchema).min(1).max(100).optional(), page: z.number().int().positive().optional() }).superRefine((v, c) => { if (v.action === 'propose' && !v.operations)
519
+ c.addIssue({ code: z.ZodIssueCode.custom, message: 'operations required' }); if (['get', 'apply', 'undo'].includes(v.action) && !v.change_id)
520
+ c.addIssue({ code: z.ZodIssueCode.custom, message: 'change_id required' }); });
521
+ const schemaOverrides = {
522
+ bricks_update_page: updatePageSchema, bricks_apply_element_operations: applyElementOperationsSchema,
523
+ bricks_upload_media: uploadMediaSchema, bricks_manage_template: manageTemplateSchema,
524
+ bricks_manage_revisions: revisionsSchema, bricks_manage_custom_css: customCssSchema,
525
+ bricks_manage_global_data: globalDataUpdateSchema, bricks_apply_global_class_operations: globalClassOperationsSchema,
526
+ bricks_resolve_dynamic_data: dynamicDataSchema, bricks_get_preview_url: previewUrlSchema, bricks_get_design_context: designContextSchema,
527
+ bricks_get_page: getPageSchema, bricks_update_media: updateMediaSchema, bricks_start_here: verboseSchema, bricks_ping: verboseSchema,
528
+ };
529
+ const catalog = tools.map(t => ({ ...t, schema: schemaOverrides[t.name] ?? t.schema }));
530
+ const extraTools = [
531
+ { name: 'bricks_list_sites', description: 'List configured site profiles without credentials. With profiles enabled, pass an explicit site_id to every other tool.', schema: z.object({}), handler: async () => ({ sites: listSites() }), annotations: { readOnlyHint: true } },
532
+ { name: 'bricks_manage_changes', description: 'List/get element history, propose changes for human review in WordPress, apply approved proposals, or undo property changes conditionally. Cannot approve proposals. Structural undo requires revision recovery. Propose does not change page content.', schema: changeSchema, handler: async (args) => { const { id, ...body } = args; return wpRequest(`/pages/${id}/changes`, args.action === 'list' ? { query: args.page ? { page: String(args.page) } : undefined } : { method: 'POST', body }); }, annotations: { readOnlyHint: false, destructiveHint: true } },
533
+ ];
534
+ function publicSchema(schema) {
535
+ const json = zodToJsonSchema(schema, { $refStrategy: 'none' });
536
+ delete json.$schema;
537
+ json.properties = { ...json.properties, site_id: { type: 'string', description: 'Required with BRICKS_PROFILES_FILE; explicit target, never a global active site.' }, expected_version: { type: 'string', description: 'Version returned by get_page, required for non-granular existing-post writes.' }, replace_document: { type: 'boolean', description: 'Explicit whole-document replacement, requires separate WordPress permission.' } };
538
+ return json;
539
+ }
505
540
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
506
- tools: tools.map(({ name, description, inputSchema }) => ({
507
- name,
508
- description,
509
- inputSchema,
510
- annotations: TOOL_ANNOTATIONS[name],
511
- })),
541
+ tools: [...catalog.map(t => ({ name: t.name, description: t.name === 'bricks_apply_element_operations' ? 'Edit by ID; prefer op:patch with JSON Pointer, expected_exists and expected old value. It preserves unrelated changes. Stable operation_id required for writes. op:duplicate {id, parent?, position?, target?} copies an element with its descendants under new ids, right after the original by default. Other operations require expected_modified plus expected_hash from the same read. Use dry_run for preview.' : t.description, inputSchema: publicSchema(t.schema), annotations: TOOL_ANNOTATIONS[t.name] })), ...extraTools.map(t => ({ name: t.name, description: t.description, inputSchema: publicSchema(t.schema), annotations: t.annotations }))],
512
542
  }));
513
543
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
514
- const tool = tools.find((t) => t.name === request.params.name);
544
+ const tool = [...catalog, ...extraTools].find((t) => t.name === request.params.name);
515
545
  if (!tool) {
516
546
  throw new Error(`Unknown tool: ${request.params.name}`);
517
547
  }
518
- const parsed = tool.schema.parse(request.params.arguments ?? {});
519
548
  try {
520
- const result = await tool.handler(parsed);
549
+ const raw = request.params.arguments ?? {};
550
+ const parsed = tool.schema.parse(raw);
551
+ const run = async () => tool.handler(parsed);
552
+ const client = server.getClientVersion();
553
+ const result = tool.name === 'bricks_list_sites' ? await run() : await requestContext.run({ ...selectProfile(typeof raw.site_id === 'string' ? raw.site_id : undefined), expectedVersion: typeof raw.expected_version === 'string' ? raw.expected_version : undefined, replaceDocument: raw.replace_document === true, client: `${client?.name ?? 'unknown'} ${client?.version ?? ''} / bricks-mcp-server` }, run);
521
554
  return {
522
555
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
556
+ structuredContent: result,
523
557
  };
524
558
  }
525
559
  catch (err) {
526
560
  const message = err instanceof Error ? err.message : String(err);
527
561
  return {
528
562
  isError: true,
529
- content: [{ type: "text", text: message }],
563
+ content: [{ type: "text", text: JSON.stringify(err instanceof WPError ? { code: err.code, message, http_status: err.httpStatus, ...err.details } : { code: err instanceof z.ZodError ? 'validation_failed' : 'client_error', message, retryable: false }) }],
530
564
  };
531
565
  }
532
566
  });
@@ -0,0 +1,40 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { readFileSync } from 'node:fs';
3
+ import { isAbsolute } from 'node:path';
4
+ export const requestContext = new AsyncLocalStorage();
5
+ function validate(profile) {
6
+ const url = new URL(profile.url);
7
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
8
+ throw new Error('Invalid site profile URL.');
9
+ if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
10
+ throw new Error('Remote sites require HTTPS.');
11
+ if (!profile.user || !profile.password)
12
+ throw new Error('Site profile credentials are missing.');
13
+ return { ...profile, url: profile.url.replace(/\/$/, '') };
14
+ }
15
+ /** Read once per call, never mutate process.env or a global active site. */
16
+ export function readProfiles() {
17
+ const file = process.env.BRICKS_PROFILES_FILE;
18
+ if (!file)
19
+ return { default: validate({ url: process.env.WP_URL ?? '', user: process.env.WP_USER ?? '', password: process.env.WP_APP_PASSWORD ?? '' }) };
20
+ if (!isAbsolute(file))
21
+ throw new Error('BRICKS_PROFILES_FILE must be an absolute path outside the repository.');
22
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
23
+ const profiles = Object.create(null);
24
+ for (const [id, profile] of Object.entries(raw)) {
25
+ if (!/^[a-zA-Z0-9_-]+$/.test(id) || !profile.password_env)
26
+ throw new Error('Invalid profile: use a stable id and password_env.');
27
+ profiles[id] = validate({ url: profile.url, user: profile.user, password: process.env[profile.password_env] ?? '' });
28
+ }
29
+ return profiles;
30
+ }
31
+ export function selectProfile(siteId) {
32
+ const profiles = readProfiles();
33
+ const id = siteId ?? (process.env.BRICKS_PROFILES_FILE ? '' : 'default');
34
+ if (!id || !Object.hasOwn(profiles, id))
35
+ throw new Error('Choose an explicit site_id from bricks_list_sites.');
36
+ return { site: profiles[id], siteId: id };
37
+ }
38
+ export function listSites() {
39
+ return Object.entries(readProfiles()).map(([site_id, p]) => ({ site_id, url: p.url, user: p.user }));
40
+ }
package/dist/wp-client.js CHANGED
@@ -1,6 +1,24 @@
1
+ import { requestContext } from './profiles.js';
2
+ import { SERVER_VERSION, compareVersions } from './contracts.js';
1
3
  const REQUIRED_ENV = ["WP_URL", "WP_USER", "WP_APP_PASSWORD"];
4
+ export class WPError extends Error {
5
+ code;
6
+ httpStatus;
7
+ details;
8
+ constructor(code, message, httpStatus, details = {}) {
9
+ super(message);
10
+ this.code = code;
11
+ this.httpStatus = httpStatus;
12
+ this.details = details;
13
+ }
14
+ }
2
15
  /** Read config from env. Throws a friendly error listing what's missing. */
3
16
  export function loadConfig() {
17
+ const selected = requestContext.getStore()?.site;
18
+ if (selected) {
19
+ const appPassword = selected.password.replace(/\s+/g, '');
20
+ return { baseUrl: selected.url, user: selected.user, appPassword, auth: Buffer.from(`${selected.user}:${appPassword}`).toString('base64') };
21
+ }
4
22
  const missing = REQUIRED_ENV.filter((k) => !process.env[k]);
5
23
  if (missing.length) {
6
24
  throw new Error(`Missing required env vars: ${missing.join(", ")}. Set them in your MCP server config.`);
@@ -11,8 +29,39 @@ export function loadConfig() {
11
29
  const auth = Buffer.from(`${user}:${appPassword}`).toString("base64");
12
30
  return { baseUrl, user, appPassword, auth };
13
31
  }
32
+ /** Bridge identity per site, reused for a minute so each write does not pay a bootstrap round trip. */
33
+ const protocolChecks = new Map();
34
+ const PROTOCOL_CHECK_TTL_MS = 60_000;
35
+ /** Forgets verified sites, e.g. after reconnecting or in tests. */
36
+ export function clearWriteProtocolCache() {
37
+ protocolChecks.clear();
38
+ }
39
+ async function checkWriteProtocol(baseUrl) {
40
+ const checkedAt = protocolChecks.get(baseUrl);
41
+ if (checkedAt && Date.now() - checkedAt < PROTOCOL_CHECK_TTL_MS)
42
+ return;
43
+ const bootstrap = await wpRequest('/start-here');
44
+ if (bootstrap.site?.write_protocol !== 'guarded-v2' || bootstrap.site.site_url?.replace(/\/$/, '') !== baseUrl) {
45
+ throw new WPError('bridge_upgrade_required', 'This server requires a guarded-v2 bridge on the selected site before sending writes.', 409, { retryable: false, next_action: 'Update the Bricks MCP Bridge plugin on the site, then run bricks_start_here.' });
46
+ }
47
+ const minimum = bootstrap.site.min_server_version;
48
+ if (minimum && compareVersions(SERVER_VERSION, minimum) < 0) {
49
+ throw new WPError('server_upgrade_required', `The bridge on this site (${bootstrap.site.plugin_version ?? 'unknown'}) requires bricks-mcp-server ${minimum} or newer; this is ${SERVER_VERSION}.`, 426, { retryable: false, next_action: `Update bricks-mcp-server to ${minimum} or newer and restart the MCP client.` });
50
+ }
51
+ protocolChecks.set(baseUrl, Date.now());
52
+ }
14
53
  export async function wpRequest(path, opts = {}) {
15
54
  const { baseUrl, auth } = loadConfig();
55
+ const context = requestContext.getStore();
56
+ // Fail closed when an older bridge cannot enforce the new write contract.
57
+ if (opts.method && opts.method !== 'GET') {
58
+ await checkWriteProtocol(baseUrl);
59
+ }
60
+ const body = opts.method && opts.method !== 'GET' ? {
61
+ ...(opts.body && typeof opts.body === 'object' ? opts.body : {}),
62
+ ...(context?.expectedVersion ? { expected_version: context.expectedVersion } : {}),
63
+ ...(context?.replaceDocument ? { replace_document: true } : {}),
64
+ } : opts.body;
16
65
  const url = new URL(`${baseUrl}/wp-json/bricks-mcp/v1${path}`);
17
66
  if (opts.query) {
18
67
  for (const [k, v] of Object.entries(opts.query)) {
@@ -24,12 +73,16 @@ export async function wpRequest(path, opts = {}) {
24
73
  try {
25
74
  res = await fetch(url, {
26
75
  method: opts.method ?? "GET",
76
+ redirect: 'error',
77
+ signal: AbortSignal.timeout(30000),
27
78
  headers: {
28
79
  Authorization: `Basic ${auth}`,
29
80
  "Content-Type": "application/json",
30
81
  Accept: "application/json",
82
+ 'X-Bricks-Site': baseUrl,
83
+ 'X-Bricks-Client': context?.client ?? 'bricks-mcp-server',
31
84
  },
32
- body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
85
+ body: body !== undefined ? JSON.stringify(body) : undefined,
33
86
  });
34
87
  }
35
88
  catch (err) {
@@ -44,7 +97,7 @@ export async function wpRequest(path, opts = {}) {
44
97
  json = text ? JSON.parse(text) : null;
45
98
  }
46
99
  catch {
47
- throw new Error(`WP returned non-JSON (status ${res.status}): ${text.slice(0, 300)}`);
100
+ throw new Error(`WP returned non-JSON (status ${res.status}). Check the site's REST endpoint and server logs.`);
48
101
  }
49
102
  if (!res.ok) {
50
103
  const code = json && typeof json === "object" && "code" in json
@@ -53,21 +106,13 @@ export async function wpRequest(path, opts = {}) {
53
106
  const message = (json && typeof json === "object" && "message" in json
54
107
  ? json.message
55
108
  : null) ?? `HTTP ${res.status}`;
56
- // On auth/route failures, run the diagnostic ladder so the user gets an
57
- // actionable cause instead of the opaque WordPress error.
58
- if (res.status === 401 || res.status === 403 || res.status === 404) {
59
- const diag = await diagnose().catch(() => null);
60
- if (diag && diag.problem) {
61
- throw new Error(formatDiagnosis(diag));
62
- }
63
- }
64
- throw new Error(`WP error: ${message}${code ? ` (${code})` : ""}`);
109
+ throw new WPError(code ?? 'http_error', message, res.status, json?.data ?? {});
65
110
  }
66
111
  return json;
67
112
  }
68
113
  async function tryFetch(url, headers = {}) {
69
114
  try {
70
- const res = await fetch(url, { headers: { Accept: "application/json", ...headers } });
115
+ const res = await fetch(url, { headers: { Accept: "application/json", ...headers }, redirect: 'error', signal: AbortSignal.timeout(15000) });
71
116
  let body;
72
117
  try {
73
118
  body = (await res.json());
@@ -87,7 +132,7 @@ async function tryFetch(url, headers = {}) {
87
132
  * credentials valid? → user has capability? → plugin installed?
88
133
  */
89
134
  export async function diagnose() {
90
- const missing = REQUIRED_ENV.filter((k) => !process.env[k]);
135
+ const missing = requestContext.getStore() ? [] : REQUIRED_ENV.filter((k) => !process.env[k]);
91
136
  if (missing.length) {
92
137
  return {
93
138
  step: "missing_env",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bricks-mcp-server",
3
- "version": "0.14.0",
3
+ "version": "0.15.0-beta.2",
4
4
  "description": "Provider-agnostic MCP server that exposes Bricks Builder pages, templates, global classes and theme styles as tools. Works with any MCP-compatible client (Claude Code, Codex CLI, etc.).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,8 +37,8 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.0.4",
40
- "bricks-ai-workspace": "file:..",
41
- "zod": "^3.23.8"
40
+ "zod": "^3.23.8",
41
+ "zod-to-json-schema": "^3.23.5"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "^20.12.0",