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 CHANGED
@@ -1,21 +1,188 @@
1
- # MCP Server (TypeScript)
1
+ # WP MCP Gateway
2
2
 
3
- Local MCP server that connects Claude to a scoped WordPress plugin gateway.
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
- ## Run
5
+ ## Quick Start
6
6
 
7
- 1. Copy `.env.example` to `.env` and fill credentials.
8
- 2. Install dependencies:
9
- - `npm install`
10
- 3. Start development server:
11
- - `npm run dev`
7
+ ### Prerequisites
12
8
 
13
- ## Test
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
- - `npm test`
13
+ ### Setup
16
14
 
17
- ## Key constraints
15
+ ```bash
16
+ npx wp-mcp-gateway --setup
17
+ ```
18
18
 
19
- - Allowed content types default to: `post,page,featured_item`
20
- - Permanent delete is intentionally not exposed in v1
21
- - Yoast operations are limited to allowlisted Tier A/B routes
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 { loadConfig } from './config.js';
3
+ import { loadAllConfigs } from './config.js';
4
4
  // ---------------------------------------------------------------------------
5
5
  // Main MCP server entry point
6
6
  // ---------------------------------------------------------------------------
7
7
  async function main() {
8
- const config = await loadConfig();
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
- const apiClient = new PluginApiClient(config);
28
- const policyService = new PolicyService(config);
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(config.rateLimitTokens, config.rateLimitRefillRate);
50
+ const rateLimiter = new RateLimiter(firstConfig.rateLimitTokens, firstConfig.rateLimitRefillRate);
31
51
  const sessionImageStore = new SessionImageStore();
32
52
  const context = {
33
53
  server,
34
- config,
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
- * Reads the existing file (defaulting to {}), removes any duplicate entries
89
- * that reference wp-mcp-gateway and target the same hostname, then sets
90
- * mcpServers["wordpress-{hostname}"] without touching unrelated keys.
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(hostname) {
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 duplicate entries that point at the same site
112
- const targetKey = `wordpress-${hostname}`;
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 === targetKey)
118
+ if (key === 'wordpress')
115
119
  continue;
116
120
  const entry = mcpServers[key];
117
- if (entryRefsWpMcp(entry) && entryTargetsHostname(entry, hostname)) {
118
- console.log(`Replacing existing entry: ${key}`);
121
+ if (entryRefsWpMcp(entry)) {
122
+ console.log(`Removing old single-site entry: ${key}`);
119
123
  delete mcpServers[key];
120
124
  }
121
125
  }
122
- mcpServers[targetKey] = {
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 wordpress-{hostname} entry from ~/.claude/mcp.json.
136
- * No-op if the file does not exist or the key is absent.
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(hostname) {
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
- delete mcpServers[`wordpress-${hostname}`];
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, client, policy, confirmations, rateLimiter, sessionImageStore } = context;
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.26",
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",