bricks-mcp-server 0.2.0
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 +73 -0
- package/dist/index.js +184 -0
- package/dist/setup.js +204 -0
- package/dist/wp-client.js +216 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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_ping`, `bricks_list_pages`, `bricks_get_page`, `bricks_update_page`, `bricks_list_templates`, `bricks_create_template`, `bricks_get_global_classes`, `bricks_update_global_classes`, `bricks_get_theme_styles`.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
GPL-2.0-or-later
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { wpRequest, diagnose, formatDiagnosis } from "./wp-client.js";
|
|
7
|
+
// Subcommand dispatch: `setup` runs the interactive wizard, `doctor` runs the
|
|
8
|
+
// diagnostic ladder once and exits. No subcommand → start the MCP stdio server.
|
|
9
|
+
const subcommand = process.argv[2];
|
|
10
|
+
if (subcommand === "setup") {
|
|
11
|
+
const { runSetup } = await import("./setup.js");
|
|
12
|
+
await runSetup();
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
if (subcommand === "doctor" || subcommand === "diagnose") {
|
|
16
|
+
const d = await diagnose();
|
|
17
|
+
// eslint-disable-next-line no-console
|
|
18
|
+
console.log(formatDiagnosis(d));
|
|
19
|
+
process.exit(d.problem ? 1 : 0);
|
|
20
|
+
}
|
|
21
|
+
const server = new Server({ name: "bricks-mcp-server", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
22
|
+
const tools = [
|
|
23
|
+
{
|
|
24
|
+
name: "bricks_ping",
|
|
25
|
+
description: "Verify the WordPress connection and report Bricks + WP version.",
|
|
26
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
27
|
+
schema: z.object({}),
|
|
28
|
+
handler: async () => wpRequest("/ping"),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "bricks_list_pages",
|
|
32
|
+
description: "List pages/posts/templates with Bricks content. Supports search, post_type filter, per_page.",
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
post_type: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "page | post | bricks_template | any (default any)",
|
|
39
|
+
},
|
|
40
|
+
search: { type: "string" },
|
|
41
|
+
per_page: { type: "number", description: "1-100, default 50" },
|
|
42
|
+
},
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
},
|
|
45
|
+
schema: z.object({
|
|
46
|
+
post_type: z.string().optional(),
|
|
47
|
+
search: z.string().optional(),
|
|
48
|
+
per_page: z.number().int().min(1).max(100).optional(),
|
|
49
|
+
}),
|
|
50
|
+
handler: async (args) => wpRequest("/pages", { query: args }),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "bricks_get_page",
|
|
54
|
+
description: "Get a page/post/template by ID, including its Bricks content/header/footer/settings JSON.",
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: { id: { type: "number" } },
|
|
58
|
+
required: ["id"],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
schema: z.object({ id: z.number().int().positive() }),
|
|
62
|
+
handler: async (args) => wpRequest(`/pages/${args.id}`),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "bricks_update_page",
|
|
66
|
+
description: "Update Bricks content/header/footer/settings for a page/post/template. Pass any subset of fields. The 'content' field must be an array of Bricks element objects.",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
id: { type: "number" },
|
|
71
|
+
title: { type: "string" },
|
|
72
|
+
content: { type: "array", description: "Bricks elements array" },
|
|
73
|
+
header: { type: "array" },
|
|
74
|
+
footer: { type: "array" },
|
|
75
|
+
settings: { type: "object" },
|
|
76
|
+
},
|
|
77
|
+
required: ["id"],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
schema: z.object({
|
|
81
|
+
id: z.number().int().positive(),
|
|
82
|
+
title: z.string().optional(),
|
|
83
|
+
content: z.array(z.any()).optional(),
|
|
84
|
+
header: z.array(z.any()).optional(),
|
|
85
|
+
footer: z.array(z.any()).optional(),
|
|
86
|
+
settings: z.record(z.any()).optional(),
|
|
87
|
+
}),
|
|
88
|
+
handler: async (args) => {
|
|
89
|
+
const { id, ...body } = args;
|
|
90
|
+
return wpRequest(`/pages/${id}`, { method: "PUT", body });
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "bricks_list_templates",
|
|
95
|
+
description: "List Bricks templates. Optional filter by template type (section, header, footer, popup, content, etc.).",
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: { type: { type: "string" } },
|
|
99
|
+
additionalProperties: false,
|
|
100
|
+
},
|
|
101
|
+
schema: z.object({ type: z.string().optional() }),
|
|
102
|
+
handler: async (args) => wpRequest("/templates", { query: args }),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "bricks_create_template",
|
|
106
|
+
description: "Create a Bricks template. Requires title and type; content is the Bricks elements array.",
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: {
|
|
110
|
+
title: { type: "string" },
|
|
111
|
+
type: { type: "string" },
|
|
112
|
+
content: { type: "array" },
|
|
113
|
+
},
|
|
114
|
+
required: ["title", "type"],
|
|
115
|
+
additionalProperties: false,
|
|
116
|
+
},
|
|
117
|
+
schema: z.object({
|
|
118
|
+
title: z.string(),
|
|
119
|
+
type: z.string(),
|
|
120
|
+
content: z.array(z.any()).optional(),
|
|
121
|
+
}),
|
|
122
|
+
handler: async (args) => wpRequest("/templates", { method: "POST", body: args }),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "bricks_get_global_classes",
|
|
126
|
+
description: "Read Bricks global classes and their categories — useful before editing pages so styling stays consistent.",
|
|
127
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
128
|
+
schema: z.object({}),
|
|
129
|
+
handler: async () => wpRequest("/global-classes"),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "bricks_update_global_classes",
|
|
133
|
+
description: "Replace Bricks global classes and/or categories. Pass full arrays — destructive.",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: "object",
|
|
136
|
+
properties: {
|
|
137
|
+
classes: { type: "array" },
|
|
138
|
+
categories: { type: "array" },
|
|
139
|
+
},
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
},
|
|
142
|
+
schema: z.object({
|
|
143
|
+
classes: z.array(z.any()).optional(),
|
|
144
|
+
categories: z.array(z.any()).optional(),
|
|
145
|
+
}),
|
|
146
|
+
handler: async (args) => wpRequest("/global-classes", { method: "PUT", body: args }),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "bricks_get_theme_styles",
|
|
150
|
+
description: "Read Bricks theme styles, color palette, and global settings — context for matching brand styling when generating new sections.",
|
|
151
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
152
|
+
schema: z.object({}),
|
|
153
|
+
handler: async () => wpRequest("/theme-styles"),
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
157
|
+
tools: tools.map(({ name, description, inputSchema }) => ({
|
|
158
|
+
name,
|
|
159
|
+
description,
|
|
160
|
+
inputSchema,
|
|
161
|
+
})),
|
|
162
|
+
}));
|
|
163
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
164
|
+
const tool = tools.find((t) => t.name === request.params.name);
|
|
165
|
+
if (!tool) {
|
|
166
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
167
|
+
}
|
|
168
|
+
const parsed = tool.schema.parse(request.params.arguments ?? {});
|
|
169
|
+
try {
|
|
170
|
+
const result = await tool.handler(parsed);
|
|
171
|
+
return {
|
|
172
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
177
|
+
return {
|
|
178
|
+
isError: true,
|
|
179
|
+
content: [{ type: "text", text: message }],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
const transport = new StdioServerTransport();
|
|
184
|
+
await server.connect(transport);
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { diagnose, formatDiagnosis } from "./wp-client.js";
|
|
7
|
+
/** npm package name used in generated client configs (npx -y <PKG>). */
|
|
8
|
+
const PKG = "bricks-mcp-server";
|
|
9
|
+
function log(msg = "") {
|
|
10
|
+
// eslint-disable-next-line no-console
|
|
11
|
+
console.log(msg);
|
|
12
|
+
}
|
|
13
|
+
function normalizeUrl(raw) {
|
|
14
|
+
let u = raw.trim().replace(/\/+$/, "");
|
|
15
|
+
if (!/^https?:\/\//i.test(u))
|
|
16
|
+
u = `https://${u}`;
|
|
17
|
+
return u;
|
|
18
|
+
}
|
|
19
|
+
/** Open a URL in the default browser, cross-platform. Best-effort. */
|
|
20
|
+
function openBrowser(url) {
|
|
21
|
+
const cmd = process.platform === "win32"
|
|
22
|
+
? "cmd"
|
|
23
|
+
: process.platform === "darwin"
|
|
24
|
+
? "open"
|
|
25
|
+
: "xdg-open";
|
|
26
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
27
|
+
try {
|
|
28
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* user can open manually */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Run WordPress' Application Passwords Authorization flow.
|
|
36
|
+
* Spins up a localhost callback server, opens the authorize page, and waits
|
|
37
|
+
* for WordPress to redirect back with user_login + password.
|
|
38
|
+
*/
|
|
39
|
+
async function authorizeViaBrowser(baseUrl) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const token = randomUUID();
|
|
42
|
+
let settled = false;
|
|
43
|
+
const srv = createServer((req, res) => {
|
|
44
|
+
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
|
45
|
+
if (reqUrl.searchParams.get("state") !== token) {
|
|
46
|
+
res.writeHead(400).end("Bad state");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const user = reqUrl.searchParams.get("user_login") ?? "";
|
|
50
|
+
const password = reqUrl.searchParams.get("password") ?? "";
|
|
51
|
+
res
|
|
52
|
+
.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
|
53
|
+
.end("<h2>✓ Listo</h2><p>Bricks MCP recibió la credencial. Vuelve a la terminal.</p>");
|
|
54
|
+
if (!settled) {
|
|
55
|
+
settled = true;
|
|
56
|
+
srv.close();
|
|
57
|
+
resolve(user && password ? { user, password } : null);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
61
|
+
const addr = srv.address();
|
|
62
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
63
|
+
const successUrl = `http://127.0.0.1:${port}/callback?state=${token}`;
|
|
64
|
+
const authorizeUrl = `${baseUrl}/wp-admin/authorize-application.php` +
|
|
65
|
+
`?app_name=${encodeURIComponent("Bricks MCP")}` +
|
|
66
|
+
`&success_url=${encodeURIComponent(successUrl)}`;
|
|
67
|
+
log("\nAbriendo el navegador para autorizar la aplicación…");
|
|
68
|
+
log("Si no se abre solo, visita esta URL e inicia sesión:");
|
|
69
|
+
log(` ${authorizeUrl}\n`);
|
|
70
|
+
openBrowser(authorizeUrl);
|
|
71
|
+
});
|
|
72
|
+
// Give up after 5 minutes.
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
if (!settled) {
|
|
75
|
+
settled = true;
|
|
76
|
+
srv.close();
|
|
77
|
+
resolve(null);
|
|
78
|
+
}
|
|
79
|
+
}, 5 * 60 * 1000).unref();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Is the `claude` CLI on PATH? */
|
|
83
|
+
function hasClaudeCli() {
|
|
84
|
+
const probe = spawnSync(process.platform === "win32" ? "claude.cmd" : "claude", [
|
|
85
|
+
"--version",
|
|
86
|
+
]);
|
|
87
|
+
if (!probe.error && probe.status === 0)
|
|
88
|
+
return true;
|
|
89
|
+
// On Windows the bare name sometimes resolves via PATHEXT only.
|
|
90
|
+
const probe2 = spawnSync("claude", ["--version"], { shell: true });
|
|
91
|
+
return !probe2.error && probe2.status === 0;
|
|
92
|
+
}
|
|
93
|
+
function jsonSnippet(baseUrl, user, pwd) {
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
mcpServers: {
|
|
96
|
+
bricks: {
|
|
97
|
+
command: "npx",
|
|
98
|
+
args: ["-y", PKG],
|
|
99
|
+
env: { WP_URL: baseUrl, WP_USER: user, WP_APP_PASSWORD: pwd },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}, null, 2);
|
|
103
|
+
}
|
|
104
|
+
function tomlSnippet(baseUrl, user, pwd) {
|
|
105
|
+
return (`[mcp_servers.bricks]\n` +
|
|
106
|
+
`command = "npx"\n` +
|
|
107
|
+
`args = ["-y", "${PKG}"]\n\n` +
|
|
108
|
+
`[mcp_servers.bricks.env]\n` +
|
|
109
|
+
`WP_URL = "${baseUrl}"\n` +
|
|
110
|
+
`WP_USER = "${user}"\n` +
|
|
111
|
+
`WP_APP_PASSWORD = "${pwd}"\n`);
|
|
112
|
+
}
|
|
113
|
+
export async function runSetup() {
|
|
114
|
+
const rl = createInterface({ input, output });
|
|
115
|
+
try {
|
|
116
|
+
log("=== Bricks MCP · Setup ===\n");
|
|
117
|
+
// 1 · Site URL
|
|
118
|
+
const urlAns = await rl.question("URL de tu WordPress (ej. https://misitio.com): ");
|
|
119
|
+
const baseUrl = normalizeUrl(urlAns);
|
|
120
|
+
// 2 · Credentials
|
|
121
|
+
let user = "";
|
|
122
|
+
let password = "";
|
|
123
|
+
const mode = (await rl.question("\n¿Cómo quieres autenticar?\n" +
|
|
124
|
+
" [1] Autorizar en el navegador (recomendado)\n" +
|
|
125
|
+
" [2] Pegar usuario y Application Password\n" +
|
|
126
|
+
"Elige 1 o 2: ")).trim();
|
|
127
|
+
if (mode === "2") {
|
|
128
|
+
user = (await rl.question("Usuario de WordPress: ")).trim();
|
|
129
|
+
password = (await rl.question("Application Password: ")).trim();
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const creds = await authorizeViaBrowser(baseUrl);
|
|
133
|
+
if (!creds) {
|
|
134
|
+
log("\n✗ No se recibió la autorización. Reintenta o usa la opción [2].");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
user = creds.user;
|
|
138
|
+
password = creds.password;
|
|
139
|
+
log(`\n✓ Autorizado como '${user}'.`);
|
|
140
|
+
}
|
|
141
|
+
// 3 · Validate via the diagnostic ladder.
|
|
142
|
+
process.env.WP_URL = baseUrl;
|
|
143
|
+
process.env.WP_USER = user;
|
|
144
|
+
process.env.WP_APP_PASSWORD = password;
|
|
145
|
+
log("\nProbando la conexión…");
|
|
146
|
+
const d = await diagnose();
|
|
147
|
+
log(formatDiagnosis(d));
|
|
148
|
+
if (d.problem) {
|
|
149
|
+
log("\nCorrige lo anterior y vuelve a ejecutar `npx " + PKG + " setup`.");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// 4 · Register with the client.
|
|
153
|
+
const client = (await rl.question("\n¿Qué cliente quieres configurar?\n" +
|
|
154
|
+
" [1] Claude Code\n" +
|
|
155
|
+
" [2] Codex CLI\n" +
|
|
156
|
+
" [3] Solo mostrar la config (la pego yo)\n" +
|
|
157
|
+
"Elige 1, 2 o 3: ")).trim();
|
|
158
|
+
if (client === "1") {
|
|
159
|
+
if (hasClaudeCli()) {
|
|
160
|
+
const args = [
|
|
161
|
+
"mcp",
|
|
162
|
+
"add",
|
|
163
|
+
"bricks",
|
|
164
|
+
"-s",
|
|
165
|
+
"user",
|
|
166
|
+
"-e",
|
|
167
|
+
`WP_URL=${baseUrl}`,
|
|
168
|
+
"-e",
|
|
169
|
+
`WP_USER=${user}`,
|
|
170
|
+
"-e",
|
|
171
|
+
`WP_APP_PASSWORD=${password}`,
|
|
172
|
+
"--",
|
|
173
|
+
"npx",
|
|
174
|
+
"-y",
|
|
175
|
+
PKG,
|
|
176
|
+
];
|
|
177
|
+
const r = spawnSync("claude", args, { stdio: "inherit", shell: process.platform === "win32" });
|
|
178
|
+
if (!r.error && r.status === 0) {
|
|
179
|
+
log("\n✓ Registrado en Claude Code. Reinicia la sesión y usa `bricks_ping`.");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
log("\n⚠ No se pudo ejecutar `claude mcp add`. Pega esto manualmente:");
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
log("\nNo encontré el CLI `claude`. Pega esto en ~/.claude.json (clave mcpServers):");
|
|
186
|
+
}
|
|
187
|
+
log("\n" + jsonSnippet(baseUrl, user, password) + "\n");
|
|
188
|
+
}
|
|
189
|
+
else if (client === "2") {
|
|
190
|
+
log("\nPega esto en ~/.codex/config.toml :\n");
|
|
191
|
+
log(tomlSnippet(baseUrl, user, password) + "\n");
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
log("\nClaude Code (~/.claude.json):\n");
|
|
195
|
+
log(jsonSnippet(baseUrl, user, password));
|
|
196
|
+
log("\nCodex CLI (~/.codex/config.toml):\n");
|
|
197
|
+
log(tomlSnippet(baseUrl, user, password));
|
|
198
|
+
}
|
|
199
|
+
log("Listo. ✨");
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
rl.close();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
const REQUIRED_ENV = ["WP_URL", "WP_USER", "WP_APP_PASSWORD"];
|
|
2
|
+
/** Read config from env. Throws a friendly error listing what's missing. */
|
|
3
|
+
export function loadConfig() {
|
|
4
|
+
const missing = REQUIRED_ENV.filter((k) => !process.env[k]);
|
|
5
|
+
if (missing.length) {
|
|
6
|
+
throw new Error(`Missing required env vars: ${missing.join(", ")}. Set them in your MCP server config.`);
|
|
7
|
+
}
|
|
8
|
+
const baseUrl = process.env.WP_URL.replace(/\/$/, "");
|
|
9
|
+
const user = process.env.WP_USER;
|
|
10
|
+
const appPassword = process.env.WP_APP_PASSWORD.replace(/\s+/g, "");
|
|
11
|
+
const auth = Buffer.from(`${user}:${appPassword}`).toString("base64");
|
|
12
|
+
return { baseUrl, user, appPassword, auth };
|
|
13
|
+
}
|
|
14
|
+
export async function wpRequest(path, opts = {}) {
|
|
15
|
+
const { baseUrl, auth } = loadConfig();
|
|
16
|
+
const url = new URL(`${baseUrl}/wp-json/bricks-mcp/v1${path}`);
|
|
17
|
+
if (opts.query) {
|
|
18
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
19
|
+
if (v !== undefined)
|
|
20
|
+
url.searchParams.set(k, String(v));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await fetch(url, {
|
|
26
|
+
method: opts.method ?? "GET",
|
|
27
|
+
headers: {
|
|
28
|
+
Authorization: `Basic ${auth}`,
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
Accept: "application/json",
|
|
31
|
+
},
|
|
32
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
// Network-level failure: surface the actionable diagnostic instead of a raw fetch error.
|
|
37
|
+
const diag = await diagnose().catch(() => null);
|
|
38
|
+
const hint = diag ? `\n\n${formatDiagnosis(diag)}` : "";
|
|
39
|
+
throw new Error(`No se pudo conectar a ${baseUrl} (${err instanceof Error ? err.message : String(err)}).${hint}`);
|
|
40
|
+
}
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let json;
|
|
43
|
+
try {
|
|
44
|
+
json = text ? JSON.parse(text) : null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`WP returned non-JSON (status ${res.status}): ${text.slice(0, 300)}`);
|
|
48
|
+
}
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const code = json && typeof json === "object" && "code" in json
|
|
51
|
+
? json.code
|
|
52
|
+
: undefined;
|
|
53
|
+
const message = (json && typeof json === "object" && "message" in json
|
|
54
|
+
? json.message
|
|
55
|
+
: 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})` : ""}`);
|
|
65
|
+
}
|
|
66
|
+
return json;
|
|
67
|
+
}
|
|
68
|
+
async function tryFetch(url, headers = {}) {
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(url, { headers: { Accept: "application/json", ...headers } });
|
|
71
|
+
let body;
|
|
72
|
+
try {
|
|
73
|
+
body = (await res.json());
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* non-JSON body */
|
|
77
|
+
}
|
|
78
|
+
return { ok: res.ok, status: res.status, code: body?.code, body };
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return { ok: false, status: 0, networkError: err instanceof Error ? err.message : String(err) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Walk the auth/connectivity ladder and return the first concrete problem found.
|
|
86
|
+
* Mirrors the manual debugging: site reachable? → WP? → header passes? →
|
|
87
|
+
* credentials valid? → user has capability? → plugin installed?
|
|
88
|
+
*/
|
|
89
|
+
export async function diagnose() {
|
|
90
|
+
const missing = REQUIRED_ENV.filter((k) => !process.env[k]);
|
|
91
|
+
if (missing.length) {
|
|
92
|
+
return {
|
|
93
|
+
step: "missing_env",
|
|
94
|
+
problem: `Faltan variables de entorno: ${missing.join(", ")}.`,
|
|
95
|
+
fix: "Define WP_URL, WP_USER y WP_APP_PASSWORD en la configuración del MCP.",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const { baseUrl, user, auth } = loadConfig();
|
|
99
|
+
// 1 · Is the site reachable and is it WordPress?
|
|
100
|
+
const root = await tryFetch(`${baseUrl}/wp-json/`);
|
|
101
|
+
if (root.networkError) {
|
|
102
|
+
return {
|
|
103
|
+
step: "site_unreachable",
|
|
104
|
+
problem: `No se pudo conectar a ${baseUrl}.`,
|
|
105
|
+
fix: "Revisa que WP_URL sea correcta (incluido https://) y que el sitio esté en línea.",
|
|
106
|
+
details: { networkError: root.networkError },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (root.status !== 200) {
|
|
110
|
+
return {
|
|
111
|
+
step: "not_wordpress",
|
|
112
|
+
problem: `${baseUrl}/wp-json/ respondió HTTP ${root.status}; no parece una REST API de WordPress.`,
|
|
113
|
+
fix: "Verifica WP_URL y que los permalinks/REST API estén activos.",
|
|
114
|
+
details: { status: root.status },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const HTACCESS_FIX = "Añade esta regla a tu .htaccess (encima de '# BEGIN WordPress'):\n" +
|
|
118
|
+
" <IfModule mod_rewrite.c>\n" +
|
|
119
|
+
" RewriteEngine On\n" +
|
|
120
|
+
" RewriteCond %{HTTP:Authorization} ^(.*)\n" +
|
|
121
|
+
" RewriteRule ^(.*) - [E=HTTP_AUTHORIZATION:%1]\n" +
|
|
122
|
+
" </IfModule>\n" +
|
|
123
|
+
"En Nginx: fastcgi_param HTTP_AUTHORIZATION $http_authorization;";
|
|
124
|
+
// 2 · Ask the plugin (public endpoint) whether the Authorization header
|
|
125
|
+
// actually reached PHP. This is the only reliable way to tell a stripped
|
|
126
|
+
// header apart from bad credentials — both look like rest_not_logged_in.
|
|
127
|
+
const authcheck = await tryFetch(`${baseUrl}/wp-json/bricks-mcp/v1/authcheck`, {
|
|
128
|
+
Authorization: `Basic ${auth}`,
|
|
129
|
+
});
|
|
130
|
+
const authcheckMissing = authcheck.status === 404 || authcheck.code === "rest_no_route";
|
|
131
|
+
if (authcheckMissing) {
|
|
132
|
+
// Older plugin (or none): the diagnostic endpoint isn't there, so we can't
|
|
133
|
+
// introspect the header. Fall back to the authenticated /ping and give a
|
|
134
|
+
// best-effort verdict.
|
|
135
|
+
const ping = await tryFetch(`${baseUrl}/wp-json/bricks-mcp/v1/ping`, {
|
|
136
|
+
Authorization: `Basic ${auth}`,
|
|
137
|
+
});
|
|
138
|
+
if (ping.ok)
|
|
139
|
+
return { step: "ok", problem: null };
|
|
140
|
+
if (ping.status === 404 || ping.code === "rest_no_route") {
|
|
141
|
+
return {
|
|
142
|
+
step: "plugin_not_installed",
|
|
143
|
+
problem: "El endpoint bricks-mcp/v1 no existe en este sitio.",
|
|
144
|
+
fix: "Instala y activa el plugin 'Bricks MCP Bridge' en este WordPress.",
|
|
145
|
+
details: { status: ping.status, code: ping.code },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (ping.status === 403) {
|
|
149
|
+
return {
|
|
150
|
+
step: "user_lacks_capability",
|
|
151
|
+
problem: `El usuario '${user}' no tiene la capacidad requerida (edit_pages).`,
|
|
152
|
+
fix: "Usa un usuario con rol Editor o Administrador, o ajusta la capability en class-auth.php.",
|
|
153
|
+
details: { status: ping.status, code: ping.code },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
step: "bad_credentials",
|
|
158
|
+
problem: `Autenticación rechazada (HTTP ${ping.status}). Puede ser credenciales inválidas o que tu hosting elimine la cabecera Authorization.`,
|
|
159
|
+
fix: "1) Verifica WP_USER y la Application Password.\n" +
|
|
160
|
+
"2) Si son correctas, " +
|
|
161
|
+
HTACCESS_FIX +
|
|
162
|
+
"\n(Actualiza el plugin a v0.4.2+ para un diagnóstico exacto.)",
|
|
163
|
+
details: { status: ping.status, code: ping.code },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const headerReceived = authcheck.body?.authorization_header_received === true;
|
|
167
|
+
const appPwAvailable = authcheck.body?.app_passwords_available !== false;
|
|
168
|
+
if (!headerReceived) {
|
|
169
|
+
return {
|
|
170
|
+
step: "auth_header_stripped",
|
|
171
|
+
problem: "WordPress no recibe la cabecera Authorization: el servidor la elimina antes de llegar a PHP.",
|
|
172
|
+
fix: HTACCESS_FIX,
|
|
173
|
+
details: { authcheck: authcheck.body },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (!appPwAvailable) {
|
|
177
|
+
return {
|
|
178
|
+
step: "bad_credentials",
|
|
179
|
+
problem: "Las Application Passwords están deshabilitadas en este sitio.",
|
|
180
|
+
fix: "Sirve el sitio por HTTPS (requisito de WordPress) o habilítalas con el filtro 'wp_is_application_passwords_available'.",
|
|
181
|
+
details: { authcheck: authcheck.body },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// 3 · Header passes and app passwords are on → any auth failure now is creds
|
|
185
|
+
// or capability. Hit the authenticated /ping to find out which.
|
|
186
|
+
const ping = await tryFetch(`${baseUrl}/wp-json/bricks-mcp/v1/ping`, {
|
|
187
|
+
Authorization: `Basic ${auth}`,
|
|
188
|
+
});
|
|
189
|
+
if (ping.ok) {
|
|
190
|
+
return { step: "ok", problem: null };
|
|
191
|
+
}
|
|
192
|
+
if (ping.status === 403) {
|
|
193
|
+
return {
|
|
194
|
+
step: "user_lacks_capability",
|
|
195
|
+
problem: `El usuario '${user}' no tiene la capacidad requerida (edit_pages).`,
|
|
196
|
+
fix: "Usa un usuario con rol Editor o Administrador, o ajusta la capability en class-auth.php.",
|
|
197
|
+
details: { status: ping.status, code: ping.code },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
// 401 here means the header arrived but WordPress rejected the credentials.
|
|
201
|
+
return {
|
|
202
|
+
step: "bad_credentials",
|
|
203
|
+
problem: `Credenciales rechazadas para el usuario '${user}' (la cabecera sí llega).`,
|
|
204
|
+
fix: "Revisa WP_USER y regenera la Application Password (Perfil → Application Passwords). " +
|
|
205
|
+
"Cópiala completa; los espacios se ignoran.",
|
|
206
|
+
details: { status: ping.status, code: ping.code },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export function formatDiagnosis(d) {
|
|
210
|
+
if (!d.problem)
|
|
211
|
+
return "✓ Conexión OK.";
|
|
212
|
+
let out = `✗ ${d.problem}`;
|
|
213
|
+
if (d.fix)
|
|
214
|
+
out += `\n→ ${d.fix}`;
|
|
215
|
+
return out;
|
|
216
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bricks-mcp-server",
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"bricks-mcp-server": "dist/index.js",
|
|
8
|
+
"bricks-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"dev": "tsc --watch",
|
|
18
|
+
"setup": "node dist/index.js setup",
|
|
19
|
+
"doctor": "node dist/index.js doctor",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"model-context-protocol",
|
|
25
|
+
"bricks",
|
|
26
|
+
"bricks-builder",
|
|
27
|
+
"wordpress",
|
|
28
|
+
"claude",
|
|
29
|
+
"claude-code",
|
|
30
|
+
"ai"
|
|
31
|
+
],
|
|
32
|
+
"author": "Juan Leonardo",
|
|
33
|
+
"license": "GPL-2.0-or-later",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
39
|
+
"zod": "^3.23.8"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^20.12.0",
|
|
43
|
+
"typescript": "^5.5.0"
|
|
44
|
+
}
|
|
45
|
+
}
|