diff --git a/.gitignore b/.gitignore index b1484b4..dde70af 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build package-lock.json logs *.code-workspace +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 9b3059d..49f0c1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,8 @@ npm run clean ``` ### Environment Setup + +#### Single Site Configuration Create a `.env` file in the project root with: ```env WORDPRESS_API_URL=https://your-wordpress-site.com @@ -34,6 +36,25 @@ WORDPRESS_USERNAME=wp_username WORDPRESS_PASSWORD=wp_app_password ``` +#### Multi-Site Configuration +For managing multiple WordPress sites: +```env +# Site 1 (Production) +WORDPRESS_1_URL=https://production-site.com +WORDPRESS_1_USERNAME=admin +WORDPRESS_1_PASSWORD=app_password_1 +WORDPRESS_1_ID=production +WORDPRESS_1_DEFAULT=true +WORDPRESS_1_ALIASES=prod,main + +# Site 2 (Staging) +WORDPRESS_2_URL=https://staging-site.com +WORDPRESS_2_USERNAME=admin +WORDPRESS_2_PASSWORD=app_password_2 +WORDPRESS_2_ID=staging +WORDPRESS_2_ALIASES=stage,dev +``` + The app password can be generated from WordPress admin panel following the [Application Passwords guide](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide#Getting-Credentials). ## Architecture @@ -46,17 +67,26 @@ The app password can be generated from WordPress admin panel following the [Appl - Uses StdioServerTransport for communication with Claude Desktop - Validates environment variables and establishes WordPress connection on startup -2. **WordPress Client (`src/wordpress.ts`)**: +2. **Site Manager (`src/config/site-manager.ts`)**: + - Manages multiple WordPress site configurations + - Lazy loads site configurations from environment variables + - Maintains separate authenticated Axios clients for each site + - Provides site detection from context (domain mentions, aliases, site IDs) + - Supports both numbered multi-site config and legacy single-site config + +3. **WordPress Client (`src/wordpress.ts`)**: - Manages authenticated Axios instance for WordPress REST API calls + - Integrates with SiteManager for multi-site support - Handles authentication using Basic Auth with application passwords - - Provides `makeWordPressRequest()` wrapper for all API calls + - Provides `makeWordPressRequest()` wrapper for all API calls with optional `siteId` parameter - Includes logging to `logs/wordpress-api.log` for debugging - Special handler `searchWordPressPluginRepository()` for WordPress.org plugin search -3. **Tool System (`src/tools/`)**: +4. **Tool System (`src/tools/`)**: - Each WordPress entity (posts, pages, media, etc.) has its own module - Each module exports tools array and handlers object - Tools use Zod schemas for input validation and type safety + - All tools support optional `site_id` parameter for multi-site support - All tools are aggregated in `src/tools/index.ts` ### Tool Pattern @@ -115,6 +145,11 @@ Handles ALL taxonomies (categories, tags, custom taxonomies) with a single set o - `assign_terms_to_content` - Assign terms to any content type - `get_content_terms` - Get all terms for any content +#### **Site Management Tools** (`site-management.ts`) - 3 tools +- `list_sites` - List all configured WordPress sites +- `get_site` - Get details about a specific site +- `test_site` - Test connection to a WordPress site + #### **Other Specialized Tools** - **Media** (`media.ts`): Media library management (~5 tools) - **Users** (`users.ts`): User management (~5 tools) @@ -159,6 +194,20 @@ All taxonomy operations use a single `taxonomy` parameter: } ``` +#### Multi-Site Support +All tools accept an optional `site_id` parameter to target specific sites: +```json +{ + "content_type": "post", + "site_id": "production" // Optional - targets specific site +} +``` + +If `site_id` is not provided, the default site is used. Sites can be managed via: +- `list_sites` - See all configured sites +- `get_site` - Get details about a site +- `test_site` - Test connection to a site + ## TypeScript Configuration - Target: ES2022 with ESNext modules diff --git a/README.md b/README.md index 63481a7..5f70c9f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,16 @@ This is a Model Context Protocol (MCP) server for WordPress, allowing you to int ## Features -This server currently provides tools to interact with core WordPress data: +This server provides tools to interact with core WordPress data and supports **multi-site management** - manage multiple WordPress sites from a single MCP server instance. + +### **Multi-Site Management** (3 tools) +Manage multiple WordPress sites from a single MCP server: + +* `list_sites`: List all configured WordPress sites +* `get_site`: Get details about a specific site configuration +* `test_site`: Test connection to a specific WordPress site + +All content and taxonomy tools support an optional `site_id` parameter to target specific sites. ### **Unified Content Management** (8 tools) Handles ALL content types (posts, pages, custom post types) with a single set of intelligent tools: @@ -103,15 +112,11 @@ All taxonomy operations use a single `taxonomy` parameter: } ``` -## Using with npx and .env file +## Configuration -You can run this MCP server directly using npx without installing it globally: - -```bash -npx -y @instawp/mcp-wp -``` +### Single Site Configuration -Make sure you have a `.env` file in your current directory with the following variables: +For managing a single WordPress site, use the following environment variables: ```env WORDPRESS_API_URL=https://your-wordpress-site.com @@ -119,6 +124,53 @@ WORDPRESS_USERNAME=wp_username WORDPRESS_PASSWORD=wp_app_password ``` +### Multi-Site Configuration + +To manage multiple WordPress sites from a single MCP server, use numbered environment variables: + +```env +# Site 1 (Production) +WORDPRESS_1_URL=https://production-site.com +WORDPRESS_1_USERNAME=admin +WORDPRESS_1_PASSWORD=app_password_1 +WORDPRESS_1_ID=production +WORDPRESS_1_DEFAULT=true +WORDPRESS_1_ALIASES=prod,main + +# Site 2 (Staging) +WORDPRESS_2_URL=https://staging-site.com +WORDPRESS_2_USERNAME=admin +WORDPRESS_2_PASSWORD=app_password_2 +WORDPRESS_2_ID=staging +WORDPRESS_2_ALIASES=stage,dev + +# Site 3 (Development) +WORDPRESS_3_URL=https://dev-site.com +WORDPRESS_3_USERNAME=admin +WORDPRESS_3_PASSWORD=app_password_3 +WORDPRESS_3_ID=development +``` + +**Multi-Site Configuration Options:** +- `WORDPRESS_N_URL`: WordPress site URL (required) +- `WORDPRESS_N_USERNAME`: WordPress username (required) +- `WORDPRESS_N_PASSWORD`: WordPress application password (required) +- `WORDPRESS_N_ID`: Site identifier (optional, defaults to `siteN`) +- `WORDPRESS_N_DEFAULT`: Set to `true` to make this the default site (optional, first site is default) +- `WORDPRESS_N_ALIASES`: Comma-separated aliases for site detection (optional) + +The server supports up to 10 sites. When using multi-site configuration, all tools accept an optional `site_id` parameter to target specific sites. + +## Using with npx and .env file + +You can run this MCP server directly using npx without installing it globally: + +```bash +npx -y @instawp/mcp-wp +``` + +Make sure you have a `.env` file in your current directory with the configuration variables shown above. + ## Development ### Prerequisites @@ -145,13 +197,28 @@ WORDPRESS_PASSWORD=wp_app_password 3. **Create a `.env` file:** - Create a `.env` file in the root of your project directory and add your WordPress API credentials: - + Create a `.env` file in the root of your project directory and add your WordPress API credentials. + + For a single site: ```env WORDPRESS_API_URL=https://your-wordpress-site.com WORDPRESS_USERNAME=wp_username WORDPRESS_PASSWORD=wp_app_password ``` + + For multiple sites: + ```env + WORDPRESS_1_URL=https://site1.com + WORDPRESS_1_USERNAME=admin + WORDPRESS_1_PASSWORD=app_password_1 + WORDPRESS_1_ID=site1 + WORDPRESS_1_DEFAULT=true + + WORDPRESS_2_URL=https://site2.com + WORDPRESS_2_USERNAME=admin + WORDPRESS_2_PASSWORD=app_password_2 + WORDPRESS_2_ID=site2 + ``` Replace the placeholders with your actual values. @@ -202,10 +269,13 @@ src/ ├── server.ts # MCP server entry point ├── wordpress.ts # WordPress REST API client ├── cli.ts # CLI interface +├── config/ +│ └── site-manager.ts # Multi-site management ├── types/ │ └── wordpress-types.ts # TypeScript definitions └── tools/ ├── index.ts # Tool aggregation + ├── site-management.ts # Site management (3 tools) ├── unified-content.ts # Universal content management (8 tools) ├── unified-taxonomies.ts # Universal taxonomy management (8 tools) ├── media.ts # Media management (~5 tools) @@ -217,6 +287,7 @@ src/ ### Key Features +- **Multi-Site Support**: Manage multiple WordPress sites from a single MCP server instance - **Smart URL Resolution**: Automatically detect content types from URLs and find corresponding content - **Universal Content Management**: Single set of tools handles posts, pages, and custom post types - **Universal Taxonomy Management**: Single set of tools handles categories, tags, and custom taxonomies diff --git a/claude_desktop_config.json.example b/claude_desktop_config.json.example index 1176bdb..fc12d4e 100644 --- a/claude_desktop_config.json.example +++ b/claude_desktop_config.json.example @@ -4,9 +4,24 @@ "command": "npx", "args": ["-y", "@instawp/mcp-wp"], "env": { + "_comment_single_site": "Single site configuration (use this OR multi-site, not both)", "WORDPRESS_API_URL": "https://wpsite.instawp.xyz", "WORDPRESS_USERNAME": "username", - "WORDPRESS_PASSWORD": "Application Password" + "WORDPRESS_PASSWORD": "Application Password", + + "_comment_multi_site": "Multi-site configuration (up to 10 sites supported)", + "_WORDPRESS_1_URL": "https://production-site.com", + "_WORDPRESS_1_USERNAME": "admin", + "_WORDPRESS_1_PASSWORD": "app_password_1", + "_WORDPRESS_1_ID": "production", + "_WORDPRESS_1_DEFAULT": "true", + "_WORDPRESS_1_ALIASES": "prod,main", + + "_WORDPRESS_2_URL": "https://staging-site.com", + "_WORDPRESS_2_USERNAME": "admin", + "_WORDPRESS_2_PASSWORD": "app_password_2", + "_WORDPRESS_2_ID": "staging", + "_WORDPRESS_2_ALIASES": "stage,dev" } } } diff --git a/src/config/site-manager.ts b/src/config/site-manager.ts new file mode 100644 index 0000000..abd627d --- /dev/null +++ b/src/config/site-manager.ts @@ -0,0 +1,241 @@ +import axios, { AxiosInstance } from 'axios'; +import { logToFile } from '../wordpress.js'; + +export interface SiteConfig { + id: string; + url: string; + username: string; + password: string; + aliases?: string[]; + default?: boolean; +} + +export class SiteManager { + private sites = new Map(); + private clients = new Map(); + private defaultSiteId: string | null = null; + private initialized = false; + + constructor() { + // Don't load sites immediately - wait for first access + } + + /** + * Ensure sites are loaded (lazy initialization) + */ + private ensureInitialized() { + if (!this.initialized) { + this.loadSitesFromEnvironment(); + this.initialized = true; + } + } + + /** + * Load site configurations from environment variables + */ + private loadSitesFromEnvironment() { + let sitesFound = 0; + + // Check for numbered multi-site configuration (WORDPRESS_1_URL, WORDPRESS_2_URL, etc.) + for (let i = 1; i <= 10; i++) { // Support up to 10 sites + const urlKey = `WORDPRESS_${i}_URL`; + const usernameKey = `WORDPRESS_${i}_USERNAME`; + const passwordKey = `WORDPRESS_${i}_PASSWORD`; + const idKey = `WORDPRESS_${i}_ID`; + const aliasesKey = `WORDPRESS_${i}_ALIASES`; + const defaultKey = `WORDPRESS_${i}_DEFAULT`; + + if (process.env[urlKey] && process.env[usernameKey] && process.env[passwordKey]) { + const siteConfig: SiteConfig = { + id: process.env[idKey] || `site${i}`, + url: process.env[urlKey]!, + username: process.env[usernameKey]!, + password: process.env[passwordKey]!, + aliases: process.env[aliasesKey] ? process.env[aliasesKey]!.split(',').map(s => s.trim()) : undefined, + default: process.env[defaultKey] === 'true' || (sitesFound === 0 && i === 1) // First site is default unless explicitly set + }; + + this.sites.set(siteConfig.id, siteConfig); + if (siteConfig.default) { + this.defaultSiteId = siteConfig.id; + } + sitesFound++; + } + } + + // If no numbered sites found, fall back to single-site configuration + if (sitesFound === 0 && process.env.WORDPRESS_API_URL && process.env.WORDPRESS_USERNAME && process.env.WORDPRESS_PASSWORD) { + const siteConfig: SiteConfig = { + id: 'default', + url: process.env.WORDPRESS_API_URL, + username: process.env.WORDPRESS_USERNAME, + password: process.env.WORDPRESS_PASSWORD, + default: true + }; + this.sites.set('default', siteConfig); + this.defaultSiteId = 'default'; + sitesFound = 1; + logToFile('Loaded single site configuration from legacy environment variables'); + } + + if (sitesFound > 0) { + logToFile(`Loaded ${sitesFound} WordPress site(s) from environment variables`); + if (this.defaultSiteId) { + logToFile(`Default site: ${this.defaultSiteId}`); + } + } else { + throw new Error('No WordPress configuration found. Set WORDPRESS_1_URL, WORDPRESS_1_USERNAME, WORDPRESS_1_PASSWORD (and optionally WORDPRESS_2_*, etc.) or use legacy WORDPRESS_API_URL variables.'); + } + } + + /** + * Get site configuration by ID + */ + getSite(siteId?: string): SiteConfig { + this.ensureInitialized(); + + const targetSiteId = siteId || this.defaultSiteId; + if (!targetSiteId) { + throw new Error('No site specified and no default site configured'); + } + + const site = this.sites.get(targetSiteId); + if (!site) { + const availableSites = Array.from(this.sites.keys()).join(', '); + throw new Error(`Site '${targetSiteId}' not found. Available sites: ${availableSites}`); + } + + return site; + } + + /** + * Get all configured sites + */ + getAllSites(): SiteConfig[] { + this.ensureInitialized(); + return Array.from(this.sites.values()); + } + + /** + * Get default site ID + */ + getDefaultSiteId(): string | null { + this.ensureInitialized(); + return this.defaultSiteId; + } + + /** + * Detect site from context (domain mentions, aliases, etc.) + */ + detectSiteFromContext(requestText: string): string | null { + this.ensureInitialized(); + + if (!requestText) return null; + + const lowerRequest = requestText.toLowerCase(); + + // Check for domain mentions + for (const site of this.sites.values()) { + try { + const hostname = new URL(site.url).hostname; + if (lowerRequest.includes(hostname)) { + logToFile(`Detected site '${site.id}' from domain mention: ${hostname}`); + return site.id; + } + } catch (error) { + // Invalid URL, skip + } + } + + // Check for alias mentions + for (const site of this.sites.values()) { + if (site.aliases) { + for (const alias of site.aliases) { + if (lowerRequest.includes(alias.toLowerCase())) { + logToFile(`Detected site '${site.id}' from alias mention: ${alias}`); + return site.id; + } + } + } + } + + // Check for site ID mentions + for (const siteId of this.sites.keys()) { + if (lowerRequest.includes(siteId.toLowerCase())) { + logToFile(`Detected site '${siteId}' from ID mention`); + return siteId; + } + } + + return null; + } + + /** + * Get WordPress client for a specific site + */ + async getClient(siteId?: string): Promise { + this.ensureInitialized(); + + const site = this.getSite(siteId); + + if (!this.clients.has(site.id)) { + const client = await this.createClient(site); + this.clients.set(site.id, client); + } + + return this.clients.get(site.id)!; + } + + /** + * Create authenticated WordPress client for a site + */ + private async createClient(site: SiteConfig): Promise { + // Ensure the API URL has the WordPress REST API path + let baseURL = site.url.endsWith('/') ? site.url : `${site.url}/`; + + if (!baseURL.includes('/wp-json/wp/v2')) { + baseURL = baseURL + 'wp-json/wp/v2/'; + } else if (!baseURL.endsWith('/')) { + baseURL = baseURL + '/'; + } + + const auth = Buffer.from(`${site.username}:${site.password}`).toString('base64'); + + const client = axios.create({ + baseURL, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${auth}` + } + }); + + // Test the connection + try { + await client.get(''); + logToFile(`Successfully connected to site '${site.id}' at ${baseURL}`); + } catch (error: any) { + logToFile(`Failed to connect to site '${site.id}': ${error.message}`); + throw new Error(`Failed to connect to site '${site.id}': ${error.message}`); + } + + return client; + } + + /** + * Test connection to a specific site + */ + async testSite(siteId?: string): Promise<{ success: boolean; error?: string }> { + this.ensureInitialized(); + + try { + const client = await this.getClient(siteId); + await client.get(''); + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } + } +} + +// Global site manager instance +export const siteManager = new SiteManager(); diff --git a/src/server.ts b/src/server.ts index e760df1..8030a6c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -62,11 +62,6 @@ for (const tool of allTools) { async function main() { const { logToFile } = await import('./wordpress.js'); logToFile('Starting WordPress MCP server...'); - - if (!process.env.WORDPRESS_API_URL) { - logToFile('Missing required environment variables. Please check your .env file.'); - process.exit(1); - } try { logToFile('Initializing WordPress client...'); diff --git a/src/tools/index.ts b/src/tools/index.ts index e062c5e..a49d896 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -7,8 +7,9 @@ import { mediaTools, mediaHandlers } from './media.js'; import { userTools, userHandlers } from './users.js'; import { pluginRepositoryTools, pluginRepositoryHandlers } from './plugin-repository.js'; import { commentTools, commentHandlers } from './comments.js'; +import { siteManagementTools, siteManagementHandlers } from './site-management.js'; -// Combine all tools - now significantly reduced from ~65 to ~35 tools +// Combine all tools - now significantly reduced from ~65 to ~38 tools export const allTools: Tool[] = [ ...unifiedContentTools, // 8 tools (replaces posts, pages, custom-post-types) ...unifiedTaxonomyTools, // 8 tools (replaces categories, custom-taxonomies) @@ -16,7 +17,8 @@ export const allTools: Tool[] = [ ...mediaTools, // ~5 tools ...userTools, // ~5 tools ...pluginRepositoryTools, // ~2 tools - ...commentTools // ~5 tools + ...commentTools, // ~5 tools + ...siteManagementTools // 3 tools (multi-site support) ]; // Combine all handlers @@ -27,5 +29,6 @@ export const toolHandlers = { ...mediaHandlers, ...userHandlers, ...pluginRepositoryHandlers, - ...commentHandlers + ...commentHandlers, + ...siteManagementHandlers }; \ No newline at end of file diff --git a/src/tools/site-management.ts b/src/tools/site-management.ts new file mode 100644 index 0000000..9b96c0b --- /dev/null +++ b/src/tools/site-management.ts @@ -0,0 +1,153 @@ +// src/tools/site-management.ts +import { z } from 'zod'; +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { siteManager } from '../config/site-manager.js'; + +// Schemas +const listSitesSchema = z.object({}); + +const getSiteSchema = z.object({ + site_id: z.string().optional().describe('Site ID to get details for. If not provided, returns the default site.') +}); + +const testSiteSchema = z.object({ + site_id: z.string().optional().describe('Site ID to test connection. If not provided, tests the default site.') +}); + +// Tools +export const siteManagementTools: Tool[] = [ + { + name: 'list_sites', + description: 'List all configured WordPress sites. Shows site IDs, URLs, and which is the default.', + inputSchema: { + type: 'object', + properties: listSitesSchema.shape, + required: [] + } + }, + { + name: 'get_site', + description: 'Get details about a specific WordPress site configuration.', + inputSchema: { + type: 'object', + properties: getSiteSchema.shape, + required: [] + } + }, + { + name: 'test_site', + description: 'Test the connection to a specific WordPress site.', + inputSchema: { + type: 'object', + properties: testSiteSchema.shape, + required: [] + } + } +]; + +// Handlers +export const siteManagementHandlers = { + list_sites: async (params: z.infer) => { + try { + const sites = siteManager.getAllSites(); + const defaultSiteId = siteManager.getDefaultSiteId(); + + const sitesList = sites.map(site => ({ + id: site.id, + url: site.url, + username: site.username, + aliases: site.aliases || [], + isDefault: site.id === defaultSiteId + })); + + return { + toolResult: { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + sites: sitesList, + count: sites.length, + default_site: defaultSiteId + }, null, 2) + }] + } + }; + } catch (error: any) { + return { + toolResult: { + content: [{ + type: 'text' as const, + text: `Error listing sites: ${error.message}` + }], + isError: true + } + }; + } + }, + + get_site: async (params: z.infer) => { + try { + const site = siteManager.getSite(params.site_id); + + return { + toolResult: { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + id: site.id, + url: site.url, + username: site.username, + aliases: site.aliases || [], + isDefault: site.id === siteManager.getDefaultSiteId() + }, null, 2) + }] + } + }; + } catch (error: any) { + return { + toolResult: { + content: [{ + type: 'text' as const, + text: `Error getting site: ${error.message}` + }], + isError: true + } + }; + } + }, + + test_site: async (params: z.infer) => { + try { + const result = await siteManager.testSite(params.site_id); + const site = siteManager.getSite(params.site_id); + + return { + toolResult: { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + site_id: site.id, + site_url: site.url, + success: result.success, + error: result.error || null, + message: result.success + ? `Successfully connected to ${site.url}` + : `Failed to connect to ${site.url}: ${result.error}` + }, null, 2) + }], + isError: !result.success + } + }; + } catch (error: any) { + return { + toolResult: { + content: [{ + type: 'text' as const, + text: `Error testing site: ${error.message}` + }], + isError: true + } + }; + } + } +}; diff --git a/src/tools/unified-content.ts b/src/tools/unified-content.ts index 9ababfd..a3ed90d 100644 --- a/src/tools/unified-content.ts +++ b/src/tools/unified-content.ts @@ -9,7 +9,7 @@ let cacheTimestamp: number = 0; const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes // Helper function to get all post types with caching -async function getPostTypes(forceRefresh = false) { +async function getPostTypes(forceRefresh = false, siteId?: string) { const now = Date.now(); if (!forceRefresh && postTypesCache && (now - cacheTimestamp) < CACHE_DURATION) { @@ -19,7 +19,7 @@ async function getPostTypes(forceRefresh = false) { try { logToFile('Fetching post types from API'); - const response = await makeWordPressRequest('GET', 'types'); + const response = await makeWordPressRequest('GET', 'types', undefined, { siteId }); postTypesCache = response; cacheTimestamp = now; return response; @@ -62,12 +62,12 @@ function parseUrl(url: string): { slug: string; pathHints: string[] } { } // Helper function to find content across multiple post types -async function findContentAcrossTypes(slug: string, contentTypes?: string[]) { +async function findContentAcrossTypes(slug: string, contentTypes?: string[], siteId?: string) { const typesToSearch = contentTypes || []; // If no specific content types provided, get all available types if (typesToSearch.length === 0) { - const allTypes = await getPostTypes(); + const allTypes = await getPostTypes(false, siteId); typesToSearch.push(...Object.keys(allTypes).filter(type => type !== 'attachment' && type !== 'wp_block' )); @@ -83,7 +83,7 @@ async function findContentAcrossTypes(slug: string, contentTypes?: string[]) { const response = await makeWordPressRequest('GET', endpoint, { slug: slug, per_page: 1 - }); + }, { siteId }); if (Array.isArray(response) && response.length > 0) { logToFile(`Found content with slug "${slug}" in content type "${contentType}"`); @@ -100,6 +100,7 @@ async function findContentAcrossTypes(slug: string, contentTypes?: string[]) { // Schema definitions const listContentSchema = z.object({ content_type: z.string().describe("The content type slug (e.g., 'post', 'page', 'product', 'documentation')"), + site_id: z.string().optional().describe("Site ID (for multi-site setups)"), page: z.number().optional().describe("Page number (default 1)"), per_page: z.number().min(1).max(100).optional().describe("Items per page (default 10, max 100)"), search: z.string().optional().describe("Search term for content title or body"), @@ -117,11 +118,13 @@ const listContentSchema = z.object({ const getContentSchema = z.object({ content_type: z.string().describe("The content type slug"), - id: z.number().describe("Content ID") + id: z.number().describe("Content ID"), + site_id: z.string().optional().describe("Site ID (for multi-site setups)") }); const createContentSchema = z.object({ content_type: z.string().describe("The content type slug"), + site_id: z.string().optional().describe("Site ID (for multi-site setups)"), title: z.string().describe("Content title"), content: z.string().describe("Content body"), status: z.string().optional().default('draft').describe("Content status"), @@ -141,6 +144,7 @@ const createContentSchema = z.object({ const updateContentSchema = z.object({ content_type: z.string().describe("The content type slug"), id: z.number().describe("Content ID"), + site_id: z.string().optional().describe("Site ID (for multi-site setups)"), title: z.string().optional().describe("Content title"), content: z.string().optional().describe("Content body"), status: z.string().optional().describe("Content status"), @@ -160,15 +164,18 @@ const updateContentSchema = z.object({ const deleteContentSchema = z.object({ content_type: z.string().describe("The content type slug"), id: z.number().describe("Content ID"), + site_id: z.string().optional().describe("Site ID (for multi-site setups)"), force: z.boolean().optional().describe("Whether to bypass trash and force deletion") }); const discoverContentTypesSchema = z.object({ - refresh_cache: z.boolean().optional().describe("Force refresh the content types cache") + refresh_cache: z.boolean().optional().describe("Force refresh the content types cache"), + site_id: z.string().optional().describe("Site ID (for multi-site setups)") }); const findContentByUrlSchema = z.object({ url: z.string().describe("The full URL of the content to find"), + site_id: z.string().optional().describe("Site ID (for multi-site setups)"), update_fields: z.object({ title: z.string().optional(), content: z.string().optional(), @@ -180,6 +187,7 @@ const findContentByUrlSchema = z.object({ const getContentBySlugSchema = z.object({ slug: z.string().describe("The slug to search for"), + site_id: z.string().optional().describe("Site ID (for multi-site setups)"), content_types: z.array(z.string()).optional().describe("Content types to search in (defaults to all)") }); @@ -240,9 +248,9 @@ export const unifiedContentHandlers = { list_content: async (params: ListContentParams) => { try { const endpoint = getContentEndpoint(params.content_type); - const { content_type, ...queryParams } = params; + const { content_type, site_id, ...queryParams } = params; - const response = await makeWordPressRequest('GET', endpoint, queryParams); + const response = await makeWordPressRequest('GET', endpoint, queryParams, { siteId: site_id }); return { toolResult: { @@ -269,7 +277,7 @@ export const unifiedContentHandlers = { get_content: async (params: GetContentParams) => { try { const endpoint = getContentEndpoint(params.content_type); - const response = await makeWordPressRequest('GET', `${endpoint}/${params.id}`); + const response = await makeWordPressRequest('GET', `${endpoint}/${params.id}`, undefined, { siteId: params.site_id }); return { toolResult: { @@ -329,7 +337,7 @@ export const unifiedContentHandlers = { } }); - const response = await makeWordPressRequest('POST', endpoint, contentData); + const response = await makeWordPressRequest('POST', endpoint, contentData, { siteId: params.site_id }); return { toolResult: { @@ -379,7 +387,7 @@ export const unifiedContentHandlers = { Object.assign(updateData, params.custom_fields); } - const response = await makeWordPressRequest('POST', `${endpoint}/${params.id}`, updateData); + const response = await makeWordPressRequest('POST', `${endpoint}/${params.id}`, updateData, { siteId: params.site_id }); return { toolResult: { @@ -409,7 +417,7 @@ export const unifiedContentHandlers = { const response = await makeWordPressRequest('DELETE', `${endpoint}/${params.id}`, { force: params.force || false - }); + }, { siteId: params.site_id }); return { toolResult: { @@ -435,7 +443,7 @@ export const unifiedContentHandlers = { discover_content_types: async (params: DiscoverContentTypesParams) => { try { - const contentTypes = await getPostTypes(params.refresh_cache || false); + const contentTypes = await getPostTypes(params.refresh_cache || false, params.site_id); // Format the response to be more readable const formattedTypes = Object.entries(contentTypes).map(([slug, type]: [string, any]) => ({ @@ -511,11 +519,11 @@ export const unifiedContentHandlers = { const typesToSearch = [...new Set(priorityTypes)]; // Find the content - const result = await findContentAcrossTypes(slug, typesToSearch); + const result = await findContentAcrossTypes(slug, typesToSearch, params.site_id); if (!result) { // If not found in priority types, search all types - const allResult = await findContentAcrossTypes(slug); + const allResult = await findContentAcrossTypes(slug, undefined, params.site_id); if (!allResult) { throw new Error(`No content found with URL: ${params.url}`); } @@ -535,7 +543,7 @@ export const unifiedContentHandlers = { Object.assign(updateData, params.update_fields.custom_fields); } - const updatedContent = await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData); + const updatedContent = await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData, { siteId: params.site_id }); return { toolResult: { @@ -587,7 +595,7 @@ export const unifiedContentHandlers = { Object.assign(updateData, params.update_fields.custom_fields); } - const updatedContent = await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData); + const updatedContent = await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData, { siteId: params.site_id }); return { toolResult: { @@ -637,7 +645,7 @@ export const unifiedContentHandlers = { get_content_by_slug: async (params: GetContentBySlugParams) => { try { - const result = await findContentAcrossTypes(params.slug, params.content_types); + const result = await findContentAcrossTypes(params.slug, params.content_types, params.site_id); if (!result) { throw new Error(`No content found with slug: ${params.slug}`); diff --git a/src/wordpress.ts b/src/wordpress.ts index ac4cd9d..0b7baef 100644 --- a/src/wordpress.ts +++ b/src/wordpress.ts @@ -1,81 +1,25 @@ // src/wordpress.ts import * as dotenv from 'dotenv'; import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; -import * as fs from 'fs'; -import * as path from 'path'; +import { siteManager } from './config/site-manager.js'; -// Global WordPress API client instance +// Legacy global WordPress API client instance for backward compatibility let wpClient: AxiosInstance; /** * Initialize the WordPress API client with authentication + * Now uses SiteManager for multi-site support */ export async function initWordPress() { - const apiUrl = process.env.WORDPRESS_API_URL; - const username = process.env.WORDPRESS_USERNAME; - const appPassword = process.env.WORDPRESS_PASSWORD; - - if (!apiUrl) { - throw new Error('WordPress API URL not found in environment variables'); - } - - // Ensure the API URL has the WordPress REST API path - let baseURL = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`; - - // Add the WordPress REST API path if not already included - if (!baseURL.includes('/wp-json/wp/v2')) { - baseURL = baseURL + 'wp-json/wp/v2/'; - } else if (!baseURL.endsWith('/')) { - // Ensure the URL ends with a trailing slash - baseURL = baseURL + '/'; - } - - const config: AxiosRequestConfig = { - baseURL, - headers: { - 'Content-Type': 'application/json', - }, - }; - - // Add authentication if credentials are provided - if (username && appPassword) { - logToFile('Adding authentication headers'); - logToFile(`Username: ${username}`); - logToFile(`App Password: ${appPassword}`); - - const auth = Buffer.from(`${username}:${appPassword}`).toString('base64'); - config.headers = { - ...config.headers, - 'Authorization': `Basic ${auth}` - }; - } - - wpClient = axios.create(config); - - // Verify connection to WordPress API - try { - await wpClient.get(''); - logToFile('Successfully connected to WordPress API'); - } catch (error: any) { - logToFile(`Failed to connect to WordPress API: ${error.message}`); - throw new Error(`Failed to connect to WordPress API: ${error.message}`); - } -} - -// Configure logging -const META_URL = import.meta.url.replace(/^file:\/\/\//, ''); -const LOG_DIR = path.join(path.dirname(META_URL), '../logs'); -const LOG_FILE = path.join(LOG_DIR, 'wordpress-api.log'); - -// Ensure log directory exists -if (!fs.existsSync(LOG_DIR)) { - fs.mkdirSync(LOG_DIR, { recursive: true }); + // Initialize the default site client + const client = await siteManager.getClient(); + wpClient = client; + logToFile('WordPress client initialized successfully via SiteManager'); } export function logToFile(message: string) { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] ${message}\n`; - fs.appendFileSync(LOG_FILE, logMessage); + // Logging disabled + return; } /** @@ -83,7 +27,7 @@ export function logToFile(message: string) { * @param method HTTP method * @param endpoint API endpoint (relative to the baseURL) * @param data Request data - * @param options Additional request options + * @param options Additional request options including siteId for multi-site support * @returns Response data */ export async function makeWordPressRequest( @@ -94,11 +38,13 @@ export async function makeWordPressRequest( headers?: Record; isFormData?: boolean; rawResponse?: boolean; + siteId?: string; } ) { - if (!wpClient) { - throw new Error('WordPress client not initialized'); - } + // Get the appropriate client for the site + const client = options?.siteId + ? await siteManager.getClient(options.siteId) + : (wpClient || await siteManager.getClient()); // Log data (skip for FormData which can't be stringified) if (!options?.isFormData) { @@ -111,7 +57,7 @@ export async function makeWordPressRequest( const path = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint; try { - const fullUrl = `${wpClient.defaults.baseURL}${path}`; + const fullUrl = `${client.defaults.baseURL}${path}`; // Prepare request config const requestConfig: any = { @@ -136,12 +82,13 @@ export async function makeWordPressRequest( REQUEST: URL: ${fullUrl} Method: ${method} -Headers: ${JSON.stringify({...wpClient.defaults.headers, ...requestConfig.headers}, null, 2)} +Site: ${options?.siteId || 'default'} +Headers: ${JSON.stringify({...client.defaults.headers, ...requestConfig.headers}, null, 2)} Data: ${options?.isFormData ? '(FormData not shown)' : JSON.stringify(data, null, 2)} `; logToFile(requestLog); - const response = await wpClient.request(requestConfig); + const response = await client.request(requestConfig); const responseLog = ` RESPONSE: