Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,10 @@ Handles ALL content types (posts, pages, custom post types) with a single set of
- `list_content` - List any content type with filtering and pagination
- `get_content` - Get specific content by ID and type
- `create_content` - Create new content of any type
- `update_content` - Update existing content of any type
- `update_content` - Update existing content of any type, including targeted partial edits
- `delete_content` - Delete content of any type
- `discover_content_types` - Find all available content types
- `find_content_by_url` - Smart URL resolver with optional update
- `find_content_by_url` - Smart URL resolver with optional full or targeted update
- `get_content_by_slug` - Search by slug across content types

#### **Unified Taxonomy Tools** (`unified-taxonomies.ts`) - 8 tools
Expand Down Expand Up @@ -185,6 +185,26 @@ All content operations use a single `content_type` parameter:
}
```

Targeted content edits are also supported through `content_edit` on `update_content` and `find_content_by_url.update_fields`:

```json
{
"content_type": "post",
"id": 42,
"content_edit": {
"operation": "append",
"value": "\n<p>Update: Early access is now open.</p>",
"content_format": "html"
}
}
```

To retrieve an exact target string for those edits, `get_content` and `find_content_by_url` also support `include_raw_content: true`, which fetches the item with WordPress edit context and returns a top-level `content_raw` field.

Rendered WordPress HTML can differ from `content.raw`, so exact partial-edit targeting should use the raw value rather than copied rendered markup.

For targeted operations, `target_text` must match the stored raw WordPress content exactly. If it appears multiple times, provide `occurrence` to choose the 1-based match.

#### Unified Taxonomy Management
All taxonomy operations use a single `taxonomy` parameter:
```json
Expand Down
54 changes: 52 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ Handles ALL content types (posts, pages, custom post types) with a single set of
- `list_content`: List any content type with filtering and pagination
- `get_content`: Get specific content by ID and type
- `create_content`: Create new content of any type
- `update_content`: Update existing content of any type
- `update_content`: Update existing content of any type, including targeted partial edits
- `delete_content`: Delete content of any type
- `discover_content_types`: Find all available content types on your site
- `find_content_by_url`: Smart URL resolver that can find and optionally update content from any WordPress URL
- `find_content_by_url`: Smart URL resolver that can find and optionally update content from any WordPress URL, including targeted partial edits
- `get_content_by_slug`: Search by slug across all content types

