@adsim/wordpress-mcp-server 4.5.1 → 4.6.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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Elementor Adapter — read-only.
3
+ *
4
+ * Provides 3 tools for reading Elementor data via the REST API (elementor/v1):
5
+ * - elementor_list_templates List templates (page, section, block, popup)
6
+ * - elementor_get_template Get full template content and elements
7
+ * - elementor_get_page_data Get Elementor editor data for a post/page
8
+ *
9
+ * All tools are read-only (riskLevel: "low") and pass responses through
10
+ * contextGuard to prevent oversized payloads from consuming LLM context.
11
+ */
12
+
13
+ import { applyContextGuard } from '../../contextGuard.js';
14
+
15
+ // ── Helpers ─────────────────────────────────────────────────────────────
16
+
17
+ function json(data) {
18
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
19
+ }
20
+
21
+ function auditLog(entry) {
22
+ console.error(`[AUDIT] ${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}`);
23
+ }
24
+
25
+ // ── Tool handlers ───────────────────────────────────────────────────────
26
+
27
+ async function handleListTemplates(args, apiRequest) {
28
+ const t0 = Date.now();
29
+ const { limit = 20, type } = args;
30
+
31
+ const data = await apiRequest('/templates', { basePath: '/wp-json/elementor/v1' });
32
+ let list = Array.isArray(data) ? data : [];
33
+
34
+ // Filter by type if requested
35
+ if (type) {
36
+ list = list.filter(t => t.type === type);
37
+ }
38
+
39
+ // Apply limit
40
+ list = list.slice(0, limit);
41
+
42
+ const templates = list.map(t => ({
43
+ id: t.id,
44
+ title: t.title?.rendered ?? t.title ?? '',
45
+ type: t.type ?? 'unknown',
46
+ author: t.author ?? null,
47
+ date: t.date ?? null,
48
+ source: t.source ?? 'local',
49
+ }));
50
+
51
+ auditLog({ tool: 'elementor_list_templates', action: 'list_templates', status: 'success', latency_ms: Date.now() - t0, params: { count: templates.length, type: type || 'all' } });
52
+ return json({ total: templates.length, templates });
53
+ }
54
+
55
+ async function handleGetTemplate(args, apiRequest) {
56
+ const t0 = Date.now();
57
+ const { id } = args;
58
+
59
+ if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) {
60
+ throw new Error('Validation error: "id" must be a positive integer');
61
+ }
62
+
63
+ const data = await apiRequest(`/templates/${id}`, { basePath: '/wp-json/elementor/v1' });
64
+
65
+ const result = {
66
+ id: data.id ?? id,
67
+ title: data.title?.rendered ?? data.title ?? '',
68
+ type: data.type ?? 'unknown',
69
+ content: data.content ?? data.elements ?? data,
70
+ };
71
+
72
+ const guarded = applyContextGuard(result, { toolName: 'elementor_get_template', mode: 'compact', maxChars: 50000 });
73
+
74
+ auditLog({ tool: 'elementor_get_template', action: 'read_template', target: id, status: 'success', latency_ms: Date.now() - t0 });
75
+ return json(guarded);
76
+ }
77
+
78
+ async function handleGetPageData(args, apiRequest) {
79
+ const t0 = Date.now();
80
+ const { post_id } = args;
81
+
82
+ if (typeof post_id !== 'number' || !Number.isInteger(post_id) || post_id <= 0) {
83
+ throw new Error('Validation error: "post_id" must be a positive integer');
84
+ }
85
+
86
+ let data;
87
+ try {
88
+ data = await apiRequest(`/document/${post_id}`, { basePath: '/wp-json/elementor/v1' });
89
+ } catch {
90
+ // Fallback: read post meta _elementor_data via WP REST API
91
+ const post = await apiRequest(`/posts/${post_id}`, { basePath: '/wp-json/wp/v2' });
92
+ data = post?.meta?._elementor_data ?? null;
93
+ }
94
+
95
+ // Parse elements to extract widget info
96
+ const elements = Array.isArray(data?.elements) ? data.elements : (Array.isArray(data) ? data : []);
97
+ const widgetsUsed = new Set();
98
+ let elementsCount = 0;
99
+
100
+ function walkElements(els) {
101
+ for (const el of els) {
102
+ elementsCount++;
103
+ if (el.widgetType) widgetsUsed.add(el.widgetType);
104
+ if (el.elType === 'widget' && el.widgetType) widgetsUsed.add(el.widgetType);
105
+ if (Array.isArray(el.elements)) walkElements(el.elements);
106
+ }
107
+ }
108
+ walkElements(elements);
109
+
110
+ const result = {
111
+ post_id,
112
+ elementor_status: elements.length > 0 ? 'active' : 'inactive',
113
+ widgets_used: [...widgetsUsed],
114
+ elements_count: elementsCount,
115
+ };
116
+
117
+ const guarded = applyContextGuard(result, { toolName: 'elementor_get_page_data', mode: 'compact', maxChars: 50000 });
118
+
119
+ auditLog({ tool: 'elementor_get_page_data', action: 'read_page_data', target: post_id, status: 'success', latency_ms: Date.now() - t0 });
120
+ return json(guarded);
121
+ }
122
+
123
+ // ── Tool definitions (MCP format) ───────────────────────────────────────
124
+
125
+ const TOOLS = [
126
+ {
127
+ name: 'elementor_list_templates',
128
+ description: 'Use to list Elementor templates (page, section, block, popup). Read-only.',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ limit: { type: 'number', default: 20, description: 'Max templates to return (default 20)' },
133
+ type: { type: 'string', enum: ['page', 'section', 'block', 'popup'], description: 'Filter by template type (optional)' },
134
+ },
135
+ },
136
+ handler: handleListTemplates,
137
+ },
138
+ {
139
+ name: 'elementor_get_template',
140
+ description: 'Use to get full Elementor template content and elements. Read-only.',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ id: { type: 'number', description: 'Template ID' },
145
+ },
146
+ required: ['id'],
147
+ },
148
+ handler: handleGetTemplate,
149
+ },
150
+ {
151
+ name: 'elementor_get_page_data',
152
+ description: 'Use to get Elementor editor data for a post or page. Read-only.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ post_id: { type: 'number', description: 'Post or page ID' },
157
+ },
158
+ required: ['post_id'],
159
+ },
160
+ handler: handleGetPageData,
161
+ },
162
+ ];
163
+
164
+ // ── Adapter export ──────────────────────────────────────────────────────
165
+
166
+ export const elementorAdapter = {
167
+ id: 'elementor',
168
+ namespace: 'elementor/v1',
169
+ riskLevel: 'low',
170
+ contextConfig: {
171
+ maxChars: 50000,
172
+ defaultMode: 'compact',
173
+ supportedModes: ['raw', 'compact', 'summary', 'ids_only'],
174
+ },
175
+ getTools: () => TOOLS,
176
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Context Guard — prevents oversized responses from consuming LLM context.
3
+ *
4
+ * Every plugin-layer tool handler must pass its response through
5
+ * applyContextGuard() before returning. If the serialised JSON exceeds
6
+ * maxChars and the caller did not request mode='raw', the response is
7
+ * truncated and a warning is attached.
8
+ */
9
+
10
+ /**
11
+ * @param {*} data Raw response data (object, array, string…)
12
+ * @param {object} options
13
+ * @param {string} options.toolName Tool name for logging
14
+ * @param {string} [options.mode] Caller-requested mode (raw = no truncation)
15
+ * @param {number} [options.maxChars] Max serialised chars (default 50 000)
16
+ * @returns {*} Original data or truncated wrapper
17
+ */
18
+ export function applyContextGuard(data, options = {}) {
19
+ const { toolName = 'unknown', mode, maxChars = 50000 } = options;
20
+ const serialised = JSON.stringify(data);
21
+ const size = serialised.length;
22
+
23
+ if (size > maxChars && mode !== 'raw') {
24
+ console.error(JSON.stringify({
25
+ tool: toolName,
26
+ event: 'context_overflow',
27
+ size_chars: size,
28
+ max_chars: maxChars,
29
+ truncated: true,
30
+ }));
31
+
32
+ const truncated = serialised.substring(0, maxChars);
33
+
34
+ // Try to parse the truncated JSON back into an object; fall back to string
35
+ let parsed;
36
+ try {
37
+ // Find last complete JSON boundary to avoid broken UTF-8 / structure
38
+ const lastBrace = Math.max(truncated.lastIndexOf('}'), truncated.lastIndexOf(']'));
39
+ if (lastBrace > 0) {
40
+ parsed = JSON.parse(truncated.substring(0, lastBrace + 1));
41
+ } else {
42
+ parsed = truncated;
43
+ }
44
+ } catch {
45
+ parsed = truncated;
46
+ }
47
+
48
+ return {
49
+ _truncated: true,
50
+ _original_size: size,
51
+ _warning: "Response truncated. Use mode='raw' to get full data.",
52
+ data: parsed,
53
+ };
54
+ }
55
+
56
+ return data;
57
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Plugin Layer Registry — runtime detection and tool aggregation.
3
+ *
4
+ * Detects active WordPress plugins (ACF, Elementor, Astra) by inspecting
5
+ * the REST API namespaces at /wp-json/. Aggregates tools from active adapters
6
+ * and respects the WP_DISABLE_PLUGIN_LAYERS governance flag.
7
+ */
8
+
9
+ const KNOWN_PLUGINS = [
10
+ { id: 'acf', namespace: 'acf/v3' },
11
+ { id: 'elementor', namespace: 'elementor/v1' },
12
+ { id: 'astra', namespace: 'astra/v1' },
13
+ ];
14
+
15
+ export class PluginRegistry {
16
+ constructor() {
17
+ /** @type {Map<string, { id: string, namespace: string, version: string|null, tools: object[] }>} */
18
+ this.active = new Map();
19
+ }
20
+
21
+ /**
22
+ * Detect active plugins by calling GET /wp-json/ and inspecting namespaces.
23
+ *
24
+ * @param {Function} apiRequest A function that takes (endpoint, options) and
25
+ * returns parsed JSON. Must support basePath override to hit /wp-json/.
26
+ * @returns {Promise<{ active: string[], disabled_by_env: boolean }>}
27
+ */
28
+ async initialize(apiRequest) {
29
+ const discovery = await apiRequest('/', { basePath: '/wp-json' });
30
+ const namespaces = discovery?.namespaces || [];
31
+
32
+ for (const known of KNOWN_PLUGINS) {
33
+ if (namespaces.includes(known.namespace)) {
34
+ this.active.set(known.id, {
35
+ id: known.id,
36
+ namespace: known.namespace,
37
+ version: null,
38
+ tools: [],
39
+ });
40
+ console.error(JSON.stringify({
41
+ event: 'plugin_layer_detected',
42
+ plugin: known.id,
43
+ namespaces_found: namespaces.filter(ns => ns.startsWith(known.id)),
44
+ }));
45
+ }
46
+ }
47
+
48
+ return this.getSummary();
49
+ }
50
+
51
+ /**
52
+ * Return all tools from active adapters.
53
+ * Returns [] if WP_DISABLE_PLUGIN_LAYERS is true.
54
+ *
55
+ * @returns {object[]}
56
+ */
57
+ getAvailableTools() {
58
+ if (process.env.WP_DISABLE_PLUGIN_LAYERS === 'true') return [];
59
+ return [...this.active.values()].flatMap(a => a.tools ?? []);
60
+ }
61
+
62
+ /**
63
+ * Check whether a plugin is detected as active.
64
+ *
65
+ * @param {string} pluginId e.g. "acf", "elementor", "astra"
66
+ * @returns {boolean}
67
+ */
68
+ isActive(pluginId) {
69
+ return this.active.has(pluginId);
70
+ }
71
+
72
+ /**
73
+ * Return a summary of the registry state.
74
+ *
75
+ * @returns {{ active: string[], disabled_by_env: boolean }}
76
+ */
77
+ getSummary() {
78
+ return {
79
+ active: [...this.active.keys()],
80
+ disabled_by_env: process.env.WP_DISABLE_PLUGIN_LAYERS === 'true',
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Register an adapter's tools for a detected plugin.
86
+ *
87
+ * @param {string} pluginId
88
+ * @param {object[]} tools
89
+ */
90
+ registerTools(pluginId, tools) {
91
+ const entry = this.active.get(pluginId);
92
+ if (entry) entry.tools = tools;
93
+ }
94
+ }
@@ -0,0 +1,151 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+
3
+ vi.mock('node-fetch', () => ({ default: vi.fn() }));
4
+
5
+ import fetch from 'node-fetch';
6
+ import { handleToolCall, getFilteredTools, pluginRegistry } from '../../index.js';
7
+ import { mockSuccess, makeRequest, parseResult } from '../helpers/mockWpRequest.js';
8
+ import { acfAdapter } from '../../src/plugins/adapters/acf/acfAdapter.js';
9
+
10
+ function call(name, args = {}) {
11
+ return handleToolCall(makeRequest(name, args));
12
+ }
13
+
14
+ let consoleSpy;
15
+ const envBackup = {};
16
+
17
+ function saveEnv(...keys) {
18
+ keys.forEach(k => { envBackup[k] = process.env[k]; });
19
+ }
20
+ function restoreEnv() {
21
+ Object.entries(envBackup).forEach(([k, v]) => {
22
+ if (v === undefined) delete process.env[k];
23
+ else process.env[k] = v;
24
+ });
25
+ }
26
+
27
+ beforeEach(() => {
28
+ fetch.mockReset();
29
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
30
+ saveEnv('WP_DISABLE_PLUGIN_LAYERS');
31
+ delete process.env.WP_DISABLE_PLUGIN_LAYERS;
32
+
33
+ // Reset registry to clean state
34
+ pluginRegistry.active.clear();
35
+ });
36
+ afterEach(() => {
37
+ consoleSpy.mockRestore();
38
+ restoreEnv();
39
+ pluginRegistry.active.clear();
40
+ });
41
+
42
+ // ── Helper: simulate ACF detected ──
43
+
44
+ function simulateAcfDetected() {
45
+ pluginRegistry.active.set('acf', {
46
+ id: 'acf',
47
+ namespace: 'acf/v3',
48
+ version: null,
49
+ tools: acfAdapter.getTools(),
50
+ });
51
+ }
52
+
53
+ // =========================================================================
54
+ // Tests
55
+ // =========================================================================
56
+
57
+ describe('Plugin Layer integration', () => {
58
+
59
+ it('listTools includes acf_get_fields when ACF is detected', () => {
60
+ simulateAcfDetected();
61
+ const tools = getFilteredTools();
62
+ const names = tools.map(t => t.name);
63
+ expect(names).toContain('acf_get_fields');
64
+ expect(names).toContain('acf_list_field_groups');
65
+ expect(names).toContain('acf_get_field_group');
66
+ });
67
+
68
+ it('listTools does NOT include ACF tools when WP_DISABLE_PLUGIN_LAYERS=true', () => {
69
+ simulateAcfDetected();
70
+ process.env.WP_DISABLE_PLUGIN_LAYERS = 'true';
71
+ const tools = getFilteredTools();
72
+ const names = tools.map(t => t.name);
73
+ expect(names).not.toContain('acf_get_fields');
74
+ expect(names).not.toContain('acf_list_field_groups');
75
+ expect(names).not.toContain('acf_get_field_group');
76
+ });
77
+
78
+ it('callTool("acf_get_fields") routes to the adapter handler', async () => {
79
+ simulateAcfDetected();
80
+
81
+ // Mock the ACF REST API response
82
+ fetch.mockResolvedValue(mockSuccess({ acf: { title: 'Hello', price: 42 } }));
83
+
84
+ const res = await call('acf_get_fields', { id: 10, post_type: 'posts' });
85
+ expect(res.isError).toBeUndefined();
86
+ const data = parseResult(res);
87
+ expect(data.acf.title).toBe('Hello');
88
+ expect(data.acf.price).toBe(42);
89
+ expect(data.id).toBe(10);
90
+ });
91
+
92
+ it('callTool("wp_list_posts") continues to work normally', async () => {
93
+ simulateAcfDetected();
94
+
95
+ const mockPost = { id: 1, title: { rendered: 'Test Post' }, slug: 'test', status: 'publish', date: '2024-01-01', link: 'https://test.example.com/test', excerpt: { rendered: 'excerpt' }, author: 1, categories: [1], tags: [] };
96
+ fetch.mockResolvedValue(mockSuccess([mockPost]));
97
+
98
+ const res = await call('wp_list_posts', { per_page: 1 });
99
+ expect(res.isError).toBeUndefined();
100
+ const data = parseResult(res);
101
+ expect(data.posts[0].title).toBe('Test Post');
102
+ });
103
+
104
+ it('callTool with unknown tool returns error', async () => {
105
+ const res = await call('totally_fake_tool', {});
106
+ expect(res.isError).toBe(true);
107
+ expect(res.content[0].text).toContain('Unknown tool');
108
+ });
109
+
110
+ it('wp_site_info includes plugin_layer in response', async () => {
111
+ simulateAcfDetected();
112
+
113
+ // wp_site_info makes 3 sequential fetch calls
114
+ const siteInfo = { name: 'Test', description: 'A test', url: 'https://test.example.com', gmt_offset: 1, timezone_string: 'UTC' };
115
+ const userMe = { id: 1, name: 'Admin', slug: 'admin', roles: ['administrator'] };
116
+ const postTypes = { post: { slug: 'post', name: 'Posts', rest_base: 'posts' } };
117
+ mockSuccess(siteInfo);
118
+ mockSuccess(userMe);
119
+ mockSuccess(postTypes);
120
+
121
+ const res = await call('wp_site_info');
122
+ const data = parseResult(res);
123
+ expect(data.plugin_layer).toBeDefined();
124
+ expect(data.plugin_layer.active).toContain('acf');
125
+ expect(data.plugin_layer.disabled_by_env).toBe(false);
126
+ });
127
+
128
+ it('plugin_layer.active is empty when no plugins detected', async () => {
129
+ // Registry is empty (cleared in beforeEach)
130
+ const siteInfo = { name: 'Test', description: 'A test', url: 'https://test.example.com', gmt_offset: 1, timezone_string: 'UTC' };
131
+ const userMe = { id: 1, name: 'Admin', slug: 'admin', roles: ['administrator'] };
132
+ const postTypes = { post: { slug: 'post', name: 'Posts', rest_base: 'posts' } };
133
+ mockSuccess(siteInfo);
134
+ mockSuccess(userMe);
135
+ mockSuccess(postTypes);
136
+
137
+ const res = await call('wp_site_info');
138
+ const data = parseResult(res);
139
+ expect(data.plugin_layer.active).toEqual([]);
140
+ });
141
+
142
+ it('none of the 86 core tools are overwritten by plugin tools', () => {
143
+ simulateAcfDetected();
144
+ const tools = getFilteredTools();
145
+ const names = tools.map(t => t.name);
146
+
147
+ // Check no duplicates
148
+ const unique = new Set(names);
149
+ expect(unique.size).toBe(names.length);
150
+ });
151
+ });
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { acfAdapter } from '../../../../src/plugins/adapters/acf/acfAdapter.js';
3
+ import { validateAdapter } from '../../../../src/plugins/IPluginAdapter.js';
4
+
5
+ let consoleSpy;
6
+ const envBackup = {};
7
+
8
+ function saveEnv(...keys) {
9
+ keys.forEach(k => { envBackup[k] = process.env[k]; });
10
+ }
11
+ function restoreEnv() {
12
+ Object.entries(envBackup).forEach(([k, v]) => {
13
+ if (v === undefined) delete process.env[k];
14
+ else process.env[k] = v;
15
+ });
16
+ }
17
+
18
+ beforeEach(() => {
19
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
20
+ saveEnv('WP_READ_ONLY');
21
+ });
22
+ afterEach(() => {
23
+ consoleSpy.mockRestore();
24
+ restoreEnv();
25
+ });
26
+
27
+ // ── Helpers ──
28
+
29
+ function parseResult(result) {
30
+ return JSON.parse(result?.content?.[0]?.text);
31
+ }
32
+
33
+ function getAuditLogs() {
34
+ return consoleSpy.mock.calls
35
+ .map(c => c[0])
36
+ .filter(m => typeof m === 'string' && m.startsWith('[AUDIT]'))
37
+ .map(m => JSON.parse(m.replace('[AUDIT] ', '')));
38
+ }
39
+
40
+ function mockApi(data, shouldThrow = false) {
41
+ if (shouldThrow) {
42
+ return async () => { throw new Error('WP API 404: Not Found'); };
43
+ }
44
+ return async () => data;
45
+ }
46
+
47
+ // ── Mock data ──
48
+
49
+ const mockAcfFields = {
50
+ acf: {
51
+ hero_title: 'Welcome to our site',
52
+ hero_subtitle: 'The best place',
53
+ price: 29.99,
54
+ show_banner: true,
55
+ gallery: [{ url: 'https://example.com/img1.jpg' }, { url: 'https://example.com/img2.jpg' }],
56
+ },
57
+ };
58
+
59
+ const mockFieldGroups = [
60
+ {
61
+ id: 101,
62
+ title: { rendered: 'Hero Section' },
63
+ fields: [{ name: 'hero_title' }, { name: 'hero_subtitle' }],
64
+ location: [[{ param: 'post_type', operator: '==', value: 'page' }]],
65
+ },
66
+ {
67
+ id: 102,
68
+ title: { rendered: 'Product Info' },
69
+ fields: [{ name: 'price' }, { name: 'sku' }],
70
+ location: [[{ param: 'post_type', operator: '==', value: 'product' }]],
71
+ },
72
+ ];
73
+
74
+ const mockFieldGroupDetail = {
75
+ id: 101,
76
+ title: { rendered: 'Hero Section' },
77
+ fields: [
78
+ { key: 'field_abc', name: 'hero_title', type: 'text', label: 'Hero Title' },
79
+ { key: 'field_def', name: 'hero_subtitle', type: 'text', label: 'Hero Subtitle' },
80
+ ],
81
+ location: [[{ param: 'post_type', operator: '==', value: 'page' }]],
82
+ active: true,
83
+ };
84
+
85
+ // =========================================================================
86
+ // Tests
87
+ // =========================================================================
88
+
89
+ describe('ACF Adapter', () => {
90
+
91
+ // 1. validateAdapter
92
+ it('passes validateAdapter without errors', () => {
93
+ const { valid, errors } = validateAdapter(acfAdapter);
94
+ expect(valid).toBe(true);
95
+ expect(errors).toEqual([]);
96
+ });
97
+
98
+ // 2. acf_get_fields — success
99
+ it('acf_get_fields returns ACF fields for a post', async () => {
100
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
101
+ const result = await tool.handler({ id: 42, post_type: 'posts' }, mockApi(mockAcfFields));
102
+ const data = parseResult(result);
103
+ expect(data.id).toBe(42);
104
+ expect(data.acf.hero_title).toBe('Welcome to our site');
105
+ expect(data.acf.price).toBe(29.99);
106
+ expect(data.fields_count).toBe(5);
107
+ });
108
+
109
+ // 3. acf_get_fields — fields filter
110
+ it('acf_get_fields filters to requested keys only', async () => {
111
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
112
+ const result = await tool.handler(
113
+ { id: 42, fields: ['hero_title', 'price'] },
114
+ mockApi(mockAcfFields)
115
+ );
116
+ const data = parseResult(result);
117
+ expect(Object.keys(data.acf)).toEqual(['hero_title', 'price']);
118
+ expect(data.acf.hero_subtitle).toBeUndefined();
119
+ expect(data.fields_count).toBe(2);
120
+ });
121
+
122
+ // 4. acf_get_fields — contextGuard truncation
123
+ it('acf_get_fields applies contextGuard on large responses', async () => {
124
+ // Generate a response > 30000 chars
125
+ const bigFields = {};
126
+ for (let i = 0; i < 500; i++) {
127
+ bigFields[`field_${i}`] = 'x'.repeat(100);
128
+ }
129
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
130
+ const result = await tool.handler(
131
+ { id: 1, mode: 'compact' },
132
+ mockApi({ acf: bigFields })
133
+ );
134
+ const data = parseResult(result);
135
+ expect(data._truncated).toBe(true);
136
+ expect(data._original_size).toBeGreaterThan(30000);
137
+ expect(data._warning).toContain('truncated');
138
+ });
139
+
140
+ // 5. acf_get_fields — 404
141
+ it('acf_get_fields propagates 404 error', async () => {
142
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
143
+ await expect(
144
+ tool.handler({ id: 99999 }, mockApi(null, true))
145
+ ).rejects.toThrow('404');
146
+ });
147
+
148
+ // 6. acf_list_field_groups — success
149
+ it('acf_list_field_groups returns the list', async () => {
150
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_list_field_groups');
151
+ const result = await tool.handler({}, mockApi(mockFieldGroups));
152
+ const data = parseResult(result);
153
+ expect(data.total).toBe(2);
154
+ expect(data.field_groups[0].title).toBe('Hero Section');
155
+ expect(data.field_groups[0].fields).toEqual(['hero_title', 'hero_subtitle']);
156
+ expect(data.field_groups[1].id).toBe(102);
157
+ });
158
+
159
+ // 7. acf_get_field_group — success
160
+ it('acf_get_field_group returns full details', async () => {
161
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_field_group');
162
+ const result = await tool.handler({ id: 101 }, mockApi(mockFieldGroupDetail));
163
+ const data = parseResult(result);
164
+ expect(data.id).toBe(101);
165
+ expect(data.fields).toHaveLength(2);
166
+ expect(data.fields[0].name).toBe('hero_title');
167
+ });
168
+
169
+ // 8. acf_get_field_group — 404
170
+ it('acf_get_field_group propagates 404 error', async () => {
171
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_field_group');
172
+ await expect(
173
+ tool.handler({ id: 99999 }, mockApi(null, true))
174
+ ).rejects.toThrow('404');
175
+ });
176
+
177
+ // 9. None of the tools are write tools — not blocked by WP_READ_ONLY
178
+ it('none of the 3 tools appear in core writeTools list', () => {
179
+ const writeTools = [
180
+ 'wp_create_post', 'wp_update_post', 'wp_delete_post', 'wp_create_page',
181
+ 'wp_update_page', 'wp_upload_media', 'wp_create_comment',
182
+ 'wp_create_taxonomy_term', 'wp_update_seo_meta', 'wp_activate_plugin',
183
+ 'wp_deactivate_plugin', 'wp_restore_revision', 'wp_delete_revision',
184
+ 'wp_submit_for_review', 'wp_approve_post', 'wp_reject_post',
185
+ 'wc_update_product', 'wc_update_stock', 'wc_update_order_status',
186
+ ];
187
+ const acfToolNames = acfAdapter.getTools().map(t => t.name);
188
+ for (const n of acfToolNames) {
189
+ expect(writeTools).not.toContain(n);
190
+ }
191
+ });
192
+
193
+ // 10. Audit log format
194
+ it('acf_get_fields logs correct audit format to stderr', async () => {
195
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
196
+ await tool.handler({ id: 42, post_type: 'posts' }, mockApi(mockAcfFields));
197
+ const logs = getAuditLogs();
198
+ const entry = logs.find(l => l.tool === 'acf_get_fields');
199
+ expect(entry).toBeDefined();
200
+ expect(entry.action).toBe('read_acf_fields');
201
+ expect(entry.target).toBe(42);
202
+ expect(entry.status).toBe('success');
203
+ expect(typeof entry.latency_ms).toBe('number');
204
+ });
205
+ });