wp-mcp-gateway 0.1.26 → 0.1.30
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 +181 -14
- package/dist/client/pluginApiClient.js +7 -0
- package/dist/config.js +72 -1
- package/dist/index.js +33 -15
- package/dist/services/siteManager.js +60 -0
- package/dist/setup.js +33 -17
- package/dist/tools/registerTools.js +114 -72
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,21 +1,188 @@
|
|
|
1
|
-
# MCP
|
|
1
|
+
# WP MCP Gateway
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP server for managing WordPress content from Claude Desktop — create posts, upload images, manage SEO, and publish without ever opening WordPress.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Quick Start
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
2. Install dependencies:
|
|
9
|
-
- `npm install`
|
|
10
|
-
3. Start development server:
|
|
11
|
-
- `npm run dev`
|
|
7
|
+
### Prerequisites
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
- WordPress 6.0+ with the [WP MCP Gateway plugin](https://github.com/XMPro/wordpress-mcp-server) installed and activated
|
|
10
|
+
- Node.js 18+
|
|
11
|
+
- Claude Desktop, Claude Code, or any MCP-compatible client
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
### Setup
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
```bash
|
|
16
|
+
npx wp-mcp-gateway --setup
|
|
17
|
+
```
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
This runs an interactive device authorization flow:
|
|
20
|
+
|
|
21
|
+
1. Enter your WordPress site URL
|
|
22
|
+
2. A 6-character code is displayed — enter it in your WordPress admin (Settings → MCP Gateway → Connect Claude)
|
|
23
|
+
3. Credentials are saved securely to `~/.wp-mcp-gateway/credentials.json` (mode `0600`)
|
|
24
|
+
4. Claude Desktop MCP config is written to `~/.claude/mcp.json`
|
|
25
|
+
|
|
26
|
+
That's it — Claude can now manage your WordPress site.
|
|
27
|
+
|
|
28
|
+
### Manage Connections
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx wp-mcp-gateway --list # Show connected sites
|
|
32
|
+
npx wp-mcp-gateway --remove example.com # Remove a site
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Manual Configuration (Advanced)
|
|
36
|
+
|
|
37
|
+
For CI or custom setups, set environment variables directly:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
WP_PLUGIN_BASE_URL=https://example.com/wp-json/wp-mcp-gateway/v1
|
|
41
|
+
WP_USERNAME=your-username
|
|
42
|
+
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
See `.env.example` for all available options.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Available Tools (39)
|
|
50
|
+
|
|
51
|
+
### Content Management
|
|
52
|
+
|
|
53
|
+
| Tool | Description |
|
|
54
|
+
|------|-------------|
|
|
55
|
+
| `wp_getting_started` | Initialize session with editorial workflow guide (call first) |
|
|
56
|
+
| `wp_site_info` | Get site metadata and capabilities |
|
|
57
|
+
| `wp_list_content_types` | List allowed content types |
|
|
58
|
+
| `wp_find_content` | Search content by type, status, author, taxonomy |
|
|
59
|
+
| `wp_get_content` | Fetch single item with full edit context |
|
|
60
|
+
| `wp_create_draft` | Create a new draft |
|
|
61
|
+
| `wp_update_content` | Patch existing content |
|
|
62
|
+
| `wp_publish_content` | Publish or schedule (confirmation required) |
|
|
63
|
+
| `wp_clone_content` | Clone a post/page as a new draft |
|
|
64
|
+
| `wp_get_preview_link` | Get WordPress preview + signed shareable URL |
|
|
65
|
+
|
|
66
|
+
### Trash & Revisions
|
|
67
|
+
|
|
68
|
+
| Tool | Description |
|
|
69
|
+
|------|-------------|
|
|
70
|
+
| `wp_trash_content` | Move to trash (confirmation required) |
|
|
71
|
+
| `wp_restore_content` | Restore from trash (confirmation required) |
|
|
72
|
+
| `wp_list_revisions` | List all revisions |
|
|
73
|
+
| `wp_restore_revision` | Restore a specific revision (confirmation required) |
|
|
74
|
+
|
|
75
|
+
### Authors & Taxonomies
|
|
76
|
+
|
|
77
|
+
| Tool | Description |
|
|
78
|
+
|------|-------------|
|
|
79
|
+
| `wp_list_authors` | List allowlisted authors |
|
|
80
|
+
| `wp_assign_author` | Change content author |
|
|
81
|
+
| `wp_list_terms` | Search taxonomy terms |
|
|
82
|
+
| `wp_create_term` | Create a new term |
|
|
83
|
+
| `wp_assign_terms` | Assign terms to content |
|
|
84
|
+
|
|
85
|
+
### Media
|
|
86
|
+
|
|
87
|
+
| Tool | Description |
|
|
88
|
+
|------|-------------|
|
|
89
|
+
| `wp_search_media` | Search the media library |
|
|
90
|
+
| `wp_get_media` | Get single media item with metadata |
|
|
91
|
+
| `wp_update_media` | Update alt text, caption, description, title |
|
|
92
|
+
| `wp_set_featured_image` | Set or remove featured image |
|
|
93
|
+
| `wp_upload_media_from_url` | Upload from a URL (server-side download) |
|
|
94
|
+
| `wp_find_media_file` | Search local filesystem for an image file |
|
|
95
|
+
| `wp_upload_media_from_path` | Upload from a local file path |
|
|
96
|
+
|
|
97
|
+
### Inline Images
|
|
98
|
+
|
|
99
|
+
| Tool | Description |
|
|
100
|
+
|------|-------------|
|
|
101
|
+
| `wp_insert_inline_image` | Insert image into content with block-aware placement |
|
|
102
|
+
| `wp_replace_inline_image` | Replace an inline image reference |
|
|
103
|
+
| `wp_remove_inline_image` | Remove an inline image from content |
|
|
104
|
+
|
|
105
|
+
### Yoast SEO
|
|
106
|
+
|
|
107
|
+
| Tool | Description |
|
|
108
|
+
|------|-------------|
|
|
109
|
+
| `wp_get_yoast_analysis` | Get readability and SEO scores |
|
|
110
|
+
| `wp_update_yoast_metadata` | Update focus keyphrase, meta description, SEO title |
|
|
111
|
+
| `wp_get_yoast_head_preview` | Preview the rendered head/meta tags |
|
|
112
|
+
|
|
113
|
+
### Image Generation & Stock Photos
|
|
114
|
+
|
|
115
|
+
| Tool | Description |
|
|
116
|
+
|------|-------------|
|
|
117
|
+
| `wp_generate_image` | Generate an image with AI (Google Imagen or OpenAI) |
|
|
118
|
+
| `wp_search_stock_photos` | Search Unsplash or Pexels |
|
|
119
|
+
| `wp_import_stock_photo` | Import a stock photo with automatic attribution |
|
|
120
|
+
| `wp_list_image_providers` | Show which providers are configured |
|
|
121
|
+
| `wp_confirm_image` | Confirm a generated image to prevent auto-cleanup |
|
|
122
|
+
|
|
123
|
+
### Upload Portal
|
|
124
|
+
|
|
125
|
+
| Tool | Description |
|
|
126
|
+
|------|-------------|
|
|
127
|
+
| `wp_create_upload_session` | Create a temporary drag-and-drop upload link (15 min) |
|
|
128
|
+
| `wp_get_upload_session` | Retrieve uploaded file details after user finishes |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
| Variable | Default | Description |
|
|
135
|
+
|----------|---------|-------------|
|
|
136
|
+
| `WP_MCP_SITE` | — | Hostname to load from token store (auto-set by `--setup`) |
|
|
137
|
+
| `WP_PLUGIN_BASE_URL` | — | Full REST base URL (legacy mode) |
|
|
138
|
+
| `WP_USERNAME` | — | WordPress username (legacy mode) |
|
|
139
|
+
| `WP_APP_PASSWORD` | — | Application password (legacy mode) |
|
|
140
|
+
| `ALLOWED_CONTENT_TYPES` | `post,page,featured_item` | Comma-separated content types |
|
|
141
|
+
| `ALLOWED_TAXONOMIES` | `category,post_tag,...` | Comma-separated taxonomies |
|
|
142
|
+
| `ALLOWED_AUTHOR_IDS` | (all) | Comma-separated author IDs |
|
|
143
|
+
| `MEDIA_MAX_SIZE_MB` | `10` | Max upload size in MB |
|
|
144
|
+
| `MEDIA_REQUIRE_ALT_TEXT` | `true` | Require alt text on uploads |
|
|
145
|
+
| `RATE_LIMIT_MAX_BURST` | `30` | Token bucket burst capacity |
|
|
146
|
+
| `RATE_LIMIT_REFILL_PER_SECOND` | `1` | Token bucket refill rate |
|
|
147
|
+
| `REQUEST_TIMEOUT_MS` | `30000` | HTTP request timeout |
|
|
148
|
+
| `CONFIRMATION_TTL_SECONDS` | `300` | Confirmation token expiry |
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Authentication
|
|
153
|
+
|
|
154
|
+
The plugin supports four authentication methods:
|
|
155
|
+
|
|
156
|
+
1. **Application Passwords** (HTTP Basic Auth) — default for local MCP clients
|
|
157
|
+
2. **Device Authorization** (RFC 8628) — used by `--setup` CLI flow
|
|
158
|
+
3. **OAuth 2.1** (PKCE S256) — for web-based clients (ChatGPT, Claude Web)
|
|
159
|
+
4. **Bearer Token** — for remote MCP-over-HTTP transport
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npm run dev # Run with tsx (hot reload)
|
|
167
|
+
npm run build # Compile TypeScript
|
|
168
|
+
npm start # Run compiled output
|
|
169
|
+
npm test # Run tests (Vitest)
|
|
170
|
+
npm run test:watch # Watch mode
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Security
|
|
176
|
+
|
|
177
|
+
- **Confirmation tokens** — destructive actions (publish, trash, restore) require a single-use token
|
|
178
|
+
- **Rate limiting** — token bucket rate limiter on all tool calls
|
|
179
|
+
- **Policy enforcement** — dual-layer (TypeScript client + PHP server) allowlist validation
|
|
180
|
+
- **Credential isolation** — secrets stored in `~/.wp-mcp-gateway/credentials.json` (mode `0600`), never in MCP config
|
|
181
|
+
- **SVG sanitization** — upload portal strips scripts, event handlers, and dangerous attributes
|
|
182
|
+
- **CSP headers** — upload portal pages use Content-Security-Policy with nonces
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
|
@@ -133,6 +133,13 @@ export class PluginApiClient {
|
|
|
133
133
|
async confirmImage(mediaId) {
|
|
134
134
|
return this.request("POST", `/image/${mediaId}/confirm`, { body: {} });
|
|
135
135
|
}
|
|
136
|
+
// ── Upload Portal ─────────────────────────────────────────────────────
|
|
137
|
+
async createUploadSession(body = {}) {
|
|
138
|
+
return this.request("POST", "/upload/session", { body });
|
|
139
|
+
}
|
|
140
|
+
async getUploadSession(sessionId) {
|
|
141
|
+
return this.request("GET", `/upload/session/${sessionId}`);
|
|
142
|
+
}
|
|
136
143
|
async request(method, path, options = {}) {
|
|
137
144
|
const url = new URL(`${this.config.wpPluginBaseUrl}${path}`);
|
|
138
145
|
for (const [key, value] of Object.entries(options.query ?? {})) {
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadCredentials } from './tokenStore.js';
|
|
1
|
+
import { loadCredentials, listSites } from './tokenStore.js';
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
3
|
// Helpers
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
@@ -67,3 +67,74 @@ export async function loadConfig() {
|
|
|
67
67
|
httpTimeout: getNumber(env['HTTP_TIMEOUT'], 30_000),
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// hostnameFromUrl
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
/**
|
|
74
|
+
* Extract the hostname from a WordPress plugin base URL.
|
|
75
|
+
*/
|
|
76
|
+
export function hostnameFromUrl(wpPluginBaseUrl) {
|
|
77
|
+
try {
|
|
78
|
+
return new URL(wpPluginBaseUrl).hostname;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Fallback: rough extraction
|
|
82
|
+
const match = wpPluginBaseUrl.match(/\/\/([^/]+)/);
|
|
83
|
+
return match ? match[1] : wpPluginBaseUrl;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// loadAllConfigs — multi-site support
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
/**
|
|
90
|
+
* Load configs for ALL connected sites.
|
|
91
|
+
*
|
|
92
|
+
* Priority:
|
|
93
|
+
* 1. If WP_MCP_SITE is set → single-site mode (backward compat).
|
|
94
|
+
* 2. Otherwise → load all sites from token store.
|
|
95
|
+
* 3. If token store is empty → fall back to legacy env-var mode.
|
|
96
|
+
*
|
|
97
|
+
* Returns a Map keyed by hostname.
|
|
98
|
+
*/
|
|
99
|
+
export async function loadAllConfigs() {
|
|
100
|
+
const env = process.env;
|
|
101
|
+
// -------------------------------------------------------------------------
|
|
102
|
+
// Mode 1: Single-site (WP_MCP_SITE set) — backward compatible
|
|
103
|
+
// -------------------------------------------------------------------------
|
|
104
|
+
if (env['WP_MCP_SITE']) {
|
|
105
|
+
const config = await loadConfig();
|
|
106
|
+
const hostname = env['WP_MCP_SITE'].trim();
|
|
107
|
+
return new Map([[hostname, config]]);
|
|
108
|
+
}
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
// Mode 2: Multi-site — load all from token store
|
|
111
|
+
// -------------------------------------------------------------------------
|
|
112
|
+
const allSites = await listSites();
|
|
113
|
+
if (allSites.length > 0) {
|
|
114
|
+
const map = new Map();
|
|
115
|
+
for (const site of allSites) {
|
|
116
|
+
map.set(site.hostname, {
|
|
117
|
+
wpPluginBaseUrl: site.wpPluginBaseUrl,
|
|
118
|
+
username: site.username,
|
|
119
|
+
appPassword: site.appPassword,
|
|
120
|
+
siteName: site.siteName,
|
|
121
|
+
// Global allowlists from env (shared across all sites)
|
|
122
|
+
allowedContentTypes: splitList(env['WP_ALLOWED_CONTENT_TYPES']),
|
|
123
|
+
allowedTaxonomies: splitList(env['WP_ALLOWED_TAXONOMIES']),
|
|
124
|
+
allowedAuthors: splitList(env['WP_ALLOWED_AUTHORS']),
|
|
125
|
+
allowedMediaTypes: splitList(env['WP_ALLOWED_MEDIA_TYPES']),
|
|
126
|
+
allowedYoastPaths: splitList(env['WP_ALLOWED_YOAST_PATHS']),
|
|
127
|
+
rateLimitTokens: getNumber(env['RATE_LIMIT_TOKENS'], 60),
|
|
128
|
+
rateLimitRefillRate: getNumber(env['RATE_LIMIT_REFILL_RATE'], 1),
|
|
129
|
+
httpTimeout: getNumber(env['HTTP_TIMEOUT'], 30_000),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return map;
|
|
133
|
+
}
|
|
134
|
+
// -------------------------------------------------------------------------
|
|
135
|
+
// Mode 3: Legacy env-var fallback (single site)
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
const config = await loadConfig();
|
|
138
|
+
const hostname = hostnameFromUrl(config.wpPluginBaseUrl);
|
|
139
|
+
return new Map([[hostname, config]]);
|
|
140
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,39 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import 'dotenv/config';
|
|
3
|
-
import {
|
|
3
|
+
import { loadAllConfigs } from './config.js';
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
// Main MCP server entry point
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
async function main() {
|
|
8
|
-
const
|
|
8
|
+
const configMap = await loadAllConfigs();
|
|
9
9
|
// Lazy-import heavy MCP SDK dependencies so the --setup path stays fast
|
|
10
10
|
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
11
11
|
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
12
|
-
const server = new McpServer({
|
|
13
|
-
name: 'wp-mcp-gateway',
|
|
14
|
-
version: '0.1.20',
|
|
15
|
-
description: 'WordPress MCP Gateway — proxies Claude tool calls to a WordPress site via the WP MCP Gateway plugin. ' +
|
|
16
|
-
'Always call wp_getting_started first. Follow the IDEATE → DRAFT → PACKAGE workflow. ' +
|
|
17
|
-
'Destructive actions (publish, trash, restore) require a confirmation token.',
|
|
18
|
-
});
|
|
19
12
|
// Dynamically import service and tool modules to keep top-level light
|
|
20
13
|
const { PluginApiClient } = await import('./client/pluginApiClient.js');
|
|
21
14
|
const { PolicyService } = await import('./services/policyService.js');
|
|
15
|
+
const { SiteManager } = await import('./services/siteManager.js');
|
|
22
16
|
const { ConfirmationService } = await import('./services/confirmationService.js');
|
|
23
17
|
const { RateLimiter } = await import('./services/rateLimiter.js');
|
|
24
18
|
const { SessionImageStore } = await import('./services/sessionImageStore.js');
|
|
25
19
|
const { registerTools } = await import('./tools/registerTools.js');
|
|
26
20
|
const { registerPrompts } = await import('./prompts/registerPrompts.js');
|
|
27
|
-
|
|
28
|
-
const
|
|
21
|
+
// Build services for each connected site
|
|
22
|
+
const siteServicesMap = new Map();
|
|
23
|
+
for (const [hostname, config] of configMap) {
|
|
24
|
+
siteServicesMap.set(hostname, {
|
|
25
|
+
config,
|
|
26
|
+
client: new PluginApiClient(config),
|
|
27
|
+
policy: new PolicyService(config),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// Determine initial active site
|
|
31
|
+
const initialSite = process.env['WP_MCP_SITE']?.trim() || Array.from(configMap.keys())[0];
|
|
32
|
+
const siteManager = new SiteManager(siteServicesMap, initialSite);
|
|
33
|
+
// Build server description with site indicator
|
|
34
|
+
const activeSiteName = siteManager.getCurrent().config.siteName || initialSite;
|
|
35
|
+
const siteCount = configMap.size;
|
|
36
|
+
const siteIndicator = siteCount > 1
|
|
37
|
+
? ` Connected to ${siteCount} sites (active: ${activeSiteName}). Use wp_switch_site to change.`
|
|
38
|
+
: '';
|
|
39
|
+
const server = new McpServer({
|
|
40
|
+
name: 'wp-mcp-gateway',
|
|
41
|
+
version: '0.1.30',
|
|
42
|
+
description: 'WordPress MCP Gateway — proxies Claude tool calls to a WordPress site via the WP MCP Gateway plugin.' +
|
|
43
|
+
siteIndicator +
|
|
44
|
+
' Always call wp_getting_started first. Follow the IDEATE → DRAFT → PACKAGE workflow. ' +
|
|
45
|
+
'Destructive actions (publish, trash, restore) require a confirmation token.',
|
|
46
|
+
});
|
|
47
|
+
// Global services (not per-site)
|
|
48
|
+
const firstConfig = configMap.get(initialSite);
|
|
29
49
|
const confirmationService = new ConfirmationService(300);
|
|
30
|
-
const rateLimiter = new RateLimiter(
|
|
50
|
+
const rateLimiter = new RateLimiter(firstConfig.rateLimitTokens, firstConfig.rateLimitRefillRate);
|
|
31
51
|
const sessionImageStore = new SessionImageStore();
|
|
32
52
|
const context = {
|
|
33
53
|
server,
|
|
34
|
-
|
|
35
|
-
client: apiClient,
|
|
36
|
-
policy: policyService,
|
|
54
|
+
siteManager,
|
|
37
55
|
confirmations: confirmationService,
|
|
38
56
|
rateLimiter,
|
|
39
57
|
sessionImageStore,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ToolError } from '../utils/errors.js';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// SiteManager
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
/**
|
|
6
|
+
* Manages multiple WordPress site connections and tracks which one is active.
|
|
7
|
+
*
|
|
8
|
+
* Tools access the active site's services via `getCurrent()`.
|
|
9
|
+
* The active site can be changed at runtime via `switchTo()`.
|
|
10
|
+
*/
|
|
11
|
+
export class SiteManager {
|
|
12
|
+
sites;
|
|
13
|
+
activeSiteHostname;
|
|
14
|
+
constructor(sites, initialHostname) {
|
|
15
|
+
this.sites = sites;
|
|
16
|
+
if (!sites.has(initialHostname)) {
|
|
17
|
+
throw new Error(`Initial site "${initialHostname}" not found in loaded sites: ${Array.from(sites.keys()).join(', ')}`);
|
|
18
|
+
}
|
|
19
|
+
this.activeSiteHostname = initialHostname;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Get the active site's services (config, client, policy).
|
|
23
|
+
*/
|
|
24
|
+
getCurrent() {
|
|
25
|
+
return this.sites.get(this.activeSiteHostname);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Get the hostname of the currently active site.
|
|
29
|
+
*/
|
|
30
|
+
getCurrentHostname() {
|
|
31
|
+
return this.activeSiteHostname;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Switch the active site to a different connected site.
|
|
35
|
+
* Throws ToolError if the hostname is not in the loaded sites.
|
|
36
|
+
*/
|
|
37
|
+
switchTo(hostname) {
|
|
38
|
+
if (!this.sites.has(hostname)) {
|
|
39
|
+
const available = Array.from(this.sites.keys()).join(', ');
|
|
40
|
+
throw new ToolError('SITE_NOT_FOUND', `Site "${hostname}" is not connected. Available sites: ${available}`);
|
|
41
|
+
}
|
|
42
|
+
this.activeSiteHostname = hostname;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* List all connected sites with an isActive flag.
|
|
46
|
+
*/
|
|
47
|
+
listSites() {
|
|
48
|
+
return Array.from(this.sites.entries()).map(([hostname, services]) => ({
|
|
49
|
+
hostname,
|
|
50
|
+
siteName: services.config.siteName || hostname,
|
|
51
|
+
isActive: hostname === this.activeSiteHostname,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* True if more than one site is loaded.
|
|
56
|
+
*/
|
|
57
|
+
isMultiSite() {
|
|
58
|
+
return this.sites.size > 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -85,11 +85,15 @@ function entryTargetsHostname(entry, hostname) {
|
|
|
85
85
|
}
|
|
86
86
|
/**
|
|
87
87
|
* Merge an entry into ~/.claude/mcp.json.
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
88
|
+
*
|
|
89
|
+
* Writes a SINGLE unified "wordpress" entry with no WP_MCP_SITE env.
|
|
90
|
+
* The MCP server loads all connected sites from the token store at startup
|
|
91
|
+
* and supports runtime switching via wp_switch_site.
|
|
92
|
+
*
|
|
93
|
+
* Also cleans up old per-site entries (e.g. "wordpress-example.com") that
|
|
94
|
+
* were created by earlier versions.
|
|
91
95
|
*/
|
|
92
|
-
async function writeMcpJson(
|
|
96
|
+
async function writeMcpJson(_hostname) {
|
|
93
97
|
const mcpJsonPath = path.join(os.homedir(), '.claude', 'mcp.json');
|
|
94
98
|
let existing = {};
|
|
95
99
|
try {
|
|
@@ -108,23 +112,22 @@ async function writeMcpJson(hostname) {
|
|
|
108
112
|
!Array.isArray(existing['mcpServers'])
|
|
109
113
|
? existing['mcpServers']
|
|
110
114
|
: {};
|
|
111
|
-
// Remove
|
|
112
|
-
|
|
115
|
+
// Remove ALL old per-site wp-mcp-gateway entries (wordpress-{hostname} pattern).
|
|
116
|
+
// The unified "wordpress" entry replaces them all.
|
|
113
117
|
for (const key of Object.keys(mcpServers)) {
|
|
114
|
-
if (key ===
|
|
118
|
+
if (key === 'wordpress')
|
|
115
119
|
continue;
|
|
116
120
|
const entry = mcpServers[key];
|
|
117
|
-
if (entryRefsWpMcp(entry)
|
|
118
|
-
console.log(`
|
|
121
|
+
if (entryRefsWpMcp(entry)) {
|
|
122
|
+
console.log(`Removing old single-site entry: ${key}`);
|
|
119
123
|
delete mcpServers[key];
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
|
-
|
|
126
|
+
// Write the unified multi-site entry (no WP_MCP_SITE — loads all from token store)
|
|
127
|
+
mcpServers['wordpress'] = {
|
|
123
128
|
command: 'npx',
|
|
124
129
|
args: ['-y', 'wp-mcp-gateway'],
|
|
125
|
-
env: {
|
|
126
|
-
WP_MCP_SITE: hostname,
|
|
127
|
-
},
|
|
130
|
+
env: {},
|
|
128
131
|
};
|
|
129
132
|
existing['mcpServers'] = mcpServers;
|
|
130
133
|
// Ensure directory exists
|
|
@@ -132,10 +135,10 @@ async function writeMcpJson(hostname) {
|
|
|
132
135
|
await fs.writeFile(mcpJsonPath, JSON.stringify(existing, null, 2), 'utf-8');
|
|
133
136
|
}
|
|
134
137
|
/**
|
|
135
|
-
* Remove the
|
|
136
|
-
*
|
|
138
|
+
* Remove the wp-mcp-gateway entry from ~/.claude/mcp.json when the last
|
|
139
|
+
* site is removed. Also cleans up any legacy per-site entries.
|
|
137
140
|
*/
|
|
138
|
-
async function removeFromMcpJson(
|
|
141
|
+
async function removeFromMcpJson(_hostname) {
|
|
139
142
|
const mcpJsonPath = path.join(os.homedir(), '.claude', 'mcp.json');
|
|
140
143
|
let existing = {};
|
|
141
144
|
try {
|
|
@@ -152,7 +155,14 @@ async function removeFromMcpJson(hostname) {
|
|
|
152
155
|
typeof existing['mcpServers'] === 'object' &&
|
|
153
156
|
!Array.isArray(existing['mcpServers'])) {
|
|
154
157
|
const mcpServers = existing['mcpServers'];
|
|
155
|
-
|
|
158
|
+
// Remove legacy per-site entry if it exists
|
|
159
|
+
delete mcpServers[`wordpress-${_hostname}`];
|
|
160
|
+
// Check if there are any remaining sites in the token store.
|
|
161
|
+
// If none remain, remove the unified "wordpress" entry too.
|
|
162
|
+
const remaining = await listSites();
|
|
163
|
+
if (remaining.length === 0) {
|
|
164
|
+
delete mcpServers['wordpress'];
|
|
165
|
+
}
|
|
156
166
|
existing['mcpServers'] = mcpServers;
|
|
157
167
|
await fs.writeFile(mcpJsonPath, JSON.stringify(existing, null, 2), 'utf-8');
|
|
158
168
|
}
|
|
@@ -191,11 +201,17 @@ export async function runSetup(siteUrlArg) {
|
|
|
191
201
|
// Step 2 — POST /wp-json/wp-mcp-gateway/v1/device/code
|
|
192
202
|
// -----------------------------------------------------------------------
|
|
193
203
|
const deviceCodeEndpoint = `${siteUrl}/wp-json/wp-mcp-gateway/v1/device/code`;
|
|
204
|
+
// Build a descriptive client label for the Connection Manager.
|
|
205
|
+
const clientType = process.env.CLAUDE_DESKTOP ? 'Claude Desktop'
|
|
206
|
+
: process.env.CLAUDE_COWORK ? 'Cowork'
|
|
207
|
+
: 'CLI';
|
|
208
|
+
const clientLabel = `${clientType} — ${os.hostname()}`;
|
|
194
209
|
let deviceCodeData;
|
|
195
210
|
try {
|
|
196
211
|
const res = await fetch(deviceCodeEndpoint, {
|
|
197
212
|
method: 'POST',
|
|
198
213
|
headers: { 'Content-Type': 'application/json' },
|
|
214
|
+
body: JSON.stringify({ client_label: clientLabel }),
|
|
199
215
|
});
|
|
200
216
|
if (res.status === 404) {
|
|
201
217
|
console.error('MCP Gateway plugin not found. Is it installed and activated?');
|
|
@@ -27,7 +27,12 @@ function requireConfirmation(confirmations, action, key, input, confirmationToke
|
|
|
27
27
|
return { confirmed: true };
|
|
28
28
|
}
|
|
29
29
|
export function registerTools(context) {
|
|
30
|
-
const { server,
|
|
30
|
+
const { server, siteManager, confirmations, rateLimiter, sessionImageStore } = context;
|
|
31
|
+
// Convenience: get the active site's services. Called inside each tool handler
|
|
32
|
+
// so it always returns the CURRENT site (respects wp_switch_site).
|
|
33
|
+
function site() {
|
|
34
|
+
return siteManager.getCurrent();
|
|
35
|
+
}
|
|
31
36
|
async function runToolWithLimit(name, input, fn) {
|
|
32
37
|
try {
|
|
33
38
|
rateLimiter.consume();
|
|
@@ -40,14 +45,14 @@ export function registerTools(context) {
|
|
|
40
45
|
return respond({ success: false, tool: name, error: serialized });
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
|
-
server.tool("wp_site_info", "Get site and capability metadata.", {}, async () => runToolWithLimit("wp_site_info", {}, async () => client.siteInfo()));
|
|
48
|
+
server.tool("wp_site_info", "Get site and capability metadata.", {}, async () => runToolWithLimit("wp_site_info", {}, async () => site().client.siteInfo()));
|
|
44
49
|
server.tool("wp_getting_started", [
|
|
45
50
|
"CALL THIS FIRST before any other WordPress tools at the start of a session.",
|
|
46
51
|
"Returns site context, theme info, and the complete editorial workflow guide.",
|
|
47
52
|
"Provides everything Claude needs to make correct decisions about content structure, shortcodes, images, SEO, and publishing.",
|
|
48
53
|
"Do not proceed with content operations without calling this first.",
|
|
49
54
|
].join(" "), {}, async () => runToolWithLimit("wp_getting_started", {}, async () => {
|
|
50
|
-
const siteInfo = await client.siteInfo();
|
|
55
|
+
const siteInfo = await site().client.siteInfo();
|
|
51
56
|
return {
|
|
52
57
|
MANDATORY_RULES: [
|
|
53
58
|
"⛔ RULE 1 — MARKDOWN FILE FIRST, NO EXCEPTIONS: Before calling wp_create_draft you MUST: (a) call create_file to write a .md file containing the metadata header + full post/page content, (b) call present_files to show it to the user, (c) wait for the user to explicitly say something like 'looks good', 'create the draft', 'send it to WordPress', or 'ready'. The words 'go ahead', 'start writing', 'yes', or 'sure' do NOT count as draft approval — they mean begin writing the .md file.",
|
|
@@ -161,7 +166,7 @@ export function registerTools(context) {
|
|
|
161
166
|
},
|
|
162
167
|
};
|
|
163
168
|
}));
|
|
164
|
-
server.tool("wp_list_content_types", "List content types allowed for this MCP server.", {}, async () => runToolWithLimit("wp_list_content_types", {}, async () => client.listContentTypes()));
|
|
169
|
+
server.tool("wp_list_content_types", "List content types allowed for this MCP server.", {}, async () => runToolWithLimit("wp_list_content_types", {}, async () => site().client.listContentTypes()));
|
|
165
170
|
server.tool("wp_find_content", "Search and list content items across allowed content types.", {
|
|
166
171
|
content_type: ContentTypeSchema.optional(),
|
|
167
172
|
search: z.string().optional(),
|
|
@@ -174,12 +179,12 @@ export function registerTools(context) {
|
|
|
174
179
|
solutions_only: z.boolean().optional(),
|
|
175
180
|
}, async (rawInput) => runToolWithLimit("wp_find_content", rawInput, async () => {
|
|
176
181
|
if (typeof rawInput.content_type === "string") {
|
|
177
|
-
policy.assertAllowedContentType(rawInput.content_type);
|
|
182
|
+
site().policy.assertAllowedContentType(rawInput.content_type);
|
|
178
183
|
}
|
|
179
184
|
if (typeof rawInput.author === "number") {
|
|
180
|
-
policy.assertAllowedAuthor(rawInput.author);
|
|
185
|
+
site().policy.assertAllowedAuthor(rawInput.author);
|
|
181
186
|
}
|
|
182
|
-
const results = await client.findContent(rawInput);
|
|
187
|
+
const results = await site().client.findContent(rawInput);
|
|
183
188
|
// Strip full content field — use wp_get_content to fetch content for a specific post
|
|
184
189
|
return results.map((item) => {
|
|
185
190
|
const { content: _content, ...rest } = item;
|
|
@@ -199,8 +204,8 @@ export function registerTools(context) {
|
|
|
199
204
|
id: z.number().int().positive(),
|
|
200
205
|
content_type: ContentTypeSchema,
|
|
201
206
|
}, async (rawInput) => runToolWithLimit("wp_get_content", rawInput, async () => {
|
|
202
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
203
|
-
return client.getContent(Number(rawInput.id));
|
|
207
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
208
|
+
return site().client.getContent(Number(rawInput.id));
|
|
204
209
|
}));
|
|
205
210
|
server.tool("wp_create_draft", [
|
|
206
211
|
"🛑 ARTIFACT-FIRST GATE: All content creation and editing happens in the conversation artifact first — NOT in WordPress.",
|
|
@@ -222,17 +227,17 @@ export function registerTools(context) {
|
|
|
222
227
|
yoast_meta: z.record(z.unknown()).optional(),
|
|
223
228
|
}, async (rawInput) => runToolWithLimit("wp_create_draft", rawInput, async () => {
|
|
224
229
|
const contentType = String(rawInput.content_type);
|
|
225
|
-
policy.assertAllowedContentType(contentType);
|
|
230
|
+
site().policy.assertAllowedContentType(contentType);
|
|
226
231
|
if (typeof rawInput.author === "number") {
|
|
227
|
-
policy.assertAllowedAuthor(rawInput.author);
|
|
232
|
+
site().policy.assertAllowedAuthor(rawInput.author);
|
|
228
233
|
}
|
|
229
234
|
if (rawInput.terms && typeof rawInput.terms === "object") {
|
|
230
235
|
for (const taxonomy of Object.keys(rawInput.terms)) {
|
|
231
|
-
policy.assertAllowedTaxonomy(taxonomy);
|
|
236
|
+
site().policy.assertAllowedTaxonomy(taxonomy);
|
|
232
237
|
}
|
|
233
238
|
}
|
|
234
239
|
if (rawInput.yoast_meta && typeof rawInput.yoast_meta === "object") {
|
|
235
|
-
policy.assertYoastMetaKeys(rawInput.yoast_meta);
|
|
240
|
+
site().policy.assertYoastMetaKeys(rawInput.yoast_meta);
|
|
236
241
|
}
|
|
237
242
|
const contentFormat = rawInput.content_format || "auto";
|
|
238
243
|
const processedInput = { ...rawInput, status: "draft" };
|
|
@@ -242,7 +247,7 @@ export function registerTools(context) {
|
|
|
242
247
|
if (typeof processedInput.excerpt === "string" && processedInput.excerpt) {
|
|
243
248
|
processedInput.excerpt = toHtml(processedInput.excerpt, contentFormat);
|
|
244
249
|
}
|
|
245
|
-
return client.createContent(processedInput);
|
|
250
|
+
return site().client.createContent(processedInput);
|
|
246
251
|
}));
|
|
247
252
|
server.tool("wp_update_content", "Patch an existing content item.", {
|
|
248
253
|
id: z.number().int().positive(),
|
|
@@ -251,11 +256,11 @@ export function registerTools(context) {
|
|
|
251
256
|
content_format: z.enum(["html", "markdown", "auto"]).optional(),
|
|
252
257
|
}, async (rawInput) => runToolWithLimit("wp_update_content", rawInput, async () => {
|
|
253
258
|
const contentType = String(rawInput.content_type);
|
|
254
|
-
policy.assertAllowedContentType(contentType);
|
|
259
|
+
site().policy.assertAllowedContentType(contentType);
|
|
255
260
|
const patch = rawInput.patch;
|
|
256
|
-
policy.assertPatchFields(patch);
|
|
261
|
+
site().policy.assertPatchFields(patch);
|
|
257
262
|
if (typeof patch.author === "number") {
|
|
258
|
-
policy.assertAllowedAuthor(patch.author);
|
|
263
|
+
site().policy.assertAllowedAuthor(patch.author);
|
|
259
264
|
}
|
|
260
265
|
const contentFormat = rawInput.content_format || "auto";
|
|
261
266
|
if (typeof patch.content === "string") {
|
|
@@ -264,7 +269,7 @@ export function registerTools(context) {
|
|
|
264
269
|
if (typeof patch.excerpt === "string" && patch.excerpt) {
|
|
265
270
|
patch.excerpt = toHtml(patch.excerpt, contentFormat);
|
|
266
271
|
}
|
|
267
|
-
const result = await client.updateContent(Number(rawInput.id), {
|
|
272
|
+
const result = await site().client.updateContent(Number(rawInput.id), {
|
|
268
273
|
content_type: contentType,
|
|
269
274
|
patch,
|
|
270
275
|
});
|
|
@@ -289,13 +294,13 @@ export function registerTools(context) {
|
|
|
289
294
|
}, async (rawInput) => runToolWithLimit("wp_publish_content", rawInput, async () => {
|
|
290
295
|
const id = Number(rawInput.id);
|
|
291
296
|
const contentType = String(rawInput.content_type);
|
|
292
|
-
policy.assertAllowedContentType(contentType);
|
|
297
|
+
site().policy.assertAllowedContentType(contentType);
|
|
293
298
|
const key = `${contentType}:${id}:publish`;
|
|
294
299
|
const gate = requireConfirmation(confirmations, "publish_content", key, rawInput, rawInput.confirmation_token);
|
|
295
300
|
if (!gate.confirmed) {
|
|
296
301
|
return gate.payload;
|
|
297
302
|
}
|
|
298
|
-
return client.publishContent(id, {
|
|
303
|
+
return site().client.publishContent(id, {
|
|
299
304
|
content_type: contentType,
|
|
300
305
|
date: rawInput.date,
|
|
301
306
|
});
|
|
@@ -307,13 +312,13 @@ export function registerTools(context) {
|
|
|
307
312
|
}, async (rawInput) => runToolWithLimit("wp_trash_content", rawInput, async () => {
|
|
308
313
|
const id = Number(rawInput.id);
|
|
309
314
|
const contentType = String(rawInput.content_type);
|
|
310
|
-
policy.assertAllowedContentType(contentType);
|
|
315
|
+
site().policy.assertAllowedContentType(contentType);
|
|
311
316
|
const key = `${contentType}:${id}:trash`;
|
|
312
317
|
const gate = requireConfirmation(confirmations, "trash_content", key, rawInput, rawInput.confirmation_token);
|
|
313
318
|
if (!gate.confirmed) {
|
|
314
319
|
return gate.payload;
|
|
315
320
|
}
|
|
316
|
-
return client.trashContent(id);
|
|
321
|
+
return site().client.trashContent(id);
|
|
317
322
|
}));
|
|
318
323
|
server.tool("wp_restore_content", "Restore content from trash (confirmation required).", {
|
|
319
324
|
id: z.number().int().positive(),
|
|
@@ -322,20 +327,20 @@ export function registerTools(context) {
|
|
|
322
327
|
}, async (rawInput) => runToolWithLimit("wp_restore_content", rawInput, async () => {
|
|
323
328
|
const id = Number(rawInput.id);
|
|
324
329
|
const contentType = String(rawInput.content_type);
|
|
325
|
-
policy.assertAllowedContentType(contentType);
|
|
330
|
+
site().policy.assertAllowedContentType(contentType);
|
|
326
331
|
const key = `${contentType}:${id}:restore`;
|
|
327
332
|
const gate = requireConfirmation(confirmations, "restore_content", key, rawInput, rawInput.confirmation_token);
|
|
328
333
|
if (!gate.confirmed) {
|
|
329
334
|
return gate.payload;
|
|
330
335
|
}
|
|
331
|
-
return client.restoreContent(id);
|
|
336
|
+
return site().client.restoreContent(id);
|
|
332
337
|
}));
|
|
333
338
|
server.tool("wp_list_revisions", "List revisions for a content item.", {
|
|
334
339
|
id: z.number().int().positive(),
|
|
335
340
|
content_type: ContentTypeSchema,
|
|
336
341
|
}, async (rawInput) => runToolWithLimit("wp_list_revisions", rawInput, async () => {
|
|
337
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
338
|
-
return client.listRevisions(Number(rawInput.id));
|
|
342
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
343
|
+
return site().client.listRevisions(Number(rawInput.id));
|
|
339
344
|
}));
|
|
340
345
|
server.tool("wp_restore_revision", "Restore a specific revision (confirmation required).", {
|
|
341
346
|
id: z.number().int().positive(),
|
|
@@ -346,23 +351,23 @@ export function registerTools(context) {
|
|
|
346
351
|
const id = Number(rawInput.id);
|
|
347
352
|
const revisionId = Number(rawInput.revision_id);
|
|
348
353
|
const contentType = String(rawInput.content_type);
|
|
349
|
-
policy.assertAllowedContentType(contentType);
|
|
354
|
+
site().policy.assertAllowedContentType(contentType);
|
|
350
355
|
const key = `${contentType}:${id}:revision:${revisionId}`;
|
|
351
356
|
const gate = requireConfirmation(confirmations, "restore_revision", key, rawInput, rawInput.confirmation_token);
|
|
352
357
|
if (!gate.confirmed) {
|
|
353
358
|
return gate.payload;
|
|
354
359
|
}
|
|
355
|
-
return client.restoreRevision(id, revisionId);
|
|
360
|
+
return site().client.restoreRevision(id, revisionId);
|
|
356
361
|
}));
|
|
357
|
-
server.tool("wp_list_authors", "List allowlisted authors.", {}, async () => runToolWithLimit("wp_list_authors", {}, async () => client.listAuthors()));
|
|
362
|
+
server.tool("wp_list_authors", "List allowlisted authors.", {}, async () => runToolWithLimit("wp_list_authors", {}, async () => site().client.listAuthors()));
|
|
358
363
|
server.tool("wp_assign_author", "Assign an allowlisted author to content.", {
|
|
359
364
|
id: z.number().int().positive(),
|
|
360
365
|
content_type: ContentTypeSchema,
|
|
361
366
|
author_id: z.number().int().positive(),
|
|
362
367
|
}, async (rawInput) => runToolWithLimit("wp_assign_author", rawInput, async () => {
|
|
363
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
364
|
-
policy.assertAllowedAuthor(Number(rawInput.author_id));
|
|
365
|
-
return client.assignAuthor(Number(rawInput.id), Number(rawInput.author_id));
|
|
368
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
369
|
+
site().policy.assertAllowedAuthor(Number(rawInput.author_id));
|
|
370
|
+
return site().client.assignAuthor(Number(rawInput.id), Number(rawInput.author_id));
|
|
366
371
|
}));
|
|
367
372
|
server.tool("wp_list_terms", "List terms for an allowlisted taxonomy.", {
|
|
368
373
|
taxonomy: z.string().min(1),
|
|
@@ -372,8 +377,8 @@ export function registerTools(context) {
|
|
|
372
377
|
per_page: z.number().int().positive().max(100).optional(),
|
|
373
378
|
}, async (rawInput) => runToolWithLimit("wp_list_terms", rawInput, async () => {
|
|
374
379
|
const taxonomy = String(rawInput.taxonomy);
|
|
375
|
-
policy.assertAllowedTaxonomy(taxonomy);
|
|
376
|
-
return client.listTerms(taxonomy, rawInput);
|
|
380
|
+
site().policy.assertAllowedTaxonomy(taxonomy);
|
|
381
|
+
return site().client.listTerms(taxonomy, rawInput);
|
|
377
382
|
}));
|
|
378
383
|
server.tool("wp_create_term", "Create a term in an allowlisted taxonomy.", {
|
|
379
384
|
taxonomy: z.string().min(1),
|
|
@@ -383,20 +388,20 @@ export function registerTools(context) {
|
|
|
383
388
|
description: z.string().optional(),
|
|
384
389
|
}, async (rawInput) => runToolWithLimit("wp_create_term", rawInput, async () => {
|
|
385
390
|
const taxonomy = String(rawInput.taxonomy);
|
|
386
|
-
policy.assertAllowedTaxonomy(taxonomy);
|
|
387
|
-
return client.createTerm(taxonomy, rawInput);
|
|
391
|
+
site().policy.assertAllowedTaxonomy(taxonomy);
|
|
392
|
+
return site().client.createTerm(taxonomy, rawInput);
|
|
388
393
|
}));
|
|
389
394
|
server.tool("wp_assign_terms", "Assign term IDs to a content item.", {
|
|
390
395
|
id: z.number().int().positive(),
|
|
391
396
|
content_type: ContentTypeSchema,
|
|
392
397
|
terms: z.record(z.array(z.number().int().positive())),
|
|
393
398
|
}, async (rawInput) => runToolWithLimit("wp_assign_terms", rawInput, async () => {
|
|
394
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
399
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
395
400
|
const terms = rawInput.terms;
|
|
396
401
|
for (const taxonomy of Object.keys(terms)) {
|
|
397
|
-
policy.assertAllowedTaxonomy(taxonomy);
|
|
402
|
+
site().policy.assertAllowedTaxonomy(taxonomy);
|
|
398
403
|
}
|
|
399
|
-
return client.assignTerms(Number(rawInput.id), {
|
|
404
|
+
return site().client.assignTerms(Number(rawInput.id), {
|
|
400
405
|
content_type: rawInput.content_type,
|
|
401
406
|
terms,
|
|
402
407
|
});
|
|
@@ -406,21 +411,21 @@ export function registerTools(context) {
|
|
|
406
411
|
page: z.number().int().positive().optional(),
|
|
407
412
|
per_page: z.number().int().positive().max(100).optional(),
|
|
408
413
|
mime_type: z.string().optional(),
|
|
409
|
-
}, async (rawInput) => runToolWithLimit("wp_search_media", rawInput, async () => client.searchMedia(rawInput)));
|
|
414
|
+
}, async (rawInput) => runToolWithLimit("wp_search_media", rawInput, async () => site().client.searchMedia(rawInput)));
|
|
410
415
|
server.tool("wp_update_media", "Update media metadata.", {
|
|
411
416
|
id: z.number().int().positive(),
|
|
412
417
|
alt_text: z.string().optional(),
|
|
413
418
|
caption: z.string().optional(),
|
|
414
419
|
description: z.string().optional(),
|
|
415
420
|
title: z.string().optional(),
|
|
416
|
-
}, async (rawInput) => runToolWithLimit("wp_update_media", rawInput, async () => client.updateMedia(Number(rawInput.id), rawInput)));
|
|
421
|
+
}, async (rawInput) => runToolWithLimit("wp_update_media", rawInput, async () => site().client.updateMedia(Number(rawInput.id), rawInput)));
|
|
417
422
|
server.tool("wp_set_featured_image", "Set or remove a featured image on content.", {
|
|
418
423
|
id: z.number().int().positive(),
|
|
419
424
|
content_type: ContentTypeSchema,
|
|
420
425
|
media_id: z.number().int().positive().nullable(),
|
|
421
426
|
}, async (rawInput) => runToolWithLimit("wp_set_featured_image", rawInput, async () => {
|
|
422
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
423
|
-
return client.setFeaturedImage(Number(rawInput.id), rawInput.media_id);
|
|
427
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
428
|
+
return site().client.setFeaturedImage(Number(rawInput.id), rawInput.media_id);
|
|
424
429
|
}));
|
|
425
430
|
server.tool("wp_insert_inline_image", "Insert an inline image into content in block-aware mode with HTML fallback.", {
|
|
426
431
|
id: z.number().int().positive(),
|
|
@@ -434,8 +439,8 @@ export function registerTools(context) {
|
|
|
434
439
|
caption: z.string().optional(),
|
|
435
440
|
alt_text: z.string().optional(),
|
|
436
441
|
}, async (rawInput) => runToolWithLimit("wp_insert_inline_image", rawInput, async () => {
|
|
437
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
438
|
-
const result = await client.insertInlineImage(Number(rawInput.id), rawInput);
|
|
442
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
443
|
+
const result = await site().client.insertInlineImage(Number(rawInput.id), rawInput);
|
|
439
444
|
if (result && typeof result === "object") {
|
|
440
445
|
const { content: _c, ...slim } = result;
|
|
441
446
|
return slim;
|
|
@@ -451,11 +456,11 @@ export function registerTools(context) {
|
|
|
451
456
|
alt_text: z.string().optional(),
|
|
452
457
|
caption: z.string().optional(),
|
|
453
458
|
}, async (rawInput) => runToolWithLimit("wp_replace_inline_image", rawInput, async () => {
|
|
454
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
459
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
455
460
|
if (!rawInput.match_media_id && !rawInput.match_src_substring) {
|
|
456
461
|
throw new ToolError("INLINE_MATCH_REQUIRED", "Either match_media_id or match_src_substring must be provided");
|
|
457
462
|
}
|
|
458
|
-
const result = await client.replaceInlineImage(Number(rawInput.id), rawInput);
|
|
463
|
+
const result = await site().client.replaceInlineImage(Number(rawInput.id), rawInput);
|
|
459
464
|
if (result && typeof result === "object") {
|
|
460
465
|
const { content: _c, ...slim } = result;
|
|
461
466
|
return slim;
|
|
@@ -468,11 +473,11 @@ export function registerTools(context) {
|
|
|
468
473
|
match_media_id: z.number().int().positive().optional(),
|
|
469
474
|
match_src_substring: z.string().optional(),
|
|
470
475
|
}, async (rawInput) => runToolWithLimit("wp_remove_inline_image", rawInput, async () => {
|
|
471
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
476
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
472
477
|
if (!rawInput.match_media_id && !rawInput.match_src_substring) {
|
|
473
478
|
throw new ToolError("INLINE_MATCH_REQUIRED", "Either match_media_id or match_src_substring must be provided");
|
|
474
479
|
}
|
|
475
|
-
const result = await client.removeInlineImage(Number(rawInput.id), rawInput);
|
|
480
|
+
const result = await site().client.removeInlineImage(Number(rawInput.id), rawInput);
|
|
476
481
|
if (result && typeof result === "object") {
|
|
477
482
|
const { content: _c, ...slim } = result;
|
|
478
483
|
return slim;
|
|
@@ -488,9 +493,9 @@ export function registerTools(context) {
|
|
|
488
493
|
id: z.number().int().positive(),
|
|
489
494
|
content_type: ContentTypeSchema,
|
|
490
495
|
}, async (rawInput) => runToolWithLimit("wp_get_yoast_analysis", rawInput, async () => {
|
|
491
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
492
|
-
policy.assertYoastPath("/yoast/analysis");
|
|
493
|
-
return client.getYoastAnalysis(Number(rawInput.id));
|
|
496
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
497
|
+
site().policy.assertYoastPath("/yoast/analysis");
|
|
498
|
+
return site().client.getYoastAnalysis(Number(rawInput.id));
|
|
494
499
|
}));
|
|
495
500
|
server.tool("wp_update_yoast_metadata", [
|
|
496
501
|
"Update allowlisted Yoast metadata for content.",
|
|
@@ -505,10 +510,10 @@ export function registerTools(context) {
|
|
|
505
510
|
content_type: ContentTypeSchema,
|
|
506
511
|
yoast_meta: z.record(z.unknown()),
|
|
507
512
|
}, async (rawInput) => runToolWithLimit("wp_update_yoast_metadata", rawInput, async () => {
|
|
508
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
509
|
-
policy.assertYoastPath("/yoast/metadata");
|
|
510
|
-
policy.assertYoastMetaKeys(rawInput.yoast_meta);
|
|
511
|
-
return client.updateYoastMetadata(Number(rawInput.id), {
|
|
513
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
514
|
+
site().policy.assertYoastPath("/yoast/metadata");
|
|
515
|
+
site().policy.assertYoastMetaKeys(rawInput.yoast_meta);
|
|
516
|
+
return site().client.updateYoastMetadata(Number(rawInput.id), {
|
|
512
517
|
content_type: rawInput.content_type,
|
|
513
518
|
yoast_meta: rawInput.yoast_meta,
|
|
514
519
|
});
|
|
@@ -517,21 +522,21 @@ export function registerTools(context) {
|
|
|
517
522
|
id: z.number().int().positive(),
|
|
518
523
|
content_type: ContentTypeSchema,
|
|
519
524
|
}, async (rawInput) => runToolWithLimit("wp_get_yoast_head_preview", rawInput, async () => {
|
|
520
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
521
|
-
policy.assertYoastPath("/yoast/head");
|
|
522
|
-
return client.getYoastHeadPreview(Number(rawInput.id));
|
|
525
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
526
|
+
site().policy.assertYoastPath("/yoast/head");
|
|
527
|
+
return site().client.getYoastHeadPreview(Number(rawInput.id));
|
|
523
528
|
}));
|
|
524
529
|
server.tool("wp_get_preview_link", "Return both WordPress preview and signed shareable preview URLs.", {
|
|
525
530
|
id: z.number().int().positive(),
|
|
526
531
|
content_type: ContentTypeSchema,
|
|
527
532
|
}, async (rawInput) => runToolWithLimit("wp_get_preview_link", rawInput, async () => {
|
|
528
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
529
|
-
return client.getPreviewLink(Number(rawInput.id));
|
|
533
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
534
|
+
return site().client.getPreviewLink(Number(rawInput.id));
|
|
530
535
|
}));
|
|
531
536
|
server.tool("wp_get_media", "Get a single media item by ID with full metadata (dimensions, URLs, alt text).", {
|
|
532
537
|
id: z.number().int().positive(),
|
|
533
538
|
}, async (rawInput) => runToolWithLimit("wp_get_media", rawInput, async () => {
|
|
534
|
-
return client.getMedia(Number(rawInput.id));
|
|
539
|
+
return site().client.getMedia(Number(rawInput.id));
|
|
535
540
|
}));
|
|
536
541
|
server.tool("wp_upload_media_from_url", "Upload media to WordPress by fetching from a URL (server-side download, no base64 needed).", {
|
|
537
542
|
url: z.string().url(),
|
|
@@ -540,7 +545,7 @@ export function registerTools(context) {
|
|
|
540
545
|
caption: z.string().optional(),
|
|
541
546
|
description: z.string().optional(),
|
|
542
547
|
}, async (rawInput) => runToolWithLimit("wp_upload_media_from_url", rawInput, async () => {
|
|
543
|
-
return client.uploadMediaFromUrl(rawInput);
|
|
548
|
+
return site().client.uploadMediaFromUrl(rawInput);
|
|
544
549
|
}));
|
|
545
550
|
// ─── Local Filesystem Image Tools ────────────────────────────────────────
|
|
546
551
|
server.tool("wp_find_media_file", [
|
|
@@ -601,7 +606,7 @@ export function registerTools(context) {
|
|
|
601
606
|
const { base64, filename, mimeType } = sessionImageStore.readImage(expanded);
|
|
602
607
|
// Policy check using the decoded byte length
|
|
603
608
|
const sizeBytes = Buffer.from(base64, "base64").byteLength;
|
|
604
|
-
policy.assertMediaPolicy(mimeType, sizeBytes, rawInput.alt_text);
|
|
609
|
+
site().policy.assertMediaPolicy(mimeType, sizeBytes, rawInput.alt_text);
|
|
605
610
|
const meta = {};
|
|
606
611
|
if (rawInput.alt_text)
|
|
607
612
|
meta.alt_text = rawInput.alt_text;
|
|
@@ -611,15 +616,15 @@ export function registerTools(context) {
|
|
|
611
616
|
meta.description = rawInput.description;
|
|
612
617
|
if (rawInput.title)
|
|
613
618
|
meta.title = rawInput.title;
|
|
614
|
-
return client.uploadMediaFromPath(expanded, base64, filename, mimeType, meta);
|
|
619
|
+
return site().client.uploadMediaFromPath(expanded, base64, filename, mimeType, meta);
|
|
615
620
|
}));
|
|
616
621
|
server.tool("wp_clone_content", "Clone an existing post or page as a new draft. Copies content, taxonomies, featured image, and Yoast meta.", {
|
|
617
622
|
id: z.number().int().positive(),
|
|
618
623
|
content_type: ContentTypeSchema,
|
|
619
624
|
title: z.string().optional(),
|
|
620
625
|
}, async (rawInput) => runToolWithLimit("wp_clone_content", rawInput, async () => {
|
|
621
|
-
policy.assertAllowedContentType(String(rawInput.content_type));
|
|
622
|
-
return client.cloneContent(Number(rawInput.id), rawInput);
|
|
626
|
+
site().policy.assertAllowedContentType(String(rawInput.content_type));
|
|
627
|
+
return site().client.cloneContent(Number(rawInput.id), rawInput);
|
|
623
628
|
}));
|
|
624
629
|
// ── Image Providers ───────────────────────────────────────────────────
|
|
625
630
|
server.tool("wp_generate_image", "Generate an image using AI (Google Imagen or OpenAI) and save it to the WordPress media library. The image is generated server-side — no image data passes through the conversation. Returns the media ID and URL so you can show the preview inline and then use wp_set_featured_image or wp_insert_inline_image. TIP: Help the user refine the prompt before generating. Show the result URL inline as a markdown image for preview.", {
|
|
@@ -627,20 +632,57 @@ export function registerTools(context) {
|
|
|
627
632
|
provider: z.enum(["google_imagen", "openai"]).optional().describe("Provider. Defaults to first enabled."),
|
|
628
633
|
aspect_ratio: z.enum(["1:1", "16:9", "9:16", "4:3", "3:4"]).optional().describe("Aspect ratio. Default: 16:9."),
|
|
629
634
|
quality: z.enum(["low", "medium", "high"]).optional().describe("Quality tier (affects OpenAI cost). Default: medium."),
|
|
630
|
-
}, async (input) => runToolWithLimit("wp_generate_image", input, async () => client.generateImage(input)));
|
|
635
|
+
}, async (input) => runToolWithLimit("wp_generate_image", input, async () => site().client.generateImage(input)));
|
|
631
636
|
server.tool("wp_search_stock_photos", "Search for free stock photos from Unsplash or Pexels. Returns preview URLs you can display inline as markdown images for the user to choose from. Once they pick one, call wp_import_stock_photo.", {
|
|
632
637
|
query: z.string().describe("Search terms."),
|
|
633
638
|
provider: z.enum(["unsplash", "pexels"]).optional().describe("Stock provider. Defaults to first enabled."),
|
|
634
639
|
orientation: z.enum(["landscape", "portrait", "square"]).optional().describe("Orientation filter."),
|
|
635
640
|
per_page: z.number().int().min(1).max(10).optional().describe("Results count (1-10). Default: 5."),
|
|
636
|
-
}, async (input) => runToolWithLimit("wp_search_stock_photos", input, async () => client.searchStockPhotos(input)));
|
|
641
|
+
}, async (input) => runToolWithLimit("wp_search_stock_photos", input, async () => site().client.searchStockPhotos(input)));
|
|
637
642
|
server.tool("wp_import_stock_photo", "Import a stock photo into the WordPress media library. Use provider + provider_id from wp_search_stock_photos results. Handles attribution automatically.", {
|
|
638
643
|
provider: z.enum(["unsplash", "pexels"]).describe("Stock provider."),
|
|
639
644
|
provider_id: z.string().describe("Photo ID from search results."),
|
|
640
645
|
alt_text: z.string().optional().describe("Override alt text."),
|
|
641
|
-
}, async (input) => runToolWithLimit("wp_import_stock_photo", input, async () => client.importStockPhoto(input)));
|
|
642
|
-
server.tool("wp_list_image_providers", "List which image generation and stock photo providers are configured and enabled on this site.", {}, async () => runToolWithLimit("wp_list_image_providers", {}, async () => client.listImageProviders()));
|
|
646
|
+
}, async (input) => runToolWithLimit("wp_import_stock_photo", input, async () => site().client.importStockPhoto(input)));
|
|
647
|
+
server.tool("wp_list_image_providers", "List which image generation and stock photo providers are configured and enabled on this site.", {}, async () => runToolWithLimit("wp_list_image_providers", {}, async () => site().client.listImageProviders()));
|
|
643
648
|
server.tool("wp_confirm_image", "Confirm a generated image after user approval. Prevents auto-cleanup. Call this when the user says they want to keep/use a generated image.", {
|
|
644
649
|
media_id: z.number().int().describe("Media ID of the generated image."),
|
|
645
|
-
}, async (input) => runToolWithLimit("wp_confirm_image", input, async () => client.confirmImage(Number(input.media_id))));
|
|
650
|
+
}, async (input) => runToolWithLimit("wp_confirm_image", input, async () => site().client.confirmImage(Number(input.media_id))));
|
|
651
|
+
// ── Upload Portal ───────────────────────────────────────────────────
|
|
652
|
+
server.tool("wp_create_upload_session", "Create a temporary upload portal link for the user to drag-and-drop files " +
|
|
653
|
+
"into the WordPress media library. The link expires in 15 minutes. Present " +
|
|
654
|
+
"the URL to the user as a clickable link. This works for ALL clients. " +
|
|
655
|
+
"For quick single-file uploads on Claude Desktop/Cowork, wp_upload_media_from_path " +
|
|
656
|
+
"may be simpler. After the user uploads files, call wp_get_upload_session.", {
|
|
657
|
+
ip_binding: z.boolean().optional().describe("Lock session to creator's IP for extra security"),
|
|
658
|
+
}, async (input) => runToolWithLimit("wp_create_upload_session", input, async () => site().client.createUploadSession({ ip_binding: input.ip_binding })));
|
|
659
|
+
server.tool("wp_get_upload_session", "Check the status of an upload session and retrieve uploaded file details " +
|
|
660
|
+
"(media IDs, URLs, filenames). Call this after the user confirms they've " +
|
|
661
|
+
"finished uploading via the portal.", {
|
|
662
|
+
session_id: z.string().describe("Session ID from wp_create_upload_session"),
|
|
663
|
+
}, async (input) => runToolWithLimit("wp_get_upload_session", input, async () => site().client.getUploadSession(String(input.session_id))));
|
|
664
|
+
// ── Multi-Site Management ─────────────────────────────────────────────
|
|
665
|
+
server.tool("wp_list_sites", "List all connected WordPress sites and indicate which is currently active. Use wp_switch_site to change the active site.", {}, async () => runToolWithLimit("wp_list_sites", {}, async () => {
|
|
666
|
+
const sites = siteManager.listSites();
|
|
667
|
+
return {
|
|
668
|
+
sites,
|
|
669
|
+
multi_site_enabled: siteManager.isMultiSite(),
|
|
670
|
+
hint: siteManager.isMultiSite()
|
|
671
|
+
? "Use wp_switch_site with a hostname to change the active site."
|
|
672
|
+
: "Only one site is connected. Connect more sites with: npx wp-mcp-gateway --setup <site-url>",
|
|
673
|
+
};
|
|
674
|
+
}));
|
|
675
|
+
server.tool("wp_switch_site", "Switch the active WordPress site. All subsequent tool calls will target the new site. Use wp_list_sites to see available sites.", {
|
|
676
|
+
hostname: z.string().min(1).describe("Hostname of the site to switch to (e.g. 'xmpro.com')"),
|
|
677
|
+
}, async (input) => runToolWithLimit("wp_switch_site", input, async () => {
|
|
678
|
+
const hostname = String(input.hostname);
|
|
679
|
+
siteManager.switchTo(hostname);
|
|
680
|
+
const { config } = siteManager.getCurrent();
|
|
681
|
+
return {
|
|
682
|
+
switched: true,
|
|
683
|
+
active_site: hostname,
|
|
684
|
+
site_name: config.siteName || hostname,
|
|
685
|
+
message: `Switched to ${config.siteName || hostname}. All WordPress tools now target this site.`,
|
|
686
|
+
};
|
|
687
|
+
}));
|
|
646
688
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wp-mcp-gateway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server for managing WordPress content from Claude Desktop — create posts, upload images, manage SEO, and publish without ever opening WordPress.",
|
|
6
6
|
"main": "dist/index.js",
|