@adsim/wordpress-mcp-server 4.5.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adsim/wordpress-mcp-server",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "A Model Context Protocol (MCP) server for WordPress REST API integration. Manage posts, search content, and interact with your WordPress site through any MCP-compatible client.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Plugin Adapter Interface — contract every adapter must implement.
3
+ *
4
+ * @typedef {Object} IPluginAdapter
5
+ * @property {string} id
6
+ * Unique identifier for the plugin (e.g. "acf", "elementor", "astra").
7
+ *
8
+ * @property {string} namespace
9
+ * REST API namespace used for detection (e.g. "acf/v3", "elementor/v1").
10
+ *
11
+ * @property {"low"|"medium"|"high"|"critical"} riskLevel
12
+ * Global risk level for audit logging.
13
+ * - low: read-only operations, zero side-effects
14
+ * - medium: modifies a single targeted resource
15
+ * - high: cascading modifications across multiple resources
16
+ * - critical: site-wide visual / structural impact
17
+ *
18
+ * @property {Object} contextConfig
19
+ * Context-size guardrails for this adapter.
20
+ * @property {number} contextConfig.maxChars
21
+ * Maximum serialised response size in characters (default 30 000).
22
+ * @property {string} contextConfig.defaultMode
23
+ * Default response mode if the caller omits it (typically "compact").
24
+ * @property {string[]} contextConfig.supportedModes
25
+ * Modes the adapter's tools accept (e.g. ["raw", "compact", "summary", "ids_only"]).
26
+ *
27
+ * @property {Function} getTools
28
+ * Returns an array of MCP tool definition objects
29
+ * ({ name, description, inputSchema, handler }).
30
+ */
31
+
32
+ const VALID_RISK_LEVELS = ['low', 'medium', 'high', 'critical'];
33
+
34
+ const REQUIRED_FIELDS = [
35
+ { key: 'id', type: 'string' },
36
+ { key: 'namespace', type: 'string' },
37
+ { key: 'riskLevel', type: 'string', enum: VALID_RISK_LEVELS },
38
+ { key: 'contextConfig', type: 'object' },
39
+ { key: 'getTools', type: 'function' },
40
+ ];
41
+
42
+ /**
43
+ * Validate that an adapter object satisfies the IPluginAdapter contract.
44
+ *
45
+ * @param {object} adapter Adapter object to validate
46
+ * @returns {{ valid: boolean, errors: string[] }}
47
+ */
48
+ export function validateAdapter(adapter) {
49
+ const errors = [];
50
+
51
+ if (!adapter || typeof adapter !== 'object') {
52
+ return { valid: false, errors: ['adapter must be a non-null object'] };
53
+ }
54
+
55
+ for (const field of REQUIRED_FIELDS) {
56
+ const value = adapter[field.key];
57
+
58
+ if (value === undefined || value === null) {
59
+ errors.push(`missing required field: "${field.key}"`);
60
+ continue;
61
+ }
62
+
63
+ if (field.type === 'function') {
64
+ if (typeof value !== 'function') {
65
+ errors.push(`"${field.key}" must be a function, got ${typeof value}`);
66
+ }
67
+ } else if (field.type === 'object') {
68
+ if (typeof value !== 'object' || Array.isArray(value)) {
69
+ errors.push(`"${field.key}" must be a plain object`);
70
+ }
71
+ } else if (typeof value !== field.type) {
72
+ errors.push(`"${field.key}" must be a ${field.type}, got ${typeof value}`);
73
+ }
74
+
75
+ if (field.enum && !field.enum.includes(value)) {
76
+ errors.push(`"${field.key}" must be one of: ${field.enum.join(', ')}; got "${value}"`);
77
+ }
78
+ }
79
+
80
+ // contextConfig sub-fields
81
+ if (adapter.contextConfig && typeof adapter.contextConfig === 'object') {
82
+ const cc = adapter.contextConfig;
83
+ if (cc.maxChars !== undefined && typeof cc.maxChars !== 'number') {
84
+ errors.push('"contextConfig.maxChars" must be a number');
85
+ }
86
+ if (cc.defaultMode !== undefined && typeof cc.defaultMode !== 'string') {
87
+ errors.push('"contextConfig.defaultMode" must be a string');
88
+ }
89
+ if (cc.supportedModes !== undefined && !Array.isArray(cc.supportedModes)) {
90
+ errors.push('"contextConfig.supportedModes" must be an array');
91
+ }
92
+ }
93
+
94
+ return { valid: errors.length === 0, errors };
95
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * ACF (Advanced Custom Fields) Adapter — read + write.
3
+ *
4
+ * Provides 4 tools for ACF data via the ACF REST API (acf/v3):
5
+ * - acf_get_fields Read custom fields for a post or page
6
+ * - acf_list_field_groups List registered field groups
7
+ * - acf_get_field_group Get full details of a field group by ID
8
+ * - acf_update_fields Update custom fields (write — blocked by WP_READ_ONLY)
9
+ *
10
+ * Read tools are riskLevel "low". The write tool checks WP_READ_ONLY before
11
+ * making any API call. All responses pass through contextGuard.
12
+ */
13
+
14
+ import { applyContextGuard } from '../../contextGuard.js';
15
+
16
+ // ── Helpers ─────────────────────────────────────────────────────────────
17
+
18
+ function json(data) {
19
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
20
+ }
21
+
22
+ function auditLog(entry) {
23
+ console.error(`[AUDIT] ${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}`);
24
+ }
25
+
26
+ // ── Tool handlers ───────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * @param {object} args
30
+ * @param {Function} apiRequest wpApiCall-compatible: (endpoint, options?) => JSON
31
+ */
32
+ async function handleGetFields(args, apiRequest) {
33
+ const t0 = Date.now();
34
+ const { id, post_type = 'posts', fields, mode = 'compact' } = args;
35
+
36
+ const data = await apiRequest(`/${post_type}/${id}`, { basePath: '/wp-json/acf/v3' });
37
+
38
+ let acfData = data?.acf ?? data ?? {};
39
+
40
+ // Filter to requested keys
41
+ if (fields && fields.length > 0) {
42
+ const filtered = {};
43
+ for (const k of fields) {
44
+ if (k in acfData) filtered[k] = acfData[k];
45
+ }
46
+ acfData = filtered;
47
+ }
48
+
49
+ const guarded = applyContextGuard(
50
+ { id, post_type, fields_count: Object.keys(acfData).length, acf: acfData },
51
+ { toolName: 'acf_get_fields', mode, maxChars: 30000 }
52
+ );
53
+
54
+ auditLog({ tool: 'acf_get_fields', action: 'read_acf_fields', target: id, status: 'success', latency_ms: Date.now() - t0 });
55
+ return json(guarded);
56
+ }
57
+
58
+ async function handleListFieldGroups(args, apiRequest) {
59
+ const t0 = Date.now();
60
+
61
+ const groups = await apiRequest('/field-groups', { basePath: '/wp-json/acf/v3' });
62
+ const list = Array.isArray(groups) ? groups : [];
63
+
64
+ const summary = list.map(g => ({
65
+ id: g.id,
66
+ title: g.title?.rendered ?? g.title ?? '',
67
+ fields: (g.fields ?? []).map(f => f.name ?? f.key ?? f.label ?? ''),
68
+ location: g.location ?? [],
69
+ }));
70
+
71
+ auditLog({ tool: 'acf_list_field_groups', action: 'list_field_groups', status: 'success', latency_ms: Date.now() - t0, params: { count: summary.length } });
72
+ return json({ total: summary.length, field_groups: summary });
73
+ }
74
+
75
+ async function handleGetFieldGroup(args, apiRequest) {
76
+ const t0 = Date.now();
77
+ const { id } = args;
78
+
79
+ const group = await apiRequest(`/field-groups/${id}`, { basePath: '/wp-json/acf/v3' });
80
+
81
+ auditLog({ tool: 'acf_get_field_group', action: 'read_field_group', target: id, status: 'success', latency_ms: Date.now() - t0 });
82
+ return json(group);
83
+ }
84
+
85
+ async function handleUpdateFields(args, apiRequest) {
86
+ const t0 = Date.now();
87
+ const { id, post_type = 'posts', fields } = args;
88
+
89
+ // Validate id is a positive integer
90
+ if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) {
91
+ throw new Error('Validation error: "id" must be a positive integer');
92
+ }
93
+
94
+ // Validate fields is a non-empty object
95
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length === 0) {
96
+ throw new Error('Validation error: "fields" must be a non-empty object of key/value pairs');
97
+ }
98
+
99
+ // Governance: WP_READ_ONLY check
100
+ if (process.env.WP_READ_ONLY === 'true') {
101
+ auditLog({ tool: 'acf_update_fields', action: 'update_acf_fields', target: id, status: 'blocked', latency_ms: Date.now() - t0 });
102
+ return json({ error: 'Blocked: READ-ONLY mode' });
103
+ }
104
+
105
+ const data = await apiRequest(`/${post_type}/${id}`, {
106
+ basePath: '/wp-json/acf/v3',
107
+ method: 'POST',
108
+ body: { fields },
109
+ });
110
+
111
+ const updatedFields = data?.acf ?? data ?? {};
112
+
113
+ auditLog({ tool: 'acf_update_fields', action: 'update_acf_fields', target: id, status: 'success', latency_ms: Date.now() - t0 });
114
+ return json({ id, post_type, updated_fields: updatedFields });
115
+ }
116
+
117
+ // ── Tool definitions (MCP format) ───────────────────────────────────────
118
+
119
+ const TOOLS = [
120
+ {
121
+ name: 'acf_get_fields',
122
+ description: 'Use to read ACF custom fields for a post or page. Read-only.',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {
126
+ id: { type: 'number' },
127
+ post_type: { type: 'string', enum: ['posts', 'pages'], default: 'posts' },
128
+ fields: { type: 'array', items: { type: 'string' }, description: 'Return only these field keys (returns all if omitted)' },
129
+ mode: { type: 'string', enum: ['raw', 'compact', 'summary'], default: 'compact' },
130
+ },
131
+ required: ['id'],
132
+ },
133
+ handler: handleGetFields,
134
+ },
135
+ {
136
+ name: 'acf_list_field_groups',
137
+ description: 'Use to list ACF field groups registered on the site. Read-only.',
138
+ inputSchema: { type: 'object', properties: {} },
139
+ handler: handleListFieldGroups,
140
+ },
141
+ {
142
+ name: 'acf_get_field_group',
143
+ description: 'Use to get full details of an ACF field group by ID. Read-only.',
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties: {
147
+ id: { type: 'number' },
148
+ },
149
+ required: ['id'],
150
+ },
151
+ handler: handleGetFieldGroup,
152
+ },
153
+ {
154
+ name: 'acf_update_fields',
155
+ description: 'Use to update ACF custom fields for a post or page. Write — blocked by WP_READ_ONLY.',
156
+ inputSchema: {
157
+ type: 'object',
158
+ properties: {
159
+ id: { type: 'number' },
160
+ post_type: { type: 'string', enum: ['posts', 'pages'], default: 'posts' },
161
+ fields: { type: 'object', additionalProperties: true, description: 'Key/value pairs of ACF fields to update' },
162
+ },
163
+ required: ['id', 'fields'],
164
+ },
165
+ handler: handleUpdateFields,
166
+ },
167
+ ];
168
+
169
+ // ── Adapter export ──────────────────────────────────────────────────────
170
+
171
+ export const acfAdapter = {
172
+ id: 'acf',
173
+ namespace: 'acf/v3',
174
+ riskLevel: 'medium',
175
+ contextConfig: {
176
+ maxChars: 30000,
177
+ defaultMode: 'compact',
178
+ supportedModes: ['raw', 'compact', 'summary', 'ids_only'],
179
+ },
180
+ getTools: () => TOOLS,
181
+ };
@@ -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
+ }