@adsim/wordpress-mcp-server 4.6.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -0
- package/README.md +851 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +353 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// src/tools/workflow.js — workflow tools (9)
|
|
2
|
+
// Definitions + handlers (v5.0.0 refactor Step B+C)
|
|
3
|
+
|
|
4
|
+
import { json } from '../shared/utils.js';
|
|
5
|
+
import { validateInput } from '../shared/governance.js';
|
|
6
|
+
import { rt } from '../shared/context.js';
|
|
7
|
+
|
|
8
|
+
// ── Named workflows ────────────────────────────────────────────
|
|
9
|
+
const NAMED_WORKFLOWS = {
|
|
10
|
+
'seo_audit_and_stage': {
|
|
11
|
+
description: 'Full SEO audit then create staging draft',
|
|
12
|
+
required_context: ['post_id'],
|
|
13
|
+
steps: [
|
|
14
|
+
{ tool: 'wp_audit_seo_score', args: { post_id: '{{post_id}}' } },
|
|
15
|
+
{ tool: 'wp_analyze_readability', args: { post_id: '{{post_id}}' } },
|
|
16
|
+
{ tool: 'wp_create_staging_draft', args: { source_post_id: '{{post_id}}' } }
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
'site_health_report': {
|
|
20
|
+
description: 'Complete site health and diagnostics',
|
|
21
|
+
required_context: [],
|
|
22
|
+
steps: [
|
|
23
|
+
{ tool: 'wp_get_site_health_status', args: {} },
|
|
24
|
+
{ tool: 'wp_list_site_health_issues', args: {} },
|
|
25
|
+
{ tool: 'wp_get_site_health_info', args: {} }
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
'content_publish_safe': {
|
|
29
|
+
description: 'Validate blocks then publish post',
|
|
30
|
+
required_context: ['post_id', 'content'],
|
|
31
|
+
steps: [
|
|
32
|
+
{ tool: 'wp_validate_block_structure', args: { content: '{{content}}' } },
|
|
33
|
+
{ tool: 'wp_update_post', args: { id: '{{post_id}}', status: 'publish' } }
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
'wc_product_audit': {
|
|
37
|
+
description: 'WooCommerce product SEO and stock audit',
|
|
38
|
+
required_context: [],
|
|
39
|
+
steps: [
|
|
40
|
+
{ tool: 'wc_audit_product_seo', args: { limit: 50 } },
|
|
41
|
+
{ tool: 'wc_audit_stock_alerts', args: { threshold: 5 } }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ── Template resolution ────────────────────────────────────────
|
|
47
|
+
function resolveTemplates(obj, context) {
|
|
48
|
+
if (typeof obj === 'string') {
|
|
49
|
+
return obj.replace(/\{\{(\w+)\}\}/g, (_, key) => context[key] !== undefined ? context[key] : `{{${key}}}`);
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(obj)) return obj.map(item => resolveTemplates(item, context));
|
|
52
|
+
if (obj && typeof obj === 'object') {
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const [k, v] of Object.entries(obj)) out[k] = resolveTemplates(v, context);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
return obj;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const definitions = [
|
|
61
|
+
{ name: 'wp_submit_for_review', _category: 'workflow', description: 'Use to transition a draft post to pending status for editorial review. Write — blocked by WP_READ_ONLY.',
|
|
62
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' }, note: { type: 'string', description: 'Optional review note (stored as post meta _mcp_review_note)' } }, required: ['id'] }},
|
|
63
|
+
{ name: 'wp_approve_post', _category: 'workflow', description: 'Use to transition a pending post to published (editor/admin action). Write — blocked by WP_READ_ONLY, WP_DRAFT_ONLY.',
|
|
64
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] }},
|
|
65
|
+
{ name: 'wp_reject_post', _category: 'workflow', description: 'Use to return a pending post to draft with a mandatory rejection reason. Write — blocked by WP_READ_ONLY.',
|
|
66
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' }, reason: { type: 'string', description: 'Reason for rejection (stored as post meta _mcp_rejection_reason)' } }, required: ['id', 'reason'] }},
|
|
67
|
+
{ name: 'wp_create_staging_draft', _category: 'workflow', description: 'Use to clone a published post/page into a shadow draft for safe editing. The live page stays untouched. Write — blocked by WP_READ_ONLY.',
|
|
68
|
+
inputSchema: { type: 'object', properties: { source_id: { type: 'number', description: 'ID of the published post or page to clone' }, post_type: { type: 'string', enum: ['post', 'page'], default: 'page', description: 'Content type of the source' } }, required: ['source_id'] }},
|
|
69
|
+
{ name: 'wp_list_staging_drafts', _category: 'workflow', description: 'Use to list all pending staging drafts, optionally filtered by source page. Read-only.',
|
|
70
|
+
inputSchema: { type: 'object', properties: { source_id: { type: 'number', description: 'Filter by original published page ID' }, per_page: { type: 'number', default: 20 }, page: { type: 'number', default: 1 } } }},
|
|
71
|
+
{ name: 'wp_get_staging_preview_url', _category: 'workflow', description: 'Use to get the native WordPress preview URL for a staging draft. Read-only.',
|
|
72
|
+
inputSchema: { type: 'object', properties: { staging_id: { type: 'number', description: 'ID of the staging draft' } }, required: ['staging_id'] }},
|
|
73
|
+
{ name: 'wp_merge_staging_to_live', _category: 'workflow', description: 'Use to push validated staging draft content to the live published page. Write — blocked by WP_READ_ONLY. Two-step: first call returns dry-run preview, pass confirm:true to execute.',
|
|
74
|
+
inputSchema: { type: 'object', properties: { staging_id: { type: 'number', description: 'ID of the staging draft' }, confirm: { type: 'boolean', default: false, description: 'false=dry-run preview, true=execute merge' } }, required: ['staging_id'] }},
|
|
75
|
+
{ name: 'wp_discard_staging_draft', _category: 'workflow', description: 'Use to permanently delete a staging draft without touching the live page. Write — blocked by WP_READ_ONLY. Two-step: first call returns dry-run, pass confirm:true to execute.',
|
|
76
|
+
inputSchema: { type: 'object', properties: { staging_id: { type: 'number', description: 'ID of the staging draft to discard' }, confirm: { type: 'boolean', default: false, description: 'false=dry-run, true=execute deletion' } }, required: ['staging_id'] }},
|
|
77
|
+
{ name: 'wp_run_workflow', _category: 'workflow', description: 'Execute a named workflow (sequence of tools) in a single call. Reduces latency and token usage vs sequential calls.',
|
|
78
|
+
inputSchema: { type: 'object', properties: { workflow: { type: 'string', description: 'Named workflow OR "custom"' }, steps: { type: 'array', description: 'Required if workflow="custom". Array of {tool, args}' }, context: { type: 'object', description: 'Shared variables available as {{key}} in step args' }, dry_run: { type: 'boolean', default: false, description: 'Return execution plan without running' }, stop_on_error: { type: 'boolean', default: true, description: 'Stop sequence on first failure' } }, required: ['workflow'] }}
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
export const handlers = {};
|
|
82
|
+
|
|
83
|
+
handlers['wp_submit_for_review'] = async (args) => {
|
|
84
|
+
const t0 = Date.now();
|
|
85
|
+
let result;
|
|
86
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
87
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
88
|
+
const { id, note } = args;
|
|
89
|
+
const p = await wpApiCall(`/posts/${id}`);
|
|
90
|
+
if (p.status !== 'draft' && p.status !== 'auto-draft') {
|
|
91
|
+
throw new Error(`Post ${id} is in "${p.status}" status. Only "draft" or "auto-draft" posts can be submitted for review.`);
|
|
92
|
+
}
|
|
93
|
+
const data = { status: 'pending' };
|
|
94
|
+
if (note) data.meta = { _mcp_review_note: note };
|
|
95
|
+
const up = await wpApiCall(`/posts/${id}`, { method: 'POST', body: JSON.stringify(data) });
|
|
96
|
+
result = json({ success: true, message: `Post ${id} submitted for review`, post: { id: up.id, title: up.title.rendered, status: up.status, link: up.link } });
|
|
97
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: 'submit_for_review', status: 'success', latency_ms: Date.now() - t0, params: { id } });
|
|
98
|
+
return result;
|
|
99
|
+
};
|
|
100
|
+
handlers['wp_approve_post'] = async (args) => {
|
|
101
|
+
const t0 = Date.now();
|
|
102
|
+
let result;
|
|
103
|
+
const { wpApiCall, getActiveControls, auditLog, name } = rt;
|
|
104
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
105
|
+
const { id } = args;
|
|
106
|
+
if (getActiveControls().draft_only) {
|
|
107
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: 'approve', status: 'blocked', latency_ms: Date.now() - t0, params: { id }, error: 'Blocked: Server is in DRAFT-ONLY mode (WP_DRAFT_ONLY=true). Publishing is not allowed.' });
|
|
108
|
+
return { content: [{ type: 'text', text: 'Error: Blocked: Server is in DRAFT-ONLY mode (WP_DRAFT_ONLY=true). Publishing is not allowed.' }], isError: true };
|
|
109
|
+
}
|
|
110
|
+
const p = await wpApiCall(`/posts/${id}`);
|
|
111
|
+
if (p.status !== 'pending') {
|
|
112
|
+
throw new Error(`Post ${id} is in "${p.status}" status. Only "pending" posts can be approved.`);
|
|
113
|
+
}
|
|
114
|
+
const up = await wpApiCall(`/posts/${id}`, { method: 'POST', body: JSON.stringify({ status: 'publish' }) });
|
|
115
|
+
result = json({ success: true, message: `Post ${id} approved and published`, post: { id: up.id, title: up.title.rendered, status: up.status, link: up.link } });
|
|
116
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: 'approve', status: 'success', latency_ms: Date.now() - t0, params: { id } });
|
|
117
|
+
return result;
|
|
118
|
+
};
|
|
119
|
+
handlers['wp_reject_post'] = async (args) => {
|
|
120
|
+
const t0 = Date.now();
|
|
121
|
+
let result;
|
|
122
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
123
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 }, reason: { type: 'string', required: true } });
|
|
124
|
+
const { id, reason } = args;
|
|
125
|
+
const p = await wpApiCall(`/posts/${id}`);
|
|
126
|
+
if (p.status !== 'pending') {
|
|
127
|
+
throw new Error(`Post ${id} is in "${p.status}" status. Only "pending" posts can be rejected.`);
|
|
128
|
+
}
|
|
129
|
+
const currentCount = parseInt(p.meta?._mcp_rejection_count || '0', 10);
|
|
130
|
+
const up = await wpApiCall(`/posts/${id}`, { method: 'POST', body: JSON.stringify({ status: 'draft', meta: { _mcp_rejection_reason: reason, _mcp_rejection_count: currentCount + 1 } }) });
|
|
131
|
+
result = json({ success: true, message: `Post ${id} rejected and moved to draft`, post: { id: up.id, title: up.title.rendered, status: up.status }, rejection: { reason, count: currentCount + 1 } });
|
|
132
|
+
auditLog({ tool: name, target: id, target_type: 'post', action: 'reject', status: 'success', latency_ms: Date.now() - t0, params: { id } });
|
|
133
|
+
return result;
|
|
134
|
+
};
|
|
135
|
+
handlers['wp_create_staging_draft'] = async (args) => {
|
|
136
|
+
const t0 = Date.now();
|
|
137
|
+
let result;
|
|
138
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
139
|
+
validateInput(args, { source_id: { type: 'number', required: true, min: 1 } });
|
|
140
|
+
const { source_id, post_type = 'page' } = args;
|
|
141
|
+
const endpoint = post_type === 'post' ? `/posts/${source_id}` : `/pages/${source_id}`;
|
|
142
|
+
const source = await wpApiCall(endpoint);
|
|
143
|
+
if (source.status !== 'publish') {
|
|
144
|
+
throw new Error(`Source ${post_type} ${source_id} is in "${source.status}" status. Only published content can be staged.`);
|
|
145
|
+
}
|
|
146
|
+
// Check if a staging draft already exists
|
|
147
|
+
const searchEndpoint = post_type === 'post' ? '/posts' : '/pages';
|
|
148
|
+
const existing = await wpApiCall(`${searchEndpoint}?status=draft&per_page=100&meta_key=_staging_source_id&meta_value=${source_id}`);
|
|
149
|
+
// WP REST API may not filter by meta — fallback: check returned posts
|
|
150
|
+
const existingDraft = Array.isArray(existing) ? existing.find(p => p.meta?._staging_source_id == source_id) : null;
|
|
151
|
+
if (existingDraft) {
|
|
152
|
+
result = json({ status: 'already_exists', staging_draft_id: existingDraft.id, source_id, message: `A staging draft already exists for ${post_type} ${source_id}` });
|
|
153
|
+
auditLog({ tool: name, target: source_id, target_type: post_type, action: 'create_staging', status: 'skipped', latency_ms: Date.now() - t0, params: { source_id } });
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
const sourceTitle = source.title?.rendered || '';
|
|
157
|
+
const draftData = {
|
|
158
|
+
title: `[STAGING] ${sourceTitle}`,
|
|
159
|
+
content: source.content?.rendered || '',
|
|
160
|
+
excerpt: source.excerpt?.rendered || '',
|
|
161
|
+
status: 'draft',
|
|
162
|
+
meta: { _staging_source_id: source_id }
|
|
163
|
+
};
|
|
164
|
+
if (post_type === 'page') {
|
|
165
|
+
draftData.parent = source.parent || 0;
|
|
166
|
+
draftData.template = source.template || '';
|
|
167
|
+
}
|
|
168
|
+
const createEndpoint = post_type === 'post' ? '/posts' : '/pages';
|
|
169
|
+
const draft = await wpApiCall(createEndpoint, { method: 'POST', body: JSON.stringify(draftData) });
|
|
170
|
+
result = json({ status: 'created', staging_draft_id: draft.id, source_id, source_title: sourceTitle, message: `Staging draft created. Edit draft ${draft.id}, then use wp_merge_staging_to_live to publish changes.` });
|
|
171
|
+
auditLog({ tool: name, target: draft.id, target_type: post_type, action: 'create_staging', status: 'success', latency_ms: Date.now() - t0, params: { source_id, staging_draft_id: draft.id } });
|
|
172
|
+
return result;
|
|
173
|
+
};
|
|
174
|
+
handlers['wp_list_staging_drafts'] = async (args) => {
|
|
175
|
+
const t0 = Date.now();
|
|
176
|
+
let result;
|
|
177
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
178
|
+
const { source_id, per_page = 20, page = 1 } = args;
|
|
179
|
+
// Search for drafts with [STAGING] prefix
|
|
180
|
+
const posts = await wpApiCall(`/posts?status=draft&per_page=${per_page}&page=${page}&search=${encodeURIComponent('[STAGING]')}`);
|
|
181
|
+
const pages = await wpApiCall(`/pages?status=draft&per_page=${per_page}&page=${page}&search=${encodeURIComponent('[STAGING]')}`);
|
|
182
|
+
let all = [
|
|
183
|
+
...(Array.isArray(posts) ? posts : []).map(p => ({ ...p, _type: 'post' })),
|
|
184
|
+
...(Array.isArray(pages) ? pages : []).map(p => ({ ...p, _type: 'page' }))
|
|
185
|
+
].filter(p => p.meta?._staging_source_id);
|
|
186
|
+
if (source_id) {
|
|
187
|
+
all = all.filter(p => p.meta._staging_source_id == source_id);
|
|
188
|
+
}
|
|
189
|
+
result = json({
|
|
190
|
+
total: all.length,
|
|
191
|
+
staging_drafts: all.map(p => ({
|
|
192
|
+
staging_id: p.id,
|
|
193
|
+
source_id: p.meta._staging_source_id,
|
|
194
|
+
type: p._type,
|
|
195
|
+
title: p.title?.rendered || '',
|
|
196
|
+
modified: p.modified,
|
|
197
|
+
link: p.link
|
|
198
|
+
}))
|
|
199
|
+
});
|
|
200
|
+
auditLog({ tool: name, action: 'list_staging', status: 'success', latency_ms: Date.now() - t0, params: { source_id } });
|
|
201
|
+
return result;
|
|
202
|
+
};
|
|
203
|
+
handlers['wp_get_staging_preview_url'] = async (args) => {
|
|
204
|
+
const t0 = Date.now();
|
|
205
|
+
let result;
|
|
206
|
+
const { wpApiCall, getActiveAuth, auditLog, name } = rt;
|
|
207
|
+
validateInput(args, { staging_id: { type: 'number', required: true, min: 1 } });
|
|
208
|
+
const { staging_id } = args;
|
|
209
|
+
// Try pages first, then posts
|
|
210
|
+
let draft, postType;
|
|
211
|
+
try {
|
|
212
|
+
draft = await wpApiCall(`/pages/${staging_id}`);
|
|
213
|
+
postType = 'page';
|
|
214
|
+
} catch (e) {
|
|
215
|
+
draft = await wpApiCall(`/posts/${staging_id}`);
|
|
216
|
+
postType = 'post';
|
|
217
|
+
}
|
|
218
|
+
if (!draft.meta?._staging_source_id) {
|
|
219
|
+
throw new Error(`${postType} ${staging_id} is not a staging draft (missing _staging_source_id meta).`);
|
|
220
|
+
}
|
|
221
|
+
const { url: siteUrl } = getActiveAuth();
|
|
222
|
+
const preview_url = `${siteUrl}/?p=${staging_id}&preview=true`;
|
|
223
|
+
result = json({ staging_id, source_id: draft.meta._staging_source_id, preview_url, status: draft.status, title: draft.title?.rendered || '' });
|
|
224
|
+
auditLog({ tool: name, target: staging_id, target_type: postType, action: 'preview_staging', status: 'success', latency_ms: Date.now() - t0 });
|
|
225
|
+
return result;
|
|
226
|
+
};
|
|
227
|
+
handlers['wp_merge_staging_to_live'] = async (args) => {
|
|
228
|
+
const t0 = Date.now();
|
|
229
|
+
let result;
|
|
230
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
231
|
+
validateInput(args, { staging_id: { type: 'number', required: true, min: 1 } });
|
|
232
|
+
const { staging_id, confirm = false } = args;
|
|
233
|
+
// Try pages first, then posts
|
|
234
|
+
let draft, postType, endpoint;
|
|
235
|
+
try {
|
|
236
|
+
draft = await wpApiCall(`/pages/${staging_id}`);
|
|
237
|
+
postType = 'page';
|
|
238
|
+
endpoint = '/pages';
|
|
239
|
+
} catch (e) {
|
|
240
|
+
draft = await wpApiCall(`/posts/${staging_id}`);
|
|
241
|
+
postType = 'post';
|
|
242
|
+
endpoint = '/posts';
|
|
243
|
+
}
|
|
244
|
+
if (!draft.meta?._staging_source_id) {
|
|
245
|
+
throw new Error(`${postType} ${staging_id} is not a staging draft (missing _staging_source_id meta).`);
|
|
246
|
+
}
|
|
247
|
+
const sourceId = draft.meta._staging_source_id;
|
|
248
|
+
const live = await wpApiCall(`${endpoint}/${sourceId}`);
|
|
249
|
+
if (!confirm) {
|
|
250
|
+
// Dry-run: show what would change
|
|
251
|
+
result = json({
|
|
252
|
+
status: 'dry_run',
|
|
253
|
+
staging_id,
|
|
254
|
+
source_id: sourceId,
|
|
255
|
+
changes_preview: {
|
|
256
|
+
title: { from: live.title?.rendered, to: draft.title?.rendered?.replace('[STAGING] ', '') },
|
|
257
|
+
content_length: { from: (live.content?.rendered || '').length, to: (draft.content?.rendered || '').length },
|
|
258
|
+
excerpt: { from: live.excerpt?.rendered, to: draft.excerpt?.rendered }
|
|
259
|
+
},
|
|
260
|
+
message: 'Call again with confirm=true to merge staging draft to live page.'
|
|
261
|
+
});
|
|
262
|
+
auditLog({ tool: name, target: staging_id, target_type: postType, action: 'merge_staging_preview', status: 'dry_run', latency_ms: Date.now() - t0, params: { staging_id, source_id: sourceId } });
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
// Execute merge
|
|
266
|
+
const mergeData = {
|
|
267
|
+
title: (draft.title?.rendered || '').replace('[STAGING] ', ''),
|
|
268
|
+
content: draft.content?.rendered || '',
|
|
269
|
+
excerpt: draft.excerpt?.rendered || ''
|
|
270
|
+
};
|
|
271
|
+
const updated = await wpApiCall(`${endpoint}/${sourceId}`, { method: 'POST', body: JSON.stringify(mergeData) });
|
|
272
|
+
// Delete the staging draft
|
|
273
|
+
await wpApiCall(`${endpoint}/${staging_id}?force=true`, { method: 'DELETE' });
|
|
274
|
+
result = json({
|
|
275
|
+
status: 'merged',
|
|
276
|
+
source_id: sourceId,
|
|
277
|
+
staging_id,
|
|
278
|
+
message: `Staging draft merged to live ${postType} ${sourceId}. Draft ${staging_id} deleted.`,
|
|
279
|
+
live: { id: updated.id, title: updated.title?.rendered, status: updated.status, link: updated.link, modified: updated.modified }
|
|
280
|
+
});
|
|
281
|
+
auditLog({ tool: name, target: sourceId, target_type: postType, action: 'merge_staging', status: 'success', latency_ms: Date.now() - t0, params: { staging_id, source_id: sourceId } });
|
|
282
|
+
return result;
|
|
283
|
+
};
|
|
284
|
+
handlers['wp_discard_staging_draft'] = async (args) => {
|
|
285
|
+
const t0 = Date.now();
|
|
286
|
+
let result;
|
|
287
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
288
|
+
validateInput(args, { staging_id: { type: 'number', required: true, min: 1 } });
|
|
289
|
+
const { staging_id, confirm = false } = args;
|
|
290
|
+
// Try pages first, then posts
|
|
291
|
+
let draft, postType, endpoint;
|
|
292
|
+
try {
|
|
293
|
+
draft = await wpApiCall(`/pages/${staging_id}`);
|
|
294
|
+
postType = 'page';
|
|
295
|
+
endpoint = '/pages';
|
|
296
|
+
} catch (e) {
|
|
297
|
+
draft = await wpApiCall(`/posts/${staging_id}`);
|
|
298
|
+
postType = 'post';
|
|
299
|
+
endpoint = '/posts';
|
|
300
|
+
}
|
|
301
|
+
if (!draft.meta?._staging_source_id) {
|
|
302
|
+
throw new Error(`${postType} ${staging_id} is not a staging draft (missing _staging_source_id meta).`);
|
|
303
|
+
}
|
|
304
|
+
if (!confirm) {
|
|
305
|
+
result = json({
|
|
306
|
+
status: 'dry_run',
|
|
307
|
+
staging_id,
|
|
308
|
+
source_id: draft.meta._staging_source_id,
|
|
309
|
+
title: draft.title?.rendered || '',
|
|
310
|
+
message: 'Call again with confirm=true to permanently delete this staging draft. The live page will not be affected.'
|
|
311
|
+
});
|
|
312
|
+
auditLog({ tool: name, target: staging_id, target_type: postType, action: 'discard_staging_preview', status: 'dry_run', latency_ms: Date.now() - t0, params: { staging_id } });
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
const srcId = draft.meta._staging_source_id;
|
|
316
|
+
await wpApiCall(`${endpoint}/${staging_id}?force=true`, { method: 'DELETE' });
|
|
317
|
+
result = json({
|
|
318
|
+
status: 'discarded',
|
|
319
|
+
staging_id,
|
|
320
|
+
source_id: srcId,
|
|
321
|
+
message: `Staging draft ${staging_id} permanently deleted. Live ${postType} ${srcId} was not modified.`
|
|
322
|
+
});
|
|
323
|
+
auditLog({ tool: name, target: staging_id, target_type: postType, action: 'discard_staging', status: 'success', latency_ms: Date.now() - t0, params: { staging_id, source_id: srcId } });
|
|
324
|
+
return result;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Factory to create the wp_run_workflow handler.
|
|
329
|
+
* Called from src/tools/index.js after allHandlers is built, to avoid circular import.
|
|
330
|
+
*/
|
|
331
|
+
export function createWorkflowHandler(getHandlerFn) {
|
|
332
|
+
return async (args) => {
|
|
333
|
+
const { auditLog } = rt;
|
|
334
|
+
const { workflow, steps: customSteps, context: ctx = {}, dry_run = false, stop_on_error = true } = args;
|
|
335
|
+
|
|
336
|
+
// 1. Resolve workflow
|
|
337
|
+
let steps;
|
|
338
|
+
if (workflow === 'custom') {
|
|
339
|
+
if (!customSteps || !Array.isArray(customSteps) || customSteps.length === 0) {
|
|
340
|
+
throw new Error('workflow="custom" requires a non-empty "steps" array of {tool, args}.');
|
|
341
|
+
}
|
|
342
|
+
steps = customSteps;
|
|
343
|
+
} else {
|
|
344
|
+
const named = NAMED_WORKFLOWS[workflow];
|
|
345
|
+
if (!named) {
|
|
346
|
+
throw new Error(`Unknown workflow "${workflow}". Available: ${Object.keys(NAMED_WORKFLOWS).join(', ')}`);
|
|
347
|
+
}
|
|
348
|
+
// 2. Validate required context
|
|
349
|
+
const missing = named.required_context.filter(k => ctx[k] === undefined);
|
|
350
|
+
if (missing.length > 0) {
|
|
351
|
+
throw new Error(`Workflow "${workflow}" requires context keys: ${missing.join(', ')}`);
|
|
352
|
+
}
|
|
353
|
+
steps = named.steps;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 3. Resolve templates
|
|
357
|
+
const resolvedSteps = steps.map(s => ({
|
|
358
|
+
tool: s.tool,
|
|
359
|
+
args: resolveTemplates(s.args || {}, ctx)
|
|
360
|
+
}));
|
|
361
|
+
|
|
362
|
+
// 4. Dry run
|
|
363
|
+
if (dry_run) {
|
|
364
|
+
return json({
|
|
365
|
+
mode: 'dry_run',
|
|
366
|
+
workflow,
|
|
367
|
+
steps_total: resolvedSteps.length,
|
|
368
|
+
execution_plan: resolvedSteps.map(s => ({ tool: s.tool, resolved_args: s.args })),
|
|
369
|
+
hint: 'Set dry_run=false to execute'
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 5. Execute
|
|
374
|
+
const t0 = Date.now();
|
|
375
|
+
const results = [];
|
|
376
|
+
for (const step of resolvedSteps) {
|
|
377
|
+
const stepT0 = Date.now();
|
|
378
|
+
try {
|
|
379
|
+
const handler = getHandlerFn(step.tool);
|
|
380
|
+
if (!handler) throw new Error(`Unknown tool: ${step.tool}`);
|
|
381
|
+
const stepResult = await handler(step.args);
|
|
382
|
+
const resultText = stepResult?.content?.[0]?.text || '';
|
|
383
|
+
let parsedResult;
|
|
384
|
+
try { parsedResult = JSON.parse(resultText); } catch { parsedResult = resultText; }
|
|
385
|
+
results.push({ tool: step.tool, status: 'success', duration_ms: Date.now() - stepT0, result: parsedResult });
|
|
386
|
+
} catch (e) {
|
|
387
|
+
results.push({ tool: step.tool, status: 'error', duration_ms: Date.now() - stepT0, error: e.message });
|
|
388
|
+
if (stop_on_error !== false) break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const completed = results.filter(r => r.status === 'success').length;
|
|
393
|
+
auditLog({
|
|
394
|
+
tool: 'wp_run_workflow', action: workflow,
|
|
395
|
+
status: completed === resolvedSteps.length ? 'success' : 'partial',
|
|
396
|
+
latency_ms: Date.now() - t0,
|
|
397
|
+
params: { steps_total: resolvedSteps.length, steps_completed: completed }
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
return json({
|
|
401
|
+
workflow,
|
|
402
|
+
steps_total: resolvedSteps.length,
|
|
403
|
+
steps_completed: completed,
|
|
404
|
+
total_duration_ms: Date.now() - t0,
|
|
405
|
+
results,
|
|
406
|
+
summary: `${completed}/${resolvedSteps.length} steps completed`
|
|
407
|
+
});
|
|
408
|
+
};
|
|
409
|
+
}
|
package/src/transport/http.js
CHANGED
|
@@ -12,10 +12,44 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createServer as createHttpServer } from 'node:http';
|
|
15
|
+
import { createReadStream, existsSync, statSync } from 'node:fs';
|
|
16
|
+
import { join, extname } from 'node:path';
|
|
15
17
|
import { randomUUID } from 'node:crypto';
|
|
16
18
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
17
19
|
import { validateBearerToken } from '../auth/bearer.js';
|
|
18
20
|
|
|
21
|
+
const STATIC_MIME = {
|
|
22
|
+
'.html': 'text/html; charset=utf-8',
|
|
23
|
+
'.js': 'application/javascript',
|
|
24
|
+
'.mjs': 'application/javascript',
|
|
25
|
+
'.css': 'text/css',
|
|
26
|
+
'.json': 'application/json',
|
|
27
|
+
'.png': 'image/png',
|
|
28
|
+
'.jpg': 'image/jpeg',
|
|
29
|
+
'.jpeg': 'image/jpeg',
|
|
30
|
+
'.svg': 'image/svg+xml',
|
|
31
|
+
'.ico': 'image/x-icon',
|
|
32
|
+
'.woff': 'font/woff',
|
|
33
|
+
'.woff2': 'font/woff2',
|
|
34
|
+
'.ttf': 'font/ttf',
|
|
35
|
+
'.txt': 'text/plain',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function serveStatic(pathname, res, staticDir) {
|
|
39
|
+
let filePath = join(staticDir, pathname === '/' ? 'index.html' : pathname);
|
|
40
|
+
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
|
|
41
|
+
filePath = join(staticDir, 'index.html'); // SPA fallback
|
|
42
|
+
}
|
|
43
|
+
if (!existsSync(filePath)) {
|
|
44
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
45
|
+
res.end('Not Found');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const contentType = STATIC_MIME[extname(filePath)] ?? 'application/octet-stream';
|
|
49
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
50
|
+
createReadStream(filePath).pipe(res);
|
|
51
|
+
}
|
|
52
|
+
|
|
19
53
|
/**
|
|
20
54
|
* @typedef {object} HttpConfig
|
|
21
55
|
* @property {number} [port=3000]
|
|
@@ -31,6 +65,7 @@ const DEFAULT_CONFIG = {
|
|
|
31
65
|
authToken: null,
|
|
32
66
|
allowedOrigins: [],
|
|
33
67
|
sessionTimeoutMs: 30 * 60 * 1000,
|
|
68
|
+
staticDir: null, // optional: serve static files from this directory
|
|
34
69
|
};
|
|
35
70
|
|
|
36
71
|
export class HttpTransportManager {
|
|
@@ -81,6 +116,10 @@ export class HttpTransportManager {
|
|
|
81
116
|
|
|
82
117
|
// ── Only /mcp from here ──
|
|
83
118
|
if (pathname !== '/mcp') {
|
|
119
|
+
if (cfg.staticDir) {
|
|
120
|
+
await serveStatic(pathname, res, cfg.staticDir);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
84
123
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
85
124
|
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
86
125
|
return;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildPaginationMeta } from '../../../index.js';
|
|
3
|
+
|
|
4
|
+
describe('buildPaginationMeta', () => {
|
|
5
|
+
it('computes total_pages, has_more, next_page for mid-range', () => {
|
|
6
|
+
const meta = buildPaginationMeta(47, 1, 10);
|
|
7
|
+
expect(meta.total).toBe(47);
|
|
8
|
+
expect(meta.total_pages).toBe(5);
|
|
9
|
+
expect(meta.has_more).toBe(true);
|
|
10
|
+
expect(meta.next_page).toBe(2);
|
|
11
|
+
expect(meta.page).toBe(1);
|
|
12
|
+
expect(meta.per_page).toBe(10);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns has_more=false when total fits in one page', () => {
|
|
16
|
+
const meta = buildPaginationMeta(10, 1, 10);
|
|
17
|
+
expect(meta.has_more).toBe(false);
|
|
18
|
+
expect(meta.next_page).toBe(null);
|
|
19
|
+
expect(meta.total_pages).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('handles zero total', () => {
|
|
23
|
+
const meta = buildPaginationMeta(0, 1, 10);
|
|
24
|
+
expect(meta.total).toBe(0);
|
|
25
|
+
expect(meta.total_pages).toBe(0);
|
|
26
|
+
expect(meta.has_more).toBe(false);
|
|
27
|
+
expect(meta.next_page).toBe(null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns has_more=false on the last page', () => {
|
|
31
|
+
const meta = buildPaginationMeta(25, 3, 10);
|
|
32
|
+
expect(meta.has_more).toBe(false);
|
|
33
|
+
expect(meta.next_page).toBe(null);
|
|
34
|
+
expect(meta.total_pages).toBe(3);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns next_page=null on the last page', () => {
|
|
38
|
+
const meta = buildPaginationMeta(50, 5, 10);
|
|
39
|
+
expect(meta.has_more).toBe(false);
|
|
40
|
+
expect(meta.next_page).toBe(null);
|
|
41
|
+
expect(meta.page).toBe(5);
|
|
42
|
+
});
|
|
43
|
+
});
|