### **Unified Taxonomy Management** (8 tools)
Expand Down Expand Up @@ -144,6 +144,56 @@ All content operations use a single `content_type` parameter:
}
```

#### Targeted Content Edits

`update_content` and `find_content_by_url.update_fields` can patch the existing raw WordPress content without resending the full document.

To make exact matching easier, `get_content` and `find_content_by_url` both accept `include_raw_content: true`. When enabled, the response is fetched with WordPress edit context and includes a top-level `content_raw` field that matches what `content_edit.target_text` needs.

```json
{
"content_type": "page",
"id": 7,
"include_raw_content": true
}
```

Append a short release note to the end of a post:

```json
{
"content_type": "post",
"id": 42,
"content_edit": {
"operation": "append",
"value": "\n<p>Update: Early access is now open.</p>",
"content_format": "html"
}
}
```

Replace a unique HTML fragment or marker comment in place:

```json
{
"content_type": "page",
"id": 7,
"content_edit": {
"operation": "replace",
"target_text": "<!-- pricing-card -->\n<p>Old price</p>\n<!-- /pricing-card -->",
"value": "<!-- pricing-card -->\n<p>New price</p>\n<!-- /pricing-card -->",
"content_format": "html"
}
}
```

Notes:

- Rendered WordPress HTML can differ from `content.raw` because entities may be escaped and markup may be expanded, so use `include_raw_content` when you need an exact `target_text`.
- `target_text` matches the stored raw WordPress content exactly.
- If the same `target_text` appears multiple times, pass `occurrence` to choose the 1-based match.
- For posts stored as Gutenberg blocks, set `content_edit.convert_to_blocks` when inserting Markdown or HTML that should become blocks.

#### Universal Taxonomy Operations

All taxonomy operations use a single `taxonomy` parameter:
Expand Down
113 changes: 55 additions & 58 deletions src/tools/sql-query.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,42 @@
// src/tools/sql-query.ts
import { z } from 'zod';
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { makeWordPressRequest } from '../wordpress.js';
import axios from 'axios';
import { siteManager } from '../config/site-manager.js';

// Schema for SQL query execution
const executeSqlQuerySchema = z.object({
query: z.string().describe('SQL query to execute (read-only queries: SELECT, WITH...SELECT, EXPLAIN only)')
query: z.string().describe('SQL query to execute (read-only queries: SELECT, WITH...SELECT, EXPLAIN only)'),
site_id: z.string().optional().describe('Site ID (for multi-site setups, e.g. "wp-fusion")')
});

// Type definition
type ExecuteSqlQueryParams = z.infer<typeof executeSqlQuerySchema>;

const DANGEROUS_PATTERNS = [
/DROP\s+/i,
/DELETE\s+/i,
/UPDATE\s+/i,
/INSERT\s+/i,
/TRUNCATE\s+/i,
/ALTER\s+/i,
/CREATE\s+/i,
/GRANT\s+/i,
/REVOKE\s+/i
];

// Tools
export const sqlQueryTools: Tool[] = [
{
name: 'execute_sql_query',
description: 'Execute a SQL query against the WordPress database. For safety, only SELECT queries are allowed. Requires the WP Fusion Database Query endpoint to be enabled.',
// server.ts rebuilds the schema with z.object(inputSchema.properties), so
// properties must be zod shapes like every other tool — raw JSON Schema
// here collapses the published schema to {} and clients strip all params.
inputSchema: {
type: 'object',
properties: executeSqlQuerySchema.shape,
required: ['query']
}
properties: executeSqlQuerySchema.shape
} as unknown as Tool['inputSchema']
}
];

Expand All @@ -30,94 +46,78 @@ export const sqlQueryHandlers = {
try {
const query = params.query.trim();
const trimmedQuery = query.toUpperCase();

// Validate that it's a read-only query
const isSelect = trimmedQuery.startsWith('SELECT');
const isWithSelect = trimmedQuery.startsWith('WITH ');
const isExplainSelect = trimmedQuery.startsWith('EXPLAIN SELECT') || trimmedQuery.startsWith('EXPLAIN ');
if (!(isSelect || isWithSelect || isExplainSelect)) {
const isExplain = trimmedQuery.startsWith('EXPLAIN ');

if (!(isSelect || isWithSelect || isExplain)) {
return {
toolResult: {
content: [{
type: 'text' as const,
text: 'Error: Only read-only queries are allowed (SELECT, WITH...SELECT, EXPLAIN SELECT). Please use a valid read-only statement.'
}],
content: [{ type: 'text' as const, text: 'Error: Only read-only queries are allowed (SELECT, WITH...SELECT, EXPLAIN). Please use a valid read-only statement.' }],
isError: true
}
};
}

// Disallow multiple statements (semicolon followed by non-whitespace)
// Remove quoted strings first to avoid false positives
// Disallow multiple statements — strip quoted strings first to avoid false positives
const queryWithoutStrings = query.replace(/(['"]).*?\1/g, '');
if (/;\s*\S/.test(queryWithoutStrings)) {
return {
toolResult: {
content: [{
type: 'text' as const,
text: 'Error: Multiple SQL statements are not allowed. Please execute one query at a time.'
}],
content: [{ type: 'text' as const, text: 'Error: Multiple SQL statements are not allowed. Please execute one query at a time.' }],
isError: true
}
};
}

// Check for dangerous patterns
const dangerousPatterns = [
/DROP\s+/i,
/DELETE\s+/i,
/UPDATE\s+/i,
/INSERT\s+/i,
/TRUNCATE\s+/i,
/ALTER\s+/i,
/CREATE\s+/i,
/GRANT\s+/i,
/REVOKE\s+/i
];

for (const pattern of dangerousPatterns) {
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(query)) {
return {
toolResult: {
content: [{
type: 'text' as const,
text: `Error: Query contains potentially dangerous SQL statement. Only read-only queries are allowed.`
}],
content: [{ type: 'text' as const, text: 'Error: Query contains potentially dangerous SQL statement. Only read-only queries are allowed.' }],
isError: true
}
};
}
}

// Execute the query via the custom endpoint
// Use environment variable or default to /mcp/v1/query
const sqlEndpoint = process.env.WORDPRESS_SQL_ENDPOINT || '/mcp/v1/query';
const response = await makeWordPressRequest(
'POST',
sqlEndpoint,
{ query },
{ headers: { 'Content-Type': 'application/json' } }
);
// Build absolute URL directly from site config.
// makeWordPressRequest prepends /wp-json/wp/v2/ to all paths, so it cannot
// be used for the SQL endpoint which lives at /wp-json/mcp/v1/query.
const site = siteManager.getSite(params.site_id);
const sqlPath = process.env.WORDPRESS_SQL_ENDPOINT || '/mcp/v1/query';
const siteBase = site.url.replace(/\/$/, '');
const url = `${siteBase}/wp-json${sqlPath}`;

Comment on lines +89 to +93
const auth = Buffer.from(`${site.username}:${site.password}`).toString('base64');

const response = await axios.post(url, { query }, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${auth}`,
'User-Agent': 'Mozilla/5.0'
},
timeout: 30000
});

// Handle large result sets
const text = JSON.stringify(response, null, 2);
const text = JSON.stringify(response.data, null, 2);
const MAX_LENGTH = 50000;
const resultText = text.length > MAX_LENGTH
const resultText = text.length > MAX_LENGTH
? text.slice(0, MAX_LENGTH) + '\n\n...(truncated - result too large)'
: text;

return {
toolResult: {
content: [{
type: 'text' as const,
text: resultText
}]
content: [{ type: 'text' as const, text: resultText }]
}
};

} catch (error: any) {
// Check if it's a 404 error (endpoint not found)
const sqlPath = process.env.WORDPRESS_SQL_ENDPOINT || '/mcp/v1/query';

if (error.response?.status === 404) {
return {
toolResult: {
Expand All @@ -127,7 +127,7 @@ export const sqlQueryHandlers = {

To enable this feature, see the setup instructions in README.md under "Enabling SQL Query Tool (Optional)".

Expected endpoint: ${process.env.WORDPRESS_SQL_ENDPOINT || '/mcp/v1/query'}
Expected endpoint: ${sqlPath}
You can customize this by setting the WORDPRESS_SQL_ENDPOINT environment variable.`
}],
isError: true
Expand All @@ -137,10 +137,7 @@ You can customize this by setting the WORDPRESS_SQL_ENDPOINT environment variabl

return {
toolResult: {
content: [{
type: 'text' as const,
text: `Error executing SQL query: ${error.message}`
}],
content: [{ type: 'text' as const, text: `Error executing SQL query: ${error.message}` }],
isError: true
}
};
Expand Down
Loading