@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.
Files changed (48) hide show
  1. package/.env.example +18 -0
  2. package/README.md +851 -499
  3. package/companion/mcp-diagnostics.php +1184 -0
  4. package/dxt/manifest.json +715 -98
  5. package/index.js +166 -4786
  6. package/package.json +14 -6
  7. package/src/data/plugin-performance-data.json +59 -0
  8. package/src/shared/api.js +79 -0
  9. package/src/shared/audit.js +39 -0
  10. package/src/shared/context.js +15 -0
  11. package/src/shared/governance.js +98 -0
  12. package/src/shared/utils.js +148 -0
  13. package/src/tools/comments.js +50 -0
  14. package/src/tools/content.js +353 -0
  15. package/src/tools/core.js +114 -0
  16. package/src/tools/editorial.js +634 -0
  17. package/src/tools/fse.js +370 -0
  18. package/src/tools/health.js +160 -0
  19. package/src/tools/index.js +96 -0
  20. package/src/tools/intelligence.js +2082 -0
  21. package/src/tools/links.js +118 -0
  22. package/src/tools/media.js +71 -0
  23. package/src/tools/performance.js +219 -0
  24. package/src/tools/plugins.js +368 -0
  25. package/src/tools/schema.js +417 -0
  26. package/src/tools/security.js +590 -0
  27. package/src/tools/seo.js +1633 -0
  28. package/src/tools/taxonomy.js +115 -0
  29. package/src/tools/users.js +188 -0
  30. package/src/tools/woocommerce.js +1008 -0
  31. package/src/tools/workflow.js +409 -0
  32. package/src/transport/http.js +39 -0
  33. package/tests/unit/helpers/pagination.test.js +43 -0
  34. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  35. package/tests/unit/tools/diagnostics.test.js +397 -0
  36. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  37. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  38. package/tests/unit/tools/fse.test.js +548 -0
  39. package/tests/unit/tools/multilingual.test.js +653 -0
  40. package/tests/unit/tools/performance.test.js +351 -0
  41. package/tests/unit/tools/runWorkflow.test.js +150 -0
  42. package/tests/unit/tools/schema.test.js +477 -0
  43. package/tests/unit/tools/security.test.js +695 -0
  44. package/tests/unit/tools/site.test.js +1 -1
  45. package/tests/unit/tools/users.crud.test.js +399 -0
  46. package/tests/unit/tools/validateBlocks.test.js +186 -0
  47. package/tests/unit/tools/visualStaging.test.js +271 -0
  48. 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
+ }
@@ -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
+ });